Artigo Técnico

Validação ZIP EOCD para Ficheiros XLSX Não Fiáveis em Delphi

Um ficheiro xlsx é um arquivo ZIP, e o ZIP não tem uma única tabela de conteúdos autoritativa. O HotXLS Excel Library para Delphi e C++Builder trata essa ambiguidade como uma superfície de ataque: o seu analisador de fim de diretório central só aceita um registo candidato depois de quatro verificações cruzadas independentes concordarem, pelo que um diretório forjado escondido num comentário ZIP nunca vence

O cenário que torna isto concreto é banal. Um servidor aceita uploads de folhas de cálculo de clientes. O ficheiro passa numa verificação antivírus, é escrito num diretório de spool, e o seu serviço Delphi abre-o para extrair três colunas. Está tudo bem, exceto que o scanner e o seu analisador não concordaram sobre o que o arquivo continha. O scanner enumerou um conjunto de membros; o seu carregador enumerou um conjunto diferente a partir dos mesmos bytes. Nenhum deles tem um bug no sentido comum. Resolveram simplesmente uma ambiguidade no formato ZIP em duas direções diferentes, e um atacante escolheu os bytes para que assim fosse

Onde vive realmente a verdade sobre um arquivo ZIP?

Vive mesmo no final, numa estrutura de 22 bytes chamada registo de fim de diretório central. Um ficheiro ZIP não é lido do início para o fim: cada membro transporta um cabeçalho de ficheiro local imediatamente antes dos seus dados comprimidos, mas o índice autoritativo é o diretório central, uma sequência de registos perto do final que nomeia cada entrada e dá o deslocamento do seu cabeçalho local. Para encontrar o diretório central é preciso primeiro encontrar o EOCD, porque o EOCD é o que diz onde o diretório começa e quantos registos contém. O HotXLS modela-o como TEndOfCentralDirectoryRecord, cujos campos mapeiam um para um o layout no disco: FDiskNumber no deslocamento 4, FStartDisk em 6, FThisDiskEntries em 8, FTotalEntries em 10, FSizeOfCD em 12, FOffsetOfStartCD em 16, e FCommentLen em 20. Esse total é FMinSize, calculado no construtor como 4*3 + 5*2. Depois vem o comentário do arquivo, até 65535 bytes de conteúdo arbitrário, o que torna FMaxSize 65557 e significa que o registo não está numa posição fixa. Tem de ir à procura dele

Por que não basta procurar para trás pela assinatura EOCD?

Porque os quatro bytes que está a procurar, PK\005\006, podem aparecer legalmente dentro do comentário do arquivo, dentro de dados comprimidos, ou dentro de um segundo EOCD que um atacante anexou de propósito. Um analisador que para na primeira assinatura que encontra ao percorrer para trás é trivialmente manipulável: coloque um EOCD isco perto do final e o analisador ingénuo segue-o, enquanto um analisador que analisa numa ordem diferente, ou que trata a última assinatura no ficheiro como autoritativa, segue o real. Esta é a família de ataques de ambiguidade ZIP, e a sua recompensa é exatamente a divisão descrita acima, onde o motor de análise e a aplicação consumidora veem conjuntos de entradas diferentes a partir de um ficheiro

