Műszaki cikk

Véletlenül működő Delphi kód: öt FPC portolási hiba

A PDF Library for Delphi öt dekóderhibát talált, miközben a CCITT, TIFF, PNG, Flate és stream-buffer kódját Free Pascalra vitte át, és mindegyik évekig átment a teljes Delphi tesztsorozaton. Egyik sem fordítóhiba volt. Mindegyik olyan Pascal volt, amit a Delphi csak egy implementációs részlet miatt futtatott helyesen: egy rejtett eredményparaméter, ami a hívó tömbjét álnevezte, egy tartományon kívüli ág, amin soha senki nem olvasott túl, egy nullahosszúságú buffer, aminek az egyetlen őre egy range-check kapcsoló volt, egy 1-alapú eltolás, amit csak egyetlen kódútvonal adott át soha másként, mint 1-ként, és egy TStream.Read szerződés, amit a memóriabeli streamek soha nem gyakorolnak. Cseréld le a fordítót, vagy adj ugyanennek a kódnak egy hibás fájlt, és a véletlen megszűnik

A következőkben mindegyik konkrét formája, a javítás, és a fegyelem, ami kijött belőle: ugyanannak a forrásnak mostantól mindkét fordítón ugyanazt a dokumentumszemantikát kell előállítania, és egy teszt-include ellenőrzi, hogy így is tesz. A testvércikk a Pascal PDF-parser felkeményítéséről rosszindulatú fájlok ellen az egész szélességről, a rekurziós mélységről és az inicializálatlan bufferekről szólt. Ez itt egy másik hibacsztályról szól: olyan kódról, ami végig hibás volt, és volt egy fordító, ami csendben fedezte

Miért működik a Delphi-n a dinamikus tömböt visszaadó függvény SetLength nélkül?

Mert a Delphi a hívó saját változóját adja be rejtett eredményparaméterként, így egy függvény, ami soha nem allokálja az eredményét, mégis tud írni egy tömbbe, amit a hívó allokált. A TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray a referencia-sor keresése a kétdimenziós Group 3 és Group 4 dekódolás szívében: adott a0 pozíció és az aktuális run színe mellett az előző scanline változó elemeit keresi, az ITU-T T.4 és T.6 kétdimenziós kódolási séma b1 és b2 elemét, és két elemű tömbként adja vissza őket. Az eredeti függvény a Result[0]-t és a Result[1]-et írta, és a Result-on soha nem hívta meg a SetLength-et

Ennek az első írásnál hibáznia kellene, és a Free Pascalon hibázik is. A Delphi-n soha nem hibázott, mert a dekóder mindkét hívási helye így néz ki: deklarál egy b: TCCITTIntegerArray-t, lefuttat egy SetLength(b, 2)-t egyszer a scanline-ciklus előtt, majd a cikluson belül hozzárendeli a b := GetNextChangingElement(a0, IsWhite) értéket, és olvassa a b[0]-t meg a b[1]-et. A Delphi nyelvi útmutatója kimondja, hogy az a függvény, aminek az eredménye long string, dinamikus tömb vagy más menedzselt típus, azt az eredményt egy további var paraméterként kapja meg, és a gyakorlatban a fordító az értékadás célpontjának címét adja át. Így a függvényen belül a Result maga a b, már két elem hosszan, és minden írás a hívó tulajdonában lévő memóriába landol. A Free Pascal friss nil tömböt ad a függvénynek, és utána rendeli hozzá a b-hez, ami a szerződésnek az az olvasata, ami ellen a kódot eleve meg kellett volna írni

PDFlibPas CCITT dekódolási eltérés: a Delphi a hívó b tömbjét adja át a GetNextChangingElement rejtett var Result paramétereként, így az írások a hívó tulajdonában lévő memóriába kerülnek, és egy kimaradt keresés az előző értékeket tartja, míg a Free Pascal friss nil tömböt ad át, amit a Length-őrnek SetLength-csel kell méreteznie az első írás előtt
A Delphi a hívó tömbjét köti be rejtett Result paraméterként, így a védelem nélküli írások is a saját memóriába kerülnek, míg a Free Pascal nil-lel érkezik, és az egysoros őr a hibát szándékolt viselkedéssé alakítja anélkül, hogy a Delphi dekódolási útvonalon változtatna

