Technischer Artikel

Delphi-Code, der nur zufällig läuft: Fünf FPC-Portier-Fallen

CCITT-, TIFF-, PNG-, Flate- und Stream-Buffer-Code auf Free Pascal zu bringen ist ein verlässlicher Weg, um Pascal zu finden, das nur zufällig unter Delphi läuft. Keines der fünf Muster unten ist ein Compiler-Bug. Jedes ist Code, den Delphi zufällig korrekt ausführt, weil ein Implementierungsdetail mitspielt: ein versteckter Result-Parameter, der ein Alias auf das Array des Aufrufers ist, ein Out-of-Range-Zweig, an dessen Grenze nie jemand vorbeigelesen hat, ein Buffer der Länge null, dessen einziger Schutz ein Range-Check-Schalter ist, ein 1-basierter Offset, den genau ein Codepfad je als 1 übergibt, und ein TStream.Read-Kontrakt, den In-Memory-Streams nie beanspruchen. Wechseln Sie den Compiler oder füttern Sie denselben Code mit einer fehlerhaften Datei, und der Zufall hält nicht mehr

Im Folgenden geht es um die konkrete Form jeder einzelnen Falle, darum, wie die PDF Library for Delphi sie handhabt, und um die Disziplin dahinter: Derselbe Quelltext muss auf beiden Compilern dieselbe Dokumentsemantik erzeugen, und der einzige Weg, das zu wissen, ist, auf beiden zu prüfen. Der Schwesterartikel zum Härten eines Pascal-PDF-Parsers gegen bösartige Dateien hat Integer-Breite, Rekursionstiefe und uninitialisierte Buffer abgehandelt. Hier geht es um eine andere Fehlerklasse: Code, der die ganze Zeit über falsch ist, während ein Compiler stillschweigend für ihn einspringt

Warum funktioniert eine Funktion mit dynamischem Array-Ergebnis unter Delphi ohne SetLength?

Weil Delphi die eigene Variable des Aufrufers als versteckten Result-Parameter hereingibt, kann eine Funktion, die ihr Ergebnis nie alloziert, trotzdem in ein Array schreiben, das der Aufrufer angelegt hat. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray ist die Referenzlinien-Suche im Kern der zweidimensionalen Group-3- und Group-4-Dekodierung: Bei gegebener aktueller Position a0 und der Farbe des aktuellen Laufs sucht sie die Changing Elements der vorherigen Scanline, die b1 und b2 des zweidimensionalen Codierungsschemas nach ITU-T T.4 und T.6, und liefert sie als Array mit zwei Slots. Die verlockende Implementierung schreibt Result[0] und Result[1] und ruft SetLength für Result nie auf

Das müsste beim ersten Schreiben krachen, und unter Free Pascal tut es das auch. Unter Delphi tut es das nicht, solange die Aufrufstellen so aussehen: b: TCCITTIntegerArray deklarieren, einmal SetLength(b, 2) vor der Scanline-Schleife ausführen und dann in der Schleife b := GetNextChangingElement(a0, IsWhite) zuweisen sowie b[0] und b[1] lesen. Der Delphi-Sprachführer schreibt, dass eine Funktion, deren Ergebnis ein Long String, ein dynamisches Array oder ein anderer managed Type ist, dieses Ergebnis als zusätzlichen var-Parameter erhält, und in der Praxis übergibt der Compiler die Adresse des Zuweisungsziels. Result innerhalb der Funktion ist also b selbst, bereits zwei Elemente lang, und jeder Schreibzugriff landet in Speicher, der dem Aufrufer gehört. Free Pascal reicht der Funktion ein frisches nil-Array und weist es danach b zu – das ist die Lesart des Kontrakts, gegen die der Code von Anfang an geschrieben sein sollte

PDFlibPas-CCITT-Dekodierung divergiert: Delphi legt das Aufrufer-Array b als versteckten var-Result von GetNextChangingElement unter, sodass Schreibzugriffe in vom Aufrufer besessenem Speicher landen und ein verfehltes Lookup die vorherigen Werte behält, während Free Pascal der Funktion ein frisches nil-Array reicht, das der Length-Guard vor dem ersten Schreiben mit SetLength bemessen muss
Delphi legt das Aufrufer-Array als versteckten Result-Parameter unter, sodass ungeschützte Schreibzugriffe trotzdem in eigenen Speicher landen, während Free Pascal mit nil ankommt und der Einzeiler-Guard aus dem Fehler das beabsichtigte Verhalten macht, ohne den Delphi-Dekodierpfad zu verändern

