Technisch artikel

Delphi-code die per toeval werkt: vijf FPC-porteertraps

CCITT-, TIFF-, PNG-, Flate- en streambuffer-code omzetten naar Free Pascal is een betrouwbare manier om Pascal te vinden dat alleen per toeval op Delphi werkt. Geen van de vijf patronen hieronder gaat over een compilerbug. Elk is code die Delphi toevallig correct uitvoert dankzij een implementatiedetail: een verborgen result-parameter die aliast met de array van de aanroeper, een out-of-range-tak waar niemand ooit voorbij leest, een buffer van lengte nul waarvan de enige bewaking een range-check-schakelaar is, een 1-based offset die slechts één codepad ooit als 1 doorgeeft, en een TStream.Read-contract dat in-memory streams nooit uitoefenen. Wissel van compiler, of voer dezelfde code een misvormd bestand, en het toeval houdt op

Hieronder volgt de precieze vorm van elke valkuil, hoe PDF Library for Delphi die aanpakt en de discipline erachter: dezelfde broncode moet op beide compilers dezelfde documentsubantiek opleveren, en de enige manier om dat te weten is op beide controleren. Het zusterartikel over het verharden van een Pascal-PDF-parser tegen kwaadwillende bestanden behandelt integerbreedte, recursiediepte en ongeïnitialiseerde buffers. Dit artikel gaat over een andere foutklasse: code die van begin af aan fout was terwijl een compiler stilletjes voor hem inviel

Waarom werkt een functie die een dynamische array teruggeeft op Delphi zonder SetLength?

Omdat Delphi de eigen variabele van de aanroeper binnenhaalt als verborgen result-parameter, kan een functie die zijn resultaat nooit alloceert toch in een array schrijven die de aanroeper heeft aangemaakt. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray is de referentielijn-opzoeking in het hart van tweedimensionale Group 3- en Group 4-decoding: gegeven de huidige positie a0 en de kleur van de huidige run zoekt hij de changing elements van de vorige scanlijn, de b1 en b2 van het tweedimensionale ITU-T T.4- en T.6-coderingsschema, en geeft ze terug als array met twee plaatsen. De verleidelijke implementatie schrijft Result[0] en Result[1] en roept SetLength op Result helemaal nooit aan

Dat zou bij de eerste schrijfactie moeten crashen, en op Free Pascal doet het dat ook. Op Delphi niet, zolang de aanroepplaatsen er zo uitzien: declareer b: TCCITTIntegerArray, doe één keer SetLength(b, 2) vóór de scanlijnlus, en wijs binnen de lus toe met b := GetNextChangingElement(a0, IsWhite) en lees b[0] en b[1]. De Delphi-taalguide stelt dat een functie waarvan het resultaat een long string, dynamische array of ander managed type is, dat resultaat als extra var-parameter ontvangt, en in de praktijk geeft de compiler het adres van het toewijzingsdoel door. Dus Result in de functie is b zelf, al twee elementen lang, en elke schrijfactie landt in geheugen dat de aanroeper bezit. Free Pascal geeft de functie een verse nil-array en wijst die daarna aan b toe, en dat is de lezing van het contract waar de code in de eerste plaats op geschreven had moeten zijn

Divergentie in CCITT-decodering bij PDFlibPas: Delphi geeft de array b van de aanroeper mee als het verborgen var Result van GetNextChangingElement, zodat writes in geheugen van de aanroeper landen en een gemiste lookup de vorige waarden behoudt, terwijl Free Pascal de functie een verse nil-array geeft die de Length-guard met SetLength op maat moet maken vóór de eerste write
Delphi aliast de array van de aanroeper als verborgen Result-parameter, zodat ongewaakte writes alsnog in eigen geheugen landen, terwijl Free Pascal met nil aankomt en de guard van één regel de fout omzet in bedoeld gedrag zonder het Delphi-decodepad te veranderen

De aliasing draagt ook een semantiek waar de decoder op leunt. Result[0] wordt alleen toegekend als de scan een element groter dan a0 vindt, en Result[1] alleen als er daarna nog een element is, dus bij een misser houden de plaatsen wat de vorige iteratie in b achterliet. De voor de hand liggende oplossing, twee plaatsen alloceren en bij elke aanroep op nul zetten, sloopt dat meenemen en verandert de gedecodeerde uitkomst op Delphi. PDF Library for Delphi gebruikt een guard in plaats van een reset: op Delphi is het dode code en blijft het decodepad byte voor byte gelijk, en op Free Pascal maakt hij van een crash het beoogde gedrag. Die asymmetrie is het hele punt, want de guard moet een no-op zijn op de compiler waar de code al gecontroleerde uitkomst geeft

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi komt hier met de array van twee elementen van de aanroeper
  // gealiast als Result, dus dit is daar een no-op. FPC komt hier met nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] worden nog steeds alleen bij een hit geschreven,
  // dus een miss houdt de waarden van de vorige iteratie precies zoals eerst
