Descompactar ZIP em PHP sem zip slip e sem bomba de descompressão
Extraia um ZIP enviado pelo usuário conferindo cada nome, limitando quantidade e bytes de verdade e gravando em quarentena com nome gerado por você.
Um cliente sobe fotos-obra.zip, 300 KB, no sistema da construtora. O código chama extractTo e segue a vida. No meio das fotos há uma entrada chamada ../../public/shell.php, e minutos depois alguém executa comandos no seu servidor pela URL. Nenhum antivírus apitou, porque o ataque está na estrutura do pacote, não num vírus. A resposta curta: leia a lista de entradas antes de gravar qualquer byte, recuse o ZIP inteiro se algum nome tiver caminho absoluto, barra invertida ou .., conte os bytes reais enquanto extrai e grave cada arquivo numa pasta de quarentena, fora do site, com nome gerado por você.
O exemplo deste guia é exatamente esse: o portal da construtora recebe ZIPs com fotos JPG e PNG e laudos em PDF. Nada além disso deve sair do pacote.
Por que extrair "do jeito simples" é perigoso
Um ZIP é uma lista de entradas, e cada entrada tem um nome escolhido por quem montou o arquivo. Nada obriga esse nome a ser só foto.jpg. Ele pode ser ../../config.php, /etc/cron.d/tarefa ou C:/inetpub/wwwroot/web.config. Quando o código de extração junta "pasta de destino + nome da entrada" sem conferir, o arquivo é gravado onde o atacante quiser. Esse ataque ficou conhecido como zip slip.
O ZipArchive::extractTo tenta neutralizar parte desses caminhos, mas não conte com isso como sua única defesa. E isso não resolve o resto: o método não limita quantidade de arquivos, não limita o tamanho descompactado e não sabe quais extensões o seu produto aceita. E bibliotecas de terceiros ou um unzip chamado por exec têm comportamentos próprios.
A bomba de descompressão
O segundo ataque é o tamanho. A compressão de dados repetidos é absurdamente eficiente: um arquivo de 1 GB só de zeros cabe em cerca de 1 MB. O caso clássico, o 42.zip, tem 42 KB e, com ZIPs aninhados, se expande para petabytes. O limite de upload de 20 MB que você configurou olha o arquivo compactado, e o disco enche na extração.
O tamanho descompactado que aparece no índice do ZIP é declarado por quem montou o arquivo. Use-o para recusar cedo, mas o limite que vale é a contagem dos bytes que você efetivamente escreveu.
Como extrair em duas passadas
- Abra o ZIP com
ZipArchivee recuse se houver mais entradas do que o razoável para o seu caso. - Na primeira passada, só leia: para cada entrada, valide o nome, a extensão e some o tamanho declarado.
- Se qualquer nome for suspeito, recuse o ZIP inteiro. Pacote honesto não traz
... - Na segunda passada, copie cada entrada por stream, em blocos, contando os bytes e abortando ao passar do limite.
- Grave na quarentena com nome aleatório e a extensão validada, guardando o nome original só como dado de exibição.
- Se algo falhar no meio, apague tudo o que foi gravado nessa extração.
<?php
// extrair-zip.php
const LIMITE_ENTRADAS = 300;
const LIMITE_BYTES = 100 * 1024 * 1024; // 100 MB descompactados
const EXTENSOES = ['jpg', 'jpeg', 'png', 'pdf'];
function nomeAceitavel(string $nome): bool
{
if ($nome === '' || str_contains($nome, chr(0)) || str_contains($nome, chr(92))) {
return false; // vazio, byte nulo ou barra invertida
}
if (str_starts_with($nome, '/') || preg_match('/^[A-Za-z]:/', $nome)) {
return false; // caminho absoluto ou letra de unidade
}
return !in_array('..', explode('/', $nome), true);
}
function extrairZip(string $arquivoZip, string $quarentena): array
{
$zip = new ZipArchive();
if ($zip->open($arquivoZip) !== true) {
throw new RuntimeException('Arquivo ZIP inválido');
}
if ($zip->numFiles > LIMITE_ENTRADAS) {
throw new RuntimeException('ZIP com arquivos demais');
}
$entradas = [];
$declarado = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$info = $zip->statIndex($i);
$nome = $info['name'];
if (!nomeAceitavel($nome)) {
throw new RuntimeException('Caminho suspeito no ZIP');
}
if (str_ends_with($nome, '/')) {
continue; // entrada de pasta: não criamos a estrutura do pacote
}
$ext = strtolower(pathinfo($nome, PATHINFO_EXTENSION));
if (!in_array($ext, EXTENSOES, true)) {
throw new RuntimeException('Tipo de arquivo não aceito no ZIP');
}
$declarado += $info['size'];
$entradas[] = ['nome' => $nome, 'ext' => $ext];
}
if ($declarado > LIMITE_BYTES) {
throw new RuntimeException('Conteúdo descompactado grande demais');
}
$gravados = [];
$total = 0;
try {
foreach ($entradas as $e) {
$origem = $zip->getStream($e['nome']);
if ($origem === false) {
throw new RuntimeException('Falha ao ler entrada do ZIP');
}
$novo = bin2hex(random_bytes(16)) . '.' . $e['ext'];
$caminho = $quarentena . '/' . $novo;
$destino = fopen($caminho, 'xb');
$gravados[] = ['original' => $e['nome'], 'arquivo' => $novo, 'caminho' => $caminho];
while (!feof($origem)) {
$bloco = fread($origem, 65536);
if ($bloco === false) {
throw new RuntimeException('Erro de leitura no ZIP');
}
$total += strlen($bloco);
if ($total > LIMITE_BYTES) {
throw new RuntimeException('Limite de bytes estourado na extração');
}
fwrite($destino, $bloco);
}
fclose($origem);
fclose($destino);
}
} catch (Throwable $erro) {
foreach ($gravados as $g) {
if (is_file($g['caminho'])) {
unlink($g['caminho']);
}
}
throw $erro;
} finally {
$zip->close();
}
return $gravados;
}
Repare em três decisões. As funções chr(0) e chr(92) representam o byte nulo e a barra invertida sem ambiguidade de escape. O modo xb do fopen falha se o arquivo já existir, então nada é sobrescrito. E o nome que vai para o disco nunca vem do ZIP: o caminho do pacote vira só um texto que você mostra na tela, escapado.
Quarentena antes do público
A pasta de quarentena fica fora da raiz do site, por exemplo /var/app/storage/quarentena. Ali, cada arquivo passa pela checagem de conteúdo: o JPG precisa ser JPG de verdade, e o PDF, PDF. A extensão bateu, mas a extensão é escolha de quem enviou, como mostra MIME type: a extensão não diz o que tem dentro. Para imagens, regravar com uma biblioteca gráfica elimina lixo escondido, técnica explicada em arquivo enviado vira imagem de novo.
Só depois disso o arquivo vai para o armazenamento definitivo. E o ZIP original é apagado quando a extração termina, com sucesso ou não. Guardar o pacote "por garantia" é guardar para sempre um arquivo que você nunca inspecionou.
Como testar com um ZIP malicioso
Não espere o ataque para descobrir se a defesa funciona. O Python monta um ZIP com caminho que sobe de pasta em uma linha:
python3 -c "import zipfile; z = zipfile.ZipFile('teste-slip.zip', 'w'); z.writestr('../../public/shell.php', 'teste'); z.writestr('fotos/frente.jpg', 'x'); z.close()"
Envie esse arquivo pelo formulário. O esperado é a mensagem "Caminho suspeito no ZIP" e nenhum arquivo novo em lugar nenhum. Para a bomba, gere um arquivo grande de zeros, compacte e envie:
head -c 300M /dev/zero > zeros.pdf
zip bomba.zip zeros.pdf
ls -lh bomba.zip
O ZIP resultante tem menos de 1 MB, passa pelo limite de upload e precisa ser barrado pelo limite de 100 MB descompactados.
Esses testes cabem em CI: um teste automatizado que chama extrairZip com os dois arquivos e espera exceção.
Erros comuns
- Confiar no
extractTocomo validação. Mesmo que ele normalize caminhos, extrai qualquer extensão, em qualquer quantidade, e o PHP enviado vai parar numa pasta que pode ser servida. - Extrair dentro da raiz pública. Um
.phpou.phtmldentro do pacote vira código executável por URL. - Limitar só o tamanho do upload. A bomba tem poucos KB compactada e enche o disco na extração, derrubando banco e logs junto.
- Confiar no tamanho declarado no índice. Ele é escrito por quem montou o ZIP. Sem contar os bytes reais, o limite é decorativo.
- Manter o nome original no disco. Nomes com espaço, acento, caracteres de controle ou extensão dupla como
foto.jpg.phpcausam desde quebra de link até execução. - Deixar arquivos parciais após erro. A extração falha no meio e metade do pacote fica na quarentena para sempre, sem dono.
Checklist
- O ZIP é recusado se qualquer entrada tiver
.., barra invertida, byte nulo, barra inicial ou letra de unidade. - Existe limite de quantidade de entradas e de bytes descompactados, contados na escrita.
- Só as extensões do produto são aceitas, e o conteúdo é conferido depois.
- Cada arquivo é gravado com nome aleatório, numa quarentena fora da raiz do site.
- Uma falha no meio apaga tudo o que foi gravado naquela extração.
- O ZIP original é apagado ao final.
- Os testes com
teste-slip.zipebomba.ziprodam e falham do jeito esperado.
Os limites de tamanho também dependem do PHP e do servidor web, como mostra limite de upload de verdade. Agora pegue o código que extrai ZIP no seu sistema e procure a linha que define o limite de bytes descompactados: ela existe, e ela conta o que foi escrito ou o que o pacote declarou?
Referência: http://www.mauricioprogramador.com.br/posts/descompactar-arquivo-com-php