Um arquivo xlsx é um arquivo ZIP, e o ZIP não tem uma única tabela de conteúdo autoritativa. A HotXLS Excel Library para Delphi e C++Builder trata essa ambiguidade como uma superfície de ataque: seu parser de fim de diretório central aceita um registro candidato somente depois que quatro verificações cruzadas independentes concordam, então um diretório forjado escondido em um comentário ZIP nunca vence
O cenário que torna isso concreto é mundano. Um servidor aceita uploads de planilhas de clientes. O arquivo passa por uma varredura de antivírus, é gravado em um diretório de spool, e seu serviço Delphi o abre para extrair três colunas. Tudo parece bem, exceto que o scanner e seu parser não concordaram sobre o que o arquivo continha. O scanner enumerou um conjunto de membros; seu carregador enumerou um conjunto diferente a partir dos mesmos bytes. Nenhum dos dois tem bug no sentido comum. Eles simplesmente resolveram uma ambiguidade no formato ZIP em duas direções diferentes, e um atacante escolheu os bytes para que isso acontecesse
Onde realmente vive a verdade sobre um arquivo ZIP?
Ela vive bem no final, em uma estrutura de 22 bytes chamada registro de fim de diretório central. Um arquivo ZIP não é lido do início ao fim: todo membro carrega um cabeçalho de arquivo local imediatamente antes de seus dados comprimidos, mas o índice autoritativo é o diretório central, uma sequência de registros perto do final que nomeia toda entrada e dá o offset de seu cabeçalho local. Para encontrar o diretório central você deve primeiro encontrar o EOCD, porque o EOCD é o que diz onde o diretório começa e quantos registros ele contém. O HotXLS o modela como TEndOfCentralDirectoryRecord, cujos campos mapeiam um para um no layout em disco: FDiskNumber no offset 4, FStartDisk no 6, FThisDiskEntries no 8, FTotalEntries no 10, FSizeOfCD no 12, FOffsetOfStartCD no 16, e FCommentLen no 20. Esse total é FMinSize, calculado no construtor como 4*3 + 5*2. Depois dele vem o comentário do arquivo, até 65535 bytes de conteúdo arbitrário, o que torna FMaxSize 65557 e significa que o registro não está em uma posição fixa. Você precisa procurá-lo
Por que varrer para trás em busca da assinatura EOCD não é suficiente?
Porque os quatro bytes que você está procurando, PK\005\006, podem legalmente aparecer dentro do comentário do arquivo, dentro de dados comprimidos, ou dentro de um segundo EOCD que um atacante anexou de propósito. Um parser que para na primeira assinatura que encontra ao caminhar para trás é trivialmente dirigível: coloque um EOCD chamariz perto do final e o parser ingênuo o segue, enquanto um parser que varre em uma ordem diferente, ou que trata a última assinatura do arquivo como canônica, segue o real. Esta é a família de ataques de ambiguidade ZIP, e seu ganho é exatamente a divisão descrita acima, onde o motor de varredura e a aplicação consumidora veem conjuntos de entrada diferentes a partir de um arquivo
TEndOfCentralDirectoryRecord.Parse de fato varre para trás. Ele define startscan como o último byte, limita endscan a lsize - FMaxSize ou zero, e caminha pela janela em buffers de 256 bytes que se sobrepõem em três bytes para que uma assinatura atravessando um limite de buffer nunca seja perdida. A diferença está no que acontece em um acerto. Encontrar a assinatura só produz um offset Candidate. O HotXLS então lê os 22 bytes naquele offset, os analisa com ReadEOCD, e exige que os campos resultantes sejam internamente consistentes com o arquivo que afirmam descrever antes de FOffsetEOCD ser 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 precisa satisfazer simultaneamente. Candidate + FMinSize + FCommentLen = lsize exige que o comprimento de comentário declarado alcance exatamente o fim do arquivo, que é o que mata o truque de chamariz no comentário: um EOCD falso enterrado dentro de um comentário real não pode também explicar cada byte depois de si mesmo. FDiskNumber = 0 e FStartDisk = 0 rejeitam os campos de expansão multidisco que nenhum xlsx jamais usou legitimamente e que existem em arquivos forjados só para confundir. FThisDiskEntries = FTotalEntries rejeita o truque de contagem dividida onde um parser dimensiona seu laço a partir de um campo e outro parser a partir do outro. E Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate exige que o diretório central termine precisamente onde o EOCD começa, então o diretório não pode ser apontado para algum blob não relacionado em outro lugar do arquivo. O cast Int64 nesse último importa: ambos os operandos são de 32 bits, e sem a ampliação, um par forjado poderia dar a volta e satisfazer o teste aritmeticamente enquanto aponta para lugar nenhum sensato
Cabeçalhos locais precisam concordar com o diretório central
As verificações EOCD fixam qual diretório é autoritativo; elas ainda não garantem que o diretório diga a verdade sobre membros individuais. Toda entrada é descrita duas vezes em um arquivo ZIP, uma vez centralmente e uma vez em seu cabeçalho local, e nada no formato força as duas descrições a coincidirem, então 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 por campo, retornando um código negativo distinto para cada tipo de discordância: o nome de entrada canonicalizado, o método de compressão, os bits de flag de propósito geral, e, quando o flag de descritor de dados está desligado, o CRC32 e ambos os tamanhos. Com esse flag ligado as cópias locais podem ser zero, já que os valores reais vivem em um descritor à direita, mas qualquer valor local diferente de zero ainda deve coincidir. Uma verificação final rejeita entradas cujos dados iriam além do fim do arquivo, comparando Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) contra inputstream.Size. Qualquer falha se propaga para fora de TCentralDirectory.Parse como um resultado não-1 e TZipArchive.OpenArchive a transforma em Can't open zip archive, em vez de entregar a você um objeto de arquivo meio confiável. Quando você só precisa saber quais planilhas um arquivo contém, executar essa validação antes de uma análise completa é barato, e o caminho leve de inspeção de planilhas dá exatamente isso sem materializar dados de célula
O que acontece quando os próprios bytes mentem?
A concordância estrutural ainda não diz nada sobre o payload, então o HotXLS envolve todo stream de entrada em TZipVerifiedStream, que aplica o tamanho e CRC32 declarados enquanto o chamador lê. Isso deliberadamente não é uma verificação posterior: uma bomba de descompressão cujo tamanho descomprimido declarado é 4 KB mas que infla para gigabytes é parada na marca de 4 KB, não depois do dano. O wrapper limita cada leitura aos bytes declarados restantes, dispara ZIP entry ended before its declared size se a origem secar cedo demais, sonda por um byte extra ao completar e dispara ZIP entry exceeds its declared size se sobrar algo, e finalmente compara o CRC32 corrente em VerifyComplete, disparando 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 planejar. O stream é somente-adiante por design; um Seek para qualquer lugar que não seja a posição atual dispara ZIP entry stream is forward-only, com uma única concessão para soEnd com offset zero para que consultas de tamanho ainda funcionem. Essa é a troca certa para entrada não confiável, porque um stream que você pode retroceder é um stream cuja contabilidade de CRC você pode derrotar, mas significa que código consumidor que espera um stream buscável precisa de seu próprio buffer. A mesma disciplina somente-adiante sustenta o leitor direto de streaming, que é a API a buscar quando a pasta de trabalho enviada é grande o suficiente para você não querer tê-la residente em memória de forma alguma
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 para fazer, e TZipEntries.Add as aplica enquanto o diretório central ainda está sendo lido, antes de um 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, junto com o caso degenerado de um tamanho descomprimido diferente de zero pareado com um tamanho comprimido zero. Nomes de entrada passam por CanonicalZipEntryName na mesma chamada, que rejeita caracteres NUL embutidos, dois-pontos, e qualquer segmento de caminho .. com Invalid ZIP entry name, e que coloca em minúsculas e normaliza segmentos para que dois membros diferindo apenas em maiúsculas/minú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 se repete onde quer que o HotXLS analise estrutura controlada pelo atacante. O exemplo mais claro fica no parser de fórmula BIFF: TXLSFormula.GetTranslated recursa através de tokens tMemFunc, então um stream de tokens rgce forjado em um .xls legado pode se aninhar arbitrariamente fundo e esgotar a pilha. O portão é uma constante, MaxTranslateDepth = 256, escolhida contra um fato conhecido a montante em vez de adivinhada. O Excel limita o aninhamento de fórmulas em 64, então 256 deixa quatro vezes de folga e nunca pode rejeitar uma fórmula que uma planilha real produziu, enquanto ainda termina um stream malicioso bem antes de a pilha esgotar
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Observe que o portão retorna nil em vez de disparar exceção. Uma fórmula profunda demais para ser genuína não produz árvore de sintaxe, a análise ao redor continua, e a pasta de trabalho ainda carrega. Essa assimetria é intencional e vale a pena copiar em seus próprios limites: um limite que existe para parar esgotamento de recursos deve degradar a menor unidade que puder, não abortar o documento. O mesmo raciocínio se aplica quando você estende a camada de cálculo, então se você registra seus próprios manipuladores através da API de funções personalizadas do motor de fórmulas, dê a eles seus próprios limites de argumento e recursão em vez de assumir que o chamador já verificou
O que essas verificações não compram para você
Seja preciso sobre o limite. As quatro verificações cruzadas EOCD tornam o índice do arquivo inequívoco, então o HotXLS e qualquer outro leitor conforme resolvem o mesmo arquivo para o mesmo conjunto de entradas; elas não dizem nada sobre se esse conjunto de entradas é benigno. A concordância de cabeçalho local para o truque das duas visões, não um payload malicioso que é descrito de forma consistente. O stream verificado para truncamento, overflow e corrupção, não uma parte XML perfeitamente bem formada que codifica algo que você não esperava. E nada disso toca em macros: um projeto VBA dentro de uma pasta de trabalho estruturalmente impecável ainda é 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 você ganha em troca é um limite de falha limpo. Um xlsx não confiável ou abre como um arquivo inequívoco cujos membros correspondem a seus tamanhos e checksums declarados, ou dispara com uma mensagem que nomeia o invariante específico que quebrou, e seu serviço pode colocar em quarentena com base na exceção em vez de adivinhar. O leitor ZIP e as camadas de parser acima dele são fornecidos como parte do componente Excel HotXLS para Delphi e C++Builder, que não precisa nem do Excel nem de automação OLE na máquina que faz a análise, e essa ausência é em si uma redução significativa do que um arquivo enviado pode alcançar