Tehnički članak

Provjera ZIP EOCD za nepouzdane XLSX datoteke u Delphiju

Xlsx datoteka je ZIP arhiva, a ZIP nema jedinstven autoritativan sadržaj. HotXLS Excel Library za Delphi i C++Builder tretira tu nejednoznačnost kao površinu napada: njegov parser kraja centralnog direktorija prihvaća kandidata za zapis tek nakon što se četiri neovisne unakrsne provjere slože, tako da lažni direktorij skriven u ZIP komentaru nikad ne pobjeđuje

Scenarij koji ovo čini konkretnim je svakodnevan. Poslužitelj prihvaća uploade proračunskih tablica od klijenata. Datoteka prolazi antivirusno skeniranje, zapisuje se u spool direktorij, a vaša Delphi usluga je otvara da izvuče tri stupca. Sve izgleda u redu, osim što se skener i vaš parser nisu složili što arhiva sadrži. Skener je popisao jedan skup članova; vaš je učitavač popisao drugi skup iz istih bajtova. Nijedan od njih nije neispravan u uobičajenom smislu. Jednostavno su razriješili nejednoznačnost u ZIP formatu u dva različita smjera, a napadač je odabrao bajtove tako da to učine

Gdje zapravo živi istina o ZIP arhivi?

Živi na samom kraju, u strukturi od 22 bajta zvanoj zapis kraja centralnog direktorija. ZIP datoteka se ne čita od početka prema kraju: svaki član nosi lokalno zaglavlje datoteke neposredno prije svojih komprimiranih podataka, ali autoritativni indeks je centralni direktorij, niz zapisa blizu kraja koji imenuje svaki unos i daje offset njegovog lokalnog zaglavlja. Da biste pronašli centralni direktorij, prvo morate pronaći EOCD, jer EOCD kaže gdje direktorij počinje i koliko zapisa sadrži. HotXLS ga modelira kao TEndOfCentralDirectoryRecord, čija polja jedan-na-jedan mapiraju na raspored na disku: FDiskNumber na offsetu 4, FStartDisk na 6, FThisDiskEntries na 8, FTotalEntries na 10, FSizeOfCD na 12, FOffsetOfStartCD na 16, i FCommentLen na 20. Taj ukupan zbroj je FMinSize, izračunat u konstruktoru kao 4*3 + 5*2. Nakon njega dolazi komentar arhive, do 65535 bajtova proizvoljnog sadržaja, što FMaxSize čini 65557 i znači da zapis nije na fiksnoj poziciji. Morate ga potražiti

Zašto pretraživanje unatrag za potpisom EOCD nije dovoljno?

Zato što se četiri bajta koja pretražujete, PK\005\006, legalno mogu pojaviti unutar komentara arhive, unutar komprimiranih podataka, ili unutar drugog EOCD-a koji je napadač namjerno dodao. Parser koji staje na prvom potpisu na koji naiđe idući unatrag trivijalno je usmjeriv: postavite lažni EOCD blizu kraja i naivni parser slijedi njega, dok parser koji pretražuje drugim redoslijedom, ili koji tretira posljednji potpis u datoteci kao kanonski, slijedi pravi. Ovo je obitelj napada ZIP nejednoznačnosti, a njena je isplata upravo podjela opisana gore, gdje modul za skeniranje i potrošačka aplikacija vide različite skupove unosa iz jedne datoteke

TEndOfCentralDirectoryRecord.Parse doista pretražuje unatrag. Postavlja startscan na posljednji bajt, steže endscan na lsize - FMaxSize ili nulu, i prolazi kroz prozor u međuspremnicima od 256 bajtova koji se preklapaju za tri bajta tako da potpis koji seže preko granice međuspremnika nikad nije propušten. Razlika je u onome što se događa pri pogotku. Pronalaženje potpisa proizvodi samo offset Candidate. HotXLS zatim čita 22 bajta na tom offsetu, raščlanjuje ih pomoću ReadEOCD, i zahtijeva da rezultirajuća polja budu interno konzistentna s datotekom koju tvrde da opisuju prije nego se FOffsetEOCD uopće dodijeli

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;