End;

Een count die zijn data overleeft: het TIFF-directory-item

Als je een array ongeldig maakt, moet je zijn count in dezelfde instructie ongeldig maken, anders gelooft code die de array nooit ziet de count nog. Een TIFF image file directory-entry (TIFF 6.0 §2, de 12-byte-layout van tag, type, count en value-or-offset) draagt een 32-bit count rechtstreeks uit het bestand mee, en PDF Library for Delphi leest elke entry via PopDE: TTIFFEntry, een record met Tag, TagType, Length, Offset en de gedecodeerde arrays IntegerValues en DoubleValues. De naïeve rangcontrole test of Offset + TypeSize * Length voorbij het einde van het bestand loopt en zet in dat geval beide arrays op lengte nul, maar laat Result.Length op de waarde uit het bestand staan

Vanaf daar gaan twee dingen mis. De functie eindigt met een fallback die zegt: als Length nul is, geef de entry één element met waarde nul, zodat aanroepers altijd element nul kunnen lezen. Omdat Length op het out-of-range-pad nooit wordt gewist, treedt die fallback nooit aan voor precies het geval waarvoor hij bestaat. En de aanroepers lezen element nul wel degelijk, onvoorwaardelijk: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip en een dozijn meer nemen E.IntegerValues[0], en de striptabellen doen Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), waarbij Length keer vier bytes uit een array wordt gekopieerd die er geen heeft. Een gewiste array met een levende count is strikt gevaarlijker dan een ongecontroleerde, want die laatste heeft in ieder geval de bytes die hij claimt

De tweede valkuil is de volgorde. Doe de twee SetLength-aanroepen vóór de rangtest, gedimensioneerd op de count uit het bestand, en een vijandige entry kan een allocatie van vele gigabytes vragen vóór er één geldigheidscontrole is geweest. Op Delphi wordt de uitzondering die dat oplevert doorgaans gevangen door een handler hoger in het laadpad en faalt het bestand gewoon, en daarom is het probleem makkelijk te missen; wat er werkelijk gebeurt is een out-of-memory-gebeurtenis die het bestand zelf koos. De juiste volgorde alloceert ná de test en laat de count met de data meereizen

TIFF-directory-entry-verharding in PDFlibPas: de 12-byte-entry draagt een count uit het bestand mee, de naïeve volgorde alloceert arrays op die count vóór de rangtest en laat Result.Length levend na het wissen, en de veilige volgorde test eerst de Int64-rekenkunde tegen de bestandslengte zodat de count samen met de arrays wordt gewist
Alloceren vóór de rangtest laat een vijandige count gigabytes vragen en laat een levende count op een geleegde array achter, dus de veilige volgorde test eerst de offset en wist Result.Length in dezelfde instructie als de arrays
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // de count reist met de waarden mee
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // pas nu
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... later, de fallback bereikt het geval waarvoor hij bestaat:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Niets aan deze valkuil is compilerspecifiek, en dat is juist waarom hij in dit lijstje hoort. Op Delphi blijft hij latent om dezelfde reden als op Free Pascal: hij vraagt om een directory-entry die voorbij het einde van het bestand wijst, en gewone testbestanden hebben die niet. Een port legt hem niet bloot. De code lezen met de vraag wat Delphi hier voor je doet die je niet zelf doet, wel

Wat gebeurt er als een PNG-IHDR een kleurtype claimt dat het formaat niet definieert?

PDF Library for Delphi weigert de afbeelding voordat de rijfilters draaien. ISO 15948 §11.2.2 definieert het IHDR-chunk en Tabel 11.1 somt de zes legale combinaties van kleurtype en bitdiepte op: grijswaarden bij 1, 2, 4, 8 of 16 bits, geïndexeerde kleur bij 1, 2, 4 of 8, en truecolor, grijswaarden met alpha en truecolor met alpha bij 8 of 16. De valkuil is een lezer die de velden compressiemethode en filtermethode van IHDR valideert en FColorType en de bitdiepte onaangetast doorlaat: een ongeldig kleurtype levert dan een scanlijn van nul bytes op en de unfilter-lussen krijgen een lege buffer

