Xlsx fajl je ZIP arhiva, a ZIP nema jedan autoritativan sadržaj. HotXLS Excel Library za Delphi i C++Builder tretira tu dvosmislenost kao površinu napada: njegov parser end of central directory prihvata kandidata za zapis tek pošto se četiri nezavisne unakrsne provere slože, tako da falsifikovan direktorijum skriven u ZIP komentaru nikad ne pobeđuje
Scenario koji ovo čini konkretnim je banalan. Server prihvata upload tabelarnih proračuna od klijenata. Fajl prolazi antivirus sken, upisuje se u spool direktorijum, a vaš Delphi servis ga otvara da izvuče tri kolone. Sve izgleda u redu, osim što se skener i vaš parser nisu složili šta arhiva sadrži. Skener je nabrojao jedan skup članova; vaš loader je nabrojao drugačiji skup iz istih bajtova. Nijedan od njih nije buggy u uobičajenom smislu. Jednostavno su razrešili dvosmislenost ZIP formata u dva različita pravca, a napadač je izabrao bajtove tako da to i učine
Gde zapravo živi istina o ZIP arhivi?
Živi na samom kraju, u strukturi od 22 bajta zvanoj end of central directory zapis. ZIP fajl se ne čita od početka ka kraju: svaki član nosi lokalno zaglavlje fajla neposredno pre svojih komprimovanih podataka, ali autoritativan indeks je central directory, niz zapisa blizu kraja koji imenuje svaki unos i daje offset njegovog lokalnog zaglavlja. Da biste pronašli central directory, prvo morate pronaći EOCD, jer EOCD kaže gde direktorijum počinje i koliko zapisa drži. HotXLS ga modeluje kao TEndOfCentralDirectoryRecord, čija polja se mapiraju jedan na jedan 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 zbir je FMinSize, izračunat u konstruktoru kao 4*3 + 5*2. Posle njega dolazi komentar arhive, do 65535 bajtova proizvoljnog sadržaja, što čini FMaxSize 65557 i znači da zapis nije na fiksnoj poziciji. Morate ga potražiti
Zašto skeniranje unazad za EOCD potpisom nije dovoljno?
Zato što se četiri bajta koje tražite, PK\005\006, mogu legalno pojaviti unutar komentara arhive, unutar komprimovanih podataka, ili unutar drugog EOCD-a koji je napadač namerno dodao. Parser koji se zaustavlja na prvom potpisu koji sretne dok ide unazad je trivijalno vodljiv: postavite lažni EOCD blizu kraja i naivan parser ga sledi, dok parser koji skenira drugačijim redosledom, ili tretira poslednji potpis u fajlu kao kanonski, sledi pravi. Ovo je porodica napada dvosmislenosti ZIP-a, a njena isplata je tačno podela opisana iznad, gde motor za skeniranje i potrošačka aplikacija vide drugačije skupove unosa iz jednog fajla
TEndOfCentralDirectoryRecord.Parse zaista skenira unazad. Postavlja startscan na poslednji bajt, steže endscan na lsize - FMaxSize ili nulu, i obilazi prozor u baferima od 256 bajtova koji se preklapaju za tri bajta tako da potpis koji preseca granicu bafera nikad nije propušten. Razlika je u tome šta se dešava pri pogotku. Pronalaženje potpisa proizvodi samo offset Candidate. HotXLS tada čita 22 bajta na tom offsetu, parsira ih sa ReadEOCD, i zahteva da rezultujuća polja budu interno konzistentna sa fajlom koji tvrde da opisuju pre nego što se FOffsetEOCD uopšte dodeli
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;
Čitajte predikat kao četiri odvojene tvrdnje koje falsifikat mora zadovoljiti istovremeno. Candidate + FMinSize + FCommentLen = lsize zahteva da deklarisana dužina komentara dosegne tačno kraj fajla, što ubija trik decoy-a u komentaru: lažan EOCD zakopan unutar pravog komentara ne može takođe objasniti svaki bajt posle sebe. FDiskNumber = 0 i FStartDisk = 0 odbacuju polja za rasprostiranje na više diskova koja nijedan xlsx nikad legitimno nije koristio, a koja u konstruisanim arhivama postoje samo da zbune. FThisDiskEntries = FTotalEntries odbacuje trik podeljenog broja gde jedan parser dimenzioniše svoju petlju iz jednog polja, a drugi parser iz drugog. A Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate zahteva da se central directory završi tačno tamo gde EOCD počinje, tako da direktorijum ne može biti usmeren na neki nepovezan blob negde drugde u fajlu. Int64 pretvaranje na tom poslednjem je bitno: oba operanda su 32-bitna, a bez proširenja, konstruisan par bi mogao da se preklopi i aritmetički zadovolji test dok pokazuje nikuda razumno
Lokalna zaglavlja se moraju slagati sa central directory
Provere EOCD fiksiraju koji direktorijum je autoritativan; još uvek ne garantuju da direktorijum govori istinu o pojedinačnim članovima. Svaki unos je opisan dvaput u ZIP fajlu, jednom centralno i jednom u svom lokalnom zaglavlju, i ništa u formatu ne prisiljava ta dva opisa da se poklope, tako da čitač koji veruje central directory-u i čitač koji veruje lokalnim zaglavljima mogu izvući drugačiji sadržaj iz jedne arhive. TZipEntry.ParseLocalHeader zatvara taj jaz parsiranjem lokalnog zaglavlja na FCdFile.LocalFileHeaderOffset i poređenjem dve kopije polje po polje, vraćajući poseban negativan kod za svaku vrstu neslaganja: kanonizovano ime unosa, metod kompresije, bitove opšte namene, i, kad je zastavica data descriptor-a isključena, CRC32 i obe veličine. Sa tom zastavicom postavljenom, lokalne kopije mogu biti nula, pošto prave vrednosti žive u prateći descriptor-u, ali svaka nenulta lokalna vrednost i dalje mora da se poklopi. Konačna provera odbacuje unose čiji bi podaci prošli van kraja fajla, poredeći Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) naspram inputstream.Size. Svaki neuspeh se prostire van TCentralDirectory.Parse kao rezultat koji nije 1, a TZipArchive.OpenArchive ga pretvara u Can't open zip archive, umesto da vam da napola pouzdan objekat arhive. Kad vam samo treba da znate koje listove fajl sadrži, pokretanje te validacije pre punog parsiranja je jeftino, a putanja lagane inspekcije listova vam daje tačno to bez materijalizovanja podataka ćelija
Šta se dešava kad sami bajtovi lažu?
Strukturalna saglasnost i dalje ne govori ništa o payload-u, tako da HotXLS obavija svaki tok unosa u TZipVerifiedStream, koji primenjuje deklarisanu veličinu i CRC32 dok pozivalac čita. Ovo je namerno ne naknadna provera: dekompresiona bomba čija deklarisana nekomprimovana veličina je 4 KB, ali koja se napumpa na gigabajte, zaustavlja se na oznaci od 4 KB, ne posle štete. Omotač steže svako čitanje na preostale deklarisane bajtove, izaziva ZIP entry ended before its declared size ako izvor presuši rano, proba za jedan dodatan bajt po završetku i izaziva ZIP entry exceeds its declared size ako je bilo šta ostalo, i konačno poredi 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 posledica je vredna planiranja. Tok je namerno samo-unapred; Seek bilo gde osim trenutne pozicije izaziva ZIP entry stream is forward-only, sa jednim ustupkom za soEnd sa offsetom nula tako da upiti veličine i dalje rade. To je ispravan kompromis za nepouzdan ulaz, jer je tok koji možete premotati tok čije CRC obračune možete poraziti, ali to znači da potrošački kod koji očekuje tok sa pretraživanjem treba sopstveni bafer. Ista samo-unapred disciplina podupire streaming direktan čitač, API kome treba posegnuti kad je uploadovana radna sveska dovoljno velika da uopšte ne želite da bude rezidentna u memoriji
Ograničenja resursa pre alokacije, ne posle
Tri konstante u lxZipArchive ograničavaju šta jedna arhiva sme tražiti od procesa da uradi, a TZipEntries.Add ih primenjuje dok se central directory još čita, pre nego što se dodirne ijedan bajt podataka unosa. ZipMaxEntryUncompressedSize ograničava jednog člana na 1 GiB, ZipMaxTotalUncompressedSize ograničava arhivu na 4 GiB, a ZipMaxCompressionRatio od 10000 odbacuje svaki deflate-ovan unos čije deklarisano proširenje prelazi desethiljaditostruko, uz degenerativan slučaj nenultih nekomprimovanih veličina uparenih sa nultom komprimovanom veličinom. Imena unosa prolaze kroz CanonicalZipEntryName u istom pozivu, koji odbacuje ugrađene NUL znakove, dvotačke, i bilo koji segment putanje .. sa Invalid ZIP entry name, i koji piše malim slovima i normalizuje segmente tako da se dva člana koja se razlikuju samo po velikim/malim slovima ili suvišnim separatorima sudare kao Duplicate ZIP entry name umesto da tiho zasene jedan drugog
Odbrana u dubini iznad ZIP sloja
ZIP sloj je jedan od nekoliko slojeva, a šablon se ponavlja gde god HotXLS parsira strukturu pod kontrolom napadača. Najjasniji primer sedi u BIFF parseru formula: TXLSFormula.GetTranslated rekurzira kroz tokene tMemFunc, tako da konstruisan niz rgce tokena u legacy .xls može ugnezditi proizvoljno duboko i iscrpiti stek. Kapija je konstanta, MaxTranslateDepth = 256, izabrana naspram poznate više-tokom činjenice, a ne nagađana. Excel ograničava ugnežđavanje formula na 64, tako da 256 ostavlja četvorostruk prostor i nikad ne može odbaciti formulu koju je pravi tabelarni proračun proizveo, dok i dalje prekida zlonameran tok dovoljno rano pre nego što stek 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;
Primetite da kapija vraća nil umesto da izaziva izuzetak. Formula preduboka da bi bila prava daje bez sintaksnog stabla, okolno parsiranje se nastavlja, a radna sveska se i dalje učitava. Ta asimetrija je namerna i vredna kopiranja u vaša sopstvena ograničenja: granica koja postoji da zaustavi iscrpljivanje resursa treba da degradira najmanju jedinicu koju može, ne da prekine dokument. Isto rezonovanje se primenjuje kad proširujete sloj izračunavanja, tako da ako registrujete sopstvene handlere kroz API prilagođenih funkcija formula engine-a, dajte im sopstvene granice argumenata i rekurzije umesto da pretpostavite da je pozivalac već proverio
Šta vam ove provere ne kupuju
Budite precizni oko granice. Četiri unakrsne provere EOCD čine indeks arhive nedvosmislenim, tako da HotXLS i bilo koji drugi usaglašen čitač razrešavaju isti fajl u isti skup unosa; ne govore ništa o tome da li je taj skup unosa bezopasan. Saglasnost lokalnog zaglavlja zaustavlja trik dva pogleda, ne zlonameran payload koji je konzistentno opisan. Verifikovan tok zaustavlja skraćivanje, prekoračenje i korupciju, ne savršeno dobro formiran XML deo koji kodira nešto što niste očekivali. I ništa od ovoga ne dira makroe: VBA projekat unutar strukturno besprekorne radne sveske je i dalje VBA projekat, a odluka da ga zadržite, uklonite ili odbijete pripada vašem sloju politike, ne ZIP čitaču
Ono što dobijate zauzvrat je čista granica otkaza. Nepouzdan xlsx se ili otvara kao jedna nedvosmislena arhiva čiji se članovi poklapaju sa deklarisanim veličinama i kontrolnim sumama, ili izaziva izuzetak sa porukom koja imenuje specifičnu invarijantu koju je prekršio, a vaš servis može karantinovati na izuzetku umesto da nagađa. ZIP čitač i slojevi parsera iznad njega isporučuju se kao deo HotXLS Excel komponente za Delphi i C++Builder, kojoj ne treba ni Excel ni OLE automatizacija na mašini koja parsira, i to odsustvo je samo po sebi značajno smanjenje onoga do čega uploadovan fajl može dosegnuti