Articolo tecnico

Validazione ZIP EOCD per file XLSX non fidati in Delphi

Un file xlsx è un archivio ZIP, e ZIP non ha un'unica tabella dei contenuti autorevole. HotXLS Excel Library per Delphi e C++Builder tratta quell'ambiguità come una superficie di attacco: il suo parser end of central directory accetta un record candidato solo dopo che quattro controlli incrociati indipendenti concordano, così una directory falsificata nascosta in un commento ZIP non vince mai

Lo scenario che rende questo concreto è banale. Un server accetta upload di fogli di calcolo dai clienti. Il file supera una scansione antivirus, viene scritto in una directory di spool, e il tuo servizio Delphi lo apre per estrarre tre colonne. Tutto sembra a posto, tranne che lo scanner e il tuo parser non hanno concordato su cosa contenesse l'archivio. Lo scanner ha enumerato un insieme di membri; il tuo loader ne ha enumerato uno diverso dagli stessi byte. Nessuno dei due ha un bug nel senso ordinario. Hanno semplicemente risolto un'ambiguità del formato ZIP in due direzioni diverse, e un attaccante ha scelto i byte perché lo facessero

Dove vive realmente la verità su un archivio ZIP?

Vive proprio alla fine, in una struttura di 22 byte chiamata record end of central directory. Un file ZIP non viene letto dall'inizio alla fine: ogni membro porta un header di file locale immediatamente prima dei propri dati compressi, ma l'indice autorevole è la central directory, una serie di record vicino alla fine che nomina ogni voce e fornisce l'offset del suo header locale. Per trovare la central directory devi prima trovare l'EOCD, perché è l'EOCD a dire dove inizia la directory e quanti record contiene. HotXLS lo modella come TEndOfCentralDirectoryRecord, i cui campi si mappano uno a uno sul layout su disco: FDiskNumber all'offset 4, FStartDisk a 6, FThisDiskEntries a 8, FTotalEntries a 10, FSizeOfCD a 12, FOffsetOfStartCD a 16, e FCommentLen a 20. Quel totale è FMinSize, calcolato nel costruttore come 4*3 + 5*2. Dopo di esso viene il commento dell'archivio, fino a 65535 byte di contenuto arbitrario, il che rende FMaxSize pari a 65557 e significa che il record non si trova a una posizione fissa. Devi andare a cercarlo

Perché non basta scandire all'indietro alla ricerca della firma EOCD?

Perché i quattro byte che stai cercando, PK\005\006, possono legalmente comparire dentro il commento dell'archivio, dentro i dati compressi, o dentro un secondo EOCD che un attaccante ha aggiunto di proposito. Un parser che si ferma alla prima firma che incontra scandendo all'indietro è banalmente manipolabile: posiziona un EOCD esca vicino alla coda e il parser ingenuo lo segue, mentre un parser che scandisce in un ordine diverso, o che tratta l'ultima firma nel file come canonica, segue quello vero. Questa è la famiglia di attacchi da ambiguità ZIP, e il suo guadagno è esattamente la divisione descritta sopra, dove il motore di scansione e l'applicazione consumatrice vedono insiemi di voci diversi da un unico file

TEndOfCentralDirectoryRecord.Parse scandisce effettivamente all'indietro. Imposta startscan all'ultimo byte, limita endscan a lsize - FMaxSize o zero, e percorre la finestra in buffer da 256 byte che si sovrappongono di tre byte così che una firma a cavallo di un confine di buffer non venga mai persa. La differenza sta in cosa succede a un colpo. Trovare la firma produce solo un offset Candidate. HotXLS poi legge i 22 byte a quell'offset, li analizza con ReadEOCD, e richiede che i campi risultanti siano internamente coerenti con il file che affermano di descrivere prima che FOffsetEOCD venga assegnato affatto

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;