Das Aliasing trägt auch eine Semantik, von der der Decoder abhängt. Result[0] wird nur zugewiesen, wenn der Scan ein Element größer als a0 findet, und Result[1] nur, wenn dahinter noch ein Element kommt, also behalten die Slots bei einem Fehltreffer genau das, was die vorherige Iteration in b hinterlassen hat. Der naheliegende Weg, zwei Slots zu allozieren und sie bei jedem Aufruf zu nullen, würde dieses Übertragen zerstören und die Dekodierausgabe unter Delphi verändern. Die PDF Library for Delphi nimmt deshalb einen Guard statt eines Resets: Unter Delphi ist er toter Code, und der Dekodierpfad bleibt byte für byte derselbe, während er unter Free Pascal aus einem Absturz das beabsichtigte Verhalten macht. Diese Asymmetrie ist der ganze Punkt, denn der Guard muss auf dem Compiler ein No-op sein, auf dem der Code bereits verifizierte Ausgabe produziert

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi kommt hier mit dem als Result untergelegten Array des
  // Aufrufers an, daher ist das dort ein No-op. FPC kommt mit nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] werden weiterhin nur bei einem Treffer
  // geschrieben, ein Fehltreffer behält die Werte der letzten Iteration
End;

Ein Zähler, der seine Daten überlebte: der TIFF-Directory-Eintrag

Wer ein Array ungültig macht, muss seinen Zähler im selben Statement mit ungültig machen, sonst glaubt jeder Code dem Zähler, der das Array nie zu sehen bekommt. Ein TIFF Image File Directory Entry (TIFF 6.0 §2, das 12-Byte-Layout aus Tag, Type, Count und Value-or-Offset) trägt einen 32-Bit-Zähler direkt aus der Datei, und die PDF Library for Delphi liest jeden über PopDE: TTIFFEntry ein, einen Record mit Tag, TagType, Length, Offset und den dekodierten Arrays IntegerValues und DoubleValues. Die naive Range-Prüfung testet, ob Offset + TypeSize * Length über das Dateiende hinausläuft, und setzt in dem Fall beide Arrays auf die Länge null, während Result.Length auf dem Wert aus der Datei stehen bleibt

Von da an gehen zwei Dinge schief. Die Funktion endet mit einem Fallback, der besagt: Ist Length null, bekommt der Eintrag ein einziges Element mit Wert null, damit Aufrufer immer Element null lesen können. Wird Length auf dem Out-of-Range-Pfad nie gelöscht, springt dieser Fallback nie für den einen Fall an, für den er existiert. Und die Aufrufer lesen Element null durchaus, bedingungslos: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip und ein Dutzend weitere nehmen E.IntegerValues[0], und die Strip-Tabellen machen Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4) und kopieren Length mal vier Bytes aus einem Array, das keines hat. Ein geleertes Array mit lebendigem Zähler ist strikt gefährlicher als ein ungeprüftes, denn das ungeprüfte enthält wenigstens die Bytes, die es behauptet

Die zweite Falle ist die Reihenfolge. Läuft die beiden SetLength-Aufrufe vor der Range-Prüfung, dimensioniert nach dem Zähler aus der Datei, kann ein feindlicher Eintrag eine Allokation von mehreren Gigabyte anfordern, bevor eine einzige Gültigkeitsprüfung stattfindet. Unter Delphi fängt ein Handler weiter oben im Bildladepfad die resultierende Exception typischerweise ab, und die Datei lädt schlicht nicht – deshalb ist das Problem leicht zu übersehen; tatsächlich passiert ein Out-of-Memory-Ereignis, das sich die Datei ausgesucht hat. Die korrekte Reihenfolge alloziert nach der Prüfung und lässt den Zähler mit den Daten reisen