TEndOfCentralDirectoryRecord.Parse analisa de facto para trás. Define startscan como o último byte, limita endscan a lsize - FMaxSize ou zero, e percorre a janela em buffers de 256 bytes que se sobrepõem em três bytes para que uma assinatura a cavalo de uma fronteira de buffer nunca seja perdida. A diferença está no que acontece quando há um acerto. Encontrar a assinatura apenas produz um deslocamento Candidate. O HotXLS lê então os 22 bytes nesse deslocamento, analisa-os com ReadEOCD, e exige que os campos resultantes sejam internamente consistentes com o ficheiro que afirmam descrever antes de FOffsetEOCD ser sequer atribuído

Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
  SetLength(RecordBuf, FMinSize);
  inputstream.Position := Candidate;
  if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
  begin
    ReadEOCD(RecordBuf[0], 0);
    if (Candidate + FMinSize + FCommentLen = lsize) and
       (FDiskNumber = 0) and (FStartDisk = 0) and
       (FThisDiskEntries = FTotalEntries) and
       (Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
    begin
      FOffsetEOCD := Candidate;
      Result := FOffsetEOCD;
      Exit;
    end;
  end;
end;

Leia o predicado como quatro afirmações separadas que uma falsificação tem de satisfazer simultaneamente. Candidate + FMinSize + FCommentLen = lsize exige que o comprimento de comentário declarado alcance exatamente o final do ficheiro, que é o que mata o truque do isco no comentário: um EOCD falso enterrado dentro de um comentário real não consegue também contabilizar cada byte depois de si próprio. FDiskNumber = 0 e FStartDisk = 0 rejeitam os campos de expansão multidisco que nenhum xlsx alguma vez usou legitimamente e que existem em arquivos forjados apenas para confundir. FThisDiskEntries = FTotalEntries rejeita o truque de contagem dividida onde um analisador dimensiona o seu ciclo a partir de um campo e outro a partir do outro. E Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate exige que o diretório central termine precisamente onde o EOCD começa, para que o diretório não possa ser apontado para algum blob não relacionado noutro ponto do ficheiro. O cast Int64 nesse último importa: ambos os operandos são de 32 bits, e sem alargamento, um par forjado poderia dar a volta e satisfazer o teste aritmeticamente enquanto aponta para lado nenhum sensato

Os cabeçalhos locais têm de concordar com o diretório central

As verificações EOCD fixam qual diretório é autoritativo; ainda não garantem que o diretório diz a verdade sobre membros individuais. Cada entrada é descrita duas vezes num ficheiro ZIP, uma vez centralmente e uma vez no seu cabeçalho local, e nada no formato obriga as duas descrições a coincidir, pelo que um leitor que confia no diretório central e um leitor que confia em cabeçalhos locais podem extrair conteúdo diferente de um arquivo. TZipEntry.ParseLocalHeader fecha essa lacuna analisando o cabeçalho local em FCdFile.LocalFileHeaderOffset e comparando as duas cópias campo a campo, devolvendo um código negativo distinto para cada tipo de desacordo: o nome de entrada canonicalizado, o método de compressão, as flags de propósito geral, e, quando a flag de descritor de dados está desligada, o CRC32 e ambos os tamanhos. Com essa flag definida, as cópias locais podem ser zero, uma vez que os valores reais vivem num descritor final, mas qualquer valor local diferente de zero ainda tem de coincidir. Uma verificação final rejeita entradas cujos dados ultrapassariam o final do ficheiro, comparando Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) contra inputstream.Size. Qualquer falha propaga-se para fora de TCentralDirectory.Parse como um resultado diferente de 1 e TZipArchive.OpenArchive transforma-a em Can't open zip archive, em vez de lhe entregar um objeto de arquivo meio confiável. Quando só precisa de saber que folhas um ficheiro contém, correr essa validação antes de uma análise completa é barato, e o percurso de inspeção leve de folhas dá-lhe exatamente isso sem materializar dados de células

O que acontece quando os próprios bytes mentem?

O acordo estrutural ainda não diz nada sobre a carga, pelo que o HotXLS envolve cada stream de entrada em TZipVerifiedStream, que impõe o tamanho e o CRC32 declarados à medida que quem chama lê. Isto deliberadamente não é uma verificação a posteriori: uma bomba de descompressão cujo tamanho descomprimido declarado é 4 KB mas que infla para gigabytes é travada na marca dos 4 KB, não depois do estrago. O wrapper limita cada leitura aos bytes declarados restantes, lança ZIP entry ended before its declared size se a origem esgotar cedo, sonda por um byte extra na conclusão e lança ZIP entry exceeds its declared size se sobrar alguma coisa, e finalmente compara o CRC32 em execução em VerifyComplete, lançando ZIP entry uncompressed size mismatch ou ZIP entry CRC32 mismatch

if Count > 0 then
begin
  Result := FSource.Read(Buffer, Count);
  if Result <= 0 then
    raise Exception.Create('ZIP entry ended before its declared size');
  FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
  Inc(FPosition, Result);
end
else
  Result := 0;

if FPosition = FExpectedSize then
begin
  if FSource.Read(Probe, 1) <> 0 then
    raise Exception.Create('ZIP entry exceeds its declared size');
  VerifyComplete;
end;

Uma consequência vale a pena planear. O stream é apenas para a frente por conceção; um Seek para qualquer lugar que não a posição atual lança ZIP entry stream is forward-only, com uma única concessão para soEnd com deslocamento zero, para que as consultas de tamanho continuem a funcionar. Essa é a troca certa para entrada não fiável, porque um stream que pode ser rebobinado é um stream cuja contabilidade de CRC pode ser derrotada, mas significa que código consumidor que espera um stream posicionável precisa do seu próprio buffer. A mesma disciplina apenas-para-a-frente sustenta o leitor direto de streaming, que é a API a que recorrer quando o livro de trabalho carregado é suficientemente grande para não o querer residente em memória de todo

