La correzione affidabile per l'OS command injection è smettere del tutto di passare a una shell stringhe influenzabili dall'utente. Migliorare l'escaping è l'opzione più debole. Il lavoro pratico consiste nel trovare ogni punto in cui il codice avvia un processo e poi decidere, caso per caso, se quella chiamata debba davvero esistere.
La command injection si verifica quando un'applicazione passa a una shell di sistema dati utente non sicuri, e i comandi iniettati di solito vengono eseguiti con i privilegi dell'applicazione. CWE-78 aggiunge un dettaglio scomodo: poiché i comandi sono eseguiti dall'applicazione, l'attività può sembrare provenire dall'applicazione stessa o dal suo proprietario.
Perché l'escaping è la risposta sbagliata da cui partire
La funzione escapeshellarg() di PHP racchiude un valore tra apici singoli, in modo che la shell lo tratti come un unico argomento. Questo impedisce che & echo "hello" venga eseguito come secondo comando. Non impedisce però all'attaccante di scegliere quale argomento, uno solo, riceverà il programma.
Ogni OS command injection è anche un'argument injection. Nella cheat sheet di OWASP, escape("--help") passato a curl stampa comunque il testo della guida. A seconda del programma, l'impatto va dalla divulgazione di informazioni alla remote code execution.
Una nota di un lettore nella pagina del manuale PHP su escapeshellarg() mostra lo stesso difetto con tar. Ogni nome di file è sottoposto a escaping corretto, eppure un valore che inizia con un trattino viene comunque interpretato come opzione, e tar ha un'opzione che esegue un altro comando. Aggiungere -- prima dei nomi dei file risolve il problema, ma la nota fa notare che non tutti i programmi supportano questo separatore.
Su Windows, secondo il manuale, escapeshellarg() usa i doppi apici e sostituisce con spazi i segni di percentuale, i punti esclamativi e i doppi apici. Una nota di un lettore aggiunge che il risultato è sicuro ma con perdita di informazioni. In entrambi i casi è un motivo per evitare la shell, non una soluzione.
Trovare le chiamate
Eseguite questi comandi su ogni repository. Adattate i tipi di file alla struttura dei Suoi progetti:
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
Il secondo pattern Python intercetta una singola stringa passata come comando. Senza shell=True funziona solo se la stringa contiene il nome di un programma e nient'altro, quindi ogni risultato richiede un controllo manuale. Il pattern PHP per i backtick è rumoroso perché trova anche i backtick nei commenti e in SQL. Cercate anche negli shell script, negli script di deploy e nei wrapper di cron, che costruiscono comandi a partire da variabili con la stessa frequenza del codice applicativo.
| Linguaggio | Cosa cercare | Con cosa sostituire |
|---|---|---|
| PHP | system(), exec(), shell_exec(), passthru(), popen(), proc_open(), l'operatore backtick | Funzioni integrate come mkdir() |
| Python | shell=True; os.system; os.popen; una singola stringa di comando | subprocess.run con una lista |
| Java | Runtime.exec(String) | ProcessBuilder con il programma e ogni argomento come elementi separati |
| .NET | cmd.exe, powershell.exe, sh -c, una stringa Arguments costruita | ProcessStartInfo con ArgumentList |
| C | system() | Una chiamata a libreria |
Includete anche i comandi hardcoded. L'approfondimento della community OWASP descrive un programma CGI privilegiato che esegue cd /var/yp && make senza alcun input utente nella stringa. Non usava un percorso assoluto per make, quindi un attaccante poteva modificare $PATH e far eseguire un proprio binario con i privilegi del programma.
Classificare i risultati
Un elenco grezzo di risultati non è un elenco di vulnerabilità. Per ciascuno, risalite all'indietro il percorso del valore e chiedetevi se una richiesta possa raggiungerlo. I parametri di route e di query, i campi dei form, i cookie e gli header HTTP sono le fonti più ovvie. I nomi dei file caricati, i valori provenienti da un database scrivibile dagli utenti, i messaggi in coda e le variabili d'ambiente sono quelle che si dimenticano più spesso.
Dividete i risultati in tre gruppi: raggiungibili da una richiesta non autenticata, raggiungibili solo dopo il login, non raggiungibili affatto. Correggete per primo il primo gruppo. Mantenete il terzo nell'elenco, perché un successivo refactoring può collegarlo a una richiesta senza che nessuno se ne accorga.
Sostituire la chiamata con un'API
Le funzioni di libreria integrate non possono essere manipolate in modo da fare altro rispetto al compito previsto. Usate mkdir() al posto di system("mkdir /dir_name"). Usate la API di posta Java in javax.mail.* invece di invocare mail tramite la shell. CWE-78 dà lo stesso consiglio per la fase di progettazione: usare chiamate a libreria anziché processi esterni.
Quando è necessario avviare un processo
Alcuni strumenti non hanno un equivalente in libreria. In tal caso usate insieme due livelli: una lista di argomenti senza shell e la validazione.
La documentazione di subprocess in Python afferma che in genere è preferibile una sequenza di argomenti, perché il modulo gestisce escaping e quoting. Contiene inoltre un avviso che invita a leggere la sezione Security Considerations prima di usare shell=True. Raccomanda anche un percorso completo per l'eseguibile, che elimina la ricerca tramite PATH sfruttata nell'esempio di make visto sopra.
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)
Il pattern rifiuta già un trattino iniziale, quindi -- sembra ridondante. È invece defence in depth: se qualcuno in seguito allentasse la regex, il separatore continuerebbe a impedire che name venga letto come opzione.
In .NET usate un percorso fisso e completo in FileName, impostate UseShellExecute = false e aggiungete ogni argomento tramite ArgumentList. Non passate mai input non attendibile a cmd.exe, powershell.exe o sh -c. Separare gli argomenti non equivale a validarli, né in Java né in .NET.
Poi validate i valori. Verificate il comando rispetto a un elenco di comandi consentiti e ciascun argomento rispetto a valori consentiti oppure a un pattern con lunghezza massima, tenendo fuori metacaratteri e spazi.
Per curl -- non basta, perché gli argomenti successivi restano comunque operandi di tipo URL e sono possibili ulteriori trasferimenti. Passate esattamente un solo URL validato, usate --globoff e applicate protezioni contro SSRF.
Eseguite l'applicazione con i privilegi minimi di cui ha bisogno e assegnate ai job con un singolo compito un account isolato dedicato. Una sandbox come chroot, AppArmor o SELinux aiuta, ma CWE-78 ne giudica l'efficacia limitata. Restringe ciò che un attaccante può raggiungere, ma non elimina il difetto.
Da dove cominciare
- Eseguite le ricerche sopra su ogni repository, compresi script di deploy, job di CI e wrapper di cron. Elencate ogni risultato con l'input che lo raggiunge e ordinate l'elenco in base al fatto che una richiesta possa raggiungerlo o meno.
- Per ogni risultato, chiedetevi se una funzione di libreria o integrata svolga lo stesso compito. In caso affermativo, sostituite la chiamata ed eliminate l'avvio del processo.
- Riscrivete le chiamate rimanenti usando una lista di argomenti, nessuna shell e un percorso assoluto dell'eseguibile. Aggiungete la validazione tramite allowlist e il separatore
--dove il programma lo supporta. - Eseguite i processi con un account dedicato a bassi privilegi, in modo che un caso trascurato abbia meno margine di manovra.
- Testate ogni chiamata rimanente con un valore come
bob.txt;sleep 5e con un valore che inizia con un trattino. Misurate il tempo di risposta anziché attendere un output. Il valore con il trattino iniziale deve essere rifiutato o trattato come nome di file. Per una verifica esterna, un penetration test proverà entrambi i casi, oppure il nostro team di sviluppo software sicuro può rivedere il codice insieme a Voi.