Leggi il predicato come quattro affermazioni separate che una falsificazione deve soddisfare simultaneamente. Candidate + FMinSize + FCommentLen = lsize richiede che la lunghezza del commento dichiarata raggiunga esattamente la fine del file, il che è ciò che uccide il trucco dell'esca nel commento: un EOCD falso sepolto dentro un commento reale non può anche rendere conto di ogni byte dopo di sé. FDiskNumber = 0 e FStartDisk = 0 rifiutano i campi di spanning multi-disco che nessun xlsx ha mai usato legittimamente e che esistono in archivi confezionati solo per confondere. FThisDiskEntries = FTotalEntries rifiuta il trucco del conteggio diviso in cui un parser dimensiona il proprio ciclo da un campo e un altro parser dall'altro. E Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate richiede che la central directory finisca precisamente dove inizia l'EOCD, così la directory non può essere puntata verso qualche blob non correlato altrove nel file. Il cast Int64 su quest'ultimo conta: entrambi gli operandi sono a 32 bit, e senza l'allargamento, una coppia confezionata potrebbe andare in overflow e soddisfare il test aritmeticamente pur non puntando da nessuna parte di sensato

Gli header locali devono concordare con la central directory

I controlli EOCD fissano quale directory sia autorevole; non garantiscono ancora che la directory dica la verità sui singoli membri. Ogni voce viene descritta due volte in un file ZIP, una volta centralmente e una volta nel proprio header locale, e niente nel formato costringe le due descrizioni a corrispondere, quindi un reader che si fida della central directory e un reader che si fida degli header locali possono estrarre contenuti diversi da un unico archivio. TZipEntry.ParseLocalHeader chiude quel varco analizzando l'header locale a FCdFile.LocalFileHeaderOffset e confrontando le due copie campo per campo, restituendo un codice negativo distinto per ogni tipo di disaccordo: il nome della voce canonicalizzato, il metodo di compressione, i bit di flag general purpose, e, quando il flag del data descriptor è disattivato, il CRC32 ed entrambe le dimensioni. Con quel flag impostato le copie locali possono essere zero, poiché i valori reali vivono in un descrittore finale, ma qualsiasi valore locale diverso da zero deve comunque corrispondere. Un controllo finale rifiuta le voci i cui dati andrebbero oltre la fine del file, confrontando Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) contro inputstream.Size. Qualsiasi fallimento si propaga fuori da TCentralDirectory.Parse come un risultato diverso da 1 e TZipArchive.OpenArchive lo trasforma in Can't open zip archive, invece di consegnarti un oggetto archivio parzialmente fidato. Quando devi solo sapere quali fogli contiene un file, eseguire quella validazione prima di un'analisi completa è economico, e il percorso di ispezione leggera dei fogli te lo offre esattamente senza materializzare i dati delle celle

Cosa succede quando i byte stessi mentono?

L'accordo strutturale non dice ancora nulla sul payload, quindi HotXLS avvolge ogni stream di voce in TZipVerifiedStream, che applica la dimensione dichiarata e il CRC32 man mano che il chiamante legge. Questo deliberatamente non è un controllo a posteriori: una bomba di decompressione la cui dimensione decompressa dichiarata è 4 KB ma che si espande a gigabyte viene fermata al segno di 4 KB, non dopo il danno. Il wrapper limita ogni lettura ai byte dichiarati rimanenti, solleva ZIP entry ended before its declared size se la sorgente si esaurisce prima, sonda un byte extra al completamento e solleva ZIP entry exceeds its declared size se rimane qualcosa, e infine confronta il CRC32 corrente in VerifyComplete, sollevando ZIP entry uncompressed size mismatch o 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;

Una conseguenza vale la pena pianificarla. Lo stream è forward-only per progetto; un Seek verso qualsiasi punto diverso dalla posizione corrente solleva ZIP entry stream is forward-only, con una singola concessione per soEnd con offset zero così che le query di dimensione funzionino ancora. Questo è il compromesso giusto per input non fidato, perché uno stream che puoi riavvolgere è uno stream la cui contabilità CRC puoi sconfiggere, ma significa che il codice consumatore che si aspetta uno stream posizionabile ha bisogno di un proprio buffer. La stessa disciplina forward-only sta alla base del reader diretto in streaming, che è l'API a cui ricorrere quando il workbook caricato è abbastanza grande da non volerlo affatto residente in memoria

Limiti di risorse prima dell'allocazione, non dopo