Az álnevezésnek volt egy szemantikája is, amire a dekóder támaszkodik. A Result[0] csak akkor kap értéket, ha a keresés talál egy a0-nál nagyobb elemet, a Result[1] pedig csak akkor, ha van utána további elem, így kihagyás esetén a helyek megtartják azt, amit az előző iteráció hagyott a b-ben. A kézenfekvő javítás — allokálj két helyet és minden hívásnál nullázd őket — megsemmisítette volna ezt az átörökítést, és megváltoztatta volna a dekódolt kimenetet a Delphi-n. A kiadott javítás nullázás helyett egy őr: a Delphi-n halott kód, és a dekódolási útvonal bájt pontos ugyanaz marad, a Free Pascalon pedig szándékolt viselkedéssé fordítja a hibát. Pont ez az aszimmetria a lényeg, hiszen a javításnak no-op-nak kellett lennie azon a fordítón, ahol a kód már ellenőrzött kimenetet állított elő

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // A Delphi ide úgy érkezik, hogy a hívó kételemű tömbje
  // Result álnéven van bekötve, így ott ez no-op. Az FPC nil-lel jön.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // A Result[0] / Result[1] továbbra is csak találatkor íródik, így egy
  // kihagyás az előző iteráció értékeit tartja, pontosan mint korábban
End;

Egy darabszám, ami túlélte az adatait: a TIFF könyvtárbejegyzés

Amikor érvénytelenítesz egy tömböt, ugyanabban az utasításban kell érvénytelenítened a darabszámát is, különben a darabszámot el fogja hinni olyan kód, ami soha nem látja a tömböt. Egy TIFF image file directory bejegyzés (TIFF 6.0 §2, a tag, type, count és value-or-offset 12 bájtos elrendezése) egy 32 bites darabszámot hoz magával egyenesen a fájlból, és a PDF Library for Delphi mindegyiket a PopDE: TTIFFEntry függvényen keresztül olvassa, ami egy rekord Tag, TagType, Length, Offset mezővel, valamint a dekódolt IntegerValues és DoubleValues tömbökkel. Az eredeti kód megnézte, hogy az Offset + TypeSize * Length túlfut-e a fájl végén, és ha igen, mindkét tömböt nulla hosszúságúra állította. A Result.Length-et meghagyta a fájlból jött értéken

Innentől két dolog romlott el. A függvény a végén egy fallbackkel zárul, ami így szól: „ha a Length nulla, adj a bejegyzésnek egy nulla értékű elemet”, hogy a hívók mindig olvashassák a nulladik elemet. Mivel a Length a tartományon kívüli úton soha nem lett törölve, ez a fallback soha nem sült el arra az egy esetre, amiért létezett. A hívók pedig feltétel nélkül olvassák a nulladik elemet: a Width, a Height, a BitsPerSample, a PhotometricInterpretation, a FillOrder, a SamplesPerPixel, a RowsPerStrip és még egy tucat más veszi az E.IntegerValues[0]-t, a strip-táblák pedig ezt csinálják: Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), azaz Length szor négy bájtot másolnak ki egy olyan tömbből, aminek nincs egyetlen eleme sem. A törölt tömb élő darabszámmal szigorúan veszélyesebb az ellenőrizetlennél, mert az ellenőrizetlen legalább a bájtokat tartalmazza, amiket állít magáról

A második probléma a sorrend volt. A két SetLength hívás a tartományvizsgálat előtt futott, a fájl darabszámából méretezve, így egy ellenséges bejegyzés több gigabájtos allokációt kérhetett még egyetlen érvényességi ellenőrzés előtt. A Delphi-n a keletkező kivételt elkapta egy handler feljebb az image-loading útvonalon, és a fájl egyszerűen nem töltődött be, ezért nem tűnt fel senkinek; ami valójában történt, az egy out-of-memory esemény volt, amit a fájl választott ki. A javítás a vizsgálat utánra teszi az allokációt, és a darabszámot együtt mozgatja az adatokkal