Pročitajte predikat kao četiri odvojene tvrdnje koje krivotvorina mora zadovoljiti istovremeno. Candidate + FMinSize + FCommentLen = lsize zahtijeva da deklarirana duljina komentara doseže točno kraj datoteke, što je ono što ubija trik lažnjaka u komentaru: lažan EOCD zakopan unutar pravog komentara ne može istovremeno objasniti svaki bajt nakon sebe. FDiskNumber = 0 i FStartDisk = 0 odbijaju polja proširenja na više diskova koja nijedna xlsx datoteka nikad legitimno nije koristila, a koja postoje u izrađenim arhivama samo radi zbunjivanja. FThisDiskEntries = FTotalEntries odbija trik podijeljenog broja gdje jedan parser dimenzionira svoju petlju iz jednog polja, a drugi iz drugog. A Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate zahtijeva da centralni direktorij završi točno gdje EOCD počinje, tako da direktorij ne može biti usmjeren na neki nepovezan blob negdje drugdje u datoteci. Kastanje u Int64 na tom posljednjem je bitno: oba su operanda 32-bitna, i bez proširenja, izrađen par mogao bi se omotati i aritmetički zadovoljiti test dok pokazuje nikamo razumno

Lokalna zaglavlja se moraju slagati s centralnim direktorijem

Provjere EOCD-a fiksiraju koji je direktorij autoritativan; još ne jamče da direktorij govori istinu o pojedinim članovima. Svaki unos je opisan dvaput u ZIP datoteci, jednom centralno i jednom u svom lokalnom zaglavlju, i ništa u formatu ne prisiljava ta dva opisa da se poklapaju, pa čitač koji vjeruje centralnom direktoriju i čitač koji vjeruje lokalnim zaglavljima mogu izvući drugačiji sadržaj iz jedne arhive. TZipEntry.ParseLocalHeader zatvara taj jaz raščlambom lokalnog zaglavlja na FCdFile.LocalFileHeaderOffset i usporedbom dviju kopija polje po polje, vraćajući poseban negativan kod za svaku vrstu neslaganja: kanonizirano ime unosa, metodu kompresije, opće bit zastavice, i, kad je zastavica opisivača podataka isključena, CRC32 i obje veličine. S tom zastavicom postavljenom, lokalne kopije mogu biti nula, jer prave vrijednosti žive u pratećem opisivaču, ali svaka neniska lokalna vrijednost i dalje se mora poklopiti. Konačna provjera odbija unose čiji bi podaci prešli kraj datoteke, uspoređujući Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) protiv inputstream.Size. Svaki neuspjeh širi se izvan TCentralDirectory.Parse kao rezultat koji nije 1, a TZipArchive.OpenArchive to pretvara u Can't open zip archive, umjesto da vam preda djelomično pouzdan objekt arhive. Kad trebate samo znati koje listove datoteka sadrži, pokretanje te provjere prije pune raščlambe je jeftino, a put lagane inspekcije listova vam daje upravo to bez materijaliziranja podataka stanica

Što se događa kad sami bajtovi lažu?

Strukturno slaganje i dalje ništa ne kaže o sadržaju, pa HotXLS omata svaki stream unosa u TZipVerifiedStream, koji provodi deklariranu veličinu i CRC32 dok pozivatelj čita. Ovo namjerno nije naknadna provjera: dekompresijska bomba čija je deklarirana nekomprimirana veličina 4 KB, ali koja se napuhne na gigabajte, zaustavlja se na oznaci 4 KB, ne nakon štete. Omotač steže svako čitanje na preostale deklarirane bajtove, izaziva ZIP entry ended before its declared size ako izvor presuši rano, ispituje za jedan dodatni bajt pri dovršetku i izaziva ZIP entry exceeds its declared size ako je nešto ostalo, i naposljetku uspoređuje tekući CRC32 u VerifyComplete, izazivajući ZIP entry uncompressed size mismatch ili 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;

Jedna posljedica vrijedi isplanirati. Stream je namjerno samo-naprijed; Seek na bilo koje mjesto osim trenutne pozicije izaziva ZIP entry stream is forward-only, s jedinim ustupkom za soEnd s offsetom nula tako da upiti veličine i dalje rade. To je pravi kompromis za nepouzdan ulaz, jer je stream koji možete premotati stream čije računovodstvo CRC-a možete pobijediti, ali doista znači da potrošački kod koji očekuje stream s pozicioniranjem treba vlastiti međuspremnik. Ista disciplina samo-naprijed podupire streaming izravni čitač, koji je API kojem posegnuti kad je učitani radni sveščić dovoljno velik da ga uopće ne želite rezidentnog u memoriji

Ograničenja resursa prije alokacije, ne poslije

