Teknisk artikkel

ZIP EOCD-validering for upålitelige XLSX-filer i Delphi

En xlsx-fil er et ZIP-arkiv, og ZIP har ingen eneste autoritativ innholdsfortegnelse. HotXLS Excel Library for Delphi og C++Builder behandler den tvetydigheten som en angrepsflate: dens end of central directory-parser aksepterer en kandidatpost bare etter at fire uavhengige kryssjekker er enige, slik at en forfalsket katalog gjemt i en ZIP-kommentar aldri vinner

Scenarioet som gjør dette konkret er hverdagslig. En server tar imot regnearkopplastinger fra kunder. Filen består en antivirus-skanning, skrives til en spool-mappe, og din Delphi-tjeneste åpner den for å trekke ut tre kolonner. Alt ser fint ut, bortsett fra at skanneren og parseren din ikke ble enige om hva arkivet inneholdt. Skanneren opplistet ett sett med medlemmer; loaderen din opplistet et annet sett fra samme bytes. Ingen av dem har feil i vanlig forstand. De løste ganske enkelt en tvetydighet i ZIP-formatet i to forskjellige retninger, og en angriper valgte bytene slik at de ville gjøre nettopp det

Hvor bor egentlig sannheten om et ZIP-arkiv?

Den bor helt til slutt, i en 22-byte struktur kalt end of central directory-posten. En ZIP-fil leses ikke forfra og bak: hvert medlem bærer en lokal filheader like foran sine komprimerte data, men den autoritative indeksen er den sentrale katalogen, en rekke poster nær slutten som navngir hver oppføring og oppgir offseten til dens lokale header. For å finne den sentrale katalogen må du først finne EOCD-en, fordi EOCD-en er det som sier hvor katalogen starter og hvor mange poster den inneholder. HotXLS modellerer den som TEndOfCentralDirectoryRecord, hvis felt mapper en-til-en på disk-oppsettet: FDiskNumber ved offset 4, FStartDisk ved 6, FThisDiskEntries ved 8, FTotalEntries ved 10, FSizeOfCD ved 12, FOffsetOfStartCD ved 16, og FCommentLen ved 20. Den totalen er FMinSize, beregnet i konstruktøren som 4*3 + 5*2. Etter det kommer arkivkommentaren, opptil 65535 bytes med vilkårlig innhold, som gjør FMaxSize 65557 og betyr at posten ikke er på en fast posisjon. Du må lete etter den

Hvorfor er det ikke nok å skanne bakover etter EOCD-signaturen?

Fordi de fire bytene du skanner etter, PK\005\006, lovlig kan dukke opp inne i arkivkommentaren, inne i komprimerte data, eller inne i en andre EOCD en angriper la til med hensikt. En parser som stopper ved den første signaturen den møter mens den går bakover er trivielt styrbar: plasser en avledningsmanøver-EOCD nær halen, og den naive parseren følger den, mens en parser som skanner i en annen rekkefølge, eller som behandler den siste signaturen i filen som kanonisk, følger den ekte. Dette er ZIP-tvetydighet-familien av angrep, og gevinsten er nøyaktig delingen beskrevet over, hvor skannermotoren og forbrukerapplikasjonen ser forskjellige oppføringssett fra én fil

TEndOfCentralDirectoryRecord.Parse skanner faktisk bakover. Den setter startscan til den siste byten, klemmer endscan til lsize - FMaxSize eller null, og går gjennom vinduet i 256-byte buffere som overlapper med tre bytes slik at en signatur som spenner over en buffergrense aldri blir oversett. Forskjellen er hva som skjer ved et treff. Å finne signaturen produserer bare en Candidate-offset. HotXLS leser deretter de 22 bytene ved den offseten, parser dem med ReadEOCD, og krever at de resulterende feltene er internt konsistente med filen de hevder å beskrive før FOffsetEOCD i det hele tatt tildeles

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;