TIFF könyvtárbejegyzés felkeményítése a PDFlibPas-ban: a 12 bájtos bejegyzés fájlból jövő darabszámot hordoz, a hibás sorrend ebből a darabszámból allokált tömböket a tartományvizsgálat előtt, és élve hagyta a Result.Length-et a törlés után, a javított sorrend pedig először Int64 aritmetikával vizsgálja a fájl hosszát, így a darabszám a tömbökkel együtt törlődik
A tartományvizsgálat előtti allokáció gigabájtokat engedett kérni egy ellenséges darabszámmal, és élő darabszámot hagyott egy kiürített tömbön, ezért a javítás először az eltolást vizsgálja, és a Result.Length-et ugyanabban az utasításban törli, mint a tömböket
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // a darabszám az értékekkel együtt megy
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // csak most
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... később a meglévő fallback végre eléri azt az esetet, amire való volt:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Ebben a javításban semmi nem fordítóspecifikus, és pont ez teszi a lista részévé. A hiba ugyanazért volt lappangó a Delphi-n, amiért lappangó volt a Free Pascalon: egyetlen tesztfájlban sem volt olyan könyvtárbejegyzés, ami a fájl végén túlra mutatott volna. A port nem hozta elő. Az hozta elő, hogy az ember ezzel a kérdéssel olvasta a kódot: „mit tesz meg itt értem a Delphi, amit én nem teszek meg magamnak”

Mi történik, ha a PNG IHDR olyan színtípust állít, amit a formátum nem definiál?

A PDF Library for Delphi ma a sor-szűrők lefutása előtt visszautasítja a képet; a v3.539.2 előtt nulla bájtos scanline-t számolt, és üres buffert adott át az unfilter ciklusoknak. Az ISO 15948 §11.2.2 definiálja az IHDR chunkot, a 11.1 táblázat pedig felsorolja a hat legális színtípus és bitmélység kombinációt: grayscale 1, 2, 4, 8 vagy 16 biten, indexed color 1, 2, 4 vagy 8 biten, valamint truecolor, grayscale alpha-val és truecolor alpha-val 8 vagy 16 biten. A TPNGReader validálta az IHDR tömörítési és szűrő módszer mezőjét, az FColorType-ot és a bitmélységet viszont érintetlenül engedte tovább

A sor-szűrő kód mindent egy Case FColorType Of alapján méretez, ami minden színtípust egy komponensszámra képez. A haton kívüli színtípus az Else ágba esik, ahol a SourceComponents 0, így a ScanlineByteCount 0, így a SetLength(PreviousScanline, 0)-t azonnal követi a FillChar(PreviousScanline[0], ScanlineByteCount, 0). Egy üres dinamikus tömb nulladik elemének indexelése egy nil-ből számolt cím. Kikapcsolt range checkinggel egy nulla bájtos fill ezen a címen csendes no-op, és a dekóder tovább masíroz olyan sorokon, amik nem léteznek; bekapcsolt range checkinggel egy ERangeError az első képen; a mögötte jövő Move hívások pedig egy lépésre vannak egy access violationtől. Az, hogy melyiket kapod, a fordítótól és a build kapcsolóktól függ, nem attól, amit a dekóder eldöntött — és ez az a jel, hogy a dekóder egyáltalán nem döntött

A javítás a specifikáció táblázata, oda alkalmazva, ahol a többi IHDR mezőt már ellenőrizték: a COLOR_GRAYSCALE elfogadja a FSourceBitDepth in [1, 2, 4, 8, 16] értéket, a COLOR_PALETTE a [1, 2, 4, 8]-at, a COLOR_RGB, a COLOR_GRAYSCALEALPHA és a COLOR_RGBALPHA pedig a [8, 16]-ot; bármi más törli a ValidImage-et, és a kép visszautasításra kerül, a szélessége és magassága érintetlenül marad a diagnosztikához. Egy kilenc bájtjánál rövidebb pHYs chunk ugyanabban a körben lett lezárva, mivel a DPI-olvasó egy olyan string S[1]–S[8] elemeit indexelte, amit a rövid chunk üresen hagyott

1-alapú eltolás, amit 0-alapú pointerként kezeltek

Az InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString 1-alapú StartPos-t vár, mert a bemenete AnsiString, a Delphi implementáció pedig a zlib bemenetet @Input[StartPos] címen éri el. A Free Pascal implementáció, ami a paszlib ellen íródott, hogy mindkét Windows cél statikusan linkelje a tömörítést, a next_in-t PAnsiChar(Input) + StartPos-ra állította, az avail_in-t pedig Length(Input) - StartPos-ra. Ez pointer-aritmetika, és az 0-alapú. Adj át 1-et, ami ennél a függvénynél azt jelenti, hogy „kezdd az elején”, és az FPC build a második bájttól kezd kicsomagolni, majd egy bájttal a vége előtt áll meg

