Command injection to atak, w którym dane od użytkownika trafiają do polecenia systemowego (na przykład przez `shell=True`, `shell_exec` albo `exec`) i powłoka wykonuje także to, co atakujący dopisał po średniku. Obrona to uruchamianie programów z listą argumentów zamiast jednego tekstu, a przy `eval` użycie parsera, takiego jak JSON albo `ast.literal_eval`. Zasada jest ta sama co przy SQL injection: nie sklejaj danych użytkownika z kodem.
Wiele aplikacji w pewnym momencie sięga po system: konwertuje obraz, wywołuje git, kompresuje plik albo uruchamia skrypt. Najprostszy sposób to złożyć polecenie jako tekst i przekazać je powłoce. To ta sama pułapka co przy zapytaniach do bazy, tylko zamiast SQL wykonuje się polecenie systemowe albo kod Twojego języka. Poniżej sprawdzam to w Pythonie, PHP i Node.js. Dla bezpieczeństwa w testach używam wyłącznie echo, ls i odczytu bieżącego katalogu na komputerze testowym, a nie niczego, co coś zmienia.
Czym jest command injection i czym różni się od eval?
To dwie pokrewne rzeczy, które łączy jedno: tekst od użytkownika zostaje potraktowany jak instrukcja.
| Command injection | Code injection przez eval | |
|---|---|---|
| Co się wykonuje | polecenie w powłoce systemu (bash, sh) | kod w języku Twojej aplikacji (Python, PHP, JavaScript) |
| Typowy punkt wejścia | shell=True, os.system, shell_exec, exec | eval, new Function |
| Skutek | dowolne polecenia z uprawnieniami aplikacji | dowolny kod z uprawnieniami aplikacji |
| Obrona | lista argumentów, brak powłoki | parser danych zamiast eval |
Podobny mechanizm przy zapytaniach opisuje artykuł o SQL injection, a przy kodzie wstawianym do strony artykuł o XSS.
Jak wygląda wstrzyknięcie polecenia?
Funkcja pozdrawia użytkownika, wywołując echo. Imię pochodzi z formularza. Wersja podatna skleja polecenie w jeden tekst, a wersja bezpieczna podaje program i argumenty osobno:
import subprocess
name = "x; echo WSTRZYKNIETE"
# podatne: całość trafia do powłoki
subprocess.run(f"echo Czesc {name}", shell=True, capture_output=True, text=True).stdout
# 'Czesc x\nWSTRZYKNIETE\n'
# bezpieczne: lista argumentów, bez powłoki
subprocess.run(["echo", "Czesc", name], capture_output=True, text=True).stdout
# 'Czesc x; echo WSTRZYKNIETE\n'
$name = "x; echo WSTRZYKNIETE";
// podatne
shell_exec("echo Czesc " . $name);
// "Czesc x\nWSTRZYKNIETE\n"
// bezpieczne: tablica argumentów w proc_open
$p = proc_open(['echo', 'Czesc', $name], [1 => ['pipe', 'w']], $pipes);
stream_get_contents($pipes[1]);
// "Czesc x; echo WSTRZYKNIETE\n"
proc_close($p);
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const name = 'x; echo WSTRZYKNIETE';
// podatne
(await promisify(exec)(`echo Czesc ${name}`)).stdout;
// 'Czesc x\nWSTRZYKNIETE\n'
// bezpieczne
(await promisify(execFile)('echo', ['Czesc', name])).stdout;
// 'Czesc x; echo WSTRZYKNIETE\n'
We wszystkich trzech językach wynik jest taki sam. W wersjach podatnych średnik kończy pierwsze polecenie i powłoka uruchamia drugie, wpisane przez użytkownika, a na wyjściu pojawia się osobna linia WSTRZYKNIETE. W wersjach bezpiecznych cały tekst jest jednym argumentem programu echo, więc znaki specjalne nie mają żadnego znaczenia. W prawdziwym ataku w miejscu echo WSTRZYKNIETE byłoby inne polecenie.
W Pythonie ten sam efekt daje os.system: polecenie z drugim echo po średniku też się wykonało.
Czy shlex.quote i escapeshellarg wystarczą?
Są sposobem na „załatanie” podatnego kodu, bo ujmują tekst w cudzysłowy tak, by powłoka nie interpretowała znaków specjalnych:
import shlex
subprocess.run(f"echo Czesc {shlex.quote(name)}", shell=True, capture_output=True, text=True).stdout
# 'Czesc x; echo WSTRZYKNIETE\n'
W PHP robi to escapeshellarg(). W obu przypadkach w moim teście wynik był poprawny (jedna linia). To jednak rozwiązanie kruche: wystarczy zapomnieć o jednym argumencie, żeby wrócił błąd, a reguły cytowania zależą od powłoki i systemu. Lista argumentów jest po prostu prostsza i nie wymaga pamiętania o niczym. Traktuj cytowanie jako awaryjną łatkę, nie jako wzorzec.
Co to jest wstrzyknięcie opcji i jak się przed nim bronić?
Nawet bez powłoki użytkownik może zaszkodzić, jeśli jego wartość zaczyna się od myślnika. Program potraktuje ją wtedy jako opcję, a nie jako nazwę pliku. Przykład z ls w katalogu z dwoma plikami:
user = "-la"
# wartość użytkownika uznana za opcję: długi format listingu
subprocess.run(["ls", user], capture_output=True, text=True, cwd="dir").stdout
# dwa myślniki kończą opcje: wartość to teraz nazwa pliku (a pliku nie ma)
subprocess.run(["ls", "--", user], capture_output=True, text=True, cwd="dir")
# kod wyjścia 1, pusty stdout
W pierwszym wywołaniu ls wypisał pliki w długim formacie, czyli zadziałał tak, jakby -la wpisał programista. W drugim, z separatorem --, wartość została nazwą pliku i program zgłosił błąd. Separator -- obsługuje wiele programów, ale nie wszystkie (przetestowałem tylko ls), więc sprawdź dokumentację programu, który wywołujesz. Lepszą obroną jest sprawdzenie wartości przed użyciem, na przykład odrzucenie nazw zaczynających się od -.
Dlaczego eval jest groźny?
eval wykonuje tekst jako kod Twojego języka. Poniżej tekst, który wygląda jak dane, ale jest kodem:
eval("__import__('os').getcwd()") # wykonał kod i zwrócił ścieżkę katalogu
eval('process.cwd()'); // wykonał kod, zwrócił ścieżkę
new Function('return process.cwd()')(); // to samo
eval("return getcwd();"); // to samo
Tu zwracam bezpieczny odczyt katalogu, ale w prawdziwym ataku w jego miejscu mogłoby być odczytanie plików lub uruchomienie poleceń. new Function w JavaScripcie jest równie niebezpieczne jak eval, więc nie traktuj go jako obejścia.
Najczęściej eval służy do zamiany tekstu na dane. Do tego istnieją bezpieczne narzędzia:
| Potrzebujesz | Zamiast eval | Co się stanie z kodem w tekście |
|---|---|---|
| Dane (listy, słowniki, liczby) | json.loads, JSON.parse, json_decode | błąd (SyntaxError) |
Literały Pythona ([1, 2, {'a': 3}]) | ast.literal_eval | błąd (ValueError) |
| Proste wyrażenie z listy dozwolonych | własny słownik funkcji, np. {"dodaj": dodaj} | niedozwolona nazwa jest odrzucana |
W teście ast.literal_eval poprawnie wczytał [1, 2, {'a': 3}], ale dla 2 + 2 i __import__('os').getcwd() zgłosił ValueError: przyjmuje wyłącznie literały, nie wyrażenia. JSON.parse('process.cwd()') w Node kończy się SyntaxError, a JSON.parse('[1, 2]') zwraca tablicę.
Gdzie to się zdarza w aplikacjach webowych?
Najczęściej tam, gdzie aplikacja używa danych użytkownika w poleceniu: nazwy pliku z uploadu przekazanej do narzędzia graficznego, adresu podanego przez użytkownika wstawionego do curl, nazwy gałęzi w git. Jeśli Twój Route Handler w Next.js albo endpoint w FastAPI uruchamia polecenie systemowe, obowiązuje dokładnie ta sama zasada: program i argumenty osobno, a wartość od użytkownika najpierw sprawdzona. Przy plikach pomaga zasada z artykułu o uploadzie plików w FastAPI: zapisuj plik pod własną nazwą i właśnie jej używaj w poleceniu, a nie nazwy od klienta. Przykładów w Next.js nie uruchamiałem, testy wykonałem w czystym Node.js.
Jakie błędy mentor poprawia najczęściej?
Uwaga mentora: nazwa pliku od użytkownika to dane z zewnątrz tak samo jak pole formularza. Nie wstawiaj jej do polecenia ani do ścieżki bez sprawdzenia.
Poniżej cała lista pułapek w jednym miejscu:
| Pułapka | Skutek | Jak uniknąć |
|---|---|---|
shell=True, shell_exec, exec() z danymi z zewnątrz | wykonanie dodatkowych poleceń po średniku | lista argumentów (subprocess.run([...]), execFile, proc_open z tablicą) |
Poleganie wyłącznie na shlex.quote / escapeshellarg | wystarczy jedno pominięcie | lista argumentów, a cytowanie tylko awaryjnie |
Wartość zaczynająca się od - | potraktowana jak opcja programu | separator -- (jeśli program go obsługuje) lub odrzucenie takich wartości |
eval na tekście od użytkownika | wykonanie kodu aplikacji | JSON, ast.literal_eval lub słownik dozwolonych funkcji |
new Function(...) jako „bezpieczny eval” | to samo ryzyko co eval | te same alternatywy |
| Nazwa pliku od klienta w poleceniu | wstrzyknięcie polecenia lub opcji | własna nazwa pliku wygenerowana przez serwer |
| Uruchamianie procesu z pełnymi uprawnieniami | skutki ataku są największe | najmniejsze uprawnienia, kontener lub osobny użytkownik |
Co dalej?
Masz teraz komplet czterech klas problemów, w których dane z zewnątrz stają się kodem lub poleceniem. Kod w przeglądarce opisuje XSS, kod w zapytaniu do bazy SQL injection, a sfałszowane żądania w imieniu użytkownika CSRF. Jeśli chcesz uczyć się budowania bezpiecznych aplikacji z mentorem, który ocenia Twój kod, sprawdź kurs Python backend albo kurs PHP.


