Articol tehnic

Validare ZIP EOCD pentru fișiere XLSX nesigure în Delphi

Un fișier xlsx este o arhivă ZIP, iar ZIP nu are un singur tabel de conținut autoritar. HotXLS Excel Library pentru Delphi și C++Builder tratează acea ambiguitate ca o suprafață de atac: parserul său de sfârșit de director central acceptă o înregistrare candidată doar după ce patru verificări încrucișate independente sunt de acord, astfel încât un director falsificat ascuns într-un comentariu ZIP nu câștigă niciodată

Scenariul care face asta concret este banal. Un server acceptă încărcări de foi de calcul de la clienți. Fișierul trece o scanare antivirus, este scris într-un director spool, iar serviciul dumneavoastră Delphi îl deschide pentru a extrage trei coloane. Totul pare în regulă, cu excepția faptului că scanerul și parserul dumneavoastră nu au fost de acord asupra a ce conținea arhiva. Scanerul a enumerat un set de membri; loader-ul dumneavoastră a enumerat un set diferit din aceiași octeți. Niciunul dintre ei nu are un bug în sensul obișnuit. Pur și simplu au rezolvat o ambiguitate din formatul ZIP în două direcții diferite, iar un atacator a ales octeții astfel încât să facă asta

Unde trăiește de fapt adevărul despre o arhivă ZIP?

Trăiește chiar la sfârșit, într-o structură de 22 de octeți numită înregistrarea de sfârșit de director central. Un fișier ZIP nu este citit de la început la sfârșit: fiecare membru poartă un antet de fișier local imediat înaintea datelor sale comprimate, dar indexul autoritar este directorul central, o serie de înregistrări aproape de sfârșit care numește fiecare intrare și dă offset-ul antetului ei local. Pentru a găsi directorul central trebuie mai întâi să găsiți EOCD, deoarece EOCD este cel care spune unde începe directorul și câte înregistrări conține. HotXLS îl modelează ca TEndOfCentralDirectoryRecord, ale cărui câmpuri se mapează unu-la-unu pe layout-ul de pe disc: FDiskNumber la offset 4, FStartDisk la 6, FThisDiskEntries la 8, FTotalEntries la 10, FSizeOfCD la 12, FOffsetOfStartCD la 16, și FCommentLen la 20. Acel total este FMinSize, calculat în constructor ca 4*3 + 5*2. După aceea vine comentariul arhivei, până la 65535 de octeți de conținut arbitrar, ceea ce face ca FMaxSize să fie 65557 și înseamnă că înregistrarea nu se află la o poziție fixă. Trebuie să o căutați

De ce nu este suficientă scanarea înapoi pentru semnătura EOCD?

Pentru că cei patru octeți pe care îi căutați, PK\005\006, pot apărea legal în interiorul comentariului arhivei, în interiorul datelor comprimate, sau într-un al doilea EOCD pe care un atacator l-a adăugat intenționat. Un parser care se oprește la prima semnătură pe care o întâlnește mergând înapoi este trivial de direcționat: plasați un EOCD momeală aproape de coadă, iar parserul naiv îl urmează, în timp ce un parser care scanează într-o altă ordine, sau care tratează ultima semnătură din fișier ca fiind canonică, îl urmează pe cel real. Aceasta este familia de atacuri de ambiguitate ZIP, iar câștigul ei este exact separarea descrisă mai sus, unde motorul de scanare și aplicația consumatoare văd seturi de intrări diferite din un singur fișier

TEndOfCentralDirectoryRecord.Parse chiar scanează înapoi. Setează startscan la ultimul octet, plafonează endscan la lsize - FMaxSize sau zero, și parcurge fereastra în buffer-e de 256 de octeți care se suprapun cu trei octeți, astfel încât o semnătură ce se întinde pe o graniță de buffer nu este niciodată ratată. Diferența este ce se întâmplă la o lovitură. Găsirea semnăturii produce doar un offset Candidate. HotXLS citește apoi cei 22 de octeți la acel offset, îi analizează cu ReadEOCD și cere ca câmpurile rezultate să fie intern consistente cu fișierul pe care pretind că îl descriu înainte ca FOffsetEOCD să fie deloc atribuit

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;