TIFF-Directory-Entry-Härtung in PDFlibPas: Der 12-Byte-Eintrag trägt einen aus der Datei stammenden Zähler, die naive Reihenfolge alloziert Arrays nach diesem Zähler vor der Range-Prüfung und lässt Result.Length nach dem Leeren weiterleben, und die sichere Reihenfolge testet die Int64-Arithmetik zuerst gegen die Dateilänge, sodass der Zähler gemeinsam mit den Arrays gelöscht wird
Die Allokation vor der Range-Prüfung lässt einen feindlichen Zähler Gigabyte anfordern und einen lebendigen Zähler auf einem geleerten Array zurück, deshalb testet die sichere Reihenfolge zuerst den Offset und löscht Result.Length im selben Statement wie die Arrays
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // der Zähler geht mit den Werten
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // erst jetzt
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... später erreicht der bestehende Fallback endlich den Fall, für den er da ist:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nichts an dieser Falle ist compilerspezifisch, und genau deshalb gehört sie in diese Liste. Sie bleibt unter Delphi aus demselben Grund latent wie unter Free Pascal: Es braucht einen Directory-Eintrag, der über das Dateiende hinaus zeigt, und gewöhnliche Testdateien haben keinen. Ein Port legt sie nicht offen. Das tut das Lesen des Codes mit der Frage, was Delphi hier für mich erledigt, das ich nicht selbst erledige

Was passiert, wenn ein PNG-IHDR einen Color Type behauptet, den das Format nicht definiert?

Die PDF Library for Delphi weist das Bild ab, bevor die Row-Filter laufen. ISO 15948 §11.2.2 definiert den IHDR-Chunk, und Tabelle 11.1 listet die sechs legalen Kombinationen aus Color Type und Bit Depth: Grayscale mit 1, 2, 4, 8 oder 16 Bits, Indexed Color mit 1, 2, 4 oder 8 sowie Truecolor, Grayscale mit Alpha und Truecolor mit Alpha mit 8 oder 16. Die Falle ist ein Reader, der die Felder für Kompressionsmethode und Filtermethode des IHDR validiert und FColorType und die Bit-Tiefe unverändert durchreicht: Ein illegaler Color Type liefert dann eine Scanline mit null Bytes, und die Unfilter-Schleifen bekommen einen leeren Buffer

Der Row-Filter-Code dimensioniert alles über ein Case FColorType Of, das jeden Color Type auf eine Komponentenzahl abbildet. Ein Color Type außerhalb der sechs fällt in den Else-Zweig, dort ist SourceComponents 0, also ist ScanlineByteCount 0, und auf SetLength(PreviousScanline, 0) folgt unmittelbar FillChar(PreviousScanline[0], ScanlineByteCount, 0). Element null eines leeren dynamischen Arrays zu indizieren ist eine aus nil berechnete Adresse. Bei ausgeschaltetem Range Checking ist eine Null-Byte-Füllung über diese Adresse ein stilles No-op, und der Decoder marschiert durch Reihen, die nicht existieren; bei eingeschaltetem Range Checking ist es ein ERangeError schon beim ersten Bild; und die danach folgenden Move-Aufrufe sind einen Schritt von einer Access Violation entfernt. Was davon Sie bekommen, hängt vom Compiler und von Build-Schaltern ab statt von irgendetwas, das der Decoder entschieden hätte – und genau das verrät, dass der Decoder nie entschieden hat

Die PDF Library for Delphi wendet die Tabelle aus der Spezifikation an der Stelle an, an der die übrigen IHDR-Felder geprüft werden: COLOR_GRAYSCALE akzeptiert FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE akzeptiert [1, 2, 4, 8], und COLOR_RGB, COLOR_GRAYSCALEALPHA und COLOR_RGBALPHA akzeptieren [8, 16]; alles andere löscht ValidImage, und das Bild wird abgelehnt, während Breite und Höhe für die Diagnose erhalten bleiben. Ein pHYs-Chunk, der kürzer als seine neun Bytes ist, wird an derselben Stelle geprüft, sodass der DPI-Reader nie S[1] bis S[8] einer Zeichenkette indiziert, die der kurze Chunk leer hinterlassen hat

Ein 1-basierter Offset, behandelt als 0-basierter Zeiger

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString nimmt einen 1-basierten StartPos, denn ihre Eingabe ist ein AnsiString, und die Delphi-Implementierung adressiert die zlib-Eingabe als @Input[StartPos]. Die Free-Pascal-Implementierung, geschrieben gegen paszlib, damit beide Windows-Ziele die Kompression statisch linken, muss dieselbe Eingabe über Zeigerarithmetik adressieren, und die ist 0-basiert. Setzen Sie next_in auf PAnsiChar(Input) + StartPos und avail_in auf Length(Input) - StartPos, übergeben Sie 1 – das heißt für diese Funktion „am Anfang starten“ –, und der FPC-Build beginnt schon beim zweiten Byte zu inflaten und hört ein Byte vor dem Ende auf

