Path Traversal: Como ../../settings.py Escapa do Seu Diretório de Mídia — e Como o safe_join do Django Impede

Path Traversal: Como ../../settings.py Escapa do Seu Diretório de Mídia — e Como o safe_join do Django Impede

Django Security Series — Post 9 | Série II: Broken Access Control
OWASP A01:2025 — Broken Access Control | Tempo de leitura: ~15 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um endpoint de "baixar um documento" que abre o arquivo nomeado em ?file=. A view com os.path.join deixa ?file=../flag.txt escalar para fora do diretório de documentos e ler uma flag que nunca deveria alcançar; a view com safe_join levanta SuspiciousFileOperation e retorna 404. Reproduza os dois pela linha de comando com curl — e veja a reviravolta na detecção: os scanners padrão dão um falso verde aqui, então o laboratório traz a regra customizada que de fato pega código realista.

Os Posts 6 a 8 trataram de falhas de controle de acesso sobre objetos no banco de dados. O atacante trocou um ID para ler a petição de outra pessoa (Post 6), injetou is_staff para se promover (Post 7) ou enganou o navegador da vítima para submeter uma requisição forjada (Post 8). Em cada caso o "recurso" sob ataque era uma instância de modelo, uma linha no PostgreSQL. O Post 9 move a mesma falha para o sistema de arquivos.

Enquanto pesquisava este post, rodei os.path.join('/app/media', '/etc/passwd') num shell Python e recebi /etc/passwd. O diretório base simplesmente sumiu. Eu sempre assumi que o primeiro argumento ancorava o resultado. Não ancora — se qualquer componente subsequente for um caminho absoluto, os.path.join descarta silenciosamente tudo antes dele. Essa sessão no shell mudou como eu penso sobre toda view de servir arquivos que já escrevi. É o tipo de bug latente que se esconde em código de produção comum — um nome de arquivo hardcoded alimentando os.path.join hoje, a um refactor de distância do perigo no momento em que esse nome passa a vir de um parâmetro da URL. Manter uma plataforma real de petições é o que torna esse modo de falha concreto em vez de acadêmico.

Path traversal fica sob A01:2025 (Broken Access Control) porque o servidor entrega um arquivo que o usuário nunca foi autorizado a ler: o "objeto" é um arquivo em disco em vez de uma linha no banco, recuperado sem verificar se está dentro do limite permitido. É disso que este post trata: a distância entre o diretório que uma view de download pretende servir e os caminhos que ela de fato abre.


O Ataque: O Que É e Como Funciona

Path traversal explora a forma como sistemas operacionais resolvem segmentos relativos de caminho. Quando um caminho contém ../, o sistema de arquivos se move um diretório acima. Se uma aplicação web constrói um caminho de arquivo juntando um diretório base com um nome fornecido pelo usuário, e o usuário fornece ../../../etc/passwd em vez de report.pdf, o caminho resultante resolve fora do diretório pretendido. A aplicação abre um arquivo que nunca deveria servir.

É a mesma causa raiz de toda injeção desta série: um input que a aplicação trata como dado age como instrução, aqui uma instrução do sistema de arquivos. E os.path.join não se importa. Ele é um juntador de strings: os.path.join('/app/media', '../../etc/passwd') retorna '/app/media/../../etc/passwd' com os pontos-pontos ainda na string, e o kernel os resolve depois, quando open() percorre o caminho. O payload vem em duas formas. Travessia relativa usa sequências ../ para escalar para fora. Injeção de caminho absoluto é pior: se qualquer componente começa com / (ou, no Windows, uma letra de drive como C:\), os.path.join descarta tudo antes dele, então os.path.join('/app/media', '/etc/passwd') é '/etc/passwd'. A documentação do Python diz isso explicitamente; ainda assim surpreende desenvolvedores que assumiram que a base sempre seria prefixada.

O alvo típico é um endpoint de download ou de servir arquivos que pega um nome de arquivo da URL ou de um parâmetro de query: /download/?file=report.pdf ou /media/serve/<filename>. O atacante troca o nome por ?file=../../../manage.py, ?file=../../../../etc/passwd ou ?file=../settings.py, e lê o que o processo do servidor web conseguir ler.