Les predikatet som fire separate påstander en forfalskning må oppfylle samtidig. Candidate + FMinSize + FCommentLen = lsize krever at den erklærte kommentarlengden når nøyaktig slutten av filen, som er det som dreper avledningsmanøver-i-kommentaren-trikset: en falsk EOCD begravet inne i en ekte kommentar kan ikke også stå til rette for hver byte etter seg selv. FDiskNumber = 0 og FStartDisk = 0 avviser felt for spredning over flere disker som ingen xlsx noensinne har brukt legitimt og som eksisterer i konstruerte arkiver bare for å forvirre. FThisDiskEntries = FTotalEntries avviser split-count-trikset hvor én parser dimensjonerer løkken sin fra ett felt og en annen parser fra det andre. Og Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate krever at den sentrale katalogen slutter nøyaktig der EOCD-en begynner, slik at katalogen ikke kan pekes mot en urelatert blob et annet sted i filen. Int64-castingen på den siste betyr noe: begge operandene er 32-bit, og uten utvidelse kunne et konstruert par pakke rundt og oppfylle testen aritmetisk mens de peker ingensteds fornuftig

Lokale headere må stemme overens med den sentrale katalogen

EOCD-sjekkene fastsetter hvilken katalog som er autoritativ; de garanterer ennå ikke at katalogen forteller sannheten om individuelle medlemmer. Hver oppføring beskrives to ganger i en ZIP-fil, én gang sentralt og én gang i sin lokale header, og ingenting i formatet tvinger de to beskrivelsene til å stemme, så en leser som stoler på den sentrale katalogen og en leser som stoler på lokale headere kan trekke ut forskjellig innhold fra ett arkiv. TZipEntry.ParseLocalHeader lukker det gapet ved å parse den lokale headeren ved FCdFile.LocalFileHeaderOffset og sammenligne de to kopiene felt for felt, og returnere en distinkt negativ kode for hver type uenighet: det kanoniserte oppføringsnavnet, komprimeringsmetoden, de generelle formålsflaggene, og, når data-deskriptor-flagget er tomt, CRC32 og begge størrelsene. Med det flagget satt kan de lokale kopiene være null, siden de reelle verdiene bor i en etterfølgende deskriptor, men enhver ikke-null lokal verdi må fortsatt stemme. En siste sjekk avviser oppføringer hvis data ville løpe forbi slutten av filen, ved å sammenligne Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) mot inputstream.Size. Enhver feil forplanter seg ut av TCentralDirectory.Parse som et ikke-1-resultat, og TZipArchive.OpenArchive gjør det om til Can't open zip archive, i stedet for å gi deg et halvveis tiltrodd arkivobjekt. Når du bare trenger å vite hvilke ark en fil inneholder, er det billig å kjøre den valideringen før en full parsing, og den lettvekts arkinspeksjonsstien gir deg nøyaktig det uten å materialisere celledata

Hva skjer når selve bytene lyver?

Strukturell enighet sier fortsatt ingenting om nyttelasten, så HotXLS pakker hver oppføringsstrøm inn i TZipVerifiedStream, som håndhever den erklærte størrelsen og CRC32 mens den som kaller leser. Dette er bevisst ikke en etterfølgende sjekk: en dekomprimeringsbombe hvis erklærte ukomprimerte størrelse er 4 KB, men som ekspanderer til gigabyte, stoppes ved 4 KB-merket, ikke etter at skaden er skjedd. Wrapperen klemmer hver lesing til de gjenværende erklærte bytene, kaster ZIP entry ended before its declared size hvis kilden går tom tidlig, prober etter én ekstra byte ved fullførelse og kaster ZIP entry exceeds its declared size hvis noe er igjen, og sammenligner til slutt den løpende CRC32 i VerifyComplete, og kaster ZIP entry uncompressed size mismatch eller 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;

Én konsekvens er verdt å planlegge for. Strømmen er kun fremover av design; en Seek til noe annet enn gjeldende posisjon kaster ZIP entry stream is forward-only, med én innrømmelse for soEnd med offset null slik at størrelsesspørringer fortsatt fungerer. Det er den riktige avveiningen for upålitelige inndata, fordi en strøm du kan spole tilbake er en strøm hvis CRC-regnskap du kan beseire, men det betyr at forbrukerkode som forventer en søkbar strøm trenger sin egen buffer. Samme fremover-bare-disiplin underbygger den strømmende direktelesningen, som er API-et å gripe til når den opplastede arbeidsboken er stor nok til at du ikke vil ha den boende i minnet i det hele tatt

