Programação Por Equipe DevBras

Capa do artigo: Descompactar ZIP em PHP sem zip slip e sem bomba de descompressão

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

  1. Abra o ZIP com ZipArchive e recuse se houver mais entradas do que o razoável para o seu caso.
  2. Na primeira passada, só leia: para cada entrada, valide o nome, a extensão e some o tamanho declarado.
  3. Se qualquer nome for suspeito, recuse o ZIP inteiro. Pacote honesto não traz ...
  4. Na segunda passada, copie cada entrada por stream, em blocos, contando os bytes e abortando ao passar do limite.
  5. Grave na quarentena com nome aleatório e a extensão validada, guardando o nome original só como dado de exibição.
  6. 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 extractTo como 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 .php ou .phtml dentro 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.php causam 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.zip e bomba.zip rodam 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?

Avalie este artigo

Referência: http://www.mauricioprogramador.com.br/posts/descompactar-arquivo-com-php