Limites de recursos antes da alocação, não depois

Três constantes em lxZipArchive limitam o que um único arquivo pode pedir ao processo, e TZipEntries.Add aplica-as enquanto o diretório central ainda está a ser lido, antes de um único byte de dados de entrada ser tocado. ZipMaxEntryUncompressedSize limita um membro a 1 GiB, ZipMaxTotalUncompressedSize limita o arquivo a 4 GiB, e ZipMaxCompressionRatio de 10000 rejeita qualquer entrada deflacionada cuja expansão declarada exceda dez mil vezes, juntamente com o caso degenerado de um tamanho descomprimido diferente de zero emparelhado com um tamanho comprimido zero. Os nomes de entrada passam por CanonicalZipEntryName na mesma chamada, que rejeita caracteres NUL incorporados, dois pontos, e qualquer segmento de caminho .. com Invalid ZIP entry name, e que converte para minúsculas e normaliza segmentos para que dois membros que diferem apenas em maiúsculas ou em separadores redundantes colidam como Duplicate ZIP entry name em vez de silenciosamente se sobreporem

Defesa em profundidade acima da camada ZIP

A camada ZIP é um nível entre vários, e o padrão repete-se sempre que o HotXLS analisa estrutura controlada por um atacante. O exemplo mais claro está no analisador de fórmulas BIFF: TXLSFormula.GetTranslated recorre através de tokens tMemFunc, pelo que um stream de tokens rgce forjado num .xls legado pode aninhar-se arbitrariamente fundo e esgotar a pilha. A proteção é uma constante, MaxTranslateDepth = 256, escolhida contra um facto conhecido a montante em vez de adivinhada. O Excel limita o aninhamento de fórmulas a 64, pelo que 256 deixa uma margem quádrupla e nunca pode rejeitar uma fórmula que uma folha de cálculo real produziu, ao mesmo tempo que ainda termina um stream malicioso com tempo suficiente antes de a pilha se esgotar

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

Note que a proteção devolve nil em vez de lançar uma exceção. Uma fórmula demasiado profunda para ser genuína não produz árvore de sintaxe, a análise circundante continua, e o livro de trabalho continua a carregar. Essa assimetria é intencional e vale a pena copiar nos seus próprios limites: um limite que existe para travar o esgotamento de recursos deve degradar a unidade mais pequena que consegue, não abortar o documento. O mesmo raciocínio aplica-se quando estende a camada de cálculo, pelo que se registar os seus próprios handlers através da API de funções personalizadas do motor de fórmulas, dê-lhes os seus próprios limites de argumentos e recursão em vez de assumir que quem chama já verificou

O que estas verificações não lhe garantem

Seja preciso quanto à fronteira. As quatro verificações cruzadas EOCD tornam o índice do arquivo inequívoco, pelo que o HotXLS e qualquer outro leitor conforme resolvem o mesmo ficheiro para o mesmo conjunto de entradas; não dizem nada sobre se esse conjunto de entradas é benigno. O acordo de cabeçalho local trava o truque de duas vistas, não uma carga maliciosa que é descrita de forma consistente. O stream verificado trava truncamento, overflow e corrupção, não uma parte XML perfeitamente bem formada que codifica algo que não esperava. E nada disto toca em macros: um projeto VBA dentro de um livro de trabalho estruturalmente impecável continua a ser um projeto VBA, e a decisão de manter, remover ou recusá-lo pertence à sua camada de política, não ao leitor ZIP

O que ganha em troca é uma fronteira de falha limpa. Um xlsx não fiável ou abre como um único arquivo inequívoco cujos membros correspondem aos seus tamanhos e checksums declarados, ou lança uma exceção com uma mensagem que nomeia o invariante específico que quebrou, e o seu serviço pode colocar em quarentena com base na exceção em vez de adivinhar. O leitor ZIP e os níveis do analisador acima dele são disponibilizados como parte do componente HotXLS Excel para Delphi e C++Builder, que não precisa nem de Excel nem de automação OLE na máquina que faz a análise, e essa ausência é por si só uma redução significativa daquilo que um ficheiro carregado consegue alcançar