Diese Falle versteckt sich leicht, denn der Aufrufer, den die meisten Tests erreichen, ist InflateStr, und der übergibt 0. Null ist zufällig der korrekte 0-basierte Offset, also sind sich die beiden Builds bei jedem simplen InflateStr-Aufruf und jedem Test, der durch ihn läuft, einig. TPDFDocument.DecodeAllStreams, die Routine, mit der SaveQDFToFile und ConvertFileToQDF einzelne FlateDecode-Streams in lesbare Form expandieren, übergibt 1. Mit dem Off-by-one lässt der übersprungene zlib-Header das Inflate fehlschlagen, aber der zlib-Stream meldet für die von ihm betrachteten Bytes weiterhin ein Consumed ungleich null, also wertet ein Aufrufer, der Consumed vertraut, die leere Nutzlast als erfolgreiche Dekodierung und ersetzt jeden Content-Stream durch eine leere Zeichenkette. Das resultierende QDF hat die richtige Seitenzahl, eine gültige Struktur und keinen Seiteninhalt – eine Datei, die in jedem Viewer fehlerfrei aufgeht und nichts zeigt

// FPC-Zweig von InflateStrFromPosition.
// StartPos ist wie im Delphi-Zweig 1-basiert; erst klemmen, dann
// an der Grenze genau einmal in einen 0-basierten Zeiger-Offset wandeln
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;

Die Prüfung, die das aufdeckt, ist die kleinstmögliche: eine Nutzlast deflaten, von Position 0 und von Position 1 aus inflaten und prüfen, dass beide dieselbe Nutzlast zurückgeben und beide ein Consumed in Höhe der vollen Streamlänge melden. Ein RFC-1950-Stream hat einen Zwei-Byte-Header und einen Vier-Byte-Adler-32-Trailer, also ist ein Off-by-one an einem der Enden keine subtile Korruption, sondern ein Stream, der entweder nicht anläuft oder nicht fertig wird. Die Lehre handelt von der Grenze, nicht von zlib: Ist der Parameter einer Funktion in einer Indexbasis definiert und die Implementierung darunter nutzt die andere, gehört die Konvertierung in genau eine Zeile, und ein Test muss ihn mit dem Wert aufrufen, der die beiden Basen unterscheidet

Warum ist ein kurzes TStream.Read nicht das Ende des Streams?

Weil TStream.Read nach Belieben weniger Bytes zurückgeben darf, als angefordert wurden, und nur eine Rückgabe von 0 bedeutet, dass nichts mehr kommt. TMemoryStream und TFileStream auf einer lokalen Platte füllen die Anforderung fast immer, weshalb Code, der „weniger zurückgegeben als angefordert“ als Dateiende behandelt, jeden Test besteht, der sie benutzt. Netzwerkbasierte Streams, Dekompressions-Streams und jeder TStream-Nachfahre, den ein Kunde geschrieben hat, können auf eine Anforderung von vierundsechzigtausend zwei Bytes zurückgeben und hinten noch Gigabyte haben

TPLBuffer ist der Reader, durch den jeder Parser in der PDF Library for Delphi läuft, und er kann einen AnsiString, einen Zeiger, ein Byte-Array oder einen TStream umschließen. Seine vier Scan-Queries DistanceToByte, DistanceToOtherByte, DistanceToAnyByte und DistanceToOtherBytes, alle mit Int64 als Ergebnis, lesen die Quelle in 64-KB-Blöcken, suchen ein Trennzeichen und melden, wie weit es entfernt ist, ohne die logische Position zu bewegen. Der natürliche Weg, so eine Schleife zu beenden, ist Until ReadCount < BlockSize. Für die drei In-Memory-Quellen ist das korrekt, denn ReadIntoBuffer liefert bis auf den letzten immer den vollen Block. Für eine Stream-Quelle bedeutet es, dass der Scan beim ersten kurzen Read aufgibt, das Trennzeichen als abwesend meldet, und der Tokenizer darüber entscheidet, das Objekt ende dort, wo es nicht endet – deshalb läuft TPLBuffer, bis ein Read 0 zurückgibt

