PDF Library for Delphi hittade fem dekoderdefekter när koden för CCITT, TIFF, PNG, Flate och strömbufferten skulle köras på Free Pascal, och alla fem hade passerat hela Delphis testsvit i åratal. Ingen av dem var en kompilatorbugg. Var och en var Pascal som Delphi råkade köra korrekt på grund av en implementationsdetalj: en dold resultatparameter som pekade på anroparens egen array, en gren utanför intervallet som ingen någonsin läste förbi, en buffert med noll längd vars enda skydd var en range-check-switch, en 1-baserad offset som bara en enda kodväg någonsin skickade in som 1, och ett TStream.Read-kontrakt som minnesströmmar aldrig prövar. Byt kompilator, eller mata samma kod med en trasig fil, och slumpträffen håller inte längre
Här följer den konkreta formen av var och en, fixen, och den disciplin som kom ut av det: samma källkod måste nu ge samma dokumentsemantik på båda kompilatorerna, och en test-include kontrollerar att den gör det. Syskonartikeln om att härda en Pascal-PDF-parser mot skadliga filer tog upp heltalsbredd, rekursionsdjup och oinitierade buffertar. Den här handlar om en annan felklass: kod som var fel hela tiden och hade en kompilator som tyst täckte över det
Varför fungerar en funktion som returnerar en dynamisk array utan SetLength i Delphi?
Därför att Delphi skickar in anroparens egen variabel som den dolda resultatparametern, så en funktion som aldrig allokerar sitt resultat kan ändå skriva in i en array som anroparen har allokerat. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray är referensradsuppslagningen i hjärtat av tvådimensionell Group 3- och Group 4-avkodning: givet den aktuella positionen a0 och färgen på den aktuella runen söker den i föregående skanningslinjes changing elements, b1 och b2 i ITU-T T.4:s och T.6:s tvådimensionella kodningsschema, och returnerar dem som en array med två platser. Den ursprungliga funktionen skrev Result[0] och Result[1] och anropade aldrig SetLength på Result över huvud taget
Det borde braka redan vid första skrivningen, och på Free Pascal gör det det. På Delphi gjorde det aldrig det, för båda anropsställena i avkodaren ser ut så här: deklarera b: TCCITTIntegerArray, kör SetLength(b, 2) en gång före skanningslinjeloopen, tilldela sedan b := GetNextChangingElement(a0, IsWhite) inne i loopen och läs b[0] och b[1]. Delphis språkguide säger att en funktion vars resultat är en long string, en dynamisk array eller en annan managed typ tar emot resultatet som en extra var-parameter, och i praktiken skickar kompilatorn adressen till tilldelningsmålet. Så Result inuti funktionen är b självt, redan två element långt, och varje skrivning landar i minne som anroparen äger. Free Pascal ger funktionen en färsk nil-array och tilldelar den till b efteråt, vilket är den läsning av kontraktet som koden borde ha varit skriven mot från början
Aliasbildningen bar också på en semantik som avkodaren är beroende av. Result[0] tilldelas bara när sökningen hittar ett element större än a0, och Result[1] bara när det finns ett element efter det, så vid en miss behåller platserna vad föregående iteration lämnade i b. Den uppenbara fixen, att allokera två platser och nolla dem vid varje anrop, skulle ha förstört den vidarekopplingen och ändrat avkodad utdata på Delphi. Fixen som levererades är en vakt i stället för en nollställning: på Delphi är den död kod och avkodningsvägen förblir byte för byte vad den var, och på Free Pascal förvandlar den ett fel till avsett beteende. Den asymmetrin är hela poängen, eftersom fixen måste vara en no-op på den kompilator där koden redan producerade verifierad utdata
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi kommer hit med anroparens tvåelementsarray aliasad
// som Result, så det här är en no-op där. FPC kommer hit med nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] skrivs fortfarande bara vid en träff, så en miss
// behåller föregående iterations värden precis som förut
End;
Ett antal som överlevde sin data: TIFF-katalopposten
När du invaliderar en array måste du invalidera dess antal i samma sats, annars kommer antalet att tro på av kod som aldrig ser arrayen. En TIFF-kataloppost (TIFF 6.0 §2, den 12 byte långa layouten med tag, type, count och value-or-offset) bär ett 32-bitars antal rakt ut ur filen, och PDF Library for Delphi läser var och en genom PopDE: TTIFFEntry, en record med Tag, TagType, Length, Offset samt de avkodade arrayerna IntegerValues och DoubleValues. Den ursprungliga koden kontrollerade om Offset + TypeSize * Length sprang förbi filens slut, och om den gjorde det satte den båda arrayerna till noll längd. Den lämnade Result.Length kvar på värdet från filen
Två saker gick fel därifrån. Funktionen avslutas med en fallback som säger "om Length är noll, ge posten ett nollelement" så att anropare alltid kan läsa element noll. Eftersom Length aldrig nollades på grenen för värden utanför intervallet, utlöstes aldrig den fallbacken för det enda fall den fanns till. Och anroparen läser element noll, villkorslöst: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip och ett dussin till tar E.IntegerValues[0], och strip-tabellerna gör Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), vilket kopierar Length gånger fyra byte ur en array som inte har några. En nollad array med ett levande antal är strikt farligare än en okontrollerad, för den okontrollerade innehåller åtminstone de byte den påstår sig innehålla
Det andra problemet var ordningen. De två SetLength-anropen kördes före intervalltestet, dimensionerade från filens antal, så en fientlig post kunde begära en allokering på flera gigabyte innan en enda giltighetskontroll. På Delphi fångades undantaget av en hanterare längre upp i bildladdningsvägen och filen laddades helt enkelt inte, vilket är varför ingen märkte något; vad som egentligen hände var en out-of-memory-händelse som filen valde. Fixen flyttar allokeringen efter testet och låter antalet följa med datan
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // antalet följer med värdena
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // först nu
SetLength(Result.DoubleValues, Result.Length);
End;
// ... senare: den befintliga fallbacken når äntligen fallet den var till för:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Ingenting i den här fixen är kompilatorspecifikt, vilket är vad som gör att den hör hemma i den här listan. Defekten låg latent på Delphi av samma skäl som den låg latent på Free Pascal: ingen testfil hade en kataloppost som pekade förbi filens slut. Portningen avslöjade den inte. Det gjorde däremot att läsa koden med frågan "vad gör Delphi åt mig här som jag inte gör själv"
Vad händer när en PNG-IHDR anger en färgtyp som formatet inte definierar?
PDF Library for Delphi avvisar nu bilden innan radfiltren körs; före v3.539.2 räknade den fram en skanningslinje på noll byte och lämnade en tom buffert till avfiltreringslooparna. ISO 15948 §11.2.2 definierar IHDR-chunken och tabell 11.1 listar de sex lagliga kombinationerna av färgtyp och bitdjup: gråskala vid 1, 2, 4, 8 eller 16 bitar, indexerad färg vid 1, 2, 4 eller 8, samt truecolor, gråskala med alfa och truecolor med alfa vid 8 eller 16. TPNGReader validerade IHDR:s fält för komprimeringsmetod och filtermetod och skickade FColorType och bitdjupet vidare orörda
Radfilterkoden dimensionerar allt från en Case FColorType Of som mappar varje färgtyp till ett antal komponenter. En färgtyp utanför de sex hamnar i Else-grenen, där SourceComponents är 0, så ScanlineByteCount blir 0, så SetLength(PreviousScanline, 0) följs omedelbart av FillChar(PreviousScanline[0], ScanlineByteCount, 0). Att indexera element noll i en tom dynamisk array är en adress räknad från nil. Med range checking avstängt är en nollbyte-fyllning genom den adressen en tyst no-op och avkodaren tågar vidare genom rader som inte finns; med range checking påslaget är det en ERangeError på första bilden; och Move-anropen som följer är ett steg från en access violation. Vilket av dem du får beror på kompilatorn och på build-switchar snarare än på något avkodaren har beslutat, och det är tecknet på att avkodaren aldrig beslutade något alls
Fixen är tabellen från specifikationen, tillämpad där de andra IHDR-fälten redan kontrollerades: COLOR_GRAYSCALE accepterar FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accepterar [1, 2, 4, 8], och COLOR_RGB, COLOR_GRAYSCALEALPHA och COLOR_RGBALPHA accepterar [8, 16]; allt annat nollar ValidImage och bilden avvisas med bredd och höjd intakta för diagnostik. En pHYs-chunk kortare än sina nio byte stängdes i samma omgång, eftersom DPI-läsaren indexerade S[1] till S[8] i en sträng som den korta chunken hade lämnat tom
En 1-baserad offset som behandlades som en 0-baserad pekare
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString tar en 1-baserad StartPos, eftersom dess indata är en AnsiString och Delphi-implementationen adresserar zlib-indatan som @Input[StartPos]. Free Pascal-implementationen, skriven mot paszlib så att båda Windows-målen länkar kompressionen statiskt, satte next_in till PAnsiChar(Input) + StartPos och avail_in till Length(Input) - StartPos. Det är pekararitmetik, och den är 0-baserad. Skicka in 1, vilket är vad "börja från början" betyder för den här funktionen, och FPC-bygget börjar blåsa upp vid andra byten och slutar en byte före slutet
Anledningen till att det överlevde är att den enda anropare som de flesta tester når är InflateStr, som skickar 0. Noll råkar vara den korrekta 0-baserade offseten, så de två byggena var överens om varje vanligt InflateStr-anrop och varje test som gick genom det. TPDFDocument.DecodeAllStreams, rutinen som SaveQDFToFile och ConvertFileToQDF använder för att expandera enkla FlateDecode-strömmar till läsbar form, skickar 1. På FPC-bygget gjorde den överhoppade zlib-headern att uppblåsningen misslyckades, men zlib-strömmen rapporterade ändå ett icke-noll Consumed för de byte den hade undersökt, så DecodeAllStreams tolkade den tomma payloaden som en lyckad avkodning och ersatte varje innehållsström med en tom sträng. Den resulterande QDF-filen hade rätt sidantal, giltig struktur och inget sidinnehåll, vilket är en fil som öppnas utan fel i varje viewer och visar ingenting
// FPC-grenen av InflateStrFromPosition, efter v3.539.16.
// StartPos är 1-baserad precis som i Delphi-grenen; kläm den och konvertera
// till en 0-baserad pekaroffset exakt en gång, vid gränsen.
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;
Regressionstestet som vaktar den är det minsta möjliga: komprimera en payload, blås upp den från position 0 och från position 1, och hävda att båda returnerar samma payload och att båda rapporterar Consumed lika med hela strömmens längd. En RFC 1950-ström har en tvåbyte-header och en fyrabyte-trailer med Adler-32, så en off-by-one i endera änden är inte en subtil korruption, det är en ström som antingen inte startar eller inte avslutar. Lärdomen handlar om gränsen, inte om zlib: när en funktions parameter är definierad i en indexbas och implementationen under den använder den andra, hör konverteringen hemma på exakt en rad, och ett test måste anropa den med det värde som skiljer de två baserna åt
Varför är en kort TStream.Read inte slutet på strömmen?
Därför att TStream.Read får returnera färre byte än begärt av vilket skäl som helst, och bara en retur på 0 betyder att det inte finns mer. TMemoryStream och TFileStream på en lokal disk fyller nästan alltid hela begäran, vilket är varför kod som behandlar "returnerade mindre än jag bad om" som end-of-file klarar varje test som använder dem. Nätverksbackade strömmar, dekomprimeringsströmmar och vilken TStream-ättling som helst som en kund har skrivit kan returnera två byte när den får frågan om sextiofyratusen och ändå ha gigabyte kvar bakom sig
TPLBuffer är läsaren som varje parser i PDF Library for Delphi går genom, och den kan wrappa en AnsiString, en pekare, en byte-array eller en TStream. Dess fyra skanningsfrågor, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte och DistanceToOtherBytes, som alla returnerar Int64, läser källan i block om 64 kB på jakt efter en avgränsare och rapporterar hur långt bort den ligger utan att flytta den logiska positionen. Varje loop avslutades med Until ReadCount < BlockSize. För de tre källorna i minnet är det korrekt, eftersom ReadIntoBuffer alltid levererar hela blocket utom det sista. För strömkällan betyder det att skanningen ger upp vid första korta läsning, rapporterar avgränsaren som frånvarande, och tokenizern ovanför bestämmer att objektet slutar där det inte gör det
// TPLBuffer.DistanceToByte, loopen efter v3.539.6.
// Noll är den enda slut-på-data-signal som TStream.Read definierar.
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; // en peek får inte flytta läsaren
End;
Testet som fäster detta är en TMemoryStream-ättling vars Read-override kapar varje begäran vid två byte. Wrappa strängen aaaaaX i den, sätt buffertpositionen till 1, och alla fyra frågorna måste rapportera ett avstånd på 4 till X, lämna positionen på 1 efteråt och rapportera -1 för en byte som inte finns. Före fixen såg den första frågan två byte, drog slutsatsen att strömmen var slut och returnerade -1. finally spelar lika stor roll som loopvillkoret: en Exit inifrån skanningen är den normala framgångsvägen, och den logiska positionen måste återställas även på den vägen, inte bara när loopen körs till slutet
En källkod, två kompilatorer, en uppsättning assertioner
Disciplinen som föll ut ur de här fem är att "Delphi-bygget passerar" är ett bevis om Delphi, inte om källkoden. Sedan v3.539.16 inkluderar både Delphis DUnitX-svit och Free Pascals konsolsvit samma Tests\CrossCompilerSemantics.inc, en enda rutin, RunCrossCompilerFileSemantics, som bygger ett tvåsidigt dokument med komprimerat innehåll genom TPDFlib, sparar det, sparar det igen som QDF genom SaveQDFToFile, reparerar QDF-filen med RepairQDFFile, krypterar den oformaterade filen med AES-128 genom EncryptFile och en behörighetsmask från EncodePermissions, och sedan laddar om varje artefakt och hävdar samma saker på båda kompilatorerna: sidantalet är 2, titeln överlever, sidan tvås text extraheras intakt från den oformaterade, den reparerade och den krypterade filen, fel lösenord avvisas med en icke-noll LastErrorCode, EncryptionStrength är 128, EncryptionAlgorithm är 2, och de enskilda behörighetsbitarna från GetUserPermissions kommer tillbaka exakt som de kodades
Jämförelsen är medvetet normaliserad i stället för byte-för-byte. Kryptering drar slumpmässiga salt och skrivaren tilldelar dokumentidentifierare, så de två byggena förväntas inte producera identiska filer; de förväntas producera filer som betyder samma sak, och assertionerna är formulerade på den nivån. QDF-delen finns specifikt på grund av offsetbuggen: en QDF med två sidor och inget innehåll klarar en sidantalskontroll och faller på en textutdragningskontroll, och matrisen hävdar det andra. Varje framtida fix som är en no-op på en kompilator och en beteendeändring på den andra, vilket beskriver fyra av de fem ovan, måste nu klara samma assertioner två gånger innan den släpps
Länkningstids-halvan av samma portning, att få Delphis OMF-objekt och Free Pascals COFF-förväntningar att komma överens, är sin egen historia i länkning av FPC Win32 OMF till COFF-objekt, och den strukturella härdningen av samma TIFF-läsare mot BigTIFF och tiled-filer finns i anteckningarna om den inbyggda TIFF-avkodaren. Avkodarna i den här artikeln, och korskompilatortestet som nu ligger under dem, levereras i PDF Library for Delphi för Delphi, C++Builder och Free Pascal, där samma källkod förväntas förtjäna samma resultat på varje kompilator den riktar sig mot i stället för att få det till skänks av en