Os alvos de alto valor num servidor Django são previsíveis. settings.py contém SECRET_KEY, credenciais de banco de dados, chaves AWS e tokens de API. Ler SECRET_KEY permite ao atacante forjar qualquer coisa que o django.core.signing assine com essa chave (cookies assinados, tokens com timestamp), forjar links de reset de senha se também souber o hash da senha e o horário do último login do usuário, forjar uma sessão autenticada de qualquer usuário cujo hash de senha também tenha se o projeto usa o backend de sessão signed_cookies, e adulterar o framework de messages baseado em cookies. /etc/passwd confirma nomes de usuário. Arquivos .env guardam credenciais que python-decouple ou django-environ leem na inicialização. Em servidores Linux, /proc/self/environ (o ambiente do processo, que frequentemente carrega segredos) e /proc/self/cmdline (o comando de inicialização) transformam o sistema de arquivos numa superfície de coleta de informações.

O path traversal chega aos arquivos por outros caminhos também. Um nome de template vindo da requisição pode escalar do mesmo jeito, embora os template loaders padrão do Django recusem qualquer nome que resolva fora dos seus diretórios. Um symlink dentro da pasta servida pode apontar para fora dela, o que uma verificação sobre o texto do caminho não percebe. E o nome de um arquivo enviado é um caminho que o atacante escolhe, que o Django verifica desde a correção da CVE-2021-31542.


Incidentes Reais

Fortinet FortiOS SSL VPN — CVE-2018-13379 (2018–2020)

Em maio de 2019, a Fortinet publicou o advisory FG-IR-18-384 para a CVE-2018-13379, uma vulnerabilidade de path traversal no portal web do SSL VPN do FortiOS (versões afetadas: FortiOS 6.0.0–6.0.4, 5.6.3–5.6.7, 5.4.6–5.4.12; corrigida em 6.0.5, 5.6.8, 5.4.13). Os pesquisadores da Devcore, Meh Chang e Orange Tsai, publicaram detalhes técnicos e apresentaram o exploit no Black Hat USA 2019 em agosto. A falha estava no handler de arquivo de idioma do portal SSL VPN (/remote/fgt_lang?lang=…): uma requisição HTTP especialmente elaborada permitia que um atacante não autenticado lesse arquivos arbitrários do appliance, incluindo sslvpn_websession, que continha nomes de usuário e senhas em texto plano de usuários VPN ativos.

Apesar da Fortinet liberar patches, a exploração foi ampla. Em novembro de 2020, dois atores separados publicaram dados de exploração em fóruns de hacking: um (“pumpedkicks”) postou 49.577 endereços IP de FortiGate vulneráveis com one-liners de exploit em 19 de novembro, e outro (“arendee2018”) postou dumps de credenciais extraídas de sslvpn_websession por volta de 25 de novembro. A CISA adicionou a CVE-2018-13379 ao seu catálogo de Vulnerabilidades Exploradas Conhecidas. Segundo o advisory conjunto AA21-321A (FBI, CISA, ACSC, NCSC, novembro de 2021), atores patrocinados pelo estado iraniano escanearam em busca da vulnerabilidade e, na avaliação do advisory, provavelmente a exploraram para obter acesso inicial a redes governamentais e de infraestrutura crítica. A vulnerabilidade recebeu pontuação 9.8 CRITICAL no CVSSv3 (NVD) — não autenticada, explorável pela rede, sem interação do usuário necessária. Um único parâmetro não validado em uma requisição HTTP, uma travessia ../ estendida até que o buffer de tamanho fixo do handler cortasse o .json que ele acrescentava, expôs as credenciais VPN de dezenas de milhares de dispositivos. No Reino Unido, o NCSC orientou organizações com dispositivos Fortinet VPN sem patches a assumir comprometimento, remover os dispositivos de serviço e iniciar resposta a incidentes.

Em termos de ATT&CK, a travessia em si é T1190 — Exploit Public-Facing Application; uma vez que o appliance estava lendo arquivos arbitrários, o ataque encadeou em T1083 — File and Directory Discovery (enumerar o sistema de arquivos), T1005 — Data from Local System (a leitura) e T1552.001 — Credentials In Files — que é exatamente o que sslvpn_websession era: um arquivo cheio de credenciais em texto plano, a mesma classe de colheita que uma travessia no Django realiza ao ler settings.py ou um .env.

Fonte: NVD — CVE-2018-13379: Fortinet FortiOS Path Traversal (CVSS 9.8)


Proteções Padrão do Django

Pergunte "o Django me protege de path traversal?" e a resposta honesta é: depende de como você serve arquivos.