Azért élte túl, mert az egyetlen hívó, amit a legtöbb teszt elér, az InflateStr, ami 0-t ad át. A nulla véletlenül a helyes 0-alapú eltolás, így a két build minden sima InflateStr hívásnál egyetértett, és minden tesztnél is, ami azon ment keresztül. A TPDFDocument.DecodeAllStreams, amit a SaveQDFToFile és a ConvertFileToQDF használ az egyszeres FlateDecode streamek olvasható formára tágításához, 1-et ad át. Az FPC builden az átugrott zlib header miatt az inflate elbukott, de a zlib stream így is nem nulla Consumed-ot jelzett a megvizsgált bájtokra, ezért a DecodeAllStreams a sikeres dekódolás üres hasznos terhét látta benne, és minden tartalom-streamet üres stringre cserélt. A kapott QDF helyes oldalszámmal, érvényes struktúrával és oldaltartalom nélkül jött ki, ami olyan fájl, ami minden megjelenítőben hiba nélkül nyílik meg, és semmit nem mutat

// Az InflateStrFromPosition FPC-ága a v3.539.16 után.
// A StartPos 1-alapú, akárcsak a Delphi-ágban; vágd be, majd alakítsd
// 0-alapú pointer-eltolássá pontosan egyszer, a határon.
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;

A regresszió, ami őrzi, a lehető legkisebb: tömöríts egy hasznos terhet, csomagold ki a 0. pozíciótól és az 1. pozíciótól, és állítsd, hogy mindkettő ugyanazt a terhet adja vissza, és mindkettő a teljes stream hosszával egyenlő Consumed-ot jelent. Egy RFC 1950 stream kétbájtos headerrel és négybájtos Adler-32 trailerrel rendelkezik, így egy off-by-one bármelyik végén nem finom sérülés, hanem egy stream, ami vagy nem indul el, vagy nem fejeződik be. A tanulság a határvonalról szól, nem a zlibről: amikor egy függvény paramétere az egyik indexbázisban van definiálva, az alatta lévő implementáció pedig a másikat használja, a konverzió pontosan egy sorba tartozik, és a tesztnek olyan értékkel kell meghívnia, ami megkülönbözteti a két bázist

Miért nem a stream vége egy rövid TStream.Read?

Mert a TStream.Read bármilyen okból visszaadhat kevesebb bájtot a kértnél, és csak a 0 visszatérése jelenti azt, hogy nincs több. A TMemoryStream és a TFileStream egy helyi lemezen szinte mindig kitölti a kérést, ezért az a kód, ami a „kevesebbet adott vissza, mint amennyit kértem” esetet fájl végének tekinti, átmegy minden olyan teszten, ami ezeket használja. Hálózati streamek, kitömörítő streamek és bármely, ügyfél által írt TStream leszármazott visszaadhat két bájtot, amikor hatvannégyezret kérsz tőle, és még mindig gigabájtok vannak mögötte

A TPLBuffer az az olvasó, amin a PDF Library for Delphi minden parsere keresztülmegy, és körbe tud venni egy AnsiString-et, egy pointert, egy bájttömböt vagy egy TStream-et. A négy pásztázó lekérdezése — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte és DistanceToOtherBytes, mind Int64-et ad vissza — 64 KB-os blokkokban olvassa a forrást egy elválasztó jelet keresve, és megmondja, milyen messze van, anélkül hogy elmozdítaná a logikai pozíciót. Minden ciklus így zárult: Until ReadCount < BlockSize. A három memóriabeli forrásnál ez helyes, mivel a ReadIntoBuffer mindig a teljes blokkot szállítja, egészen az utolsóig. A stream forrásnál ez azt jelenti, hogy a pásztázás feladja az első rövid olvasásnál, az elválasztót hiányzóként jelenti, a felette lévő tokenizer pedig úgy dönt, hogy az objektum ott végződik, ahol nem