Ressursgrenser før allokering, ikke etter

Tre konstanter i lxZipArchive avgrenser hva ett enkelt arkiv kan be prosessen om å gjøre, og TZipEntries.Add anvender dem mens den sentrale katalogen fortsatt leses, før en eneste byte oppføringsdata er rørt. ZipMaxEntryUncompressedSize setter et tak på ett medlem ved 1 GiB, ZipMaxTotalUncompressedSize setter et tak på arkivet ved 4 GiB, og ZipMaxCompressionRatio på 10000 avviser enhver deflatert oppføring hvis erklærte ekspansjon overstiger titusenfold, sammen med det degenererte tilfellet av en ikke-null ukomprimert størrelse paret med en null komprimert størrelse. Oppføringsnavn går gjennom CanonicalZipEntryName i samme kall, som avviser innebygde NUL-tegn, kolon, og ethvert ..-stisegment med Invalid ZIP entry name, og som gjør segmenter små bokstaver og normaliserer dem slik at to medlemmer som bare skiller seg i store/små bokstaver eller i overflødige skilletegn kolliderer som Duplicate ZIP entry name i stedet for stille å skygge for hverandre

Forsvar i dybden over ZIP-laget

ZIP-laget er ett av flere nivåer, og mønsteret gjentar seg overalt hvor HotXLS parser angriper-kontrollert struktur. Det klareste eksempelet sitter i BIFF-formelparseren: TXLSFormula.GetTranslated rekurserer gjennom tMemFunc-tokener, så en konstruert rgce-tokenstrøm i en eldre .xls kan nøste vilkårlig dypt og tømme stakken. Porten er en konstant, MaxTranslateDepth = 256, valgt mot et kjent oppstrøms-faktum snarere enn gjettet. Excel setter et tak på formelnøsting ved 64, så 256 etterlater firedobbel takhøyde og kan aldri avvise en formel et reelt regneark produserte, samtidig som den termine­rer en fiendtlig strøm lenge nok før stakken går tom

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

Merk at porten returnerer nil i stedet for å kaste et unntak. En formel for dyp til å være ekte gir ikke noe syntaktre, den omkringliggende parsingen fortsetter, og arbeidsboken lastes fortsatt. Den asymmetrien er tilsiktet og verdt å kopiere i dine egne grenser: en grense som finnes for å stoppe ressursuttømming bør degradere den minste enheten den kan, ikke avbryte dokumentet. Samme resonnement gjelder når du utvider beregningslaget, så hvis du registrerer dine egne håndterere gjennom formelmotorens API for tilpassede funksjoner, gi dem sine egne argument- og rekursjonsgrenser i stedet for å anta at den som kaller allerede har sjekket

Hva disse sjekkene ikke gir deg

Vær presis om grensen. De fire EOCD-kryssjekkene gjør arkivindeksen utvetydig, så HotXLS og enhver annen samsvarende leser løser samme fil til samme oppføringssett; de sier ingenting om hvorvidt det oppføringssettet er ufarlig. Enighet om lokal header stopper to-visninger-trikset, ikke en ondsinnet nyttelast som er konsekvent beskrevet. Den verifiserte strømmen stopper avkortning, overflyt og korrupsjon, ikke en perfekt velformet XML-del som koder noe du ikke forventet. Og ingenting av dette rører makroer: et VBA-prosjekt inne i en strukturelt feilfri arbeidsbok er fortsatt et VBA-prosjekt, og avgjørelsen om å beholde, fjerne eller avvise det hører hjemme i policylaget ditt, ikke i ZIP-leseren

Hva du får i bytte er en ren feilgrense. En upålitelig xlsx enten åpner som ett utvetydig arkiv hvis medlemmer stemmer med sine erklærte størrelser og sjekksummer, eller den kaster et unntak med en melding som navngir den spesifikke invarianten den brøt, og tjenesten din kan sette i karantene på unntaket i stedet for å gjette. ZIP-leseren og parser-lagene over den leveres som del av HotXLS Excel-komponent for Delphi og C++Builder, som verken trenger Excel eller OLE-automasjon på maskinen som gjør parsingen, og det fraværet er i seg selv en meningsfull reduksjon i hva en opplastet fil kan nå