Se a recuperação passa pelo FileField ou ImageField de um modelo (que é o que deveria fazer), o nome do arquivo nunca é um caminho bruto do usuário. O upload_to do FileField controla o diretório, o backend de storage controla onde o arquivo fica, e a recuperação passa por FieldFile.open() com o nome que o modelo guardou. O usuário nunca fornece um caminho no sistema de arquivos. Ele interage com uma instância de modelo, e o modelo guarda a chave de storage. Este é o padrão IDOR do Post 6 aplicado a arquivos: o "identificador" é uma chave primária ou UUID do banco de dados, não um nome de arquivo, e o queryset é escopado para objetos autorizados. Path traversal na recuperação não pode acontecer porque nenhum caminho fornecido pelo usuário chega ao sistema de arquivos. Entregue um nome vindo da requisição direto a default_storage.open() e esse desenho se perde: o FileSystemStorage ainda contém o nome com safe_join, mas um backend customizado não precisa fazer isso.

O perigo surge quando um desenvolvedor sai da abstração de storage: um endpoint de download customizado que pega um nome de arquivo da URL, uma view que constrói um caminho com os.path.join(MEDIA_ROOT, request.GET['file']), ou uma view de servir arquivos que abre um caminho controlado pelo usuário. É aí que a utilidade dedicada do Django importa.

django.utils._os.safe_join faz o que os.path.join não faz: depois de juntar a base e o componente fornecido pelo usuário, ele resolve o resultado para um caminho absoluto e verifica que ainda começa com a base. Se não começa, se sequências ../ ou um caminho absoluto escaparam do sandbox, ele levanta SuspiciousFileOperation, uma subclasse de SuspiciousOperation que o tratamento de erros padrão do Django transforma num 400. O FileSystemStorage (e, portanto, o StaticFilesStorage e o collectstatic), o django.views.static.serve apenas para desenvolvimento e os template loaders de sistema de arquivos o chamam. A verificação é simples e eficaz, com uma ressalva: safe_join vive em django.utils._os, um módulo privado (underscore inicial). Não há entrada de referência de API pública, nenhum ciclo de depreciação e nenhuma garantia de compatibilidade retroativa. A primitiva de segurança de caminho mais útil do Django é uma que ele não promete manter.

O que o Django NÃO protege automaticamente:

  • Qualquer view customizada que constrói um caminho de arquivo a partir de input do usuário usando os.path.join, pathlib.Path ou concatenação de strings.
  • Qualquer view que passa um nome fornecido pelo usuário para open(), FileResponse() ou Path.read_bytes() sem validação de caminho.
  • Comandos de management ou tasks Celery que processam nomes de arquivo fornecidos pelo usuário do banco de dados.

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

Padrão 1 — os.path.join com um Nome de Arquivo da Requisição

A view de download do laboratório é o exemplo clássico: ela abre o que ?file= nomear sob uma raiz de documentos. A correção substitui o join:

     name = request.GET.get("file", "")
     ...
-    path = os.path.join(str(DOCS_ROOT), name)
+    try:
+        path = safe_join(str(DOCS_ROOT), name)
+    except SuspiciousFileOperation:
+        raise Http404()
     try:
         data = open(path, "rb").read()
     except OSError:
         raise Http404()
     return HttpResponse(data, content_type="text/plain")

Na view vulnerável, ?file=../flag.txt sobe de documents/ para o diretório pai e retorna a flag, e o caminho absoluto do arquivo da flag também funciona, porque os.path.join descarta a base. Na view segura, os dois payloads levantam SuspiciousFileOperation e respondem 404, enquanto ?file=readme.txt continua sendo servido. As duas views estão no laboratório: views_vulnerable.py e views_secure.py. Num projeto a base é MEDIA_ROOT: com MEDIA_ROOT = BASE_DIR / 'media', ?file=../../projectname/settings.py contra uma subpasta documents/ sobe até BASE_DIR e lê o módulo de settings, e uma verificação os.path.exists antes do open() não muda nada, porque o arquivo existe.

Capture SuspiciousFileOperation e responda 404 em vez de deixá-la passar: sem captura, o Django responde um 400 genérico, que diz ao atacante que a verificação disparou, enquanto um 404 é indistinguível de "o arquivo não existe". Capturá-la tem um custo que um analista deve conhecer: o Django registra uma SuspiciousFileOperation não capturada, com o caminho resolvido, no logger django.security.SuspiciousFileOperation, e o except pula esse registro, então registre a tentativa você mesmo antes de levantar Http404.

Padrão 2 — Um Nome de Arquivo na URL em Vez de um Registro

A correção mais forte tira o nome do arquivo da requisição por completo:

-def serve_report(request, filename):
-    path = os.path.join(settings.MEDIA_ROOT, filename)
-    return FileResponse(open(path, 'rb'))
+@login_required
+def download_document(request, document_id):
+    doc = get_object_or_404(Document, pk=document_id, owner=request.user)
+    return FileResponse(doc.file.open('rb'), as_attachment=True,
+                        filename=os.path.basename(doc.file.name))

O usuário fornece um identificador que mapeia para uma instância de modelo, e o FileField do modelo resolve o caminho de storage internamente, então não há caminho para atravessar. Ela também herda a proteção contra IDOR do Post 6: o queryset é escopado para owner=request.user, então a verificação de autorização e a garantia de segurança de caminho vêm da mesma linha. Este é o padrão que o Petição Brasil usa para os PDFs de assinatura: o usuário fornece um UUID, a view busca a Signature, verifica se quem pede é o criador da petição, o signatário ou staff, e pede ao storage privado do campo uma URL pré-assinada do S3 (signature.signed_pdf.url). Nenhuma string fornecida pelo usuário toca um caminho no sistema de arquivos.

Quando o endpoint realmente precisa aceitar um nome (um de um conjunto conhecido de relatórios, por exemplo), mapeie uma chave para um caminho definido no servidor, ALLOWED_REPORTS = {'q1-2026': 'reports/q1_2026_summary.pdf'}, para que ../../settings.py não corresponda a nenhuma entrada e responda 404. Onde nem isso for prático, exija um padrão estrito como \A[A-Za-z0-9_-]+\.[A-Za-z0-9]+\Z, que não admite /, \, .. nem byte nulo, e ainda construa o caminho com safe_join: a regex pega os payloads óbvios, e o safe_join pega o que ela deixar passar.


A Visão do Analista

Para um analista CySA+, path traversal é um lembrete útil de que um scan limpo não é o mesmo que uma aplicação segura. A CWE-22 é, no fundo, uma falha de canonicalização — o servidor decide qual arquivo servir antes de resolver para onde o caminho realmente aponta — e bugs de canonicalização são notoriamente difíceis para o SAST baseado em padrões enxergar, porque o defeito é a ausência de uma etapa de resolver-e-conter, não uma chamada perigosa que se possa casar. Como a seção de detecção abaixo mostra, as ferramentas padrão não retornam nada na forma realista aqui, e uma delas retorna um falso verde que é pior que o silêncio. Um analista que lê "0 achados" como "sem path traversal" leu a ferramenta errado.

Isso desloca o peso da detecção para o julgamento humano e o teste dinâmico: uma revisão de código dirigida a cada view que serve arquivos (grep por os.path.join, open() e FileResponse alimentados pela requisição) e uma sonda ../ contra o endpoint em execução. Esta é a lição recorrente da série de controle de acesso — autorização e contenção são propriedades que um scanner raramente consegue confirmar, então pertencem ao checklist de modelagem de ameaças, não apenas ao pipeline de CI.


Detectando Automaticamente

Testando Sua Defesa

Um único teste prova a correção contra este payload: uma requisição ../ à view segura precisa responder 404 sem a flag. É o que o laboratório usa:

def test_secure_view_blocks_relative_traversal(self):
    resp = self.client.get("/path-traversal/secure/", {"file": RELATIVE_PAYLOAD})
    # safe_join raised SuspiciousFileOperation, turned into a generic 404.
    self.assertEqual(resp.status_code, 404)
    self.assertNotContains(resp, "FLAG{", status_code=404)

RELATIVE_PAYLOAD é ../flag.txt, e o setUp planta documents/readme.txt mais o arquivo da flag um nível acima. Aponte o teste para /path-traversal/vulnerable/ e ele falha: a resposta é 200 e traz a flag. O tests.py do laboratório acrescenta o payload de caminho absoluto contra as duas views e a metade que mantém a correção honesta: a view segura ainda serve readme.txt, sem o que uma view que rejeita tudo passaria em todas as verificações de rejeição. Payloads com percent-encoding precisam de uma query string bruta no teste (?file=..%2f..%2fsettings.py): passar '..%2f..' no dicionário de dados recodifica o %, e a view nunca vê uma travessia.

Fazendo a Varredura

Eu esperava que este fosse um scan fácil. Path traversal via os.path.join é um bug de manual, então apontei as ferramentas de sempre para as duas views do laboratório assumindo que ambas acenderiam a vulnerável. Em vez disso recebi um atestado de saúde limpo sobre código que eu sei que vaza a flag — e caçar o porquê virou a coisa mais útil deste post.