Rijfiltercode dimensioneert doorgaans alles vanuit een Case FColorType Of dat elk kleurtype op een componentaantal mapt. Een kleurtype buiten de zes valt in de Else-tak, waar SourceComponents 0 is, dus ScanlineByteCount is 0, dus op SetLength(PreviousScanline, 0) volgt onmiddellijk FillChar(PreviousScanline[0], ScanlineByteCount, 0). Element nul van een lege dynamische array indexeren is een adres gerekend uit nil. Met range checking uit is een vulling van nul bytes via dat adres een stille no-op en marchert de decoder verder door rijen die niet bestaan; met range checking aan is het een ERangeError bij de eerste afbeelding; en de Move-aanroepen die erna komen staan één stap van een access violation. Welke van de drie je krijgt hangt af van de compiler en van build-schakelaars, niet van iets dat de decoder besloot, en daaraan zie je dat de decoder nooit iets besloten heeft

PDF Library for Delphi past de tabel uit de specificatie toe op de plek waar de andere IHDR-velden worden gecontroleerd: COLOR_GRAYSCALE accepteert FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accepteert [1, 2, 4, 8], en COLOR_RGB, COLOR_GRAYSCALEALPHA en COLOR_RGBALPHA accepteren [8, 16]; alles andere wist ValidImage en de afbeelding wordt geweigerd met breedte en hoogte intact voor de diagnostiek. Een pHYs-chunk korter dan zijn negen bytes wordt op dezelfde plek gecontroleerd, dus de DPI-lezer indexeert nooit S[1] tot en met S[8] van een string die de korte chunk leeg liet

Een 1-based offset die als 0-based pointer wordt behandeld

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString neemt een 1-based StartPos, want zijn invoer is een AnsiString en de Delphi-implementatie adresseert de zlib-invoer als @Input[StartPos]. De Free Pascal-implementatie, geschreven tegen paszlib zodat beide Windows-doelen compressie statisch linken, moet dezelfde invoer via pointerberekeningen adresseren, en pointerberekeningen zijn 0-based. Zet next_in op PAnsiChar(Input) + StartPos en avail_in op Length(Input) - StartPos, geef 1 door — wat voor deze functie “bij het begin” betekent — en de FPC-build begint met inflaten bij de tweede byte en stopt één byte vóór het einde

Deze valkuil blijft makkelijk verborgen omdat de aanroeper die de meeste tests bereiken InflateStr is, die 0 doorgeeft. Nul blijkt toevallig de juiste 0-based offset, dus de twee builds zijn het op elke kale InflateStr-aanroep en elke test die daarlangs gaat met elkaar eens. TPDFDocument.DecodeAllStreams, de routine die SaveQDFToFile en ConvertFileToQDF gebruiken om losse FlateDecode-streams leesbaar uit te pakken, geeft 1 door. Met de off-by-one aan boord laat het overgeslagen zlib-berichtkops de inflate falen, maar de zlib-stream meldt voor de bytes die hij bekeek nog steeds een niet-nul Consumed, dus een aanroeper die Consumed vertrouwt neemt de lege lading als geslaagde decode en vervangt elke content stream door een lege string. De resulterende QDF heeft het juiste paginatantal, geldige structuur en geen pagina-inhoud: een bestand dat in elke viewer zonder foutmelding opent en niets toont

// De FPC-tak van InflateStrFromPosition.
// StartPos is 1-based zoals in de Delphi-tak; klem hem af en zet hem
// precies één keer om naar een 0-based pointer-offset, op de grens.
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;

De controle die dit vangt is de kleinst mogelijke: deflateer een lading, inflateer hem vanaf positie 0 en vanaf positie 1, en bevestig dat beide dezelfde lading teruggeven en beide Consumed gelijk aan de volledige streamlengte melden. Een RFC 1950-stream heeft een kop van twee bytes en een Adler-32-staart van vier bytes, dus een off-by-one aan weerskanten is geen subtiele beschadiging maar een stream die niet kan beginnen of niet kan eindigen. De les gaat over de grens, niet over zlib: als een parameter van een functie in de ene indexbasis is gedefinieerd en de implementatie eronder de andere gebruikt, hoort de omzetting op exact één regel, en een test moet hem aanroepen met de waarde die de twee bases van elkaar onderscheidt

Waarom is een korte TStream.Read niet het einde van de stream?

Omdat TStream.Read minder bytes mag teruggeven dan gevraagd, om welke reden dan ook, en alleen een return van 0 betekent dat er niets meer is. TMemoryStream en TFileStream op een lokale schijf vullen de aanvraag bijna altijd, en daarom haalt code die "minder teruggekregen dan gevraagd" als end-of-file behandelt elke test die deze klassen gebruikt. Network-backed streams, decompressiestreams en elke TStream-afstammeling die een klant schreef kunnen twee bytes teruggeven bij een vraag om vierenzestigduizend en nog steeds gigabytes achter zich hebben