Rövid olvasás kezelése a PDFlibPas stream-bufferében: a DistanceToByte 64 KB-os blokkokban pásztáz, a régi ciklus az Until ReadCount < BlockSize feltételt adatvégként kezelte és feladta az első rövid olvasásnál, a javított ciklus pedig addig fut, míg a ReadCount nulla nem lesz, megtalálja az elválasztót, és egy finally blokkban visszaállítja a pozíciót
Egy stream visszaadhat két bájtot, amikor hatvannégyezret kérsz tőle, így a pásztázás csak a nullában bízhat mint adatvége jelzésben, a finally ág pedig akkor is visszaállítja a logikai pozíciót, amikor megtalálja az elválasztót és a ciklus kilép
// TPLBuffer.DistanceToByte, a ciklus a v3.539.6 után.
// A nulla az egyetlen adatvége jelzés, amit a TStream.Read definiál.
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;   // egy bepillantás nem mozdíthatja az olvasót
End;

A teszt, ami ezt rögzíti, egy TMemoryStream leszármazott, aminek a Read felülírása minden kérést két bájtra vág. Csomagold bele az aaaaaX stringet, állítsd a buffer pozícióját 1-re, és mind a négy lekérdezésnek 4-et kell jelentenie távolságként az X-ig, utána 1-en hagynia a pozíciót, és -1-et jelentenie egy olyan bájtra, ami nincs ott. A javítás előtt az első lekérdezés két bájtot látott, arra jutott, hogy a stream kimerült, és -1-et adott vissza. A finally legalább annyit számít, mint a ciklusfeltétel: a pásztázáson belüli Exit a normál sikerút, és a logikai pozíciót azon az úton is vissza kell állítani, nem csak akkor, amikor a ciklus a végéig lefut

Egy forrás, két fordító, egy állításkészlet

A fegyelem, ami ebből az ötből kijött, az, hogy „a Delphi build átmegy” a Delphi-ről szóló bizonyíték, nem a forrásról szóló. A v3.539.16 óta a Delphi DUnitX sorozat és a Free Pascal konzol sorozat is ugyanazt a Tests\CrossCompilerSemantics.inc-et tartalmazza: egyetlen rutint, a RunCrossCompilerFileSemantics-et, ami TPDFlib-en keresztül épít egy kétoldalas dokumentumot tömörített tartalommal, elmenti, majd QDF-ként is elmenti a SaveQDFToFile-dal, kijavítja a QDF-et a RepairQDFFile-jal, titkosítja a sima fájlt AES-128-cal az EncryptFile-on és az EncodePermissions-ból jövő jogosultsági maszkon keresztül, végül újratölt minden artefaktumot, és mindkét fordítón ugyanazokat az állításokat teszi: az oldalszám 2, a cím túlél, a második oldal szövege épségben kinyerhető a sima, a kijavított és a titkosított fájlból, a rossz jelszó elutasításra kerül nem nulla LastErrorCode-dal, az EncryptionStrength 128, az EncryptionAlgorithm 2, a GetUserPermissions-ból jövő egyedi jogosultsági bitek pedig pontosan úgy jönnek vissza, ahogy kódolva lettek

Az összehasonlítás szándékosan normalizált, nem bájt pontos. A titkosítás véletlen sókat húz, az író pedig dokumentumazonosítókat oszt ki, így a két buildtől nem az várható, hogy azonos fájlokat adjon ki; az várható, hogy ugyanazt jelentő fájlokat adjon ki, és az állítások ezen a szinten fogalmazódnak meg. A QDF láb kifejezetten az off-by-one hiba miatt van ott: egy kétoldalas, tartalom nélküli QDF átmegy egy oldalszám-ellenőrzésen, és elbukik egy szövegkinyerés-ellenőrzésen, a mátrix pedig az utóbbit állítja. Minden jövőbeli javításnak, ami az egyik fordítón no-op és a másikon viselkedésváltozás — ami az ötből négyet leír —, mostantól kétszer kell ugyanazokat az állításokat teljesítenie, mielőtt kimegy

Ugyanennek a portnak a link-idő fele, a Delphi OMF objektumainak és a Free Pascal COFF elvárásainak összeegyeztetése, a saját története az FPC Win32 OMF–COFF objektumlinkelésről szóló cikkben, ugyanannak a TIFF-olvasónak a BigTIFF és csempézett fájlok elleni strukturális felkeményítése pedig a beépített TIFF-dekóder jegyzeteiben. Az ebben a cikkben szereplő dekóderek, és a most alattuk fekvő cross-compiler teszt, a PDF Library for Delphi termékben jelennek meg Delphihez, C++Builderhez és Free Pascalhoz, ahol ugyanannak a forrásnak minden célzott fordítón ki kell érdemelnie ugyanazt az eredményt, nem pedig megkapnia az egyiktől