PDF Library for Delphi našla päť defektov v dekodéroch pri prenose kódu pre CCITT, TIFF, PNG, Flate a stream buffer na Free Pascal, a každý z nich roky prechádzal kompletnou testovacou sadou pre Delphi. Ani jeden nebola chyba kompilátora. Každý bol Pascal, ktorý Delphi náhodou vykonávalo správne vďaka implementačnému detailu: skrytý parameter výsledku, ktorý aliasoval pole volajúceho, vetva mimo rozsahu, za ktorú sa nikto nikdy nepozrel, buffer s nulovou dĺžkou, ktorého jedinou strážou bol prepínač range check, 1-based offset, ktorý ako 1 odovzdala len jedna cesta kódu, a kontrakt TStream.Read, ktorý in-memory streamy nikdy nepreveria. Zmeňte kompilátor alebo naservírujte tomu istému kódu poškodený súbor a náhoda prestane platiť
Nižšie je konkrétny tvar každého z nich, oprava a disciplína, ktorá z toho vzišla: ten istý zdroj musí na oboch kompilátoroch produkovať rovnakú sémantiku dokumentu a testovací include to overuje. Súbežný článok o spevnení Pascal PDF parsera proti škodlivým súborom pokrýval šírku celých čísel, hĺbku rekurzie a neinicializované buffery. Tento je o inej triede zlyhaní: kóde, ktorý bol zlý odjakživa a kompilátor ho potichu kryl
Prečo funkcia vracajúca dynamické pole funguje na Delphi bez SetLength?
Pretože Delphi odovzdá ako skrytý parameter výsledku samotnú premennú volajúceho, takže funkcia, ktorá svoj výsledok nikdy nealokuje, dokáže zapisovať do poľa, ktoré alokoval volajúci. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray je vyhľadávanie v referenčnom riadku v srdci dvojrozmerného dekódovania Group 3 a Group 4: pre aktuálnu pozíciu a0 a farbu aktuálneho run-u prehľadá changing elementy predchádzajúceho scanline-u, teda b1 a b2 z dvojrozmernej schémy ITU-T T.4 a T.6, a vráti ich ako pole s dvoma slotmi. Pôvodná funkcia zapisovala do Result[0] a Result[1] a SetLength na Result nezavolala vôbec nikdy
To by malo spadnúť pri prvom zápise a na Free Pascal aj spadne. Na Delphi to nikdy nespadlo, pretože obe volania v dekodéri vyzerajú takto: deklarujte b: TCCITTIntegerArray, raz pred slučkou cez scanline spustite SetLength(b, 2), potom vo vnútri slučky priraďte b := GetNextChangingElement(a0, IsWhite) a čítajte b[0] a b[1]. Príručka jazyka Delphi hovorí, že funkcia, ktorej výsledkom je long string, dynamické pole alebo iný managed typ, dostane tento výsledok ako dodatočný parameter var, a v praxi kompilátor odovzdá adresu cieľa priradenia. Takže Result vo vnútri funkcie je samo b, už dvojprvkové, a každý zápis pristane v pamäti, ktorú vlastní volajúci. Free Pascal odovzdá funkcii čerstvé nil pole a to sa do b priradí až potom, čo je práve to čítanie kontraktu, proti ktorému sa ten kód mal písať od začiatku
Aliasing niesol aj sémantiku, na ktorej dekodér závisí. Result[0] sa priradí len vtedy, keď sken nájde element väčší než a0, a Result[1] len vtedy, keď existuje element za ním, takže po netrafení zostanú v slotoch hodnoty, ktoré v b nechala predchádzajúca iterácia. Zjavná oprava — naalokovať dva sloty a pri každom volaní ich vynulovať — by toto prenášanie zničila a zmenila dekódovaný výstup na Delphi. Doručená oprava je stráž namiesto resetu: na Delphi je to mŕtvy kód a dekódovacia cesta zostáva bajt po bajte taká, aká bola, a na Free Pascal mení pád na zamýšľané správanie. Práve tá asymetria je celá pointa, keďže oprava musela byť no-op na kompilátore, kde kód už produkoval overený výstup
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi sem dorazí s dvojprvkovým poľom volajúceho aliasovaným
// ako Result, takže je to tu no-op. FPC dorazí s nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] sa stále zapisujú len pri zhode, takže netrafenie
// ponechá hodnoty z predchádzajúcej iterácie presne ako predtým
End;
Počet, ktorý prežil svoje dáta: záznam TIFF directory
Keď zneplatníte pole, musíte v tom istom príkaze zneplatniť aj jeho počet, inak tomu počtu uverí kód, ktorý pole nikdy nevidí. Záznam TIFF image file directory (TIFF 6.0 §2, dvanásťbajtové rozloženie tag, type, count a value-or-offset) nesie 32-bitový počet priamo zo súboru a PDF Library for Delphi číta každý z nich cez PopDE: TTIFFEntry, záznam s Tag, TagType, Length, Offset a dekódovanými poľami IntegerValues a DoubleValues. Pôvodný kód overoval, či Offset + TypeSize * Length preteká za koniec súboru, a ak áno, nastavil obe polia na nulovú dĺžku. Result.Length nechal na hodnote zo súboru
Dve veci sa odtiaľ zvrtli. Funkcia končí fallbackom, ktorý hovorí "ak je Length nula, daj záznamu jeden element s hodnotou nula", aby volajúci vždy mohli prečítať element nula. Keďže sa Length na ceste mimo rozsahu nikdy nevyčistil, ten fallback nikdy nezabral presne pre ten jediný prípad, kvôli ktorému existoval. A volajúci element nula naozaj čítajú, bezpodmienečne: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip a tucet ďalších berie E.IntegerValues[0], a tabuľky stripov robia Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), teda kopírujú Length krát štyri bajty z poľa, ktoré nemá ani jeden. Vyčistené pole so živým počtom je striktne nebezpečnejšie než neoverené, pretože to neoverené aspoň drží bajty, ktoré tvrdí, že drží
Druhý problém bol v poradí. Obe volania SetLength bežali pred testom rozsahu, s veľkosťou podľa počtu zo súboru, takže nepriateľský záznam si mohol vyžiadať alokáciu niekoľkých gigabajtov ešte pred jedinou kontrolou platnosti. Na Delphi výslednú výnimku zachytil handler vyššie v ceste načítania obrázka a súbor sa jednoducho nenačítal, preto si to nikto nevšimol; v skutočnosti išlo o out-of-memory udalosť, ktorú si vybral súbor. Oprava presúva alokáciu za test a nechá počet cestovať spolu s dátami
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // počet ide spolu s hodnotami
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // až teraz
SetLength(Result.DoubleValues, Result.Length);
End;
// ... neskôr sa existujúci fallback konečne dostane k prípadu, pre ktorý bol:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Na tejto oprave nie je nič špecifické pre kompilátor, a práve preto patrí do tohto zoznamu. Defekt bol na Delphi latentný z toho istého dôvodu ako na Free Pascal: žiadny testovací súbor nemal directory záznam ukazujúci za koniec súboru. Port ho neodhalil. Odhalilo ho čítanie kódu s otázkou "čo tu za mňa robí Delphi, čo nerobím sám"
Čo sa stane, keď PNG IHDR hlási color type, ktorý formát nedefinuje?
PDF Library for Delphi teraz obrázok odmietne ešte pred spustením riadkových filtrov; pred verziou v3.539.2 spočítala scanline s nulovou dĺžkou a odovzdala unfilter slučkám prázdny buffer. ISO 15948 §11.2.2 definuje chunk IHDR a tabuľka 11.1 uvádza šesť povolených kombinácií color type a bit depth: grayscale s 1, 2, 4, 8 alebo 16 bitmi, indexed color s 1, 2, 4 alebo 8 a truecolor, grayscale s alfou a truecolor s alfou s 8 alebo 16 bitmi. TPNGReader validoval polia compression method a filter method v IHDR a FColorType aj bit depth prepúšťal bez dotknutia
Kód riadkových filtrov odvodzuje všetko z Case FColorType Of, ktorý každému color type priradí počet komponentov. Color type mimo tých šiestich padne do vetvy Else, kde je SourceComponents nula, teda ScanlineByteCount je nula, teda po SetLength(PreviousScanline, 0) okamžite nasleduje FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexovanie elementu nula prázdneho dynamického poľa je adresa spočítaná z nil. S vypnutým range checking je nulový fill cez tú adresu tichý no-op a dekodér kráča ďalej cez riadky, ktoré neexistujú; so zapnutým range checking je to ERangeError už na prvom obrázku; a volania Move, ktoré nasledujú, sú na krok od access violation. Ktoré z toho dostanete, závisí od kompilátora a od build prepínačov, nie od toho, čo dekodér rozhodol, a to je ten signál, že dekodér nerozhodol vôbec nič
Opravou je tabuľka zo špecifikácie, nasadená tam, kde sa ostatné polia IHDR už kontrolovali: COLOR_GRAYSCALE akceptuje FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE akceptuje [1, 2, 4, 8] a COLOR_RGB, COLOR_GRAYSCALEALPHA a COLOR_RGBALPHA akceptujú [8, 16]; čokoľvek iné vyčistí ValidImage a obrázok je odmietnutý so zachovanou šírkou a výškou pre diagnostiku. Chunk pHYs kratší než svojich deväť bajtov sa zavrel v tej istej dávke, keďže čítač DPI indexoval S[1] až S[8] v reťazci, ktorý krátky chunk nechal prázdny
1-based offset, s ktorým sa zaobchádzalo ako s 0-based pointerom
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString berie StartPos ako 1-based, pretože jeho vstupom je AnsiString a implementácia v Delphi adresuje vstup pre zlib ako @Input[StartPos]. Implementácia pre Free Pascal, napísaná proti paszlib, aby oba Windows targety linkovali kompresiu staticky, nastavila next_in na PAnsiChar(Input) + StartPos a avail_in na Length(Input) - StartPos. To je pointer aritmetika a tá je 0-based. Odovzdajte 1, čo pre túto funkciu znamená "začni na začiatku", a FPC build začne inflate od druhého bajtu a skončí jeden bajt pred koncom
Prežilo to preto, že jediný volajúci, ku ktorému sa väčšina testov dostane, je InflateStr, a ten odovzdáva 0. Nula je náhodou správny 0-based offset, takže oba buildy sa zhodovali pri každom čistom volaní InflateStr aj v každom teste, ktorý cez neho prešiel. TPDFDocument.DecodeAllStreams, rutina, ktorou SaveQDFToFile a ConvertFileToQDF rozbaľujú streamy s jediným FlateDecode do čitateľnej podoby, odovzdáva 1. Na FPC build-e preskočená zlib hlavička spôsobila, že inflate zlyhal, ale zlib stream napriek tomu hlásil nenulový Consumed za bajty, ktoré stihol preskúmať, takže DecodeAllStreams vzal prázdny payload ako úspešné dekódovanie a nahradil každý content stream prázdnym reťazcom. Výsledný QDF mal správny počet strán, platnú štruktúru a žiadny obsah strán, teda súbor, ktorý sa v každom viewer-i otvorí bez chyby a nič nezobrazí
// Vetva FPC pre InflateStrFromPosition, po v3.539.16.
// StartPos je 1-based ako vo vetve Delphi; orežte ho a potom ho
// na 0-based offset pointera preveďte práve raz, na hranici.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
Regresný test, ktorý to stráži, je najmenší možný: zdeflateujte payload, inflateujte ho z pozície 0 a z pozície 1 a overte, že obe vrátia rovnaký payload a obe hlásia Consumed rovnajúci sa celej dĺžke streamu. Stream podľa RFC 1950 má dvojbajtovú hlavičku a štvorbajtový Adler-32 trailer, takže off-by-one na ktoromkoľvek konci nie je jemná korupcia, ale stream, ktorý buď nezvládne začať, alebo nezvládne skončiť. Lekcia je o hranici, nie o zlib-e: keď je parameter funkcie definovaný v jednej indexovej báze a implementácia pod ním používa druhú, prevod patrí presne na jeden riadok a test ho musí zavolať s hodnotou, ktorá obe bázy rozlíši
Prečo krátky TStream.Read nie je koniec streamu?
Pretože TStream.Read smie vrátiť menej bajtov, než koľko si vyžiadate, z hocijakého dôvodu, a len návratová hodnota 0 znamená, že už nič nie je. TMemoryStream a TFileStream na lokálnom disku požiadavku takmer vždy naplnia, a preto kód, ktorý berie "vrátil menej, než som žiadal" ako koniec súboru, prejde každým testom, ktorý ich používa. Streamy nad sieťou, dekompresné streamy a hocijaký TStream potomok od zákazníka vráti dva bajty, keď si vypýtate šesťdesiatštyri tisíc, a za sebou ešte majú gigabajty
TPLBuffer je čítačka, cez ktorú prechádza každý parser v PDF Library for Delphi, a dokáže obaliť AnsiString, pointer, bajtové pole alebo TStream. Jeho štyri skenovacie dotazy DistanceToByte, DistanceToOtherByte, DistanceToAnyByte a DistanceToOtherBytes, všetky vracajúce Int64, čítajú zdroj v 64 KB blokoch, hľadajú oddeľovač a hlásia, ako ďaleko je, bez posunu logickej pozície. Každá slučka končila s Until ReadCount < BlockSize. Pre tri in-memory zdroje je to správne, keďže ReadIntoBuffer vždy dodá celý blok až do posledného. Pre streamový zdroj to znamená, že sken to vzdá pri prvom krátkom čítaní, oddeľovač nahlási ako neprítomný a tokenizer nad ním sa rozhodne, že objekt končí tam, kde nekončí
// TPLBuffer.DistanceToByte, slučka po v3.539.6.
// Nula je jediný signál konca dát, ktorý TStream.Read definuje.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // peek nesmie pohnúť čítačkou
End;
Test, ktorý to pripína, je potomok TMemoryStream, ktorého prepísaný Read obmedzí každú požiadavku na dva bajty. Zabaľte doňho reťazec aaaaaX, nastavte pozíciu buffra na 1 a všetky štyri dotazy musia hlásiť vzdialenosť 4 k X, potom nechať pozíciu na 1 a vrátiť -1 pre bajt, ktorý tam nie je. Pred opravou prvý dotaz videl dva bajty, usúdil, že stream je vyčerpaný, a vrátil -1. finally je rovnako dôležitý ako podmienka slučky: Exit zvnútra skenu je normálna úspešná cesta a logická pozícia sa musí obnoviť aj na tej ceste, nielen keď slučka dobehne do konca
Jeden zdroj, dva kompilátory, jedna sada assertov
Disciplína, ktorá z týchto piatich vzišla, znie: "build pre Delphi prechádza" je dôkaz o Delphi, nie o zdroji. Od verzie v3.539.16 sada DUnitX pre Delphi aj konzolová sada pre Free Pascal obsahujú ten istý Tests\CrossCompilerSemantics.inc, jednu rutinu RunCrossCompilerFileSemantics, ktorá cez TPDFlib postaví dvojstranový dokument s komprimovaným obsahom, uloží ho, uloží ho znova ako QDF cez SaveQDFToFile, opraví QDF cez RepairQDFFile, zašifruje čistý súbor AES-128 cez EncryptFile a masku práv z EncodePermissions, a potom znovu načíta každý artefakt a na oboch kompilátoroch overí to isté: počet strán je 2, titulok prežije, text druhej strany sa vytiahne neporušený z čistého, opraveného aj zašifrovaného súboru, nesprávne heslo je odmietnuté s nenulovým LastErrorCode, EncryptionStrength je 128, EncryptionAlgorithm je 2 a jednotlivé bity práv z GetUserPermissions sa vrátia presne tak, ako boli zakódované
Porovnanie je zámerne normalizované, nie bajt po bajte. Šifrovanie ťahá náhodné soli a writer prideľuje identifikátory dokumentu, takže od tých dvoch buildov nikto nečaká identické súbory; čaká sa, že vyprodukujú súbory s rovnakým významom, a asserty sú formulované na tejto úrovni. QDF vetva je tam práve kvôli offset chybe: QDF s dvoma stranami a žiadnym obsahom prejde kontrolou počtu strán a neprejde kontrolou extrakcie textu, a matica overuje tú druhú. Každá budúca oprava, ktorá je na jednom kompilátore no-op a na druhom zmenou správania, čo platí pre štyri z piatich vyššie, musí teraz pred vydaním prejsť tou istou sadou assertov dvakrát
Linkovacia polovica toho istého portu, teda zosúladenie OMF objektov z Delphi s tým, čo očakáva COFF z Free Pascal, je samostatný príbeh v linkovaní OMF to COFF objektov pre FPC Win32, a štrukturálny hardening toho istého TIFF čítača proti BigTIFF a tiled súborom je v poznámkach k vstavanému TIFF dekodéru. Dekodéry z tohto článku aj cross-compiler test, ktorý teraz sedí pod nimi, sú súčasťou PDF Library for Delphi pre Delphi, C++Builder a Free Pascal, kde sa od toho istého zdroja očakáva, že si rovnaký výsledok vyslúži na každom kompilátore, na ktorý mieri, a nedostane ho od jedného z nich zadarmo