Teknisk artikel

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

En xlsx-fil er et ZIP-arkiv, og ZIP har ingen enkelt autoritativ indholdsfortegnelse. HotXLS Excel Library til Delphi og C++Builder behandler den tvetydighed som en angrebsflade: dens end of central directory-parser accepterer kun en kandidat-record, efter fire uafhængige krydstjek er enige, så en forfalsket mappe gemt i en ZIP-kommentar aldrig vinder

Scenariet, der gør dette konkret, er hverdagsagtigt. En server accepterer regneark-uploads fra kunder. Filen består en antivirusscanning, skrives til en spool-mappe, og din Delphi-tjeneste åbner den for at hente tre kolonner ud. Alt ser fint ud, bortset fra at scanneren og din parser ikke var enige om, hvad arkivet indeholdt. Scanneren opremsede ét sæt medlemmer; din loader opremsede et andet sæt fra de samme bytes. Ingen af dem er fejlbehæftet i almindelig forstand. De løste blot en tvetydighed i ZIP-formatet i to forskellige retninger, og en angriber valgte bytene, så de ville

Hvor bor sandheden om et ZIP-arkiv egentlig?

Den bor helt til sidst, i en 22-byte struktur kaldet end of central directory-recorden. En ZIP-fil læses ikke forfra og bagud: hvert medlem bærer et lokalt filhoved umiddelbart før sine komprimerede data, men det autoritative indeks er den centrale mappe, en række records nær slutningen, der navngiver hver post og angiver offsetten på dens lokale hoved. For at finde den centrale mappe skal man først finde EOCD'en, fordi EOCD'en er det, der siger, hvor mappen begynder, og hvor mange records den holder. HotXLS modellerer den som TEndOfCentralDirectoryRecord, hvis felter mapper én til én ind på det diskbaserede layout: FDiskNumber ved offset 4, FStartDisk ved 6, FThisDiskEntries ved 8, FTotalEntries ved 10, FSizeOfCD ved 12, FOffsetOfStartCD ved 16, og FCommentLen ved 20. Den total er FMinSize, beregnet i konstruktøren som 4*3 + 5*2. Efter den kommer arkivkommentaren, op til 65535 bytes vilkårligt indhold, hvilket gør FMaxSize 65557 og betyder, at recorden ikke er på en fast position. Man må ud og lede

Hvorfor er baglæns-scanning efter EOCD-signaturen ikke nok?

Fordi de fire bytes, man scanner efter, PK\005\006, lovligt kan optræde inde i arkivkommentaren, inde i komprimerede data, eller inde i en anden EOCD, en angriber har tilføjet med vilje. En parser, der stopper ved den første signatur, den møder, mens den går baglæns, er trivielt styrbar: placér en lokke-EOCD nær halen, og den naive parser følger den, mens en parser, der scanner i en anden rækkefølge, eller som behandler den sidste signatur i filen som autoritativ, følger den rigtige. Dette er ZIP-tvetydighedsfamilien af angreb, og dens gevinst er præcis den spaltning beskrevet ovenfor, hvor scanningsmotoren og den forbrugende applikation ser forskellige postsæt fra én fil

TEndOfCentralDirectoryRecord.Parse scanner rent faktisk baglæns. Den sætter startscan til den sidste byte, klemmer endscan til lsize - FMaxSize eller nul, og gennemgår vinduet i 256-byte buffere, der overlapper med tre bytes, så en signatur, der spænder over en buffergrænse, aldrig overses. Forskellen er, hvad der sker ved et hit. At finde signaturen producerer kun en Candidate-offset. HotXLS læser så de 22 bytes ved den offset, parser dem med ReadEOCD, og kræver, at de resulterende felter er internt konsistente med filen, de hævder at beskrive, før FOffsetEOCD overhovedet 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;