Citiți predicatul ca patru afirmații separate pe care o falsificare trebuie să le satisfacă simultan. Candidate + FMinSize + FCommentLen = lsize cere ca lungimea de comentariu declarată să ajungă exact la sfârșitul fișierului, ceea ce ucide trucul momelii-în-comentariu: un EOCD fals îngropat într-un comentariu real nu poate contabiliza și fiecare octet de după el. FDiskNumber = 0 și FStartDisk = 0 resping câmpurile de întindere multi-disc pe care niciun xlsx nu le-a folosit vreodată legitim și care există în arhive create doar pentru a deruta. FThisDiskEntries = FTotalEntries respinge trucul de numărare divizată, unde un parser își dimensionează bucla dintr-un câmp, iar altul din celălalt. Iar Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate cere ca directorul central să se termine exact acolo unde începe EOCD, astfel încât directorul nu poate fi îndreptat spre vreun blob fără legătură aflat altundeva în fișier. Conversia Int64 de pe ultimul contează: ambii operanzi sunt pe 32 de biți, iar fără lărgire, o pereche creată intenționat ar putea reveni ciclic și satisface testul aritmetic în timp ce indică nicăieri sănătos

Anteturile locale trebuie să fie de acord cu directorul central

Verificările EOCD fixează care director este autoritar; ele nu garantează încă că directorul spune adevărul despre membrii individuali. Fiecare intrare este descrisă de două ori într-un fișier ZIP, o dată central și o dată în antetul ei local, iar nimic din format nu forțează cele două descrieri să se potrivească, așa că un cititor care are încredere în directorul central și un cititor care are încredere în anteturile locale pot extrage conținut diferit dintr-o arhivă. TZipEntry.ParseLocalHeader închide acel gol analizând antetul local la FCdFile.LocalFileHeaderOffset și comparând cele două copii câmp cu câmp, returnând un cod negativ distinct pentru fiecare tip de dezacord: numele de intrare canonicalizat, metoda de compresie, biții de flag de scop general, și, când flag-ul de descriptor de date este șters, CRC32 și ambele dimensiuni. Cu acel flag setat, copiile locale pot fi zero, deoarece valorile reale trăiesc într-un descriptor final, dar orice valoare locală diferită de zero trebuie totuși să se potrivească. O verificare finală respinge intrările ale căror date ar depăși sfârșitul fișierului, comparând Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) cu inputstream.Size. Orice eșec se propagă din TCentralDirectory.Parse ca un rezultat diferit de 1, iar TZipArchive.OpenArchive îl transformă în Can't open zip archive, în loc să vă predea un obiect arhivă parțial de încredere. Când aveți nevoie doar să știți ce foi conține un fișier, rularea acelei validări înainte de o analiză completă este ieftină, iar calea de inspecție ușoară a foilor vă oferă exact asta fără să materializeze datele de celulă

Ce se întâmplă când octeții înșiși mint?

Acordul structural tot nu spune nimic despre payload, așa că HotXLS înfășoară fiecare flux de intrare în TZipVerifiedStream, care aplică dimensiunea și CRC32 declarate pe măsură ce apelantul citește. Aceasta nu este deliberat o verificare ulterioară: o bombă de decompresie a cărei dimensiune necomprimată declarată este 4 KB, dar care se umflă la gigabytes, este oprită la marcajul de 4 KB, nu după pagubă. Wrapper-ul plafonează fiecare citire la octeții declarați rămași, ridică ZIP entry ended before its declared size dacă sursa se termină devreme, sondează pentru un octet suplimentar la finalizare și ridică ZIP entry exceeds its declared size dacă a mai rămas ceva, și în final compară CRC32-ul curent în VerifyComplete, ridicând ZIP entry uncompressed size mismatch sau 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;

O consecință merită planificată. Fluxul este doar-înainte prin design; un Seek spre orice altă poziție decât cea curentă ridică ZIP entry stream is forward-only, cu o singură concesie pentru soEnd cu offset zero, astfel încât interogările de dimensiune tot funcționează. Acesta este compromisul corect pentru intrare nesigură, deoarece un flux pe care îl puteți rebobina este un flux a cărui contabilizare CRC o puteți învinge, dar înseamnă că un cod consumator care se așteaptă la un flux ce poate fi căutat are nevoie de propriul lui buffer. Aceeași disciplină doar-înainte stă la baza cititorului direct în streaming, care este API-ul de folosit atunci când registrul de calcul încărcat este suficient de mare încât nu doriți deloc să fie rezident în memorie