Tre costanti in lxZipArchive vincolano cosa un singolo archivio può chiedere al processo di fare, e TZipEntries.Add le applica mentre la central directory viene ancora letta, prima che un solo byte di dati di voce venga toccato. ZipMaxEntryUncompressedSize limita un membro a 1 GiB, ZipMaxTotalUncompressedSize limita l'archivio a 4 GiB, e ZipMaxCompressionRatio di 10000 rifiuta qualsiasi voce deflated la cui espansione dichiarata superi il rapporto diecimila a uno, insieme al caso degenere di una dimensione decompressa diversa da zero accoppiata con una dimensione compressa a zero. I nomi delle voci passano attraverso CanonicalZipEntryName nella stessa chiamata, che rifiuta caratteri NUL incorporati, due punti, e qualsiasi segmento di percorso .. con Invalid ZIP entry name, e che converte in minuscolo e normalizza i segmenti così che due membri che differiscono solo per il maiuscolo/minuscolo o per separatori ridondanti collidano come Duplicate ZIP entry name invece di oscurarsi silenziosamente a vicenda

Difesa in profondità sopra il livello ZIP

Il livello ZIP è uno dei diversi livelli, e il pattern si ripete ovunque HotXLS analizzi struttura controllata dall'attaccante. L'esempio più chiaro sta nel parser di formule BIFF: TXLSFormula.GetTranslated ricorre attraverso token tMemFunc, così uno stream di token rgce confezionato in un .xls legacy può annidarsi arbitrariamente in profondità ed esaurire lo stack. Il cancello è una costante, MaxTranslateDepth = 256, scelta contro un fatto upstream noto piuttosto che indovinata. Excel limita l'annidamento delle formule a 64, quindi 256 lascia un margine quadruplo e non può mai rifiutare una formula che un vero foglio di calcolo ha prodotto, pur terminando comunque uno stream malevolo abbastanza presto prima che lo stack si esaurisca

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

Nota che il cancello restituisce nil invece di sollevare un'eccezione. Una formula troppo profonda per essere genuina non produce alcun albero sintattico, l'analisi circostante continua, e il workbook si carica comunque. Quell'asimmetria è intenzionale e vale la pena copiarla nei tuoi stessi limiti: un vincolo che esiste per fermare l'esaurimento delle risorse dovrebbe degradare l'unità più piccola che può, non interrompere il documento. Lo stesso ragionamento si applica quando estendi il livello di calcolo, quindi se registri i tuoi stessi handler tramite la API delle funzioni personalizzate del motore di formule, dai loro i propri limiti di argomenti e ricorsione invece di supporre che il chiamante abbia già controllato

Cosa non ti comprano questi controlli

Sii preciso sul confine. I quattro controlli incrociati EOCD rendono l'indice dell'archivio non ambiguo, così HotXLS e qualsiasi altro reader conforme risolvono lo stesso file allo stesso insieme di voci; non dicono nulla su se quell'insieme di voci sia benigno. L'accordo tra header locali ferma il trucco delle due viste, non un payload malevolo che è descritto in modo coerente. Lo stream verificato ferma il troncamento, l'overflow e la corruzione, non una parte XML perfettamente ben formata che codifica qualcosa che non ti aspettavi. E niente di tutto questo tocca le macro: un progetto VBA dentro un workbook strutturalmente impeccabile è comunque un progetto VBA, e la decisione di mantenerlo, rimuoverlo o rifiutarlo appartiene al tuo livello di policy, non al reader ZIP

Ciò che ottieni in cambio è un confine di fallimento pulito. Un xlsx non fidato o si apre come un unico archivio non ambiguo i cui membri corrispondono alle dimensioni e ai checksum dichiarati, oppure solleva un'eccezione con un messaggio che nomina l'invariante specifico che ha violato, e il tuo servizio può mettere in quarantena sull'eccezione invece di indovinare. Il reader ZIP e i livelli di parser sopra di esso sono distribuiti come parte del componente Excel HotXLS per Delphi e C++Builder, che non ha bisogno né di Excel né di automazione OLE sulla macchina che esegue l'analisi, e quell'assenza è essa stessa una riduzione significativa di ciò che un file caricato può raggiungere