Læs prædikatet som fire separate påstande, en forfalskning skal opfylde samtidig. Candidate + FMinSize + FCommentLen = lsize kræver, at den erklærede kommentarlængde når helt til filens ende, hvilket er det, der dræber lokke-i-kommentaren-tricket: en falsk EOCD begravet inde i en ægte kommentar kan ikke også stå til rette for hver byte efter sig selv. FDiskNumber = 0 og FStartDisk = 0 afviser felterne for spredning over flere diske, som ingen xlsx nogensinde legitimt har brugt, og som kun findes i konstruerede arkiver for at forvirre. FThisDiskEntries = FTotalEntries afviser split-count-tricket, hvor én parser dimensionerer sin løkke fra ét felt og en anden parser fra det andet. Og Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate kræver, at den centrale mappe slutter præcis, hvor EOCD'en begynder, så mappen ikke kan pege på en urelateret blob et andet sted i filen. Int64-casten på den sidste betyder noget: begge operander er 32-bit, og uden udvidelse kunne et konstrueret par tilfredsstille testen aritmetisk, mens den peger ingen steder fornuftigt

Lokale hoveder skal stemme overens med den centrale mappe

EOCD-tjekkene fastlægger, hvilken mappe der er autoritativ; de garanterer endnu ikke, at mappen fortæller sandheden om individuelle medlemmer. Hver post beskrives to gange i en ZIP-fil, én gang centralt og én gang i sit lokale hoved, og intet i formatet tvinger de to beskrivelser til at matche, så en læser, der stoler på den centrale mappe, og en læser, der stoler på lokale hoveder, kan udtrække forskelligt indhold fra ét arkiv. TZipEntry.ParseLocalHeader lukker det hul ved at parse det lokale hoved ved FCdFile.LocalFileHeaderOffset og sammenligne de to kopier felt for felt, og returnerer en distinkt negativ kode for hver slags uoverensstemmelse: det kanonicaliserede postnavn, komprimeringsmetoden, de generelle formålsbit-flag, og, når data-descriptor-flaget er ryddet, CRC32 og begge størrelser. Med det flag sat kan de lokale kopier være nul, da de rigtige værdier bor i en efterfølgende descriptor, men enhver ikke-nul lokal værdi skal stadig matche. Et sidste tjek afviser poster, hvis data ville løbe forbi filens ende, ved at sammenligne Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) mod inputstream.Size. Enhver fejl forplanter sig ud af TCentralDirectory.Parse som et resultat forskelligt fra 1, og TZipArchive.OpenArchive gør det til Can't open zip archive, frem for at give dig et halvvejs betroet arkivobjekt. Skal man kun vide, hvilke ark en fil indeholder, er det billigt at køre den validering før en fuld parsing, og den letvægts arkinspektionssti giver dig netop det uden at materialisere celledata

Hvad sker der, når selve bytene lyver?

Strukturel overensstemmelse siger stadig intet om payloaden, så HotXLS pakker hver post-strøm ind i TZipVerifiedStream, som håndhæver den erklærede størrelse og CRC32, mens kalderen læser. Det er bevidst ikke et efterfølgende tjek: en dekomprimeringsbombe, hvis erklærede ukomprimerede størrelse er 4 KB, men som oppustes til gigabytes, stoppes ved 4 KB-mærket, ikke efter skaden er sket. Wrapperen klemmer hver læsning til de resterende erklærede bytes, rejser ZIP entry ended before its declared size, hvis kilden løber tør tidligt, sonderer for én ekstra byte ved afslutning og rejser ZIP entry exceeds its declared size, hvis der er noget tilbage, og sammenligner endelig den løbende CRC32 i VerifyComplete, og rejser 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 værd at planlægge for. Strømmen er kun fremadgående af design; en Seek til andet end den aktuelle position rejser ZIP entry stream is forward-only, med én undtagelse for soEnd med offset nul, så størrelsesforespørgsler stadig virker. Det er den rigtige afvejning for upålideligt input, fordi en strøm, man kan spole tilbage, er en strøm, hvis CRC-regnskab man kan besejre, men det betyder, at forbrugerkode, der forventer en søgbar strøm, behøver sin egen buffer. Samme fremadgående disciplin ligger bag den strømmende direkte læser, som er API'en at gribe til, når den uploadede projektmappe er stor nok til, at man slet ikke ønsker den residente i hukommelsen