Bandit (um lembrete rápido: ele percorre a AST do Python procurando chamadas arriscadas numeradas com B — shell=True, eval, yaml.load) reporta zero achados nas duas views. Isso não é um problema de tuning: a única verificação de path traversal do Bandit, a B202, cobre tarfile.extractall, não um caminho montado a partir da requisição, e ele não faz dataflow, então não tem como ligar request.GET → os.path.join → open. Nesta forma o Bandit é simplesmente cego.

Semgrep community (casamento estrutural de padrões; seu motor OSS segue taint apenas dentro de uma função) reporta zero também — de p/django, p/python e p/owasp-top-ten juntos. Isso me surpreendeu mais, porque o Semgrep tem uma regra Django de path traversal: python.django.security.injection.path-traversal.path-traversal-join. Duas coisas a impedem de ajudar, e ambas merecem a atenção de um analista:

  • Ela não está nos pacotes curados. Seus metadados a marcam como subcategory: audit, confidence: LOW, e nenhum dos três pacotes a inclui, como as regras de @csrf_exempt e mark_safe mais cedo nesta série. Você tem que pedir por ela pelo nome.
  • Mesmo pedida pelo nome, ela erra código realista. Ela não é uma regra de taint, e sim uma lista de formas fixas, e sua única forma de dois saltos lê o nome do arquivo como request.$W.get($X), uma chamada com um argumento. O laboratório, como a maioria das views de download, lê com um valor padrão, request.GET.get("file", ""), então a forma nunca casa.

Ler o nome do arquivo com um valor padrão, montar o caminho em outra variável e então abri-lo é um jeito comum de escrever uma view de download, e a regra fica em silêncio nele. Tire o valor padrão, request.GET.get("file"), e a mesma cadeia de dois saltos dispara; acrescente um terceiro passo e nada casa. Então a regra pronta dá um falso verde: dispara numa grafia da cadeia e fica quieta na do laboratório. Esta seção explicava a falha de outro jeito, como um limite do motor: o motor OSS "só segue uma indireção de variável", e ligar dois saltos exige o motor Pro do Semgrep. Eu estava errado. As sondas que separam as duas explicações, a leitura com e sem valor padrão, estão capturadas em scans/semgrep.txt no laboratório.

Um falso verde é pior que nenhum sinal, porque convida você a confiar nele. Por isso o laboratório traz uma pequena regra Semgrep customizada, rules/path_traversal.yaml, escrita em modo taint, que o motor gratuito roda dentro de uma função: tudo o que é lido de request, ou um parâmetro de URL na assinatura da view, é uma fonte; um caminho entregue a open(), ou um cujo .open(), .read_bytes() ou .read_text() é chamado, é um sink; safe_join(), uma busca de registro e uma verificação is_relative_to() sobre um caminho resolvido que levanta exceção são os sanitizadores. Ela dispara na view vulnerável, em cadeias mais longas e nas leituras com pathlib, e fica em silêncio na correção com safe_join, na busca de registro do Padrão 2 e num join de nomes hardcoded. Seus limites estão documentados na fixture: um nome que a view validou sozinha de qualquer outro jeito, com uma regex de allowlist por exemplo, ainda dispara, porque o Semgrep não consegue ler a verificação; e o taint não atravessa funções, então um nome lido numa função e aberto em outra passa despercebido.

grep -rn "os.path.join" --include="*.py" .   # then read every open()/FileResponse() fed from the request

# the standard tools — miss it
bandit -r labs/post_09_path_traversal/views_vulnerable.py labs/post_09_path_traversal/views_secure.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_09_path_traversal/views_vulnerable.py

# the custom taint rule — catches it
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_vulnerable.py   # 1 finding
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_secure.py       # 0 findings

# and the dynamic half, against the running lab
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8000/path-traversal/secure/?file=../flag.txt"   # 404; /vulnerable/ answers 200

A lição prática para uma revisão de código: não despreze o grep. O grep acima, mais um olhar em cada open() / FileResponse() que recebe um nome da requisição, encontra esta classe mais rápido que um scan de SAST que não reporta nada, e um payload ../ contra um endpoint em execução prova a correção. Um job de CI hermético verifica o par dispara/silencia da regra customizada a cada push.


O hábito que este post me deixou: um nome vindo de uma requisição nunca chega a open() a não ser por safe_join, e, onde posso, a requisição leva o ID de um registro em vez de um nome de arquivo.

O Post 10 encerra a Série II com Mass Assignment, onde o atacante escreve campos que o desenvolvedor nunca expôs, transformando um endpoint de edição de perfil numa superfície de escrita em todo o banco de dados.

Leitura Complementar

← Voltar para a série