Tri konstante u lxZipArchive ograničavaju što jedna arhiva može zatražiti od procesa, a TZipEntries.Add primjenjuje ih dok se centralni direktorij još čita, prije nego se dotakne ijedan bajt podataka unosa. ZipMaxEntryUncompressedSize ograničava jednog člana na 1 GiB, ZipMaxTotalUncompressedSize ograničava arhivu na 4 GiB, a ZipMaxCompressionRatio od 10000 odbija svaki deflatirani unos čije deklarirano proširenje premašuje deset tisuća puta, uz degenerirani slučaj neniske nekomprimirane veličine sparene s nultom komprimiranom veličinom. Imena unosa prolaze kroz CanonicalZipEntryName u istom pozivu, koji odbija ugrađene NUL znakove, dvotočke, i svaki segment putanje .. s Invalid ZIP entry name, i koji pretvara u mala slova i normalizira segmente tako da se dva člana koja se razlikuju samo veličinom slova ili suvišnim odjeljivačima sudaraju kao Duplicate ZIP entry name umjesto da se tiho zasjenjuju

Obrana u dubinu iznad razine ZIP-a

Razina ZIP-a je jedna od nekoliko razina, a obrazac se ponavlja gdje god HotXLS raščlanjuje strukturu kontroliranu od napadača. Najjasniji primjer sjedi u parseru BIFF formula: TXLSFormula.GetTranslated rekurzira kroz tokene tMemFunc, tako da izrađen niz rgce tokena u naslijeđenoj .xls datoteci može biti ugniježđen proizvoljno duboko i iscrpiti stog. Zaštita je konstanta, MaxTranslateDepth = 256, odabrana prema poznatoj činjenici iz Excela, a ne nagađanjem. Excel ograničava ugniježđenje formula na 64, pa 256 ostavlja četverostruki manevarski prostor i nikad ne može odbiti formulu koju je proizvela prava proračunska tablica, dok i dalje prekida zlonamjeran niz dovoljno rano prije nego stog ostane bez prostora

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

Primijetite da zaštita vraća nil umjesto da izaziva iznimku. Formula preduboka da bi bila prava ne daje sintaktičko stablo, okolna raščlamba nastavlja, a radni sveščić se svejedno učitava. Ta je asimetrija namjerna i vrijedi je kopirati u vlastita ograničenja: granica koja postoji da zaustavi iscrpljivanje resursa trebala bi degradirati najmanju jedinicu koju može, a ne prekinuti dokument. Isto obrazloženje vrijedi kad proširujete sloj izračuna, pa ako registrirate vlastite rukovatelje kroz API prilagođenih funkcija modula formula, dajte im vlastite granice argumenata i rekurzije umjesto pretpostavke da je pozivatelj već provjerio

Što vam ove provjere ne kupuju

Budite precizni oko granice. Četiri unakrsne provjere EOCD-a čine indeks arhive nejednoznačnim, pa HotXLS i svaki drugi usklađeni čitač razrješavaju istu datoteku na isti skup unosa; ne kažu ništa o tome je li taj skup unosa bezopasan. Slaganje lokalnog zaglavlja zaustavlja trik dva pogleda, ne zlonamjeran payload koji je konzistentno opisan. Provjereni stream zaustavlja skraćivanje, prelijevanje i oštećenje, ne savršeno dobro oblikovan XML dio koji kodira nešto što niste očekivali. I ništa od ovoga ne dira makronaredbe: VBA projekt unutar strukturno besprijekornog radnog sveščića i dalje je VBA projekt, a odluka o zadržavanju, uklanjanju ili odbijanju pripada vašem sloju politike, ne ZIP čitaču

Ono što dobivate zauzvrat je čista granica neuspjeha. Nepouzdana xlsx datoteka ili se otvara kao jedna nejednoznačna arhiva čiji se članovi poklapaju s deklariranim veličinama i kontrolnim zbrojevima, ili izaziva iznimku s porukom koja imenuje specifičnu invarijantu koju je prekršila, a vaša usluga može karantenirati na iznimku umjesto nagađanja. ZIP čitač i razine parsera iznad njega isporučuju se kao dio HotXLS Excel komponente za Delphi i C++Builder, kojoj na stroju koji radi raščlambu ne treba ni Excel ni OLE automatizacija, a ta odsutnost je sama po sebi značajno smanjenje onoga do čega uploadana datoteka može dosegnuti