Ressourcegrænser før allokering, ikke efter

Tre konstanter i lxZipArchive afgrænser, hvad ét arkiv kan bede processen om at gøre, og TZipEntries.Add anvender dem, mens den centrale mappe stadig læses, før en eneste byte postdata røres. ZipMaxEntryUncompressedSize begrænser ét medlem til 1 GiB, ZipMaxTotalUncompressedSize begrænser arkivet til 4 GiB, og ZipMaxCompressionRatio på 10000 afviser enhver deflateret post, hvis erklærede udvidelse overstiger titusindfold, sammen med det degenererede tilfælde af en ikke-nul ukomprimeret størrelse parret med en nul komprimeret størrelse. Postnavne går gennem CanonicalZipEntryName i samme kald, som afviser indlejrede NUL-tegn, kolon og ethvert ..-sti-segment med Invalid ZIP entry name, og som lowercaser og normaliserer segmenter, så to medlemmer, der kun adskiller sig i store/små bogstaver eller i redundante skilletegn, kolliderer som Duplicate ZIP entry name i stedet for stiltiende at skygge for hinanden

Forsvar i dybden over ZIP-laget

ZIP-laget er ét niveau blandt flere, og mønstret gentager sig, hvor end HotXLS parser angriberkontrolleret struktur. Det klareste eksempel sidder i BIFF-formelparseren: TXLSFormula.GetTranslated rekurserer gennem tMemFunc-tokens, så en konstrueret rgce-token-strøm i en gammel .xls kan indlejre vilkårligt dybt og udmatte stakken. Vagten er en konstant, MaxTranslateDepth = 256, valgt mod et kendt upstream-faktum frem for gættet. Excel begrænser formelindlejring til 64, så 256 efterlader firedobbelt margin og kan aldrig afvise en formel, et rigtigt regneark producerede, mens den stadig termineres en ondsindet strøm længe nok, før stakken løber tør

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

Bemærk, at vagten returnerer nil frem for at rejse. En formel for dyb til at være ægte giver intet syntakstræ, den omgivende parsing fortsætter, og projektmappen indlæses stadig. Den asymmetri er tilsigtet og værd at kopiere i egne grænser: en grænse, der findes for at stoppe ressourceudmattelse, bør nedgradere den mindste enhed, den kan, ikke afbryde dokumentet. Samme ræsonnement gælder, når man udvider beregningslaget, så registrerer man sine egne handlere gennem formelmotorens brugerdefinerede funktions-API, giv dem deres egne argument- og rekursionsgrænser i stedet for at antage, at kalderen allerede tjekkede

Hvad disse tjek ikke køber dig

Vær præcis om grænsen. De fire EOCD-krydstjek gør arkivindekset entydigt, så HotXLS og enhver anden overholdende læser løser samme fil til samme postsæt; de siger intet om, hvorvidt det postsæt er godartet. Overensstemmelse i lokale hoveder stopper to-visninger-tricket, ikke en ondsindet payload, der konsekvent beskrives. Den verificerede strøm stopper afkortning, overløb og korruption, ikke en helt velformet XML-del, der koder noget, man ikke forventede. Og intet af dette rører makroer: et VBA-projekt inde i en strukturelt fejlfri projektmappe er stadig et VBA-projekt, og beslutningen om at beholde, fjerne eller afvise det hører til dit politiklag, ikke ZIP-læseren

Hvad man får til gengæld, er en ren fejlgrænse. En upålidelig xlsx åbner enten som ét entydigt arkiv, hvis medlemmer matcher deres erklærede størrelser og tjeksummer, eller den rejser med en besked, der navngiver den specifikke invariant, den brød, og din tjeneste kan sætte i karantæne på undtagelsen frem for at gætte. ZIP-læseren og parser-niveauerne over den leveres som en del af HotXLS Excel-komponenten til Delphi og C++Builder, som ikke behøver hverken Excel eller OLE-automation på maskinen, der udfører parsingen, og det fravær er i sig selv en meningsfuld reduktion af, hvad en uploadet fil kan nå