Niezawodna obrona przed OS command injection polega na tym, by w ogóle nie przekazywać do shella ciągów znaków zależnych od użytkownika. Lepszy escaping to rozwiązanie słabsze. Praktyczna praca sprowadza się do znalezienia każdego miejsca, w którym kod uruchamia proces, a potem do decyzji, czy dane wywołanie w ogóle powinno istnieć.
Command injection występuje wtedy, gdy aplikacja przekazuje niezweryfikowane dane użytkownika do powłoki systemowej, a wstrzyknięte polecenia zwykle wykonują się z uprawnieniami aplikacji. CWE-78 dodaje niewygodny szczegół: ponieważ polecenia wykonuje aplikacja, aktywność może wyglądać tak, jakby pochodziła od samej aplikacji lub jej właściciela.
Dlaczego escaping to zła pierwsza odpowiedź
Funkcja escapeshellarg() w PHP opakowuje wartość w apostrofy, dzięki czemu shell traktuje ją jako jeden argument. To powstrzymuje uruchomienie & echo "hello" jako drugiego polecenia. Nie powstrzymuje jednak atakującego przed wyborem tego, jaki jeden argument otrzyma program.
Każdy OS command injection jest zarazem argument injection. W ściądze OWASP wywołanie escape("--help") przekazane do curl nadal wypisuje tekst pomocy. W zależności od programu skutki sięgają od ujawnienia informacji po zdalne wykonanie kodu.
Uwaga czytelnika na stronie podręcznika PHP o escapeshellarg() pokazuje tę samą wadę na przykładzie tar. Każda nazwa pliku jest poprawnie escapowana, a mimo to wartość zaczynająca się od myślnika jest nadal odczytywana jako opcja, a tar ma opcję uruchamiającą inne polecenie. Dodanie -- przed nazwami plików rozwiązuje problem, ale autor uwagi zauważa, że nie każdy program obsługuje taki separator.
W systemie Windows, jak podaje podręcznik, escapeshellarg() używa cudzysłowów i zastępuje spacjami znaki procenta, wykrzykniki oraz cudzysłowy. Uwaga czytelnika dodaje, że wynik jest bezpieczny, ale stratny. Tak czy inaczej, to powód, by unikać shella, a nie poprawka.
Znajdź wywołania
Uruchom poniższe polecenia na każdym repozytorium. Dostosuj typy plików do swojej struktury:
rg -n 'shell=True|os\.system|os\.popen' --type py
rg -n "subprocess\.(run|Popen|call|check_output)\(\s*[f\"']" --type py
rg -n '\b(system|exec|shell_exec|passthru|popen|proc_open)\s*\(' --type php
rg -n '`[^`]*\$' --type php
rg -n 'Runtime\.getRuntime\(\)\.exec|ProcessBuilder' --type java
rg -n 'ProcessStartInfo|Process\.Start|UseShellExecute' --type cs
rg -n '\bsystem\s*\(|popen\s*\(|exec[lv]p?e?\s*\(' --type c
Drugi wzorzec dla Pythona wyłapuje pojedynczy ciąg znaków przekazany jako polecenie. Bez shell=True działa to tylko wtedy, gdy ciąg wskazuje sam program i nic więcej, więc każdy wynik wymaga ręcznego przejrzenia. Wzorzec dla backticków w PHP generuje dużo szumu, bo pasuje także do backticków w komentarzach i w SQL. Przeszukaj również skrypty shellowe, skrypty wdrożeniowe i wrappery cron, które budują polecenia ze zmiennych równie często jak kod aplikacji.
| Język | Czego szukać | Czym zastąpić |
|---|---|---|
| PHP | system(), exec(), shell_exec(), passthru(), popen(), proc_open(), operator backtick | Wbudowane funkcje, np. mkdir() |
| Python | shell=True; os.system; os.popen; pojedynczy ciąg z poleceniem | subprocess.run z listą |
| Java | Runtime.exec(String) | ProcessBuilder z programem i każdym argumentem jako osobnym elementem |
| .NET | cmd.exe, powershell.exe, sh -c, zbudowany ciąg Arguments | ProcessStartInfo z ArgumentList |
| C | system() | Wywołanie biblioteczne |
Uwzględnij też polecenia zakodowane na stałe. Opis społeczności OWASP przedstawia uprzywilejowany program CGI, który uruchamia cd /var/yp && make bez żadnych danych użytkownika w ciągu. Nie użyto w nim ścieżki bezwzględnej do make, więc atakujący mógł zmienić $PATH i sprawić, by jego własny plik binarny wykonał się z uprawnieniami programu.
Przeprowadź triaż wyników
Surowa lista trafień to jeszcze nie lista podatności. Dla każdego trafienia prześledź wartość wstecz i zapytaj, czy może do niej dotrzeć żądanie. Oczywiste źródła to parametry trasy i zapytania, pola formularzy, ciasteczka i nagłówki HTTP. Łatwo zapomnieć o nazwach przesyłanych plików, wartościach z bazy danych, do której użytkownicy mogą zapisywać, wiadomościach z kolejek i zmiennych środowiskowych.
Podziel trafienia na trzy grupy: osiągalne z żądania bez uwierzytelnienia, osiągalne dopiero po zalogowaniu i całkowicie nieosiągalne. Najpierw napraw pierwszą grupę. Trzecią zostaw na liście, bo późniejszy refaktoring może połączyć ją z żądaniem, a nikt tego nie zauważy.
Zastąp wywołanie API
Wbudowanych funkcji bibliotecznych nie da się nakłonić do niczego poza ich zamierzonym zadaniem. Używaj mkdir() zamiast system("mkdir /dir_name"). Używaj Java mail API z javax.mail.* zamiast wywoływać polecenie mail przez shell. CWE-78 daje tę samą radę na poziomie projektowania: używaj wywołań bibliotecznych zamiast zewnętrznych procesów.
Gdy musisz uruchomić proces
Niektóre narzędzia nie mają odpowiednika w bibliotece. Wtedy stosuj razem dwie warstwy: listę argumentów bez shella oraz walidację.
Dokumentacja modułu subprocess w Pythonie mówi, że zwykle lepsza jest sekwencja argumentów, ponieważ moduł sam zajmuje się escapingiem i cytowaniem. Zawiera też zalecenie, by przed użyciem shell=True przeczytać sekcję Security Considerations. Zaleca również pełną ścieżkę do pliku wykonywalnego, co eliminuje wyszukiwanie w PATH, które wykorzystuje opisany wyżej przykład z make.
import re, subprocess
if not re.fullmatch(r"[a-z0-9]{3,10}", name):
raise ValueError("rejected")
subprocess.run(["/usr/bin/tar", "cf", "out.tar", "--", name], check=True)
Wzorzec już odrzuca wiodący myślnik, więc -- wygląda na zbędne. To jednak defence in depth: jeśli ktoś później poluzuje wyrażenie regularne, separator nadal uniemożliwi odczytanie name jako opcji.
W .NET użyj stałej, pełnej ścieżki w FileName, ustaw UseShellExecute = false i dodawaj każdy argument przez ArgumentList. Nigdy nie przekazuj niezaufanych danych do cmd.exe, powershell.exe ani sh -c. Samo rozdzielenie argumentów nie jest walidacją, ani w Javie, ani w .NET.
Następnie zwaliduj wartości. Sprawdź polecenie względem listy dozwolonych poleceń, a każdy argument względem dozwolonych wartości lub wzorca z maksymalną długością, bez metaznaków i białych znaków.
W przypadku curl samo -- nie wystarczy, ponieważ kolejne argumenty nadal są operandami URL i możliwe są dalsze transfery. Przekaż dokładnie jeden zwalidowany URL, użyj --globoff i zastosuj zabezpieczenia przed SSRF.
Uruchamiaj aplikację z najniższymi uprawnieniami, jakich potrzebuje, a zadania jednorazowe wykonuj na osobnym, odizolowanym koncie. Piaskownica, taka jak chroot, AppArmor czy SELinux, pomaga, ale CWE-78 ocenia jej skuteczność jako ograniczoną. Zawęża to, do czego może dotrzeć atakujący, ale nie usuwa podatności.
Od czego zacząć
- Uruchom powyższe wyszukiwania na każdym repozytorium, łącznie ze skryptami wdrożeniowymi, zadaniami CI i wrapperami cron. Wypisz każde trafienie wraz z danymi wejściowymi, które do niego docierają, i posortuj listę według tego, czy żądanie może je osiągnąć.
- Dla każdego trafienia zapytaj, czy biblioteka lub funkcja wbudowana wykonuje to samo zadanie. Jeśli tak, zastąp wywołanie i usuń uruchamianie procesu.
- Przepisz pozostałe wywołania tak, by używały listy argumentów, żadnego shella i bezwzględnej ścieżki do pliku wykonywalnego. Dodaj walidację opartą na allowlist oraz separator
--tam, gdzie program go obsługuje. - Uruchamiaj procesy na dedykowanym koncie o niskich uprawnieniach, aby pominięty przypadek dawał atakującemu mniej możliwości.
- Przetestuj każde pozostałe wywołanie wartością w rodzaju
bob.txt;sleep 5oraz wartością zaczynającą się od myślnika. Mierz czas odpowiedzi zamiast czekać na wynik. Wartość z wiodącym myślnikiem powinna zostać odrzucona lub potraktowana jako nazwa pliku. Jeśli chcesz niezależnej weryfikacji, test penetracyjny sprawdzi oba przypadki, a nasz zespół bezpiecznego wytwarzania oprogramowania może przejrzeć z Tobą kod.