Short-Read-Behandlung im PDFlibPas-Stream-Buffer: DistanceToByte scannt 64-KB-Blöcke, die alte Schleife behandelte Until ReadCount < BlockSize als Datenende und gab beim ersten kurzen Read auf, während die korrigierte Schleife läuft, bis ReadCount null erreicht, das Trennzeichen findet und die Position in einem finally-Block wiederherstellt
Ein Stream darf auf eine Anforderung von vierundsechzigtausend zwei Bytes zurückgeben, also ist null das einzige Datenende-Signal, dem der Scan vertrauen darf, und die finally-Klausel stellt die logische Position wieder her, wenn das Trennzeichen gefunden wurde und die Schleife vorzeitig aussteigt
// TPLBuffer.DistanceToByte, die Scan-Schleife.
// Null ist das einzige Datenende-Signal, das TStream.Read definiert.
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;   // ein Peek darf den Reader nicht bewegen
End;

Der Weg, das festzunageln, ist ein TMemoryStream-Nachfahre, dessen Read-Override jede Anforderung auf zwei Bytes kappt. Wickeln Sie die Zeichenkette aaaaaX darin ein, setzen Sie die Buffer-Position auf 1, und alle vier Queries müssen einen Abstand von 4 zum X melden, die Position danach auf 1 lassen und für ein nicht vorhandenes Byte -1 melden. Ein Scan, der beim ersten kurzen Read stoppt, sieht zwei Bytes, schließt auf ein erschöpftes Stream-Ende und gibt -1 zurück. Das finally ist genauso wichtig wie die Schleifenbedingung: Ein Exit aus dem Scan heraus ist der normale Erfolgspfad, und auch auf diesem Pfad muss die logische Position wiederhergestellt werden, nicht nur, wenn die Schleife bis zum Ende läuft

Ein Quelltext, zwei Compiler, ein Satz Assertions

Die Disziplin hinter diesen fünf Fällen: „Der Delphi-Build läuft durch“ ist ein Beweis über Delphi, nicht über den Quelltext. Eine Cross-Compiler-Prüfung, die man haben will, läuft eine einzige Routine unter beiden Compilern: Ein Dokument mit zwei Seiten und komprimiertem Inhalt wird über TPDFlib gebaut, gespeichert, erneut als QDF über SaveQDFToFile gespeichert, das QDF mit RepairQDFFile repariert, die Klartextdatei mit AES-128 über EncryptFile und einer Berechtigungsmaske aus EncodePermissions verschlüsselt, und dann wird jedes Artefakt neu geladen und auf beiden Compilern dasselbe geprüft: Die Seitenzahl ist 2, der Titel überlebt, der Text von Seite zwei extrahiert intakt aus den Klartext-, reparierten und verschlüsselten Dateien, das falsche Passwort wird mit einem LastErrorCode ungleich null abgewiesen, EncryptionStrength ist 128, EncryptionAlgorithm ist 2, und die einzelnen Berechtigungsbits aus GetUserPermissions kommen exakt so codiert zurück

Vergleichen Sie normalisierte Semantik statt Bytes. Die Verschlüsselung zieht zufällige Salts, und der Writer vergibt Dokument-Identifikatoren, also wird nicht erwartet, dass die beiden Builds identische Dateien emitieren; erwartet wird, dass sie Dateien mit derselben Bedeutung emittieren, und die Prüfungen sollten auf dieser Ebene formuliert sein. Der QDF-Ast verdient seinen Platz wegen der Offset-Falle: Ein QDF mit zwei Seiten und ohne Inhalt besteht eine Seitenzahl-Prüfung und fällt durch eine Textextraktions-Prüfung, also prüfen Sie die zweite. Jede Änderung, die auf dem einen Compiler ein No-op ist und auf dem anderen eine Verhaltensänderung – was auf vier der fünf oben genannten zutrifft –, muss dieselben Prüfungen auf beiden bestehen

Die Link-Zeit-Hälfte desselben Ports – Delphis OMF-Objekte und Free Pascals COFF-Erwartungen zur Übereinstimmung zu bringen – ist eine eigene Geschichte unter Objekt-Linking von OMF zu COFF unter FPC Win32, und die strukturelle Härtung desselben TIFF-Readers gegen BigTIFF und getile Dateien steht in den Notizen zum eingebauten TIFF-Decoder. Die Decoder in diesem Artikel kommen in der PDF Library for Delphi für Delphi, C++Builder und Free Pascal daher, wo derselbe Quelltext auf jedem anvisierten Compiler dasselbe Ergebnis verdienen soll, statt es von einem geschenkt zu bekommen