TPLBuffer is de lezer waar elke parser in PDF Library for Delphi doorheen loopt, en hij kan een AnsiString, een pointer, een byte-array of een TStream omwikkelen. Zijn vier scanvragen, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte en DistanceToOtherBytes, alle vier met een Int64 als terugkomst, lezen de bron in blokken van 64 KB op zoek naar een scheidingsteken en melden hoe ver hij weg is zonder de logische positie te verplaatsen. De natuurlijke manier om zo’n lus te eindigen is Until ReadCount < BlockSize. Voor de drie in-memory-bronnen is dat juist, want ReadIntoBuffer levert tot de laatste altijd het volle blok. Voor een streambron betekent het dat de scan opgeeft bij de eerste korte read, het scheidingsteken als afwezig meldt en de tokenizer erboven laat beslissen dat het object eindigt waar dat niet zo is; daarom loopt TPLBuffer tot een read 0 teruggeeft

Omgaan met korte reads in de streambuffer van PDFlibPas: DistanceToByte scant blokken van 64 KB, de oude lus behandelde Until ReadCount < BlockSize als einde van de data en gaf op bij de eerste korte read, terwijl de herstelde lus doorloopt tot ReadCount nul is, de delimiter vindt en de positie in een finally-blok herstelt
Een stream mag twee bytes teruggeven bij een vraag om vierenzestigduizend, dus nul is het enige einde-van-data-signaal dat de scan mag vertrouwen, en de finally-clausule herstelt de logische positie wanneer de delimiter wordt gevonden en de lus vroegtijdig afbreekt
// Scanlus van TPLBuffer.DistanceToByte.
// Nul is het enige einde-van-data-signaal dat TStream.Read definieert.
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;   // een peek mag de lezer niet verplaatsen
End;

De manier om dit vast te zetten is een TMemoryStream-nazaat waarvan de Read-override elk verzoek afknijpt op twee bytes. Wikkel de string aaaaaX erin, zet de bufferpositie op 1, en alle vier de vragen moeten een afstand van 4 tot de X melden, daarna de positie op 1 laten en -1 melden voor een byte die er niet is. Een scan die stopt bij de eerste korte read ziet twee bytes, concludeert dat de stream uitgeput is en geeft -1 terug. De finally weegt even zwaar als de lusvoorwaarde: een Exit vanuit de scan is het normale succespad, en ook op dat pad moet de logische positie worden hersteld, niet alleen wanneer de lus tot het einde loopt

Eén source, twee compilers, één set asserties

De discipline achter deze vijf is dat het halen van de Delphi-build bewijs is over Delphi, niet over de bron. Een cross-compilercontrole die het waard is, draait één routine onder beide compilers: bouw via TPDFlib een document van twee pagina's met gecomprimeerde inhoud, sla het op, sla het opnieuw op als QDF via SaveQDFToFile, repareer de QDF met RepairQDFFile, versleutel het kale bestand met AES-128 via EncryptFile en een permissiemasker uit EncodePermissions, en laad daarna elk artefact opnieuw en controleer op beide compilers hetzelfde: het paginatantal is 2, de titel overleeft het, de tekst van pagina twee extraheert intact uit het kale, gerepareerde en versleutelde bestand, het verkeerde wachtwoord wordt geweigerd met een niet-nul LastErrorCode, EncryptionStrength is 128, EncryptionAlgorithm is 2, en de individuele permissiebits uit GetUserPermissions komen precies zoals ingecodeerd terug

Vergelijk genormaliseerde semantiek in plaats van bytes. Versleuteling trekt willekeurige salts en de schrijver kent document-identificatoren toe, dus de twee builds hoeven geen identieke bestanden te schrijven; ze moeten bestanden schrijven die hetzelfde betekenen, en de controles horen op dat niveau geformuleerd te zijn. De QDF-poot verdient zijn plek vanwege de offsetvalkuil: een QDF met twee pagina's en geen inhoud doorstaat een paginatantaltelling en zakt voor een tekstextractiecontrole, dus controleer die tweede. Elke wijziging die op de ene compiler een no-op is en op de andere een gedragsverandering — en dat geldt voor vier van de vijf hierboven — moet aan dezelfde controles op beide voldoen

De linktijdhelft van dezelfde port, het aan elkaar knopen van Delphi's OMF-objecten en Free Pascal's COFF-verwachtingen, is een eigen verhaal in FPC Win32 OMF naar COFF-objectlinking, en de structurele verharding van dezelfde TIFF-lezer tegen BigTIFF en getilede bestanden staat in de notities bij de ingebouwde TIFF-decoder. De decoders in dit artikel verschepen in de PDF Library for Delphi voor Delphi, C++Builder en Free Pascal, waar van dezelfde broncode op elke compiler hetzelfde resultaat wordt verwacht in plaats van geschonken door één ervan