Limite de resurse înainte de alocare, nu după

Trei constante din lxZipArchive mărginesc ce poate cere o singură arhivă procesului să facă, iar TZipEntries.Add le aplică în timp ce directorul central încă este citit, înainte ca un octet de date de intrare să fie atins. ZipMaxEntryUncompressedSize plafonează un membru la 1 GiB, ZipMaxTotalUncompressedSize plafonează arhiva la 4 GiB, iar ZipMaxCompressionRatio de 10000 respinge orice intrare deflate a cărei expansiune declarată depășește de zece mii de ori, împreună cu cazul degenerat al unei dimensiuni necomprimate diferite de zero asociate cu o dimensiune comprimată zero. Numele de intrare trec prin CanonicalZipEntryName în același apel, care respinge caracterele NUL încorporate, două puncte și orice segment de cale .. cu Invalid ZIP entry name, și care transformă în litere mici și normalizează segmentele astfel încât doi membri care diferă doar prin majuscule sau prin separatori redundanți se ciocnesc ca Duplicate ZIP entry name în loc să se umbrească tacit unul pe celălalt

Apărare în profunzime deasupra stratului ZIP

Stratul ZIP este unul din mai multe niveluri, iar tiparul se repetă oriunde HotXLS analizează o structură controlată de atacator. Exemplul cel mai clar stă în parserul de formule BIFF: TXLSFormula.GetTranslated recursează prin token-uri tMemFunc, astfel încât un flux de token-uri rgce creat intenționat într-un .xls vechi se poate imbrica arbitrar de adânc și epuiza stiva. Poarta este o constantă, MaxTranslateDepth = 256, aleasă pe baza unui fapt din amonte cunoscut, nu ghicit. Excel plafonează imbricarea de formule la 64, așa că 256 lasă o marjă de patru ori și nu poate respinge niciodată o formulă pe care o foaie de calcul reală a produs-o, în timp ce tot termină un flux malițios cu suficient timp înainte ca stiva să se epuizeze

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

Observați că poarta returnează nil în loc să ridice o excepție. O formulă prea adâncă pentru a fi genuină nu produce niciun arbore de sintaxă, analiza înconjurătoare continuă, iar registrul de calcul tot se încarcă. Acea asimetrie este intenționată și merită copiată în propriile dumneavoastră limite: o limită care există pentru a opri epuizarea de resurse ar trebui să degradeze cea mai mică unitate posibilă, nu să abandoneze documentul. Același raționament se aplică atunci când extindeți stratul de calcul, așa că dacă vă înregistrați propriile handlere prin API-ul de funcții personalizate al motorului de formule, dați-le propriile limite de argumente și recursiune, în loc să presupuneți că apelantul a verificat deja

Ce nu vă oferă aceste verificări

Fiți precis despre limită. Cele patru verificări încrucișate EOCD fac indexul arhivei neambiguu, așa că HotXLS și orice alt cititor conform rezolvă același fișier la același set de intrări; ele nu spun nimic despre dacă acel set de intrări este benign. Acordul antetului local oprește trucul celor două vederi, nu un payload malițios care este descris în mod consistent. Fluxul verificat oprește trunchierea, overflow-ul și corupția, nu o parte XML perfect bine formată care codifică ceva la care nu vă așteptați. Iar nimic din asta nu atinge macrourile: un proiect VBA în interiorul unui registru de calcul structural impecabil este tot un proiect VBA, iar decizia de a-l păstra, elimina sau refuza aparține stratului dumneavoastră de politică, nu cititorului ZIP

Ce obțineți în schimb este o graniță de eșec curată. Un xlsx nesigur fie se deschide ca o singură arhivă neambiguă ai cărei membri se potrivesc cu dimensiunile și checksum-urile lor declarate, fie ridică o excepție cu un mesaj care numește invariantul specific pe care l-a încălcat, iar serviciul dumneavoastră poate pune în carantină pe baza excepției, în loc să ghicească. Cititorul ZIP și nivelurile de parser de deasupra lui vin ca parte din componenta Excel HotXLS pentru Delphi și C++Builder, care nu are nevoie nici de Excel, nici de automatizare OLE pe mașina care face analiza, iar acea absență este ea însăși o reducere semnificativă a ceea ce poate atinge un fișier încărcat