Travessia de caminho no tarfile.extractall do Python (CVE-2007-4559): como acontece e como corrigir
Antes dos filtros de extração, tarfile.extractall() gravava cada membro no caminho guardado no arquivo compactado, então nomes com .., caminhos absolutos ou links podiam pôr arquivos fora da pasta de destino. Como o argumento filter="data" da PEP 706 bloqueia isso, por que o destino não pode vir da entrada do usuário e quais limites de tamanho adicionar, com um exemplo em Python.
Explicação em Linguagem Simples (ELI5)
Uma empresa de mudanças desembala caixas no seu novo apartamento e coloca cada item onde a etiqueta mandar. Uma etiqueta que diz 'cozinha, depois pela porta da frente, depois no quarto do vizinho' é seguida ao pé da letra, porque os carregadores só leem etiquetas e nunca conferem se o endereço ainda fica dentro do seu apartamento. Um arquivo compactado funciona igual: cada arquivo traz o próprio caminho de destino. A correção é um encarregado que lê cada etiqueta antes e recusa tudo o que sairia do apartamento, além de qualquer coisa que não seja uma caixa comum, como uma placa apontando para outro lugar.
Conceitos Centrais e Termos
- CVE-2007-4559
- O módulo
tarfiledo Python não impedia que nomes de membros saíssem do diretório de extração. O problema era conhecido desde 2007, e em 2022 a Trellix estimou que cerca de 350 mil projetos de código aberto chamavamextractall()em arquivos não confiáveis. - Nomes de membro com .. ou caminhos absolutos
- Uma entrada tar chamada
../outside.txtou/etc/cron.d/jobresolve para fora do destino quando é juntada a ele. Sem filtro,extractall()grava nesse caminho resolvido com as permissões do processo. - Membros que são links simbólicos ou físicos
- Um arquivo compactado pode primeiro criar dentro do destino um link que aponta para outro lugar e depois gravar um arquivo comum através dele. Verificar só os nomes não pega isso, e por isso os filtros também inspecionam o alvo dos links.
- Filtros de extração (PEP 706)
filter="data"rejeita nomes absolutos, escapes com.., links que saem do destino e arquivos de dispositivo, e remove os bits set-user-ID e de execução para outros. Ele existe a partir do Python 3.12 e nas versões de segurança do 3.8 ao 3.11, e passa a ser o padrão no Python 3.14.- Limites de recursos
- Os filtros não limitam tamanho. Um arquivo compactado pequeno pode se expandir em milhões de membros ou muitos gigabytes, então conte os membros e o tamanho total antes de extrair.
Fluxo de Ataque Passo a Paso
O app aceita um arquivo compactado enviado
Uma importação de dados, instalador de plugins ou manipulador de artefatos de CI recebe um .tar.gz de um usuário.
Um nome de membro aponta para fora da pasta
O caminho guardado de uma entrada sobe para fora do destino ou é absoluto. Um arquivo de teste com um membro chamado ../outside.txt ilustra o caso.
O extractall grava no caminho resolvido
O arquivo vai parar onde o nome resolver, e o próprio destino pode vir de um job_id enviado pelo usuário.
Arquivos da aplicação são substituídos
Configurações, templates ou scripts sobrescritos rodam na próxima vez que a aplicação os carregar, e é assim que essa travessia de caminho vira execução de código.
Código-Fonte: Vulnerável vs. Seguro
# imports.py: cada membro vai para onde o nome guardado apontar
import tarfile
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
# pasta escolhida por quem chama, sem filtro, sem limites de tamanho
def import_bundle(archive_path: str, job_id: str) -> Path:
dest = UPLOAD_ROOT / job_id
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path) as tar:
tar.extractall(dest)
return dest
# imports.py: filtro data, pasta com nome do servidor e limites de tamanho
import tarfile
import uuid
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024
# rejeita arquivos com membros ou bytes demais
def check_limits(tar: tarfile.TarFile) -> None:
members = tar.getmembers()
if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
raise ValueError("archive too large")
# filter='data' bloqueia .., nomes absolutos, links para fora e arquivos de dispositivo
def import_bundle(archive_path: str) -> Path:
dest = UPLOAD_ROOT / uuid.uuid4().hex
dest.mkdir(parents=True)
with tarfile.open(archive_path, mode="r:*") as tar:
check_limits(tar)
tar.extractall(dest, filter="data")
return dest
Lista de Verificação de Segurança para Engenharia
- Passe
filter="data"em toda chamada aextractall()eextract(), e falhe o CI quando o Bandit B202 ou uma regra Semgrep encontrar uma semfilter=. - Extraia numa pasta nova com nome definido pelo servidor, como um UUID, nunca num caminho montado com dados da requisição.
- Verifique a quantidade de membros e o tamanho total descompactado antes de extrair, e rejeite arquivos acima dos limites.
- Rode o worker de extração com um usuário sem permissão de escrita no código ou na configuração da aplicação.
- Adicione um teste no CI com um arquivo que contenha
../outside.txte um membro com caminho absoluto, esperandotarfile.FilterError.