Artykuł techniczny

Kod Delphi działający przypadkiem: 5 pułapek portu na FPC

Przenoszenie kodu CCITT, TIFF, PNG, Flate i buforów strumieni na Free Pascal to niezawodny sposób, żeby znaleźć Pascal, który na Delphi działa wyłącznie przez przypadek. Żaden z pięciu wzorców poniżej nie dotyczy błędu kompilatora. Każdy to kod, który Delphi z przyczyn losowych wykonuje poprawnie dzięki szczegółowi implementacji: ukrytemu parametrowi wyniku aliasującemu tablicę wołającego, gałęzi poza zakresem, za którą nikt nigdy nie czyta, buforowi o zerowej długości, którego jedyną ochroną jest przełącznik sprawdzania zakresu, przesunięciu liczonemu od 1, które tylko jedna ścieżka kodu podaje jako 1, oraz kontraktowi TStream.Read, którego strumienie w pamięci nigdy nie ćwiczą. Zmień kompilator albo podaj temu samemu kodowi zniekształcony plik, a przypadek przestaje się trzymać

Dalej jest konkretny kształt każdej pułapki, sposób, w jaki radzi sobie z nią PDF Library for Delphi, i dyscyplina, która za tym stoi: to samo źródło ma produkować tę samą semantykę dokumentu pod oboma kompilatorami, a jedyny sposób, żeby to wiedzieć, to sprawdzić na obu. Pokrewny artykuł o utwardzaniu parsera PDF w Pascalu przeciw złośliwym plikom omawia szerokość typów całkowitych, głębokość rekurencji i niezainicjowane bufory. Ten jest o innej klasie awarii: o kodzie, który był błędny od zawsze, podczas gdy kompilator po cichu go krył

Dlaczego funkcja zwracająca tablicę dynamiczną działa na Delphi bez SetLength?

Bo Delphi przekazuje zmienną samego wołającego jako ukryty parametr wyniku, więc funkcja, która nigdy nie alokuje wyniku, i tak potrafi pisać do tablicy zaalokowanej przez wołającego. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray to wyszukiwanie w linii referencyjnej w samym sercu dwuwymiarowego dekodowania Group 3 i Group 4: mając bieżącą pozycję a0 i kolor bieżącego ciągu, przeszukuje elementy zmieniające poprzedniej linii skanowania — b1 i b2 z dwuwymiarowego schematu kodowania ITU-T T.4 i T.6 — i zwraca je jako dwuslotową tablicę. Kusząca implementacja pisze do Result[0] i Result[1] i ani razu nie woła SetLength na Result

To powinno wysypać się przy pierwszym zapisie i na Free Pascalu tak się dzieje. Na Delphi nie, dopóki miejsca wywołań wyglądają tak: deklarujesz b: TCCITTIntegerArray, raz przed pętlą po liniach skanowania wołasz SetLength(b, 2), a potem w pętli przypisujesz b := GetNextChangingElement(a0, IsWhite) i czytasz b[0] oraz b[1]. Przewodnik po języku Delphi mówi, że funkcja, której wynikiem jest długi łańcuch, tablica dynamiczna albo inny typ zarządzany, dostaje ten wynik jako dodatkowy parametr var, a w praktyce kompilator przekazuje adres celu przypisania. Więc Result wewnątrz funkcji to samo b, już dwuelementowe, i każdy zapis ląduje w pamięci należącej do wołającego. Free Pascal podaje funkcji świeżą, nilową tablicę i przypisuje ją do b dopiero potem — i to jest odczytanie kontraktu, przeciw któremu ten kod powinien był powstać od początku

Rozbieżność dekodowania CCITT w PDFlibPas: Delphi przekazuje tablicę b wywołującego jako ukryty parametr var Result funkcji GetNextChangingElement, więc zapisy lądują w pamięci należącej do wołającego, a nietrafione wyszukiwanie zachowuje poprzednie wartości, natomiast Free Pascal podaje funkcji świeżą nilową tablicę, którą zabezpieczenie Length musi powiększyć przez SetLength przed pierwszym zapisem
Delphi aliasuje tablicę wywołującego jako ukryty parametr Result, więc niezabezpieczone zapisy i tak trafiają do własnej pamięci, a Free Pascal przychodzi z nil i jednowierszowe zabezpieczenie zamienia awarię w zamierzone zachowanie, nie ruszając ścieżki dekodowania na Delphi

Aliasowanie niesie też semantykę, od której zależy dekoder. Result[0] jest przypisywane tylko wtedy, gdy skan znajdzie element większy od a0, a Result[1] tylko wtedy, gdy jest jeszcze jakiś element po nim, więc przy nietrafionym wyszukiwaniu sloty zachowują to, co poprzednia iteracja zostawiła w b. Oczywista poprawka — zaalokowanie dwóch slotów i zerowanie ich przy każdym wywołaniu — niszczy to przenoszenie i zmienia zdekodowane wyjście na Delphi. PDF Library for Delphi używa zabezpieczenia zamiast resetu: na Delphi to martwy kod i ścieżka dekodowania zostaje bajt w bajt taka sama, a na Free Pascalu zamienia awarię w zamierzone zachowanie. Ta asymetria jest całą istotą rzeczy, bo zabezpieczenie musi być no-opem pod kompilatorem, który już produkuje zweryfikowane wyjście

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi przychodzi tu z dwuelementową tablicą wywołującego
  // zaaliasowaną jako Result, więc to jest tam no-op. FPC przychodzi z nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] nadal są zapisywane tylko przy trafieniu,
  // więc nietrafienie zachowuje wartości z poprzedniej iteracji
End;

Licznik, który przeżył swoje dane: wpis katalogu TIFF

Kiedy unieważniasz tablicę, musisz unieważnić jej licznik w tym samym wyrażeniu, inaczej uwierzy w niego kod, który samej tablicy nigdy nie widzi. Wpis katalogu obrazu TIFF (TIFF 6.0 §2, dwunastobajtowy układ tag, typ, licznik i wartość-albo-przesunięcie) niesie 32-bitowy licznik wprost z pliku, a PDF Library for Delphi czyta każdy z nich przez PopDE: TTIFFEntry, rekord z polami Tag, TagType, Length, Offset oraz zdekodowanymi tablicami IntegerValues i DoubleValues. Naiwne sprawdzenie zakresu testuje, czy Offset + TypeSize * Length wychodzi poza koniec pliku i jeśli tak, ustawia obie tablice na zerową długość, zostawiając Result.Length na wartości z pliku

Od tego miejsca psują się dwie rzeczy. Funkcja kończy się fallbackiem, który mówi: „jeśli Length jest zerowe, daj wpisowi jeden element o wartości zero”, żeby wołający zawsze mogli odczytać element zerowy. Ponieważ Length nigdy nie było czyszczone na ścieżce poza zakresem, ten fallback nigdy się nie odpala w jedynym przypadku, dla którego istnieje. A wołający czytają element zerowy bezwarunkowo: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip i tuzin innych bierze E.IntegerValues[0], a tabele pasów robią Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), kopiując Length razy po cztery bajty z tablicy, która nie ma ani jednego. Wyczyszczona tablica z żywym licznikiem jest zdecydowanie groźniejsza niż niesprawdzona, bo ta niesprawdzona przynajmniej trzyma bajty, które deklaruje

Drugą pułapką jest kolejność. Uruchom dwa wołania SetLength przed testem zakresu, wymiarowane licznikiem z pliku, a wrogi wpis może zażądać wielogigabajtowej alokacji przed jakimkolwiek sprawdzeniem poprawności. Na Delphi wynikły wyjątek typowo łapie handler wyżej na ścieżce wczytywania obrazu i plik po prostu nie wczytuje się, dlatego ten problem łatwo przegapić; tak naprawdę dzieje się zdarzenie out-of-memory, które wybrał sobie plik. Poprawna kolejność alokuje po teście i każe licznikowi podróżować razem z danymi

Utwardzanie wpisu katalogu TIFF w PDFlibPas: dwunastobajtowy wpis niesie licznik podany przez plik, naiwna kolejność alokuje tablice z tego licznika przed testem zakresu i zostawia żywe Result.Length po ich wyczyszczeniu, a bezpieczna kolejność najpierw testuje arytmetykę Int64 względem długości pliku, więc licznik jest czyszczony razem z tablicami
Alokacja przed testem zakresu pozwala wrogiemu licznikowi zażądać gigabajtów i zostawia żywy licznik na opróżnionej tablicy, więc bezpieczna kolejność najpierw testuje przesunięcie i czyści Result.Length w tym samym wyrażeniu co tablice
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // licznik idzie razem z wartościami
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // dopiero teraz
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... dalej fallback trafia w przypadek, dla którego istnieje:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nic w tej pułapce nie jest zależne od kompilatora i właśnie dlatego należy do tej listy. Pozostaje utajniona na Delphi z tego samego powodu, z jakiego pozostaje utajniona na Free Pascalu: potrzebuje wpisu katalogu wskazującego poza koniec pliku, a zwykłe pliki testowe takiego nie mają. Port jej nie ujawnia. Ujawnia czytanie kodu z pytaniem „co Delphi robi tu za mnie, czego sam nie robię”

Co się dzieje, gdy IHDR w PNG poda typ koloru, którego format nie definiuje?

PDF Library for Delphi odrzuca obraz, zanim ruszą filtry wierszy. ISO 15948 §11.2.2 definiuje chunk IHDR, a Tabela 11.1 wymienia sześć legalnych kombinacji typu koloru i głębi bitowej: odcienie szarości przy 1, 2, 4, 8 albo 16 bitach, kolor indeksowany przy 1, 2, 4 albo 8 oraz truecolor, odcienie szarości z kanałem alfa i truecolor z kanałem alfa przy 8 albo 16. Pułapką jest czytnik, który waliduje pola compression method i filter method w IHDR, a FColorType i głębię bitową przepuszcza nietknięte: nielegalny typ koloru daje wtedy zerobajtową linię skanowania, a pętle odfiltrowujące dostają pusty bufor

Kod filtrów wierszy typowo wymiaruje wszystko z Case FColorType Of, który mapuje każdy typ koloru na liczbę komponentów. Typ koloru spoza szóstki wpada do gałęzi Else, gdzie SourceComponents wynosi 0, więc ScanlineByteCount wynosi 0, więc zaraz po SetLength(PreviousScanline, 0) idzie FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indeksowanie elementu zerowego pustej tablicy dynamicznej to adres policzony od nil. Przy wyłączonym sprawdzaniu zakresu zerobajtowe wypełnienie pod tym adresem jest cichym no-opem i dekoder maszeruje dalej przez wiersze, których nie ma; przy włączonym to ERangeError już na pierwszym obrazie; a wołane zaraz po nim Move to krok od access violation. To, który wariant dostaniesz, zależy od kompilatora i przełączników budowania, a nie od czegokolwiek, co zdecydował dekoder — i to jest znak, że dekoder niczego nie zdecydował

PDF Library for Delphi stosuje tabelę ze specyfikacji tam, gdzie sprawdzane są pozostałe pola IHDR: COLOR_GRAYSCALE przyjmuje FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE przyjmuje [1, 2, 4, 8], a COLOR_RGB, COLOR_GRAYSCALEALPHA i COLOR_RGBALPHA przyjmują [8, 16]; wszystko inne czyści ValidImage i obraz zostaje odrzucony z nienaruszonymi szerokością i wysokością na potrzeby diagnostyki. Chunk pHYs krótszy niż swoje dziewięć bajtów jest sprawdzany w tym samym miejscu, więc czytnik DPI nigdy nie indeksuje S[1] do S[8] w łańcuchu, który krótki chunk zostawił pusty

Przesunięcie liczone od 1 potraktowane jak wskaźnik liczony od 0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString przyjmuje StartPos liczone od 1, bo jego wejściem jest AnsiString, a implementacja Delphi adresuje wejście zlib jako @Input[StartPos]. Implementacja dla Free Pascala, napisana pod paszlib, żeby oba cele Windows linkowały kompresję statycznie, musi adresować to samo wejście przez arytmetykę wskaźników, a ta liczy od 0. Ustaw next_in na PAnsiChar(Input) + StartPos i avail_in na Length(Input) - StartPos, podaj 1 — czyli to, co dla tej funkcji znaczy „zacznij od początku” — a build FPC zaczyna rozpakowywać od drugiego bajtu i kończy bajt przed końcem

To łatwo się kryje, bo wołającym, do którego dociera większość testów, jest InflateStr, a on podaje 0. Zero z przypadku jest poprawnym przesunięciem liczonym od 0, więc oba buildy zgadzają się przy każdym zwykłym wołaniu InflateStr i w każdym teście, który przez nie przechodzi. TPDFDocument.DecodeAllStreams, procedura, której SaveQDFToFile i ConvertFileToQDF używają, żeby rozwinąć strumienie z pojedynczym FlateDecode do czytelnej postaci, podaje 1. Przy obecnym off-by-one pominięty nagłówek zlib sprawia, że inflate zawodzi, ale strumień zlib wciąż zgłasza niezerowe Consumed za bajty, które zbadał, więc wołający ufający Consumed bierze pusty ładunek za udane dekodowanie i podmienia każdy strumień treści na pusty łańcuch. Powstały QDF ma właściwą liczbę stron, poprawną strukturę i zero treści stron — czyli to plik, który otwiera się bez błędu w każdym czytniku i nic nie pokazuje

// Gałąź FPC funkcji InflateStrFromPosition.
// StartPos liczone od 1 tak jak w gałęzi Delphi; przytnij je, a potem
// zamień na przesunięcie wskaźnika liczone od 0 dokładnie raz, na granicy.
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;

Test, który to łapie, jest najmniejszy z możliwych: spakuj ładunek, rozpakuj go od pozycji 0 i od pozycji 1 i sprawdź, że oba zwracają ten sam ładunek i oba zgłaszają Consumed równe pełnej długości strumienia. Strumień RFC 1950 ma dwubajtowy nagłówek i czterobajtową stopkę Adler-32, więc przesunięcie o jeden na którymkolwiek z końców to nie subtelne uszkodzenie, tylko strumień, który albo nie chce się zacząć, albo nie chce się skończyć. Lekcja dotyczy granicy, nie zlib: kiedy parametr funkcji jest zdefiniowany w jednej bazie indeksu, a implementacja pod spodem używa drugiej, konwersja należy do dokładnie jednej linii, a test musi wołać funkcję z wartością, która te dwie bazy rozróżnia

Dlaczego krótki odczyt TStream.Read nie oznacza końca strumienia?

Bo TStream.Read może zwrócić mniej bajtów, niż poproszono, z dowolnego powodu, jaki mu się podoba, i tylko zwrot 0 znaczy, że dalej nic nie ma. TMemoryStream i TFileStream na lokalnym dysku prawie zawsze wypełniają żądanie i dlatego kod, który traktuje zwrócenie mniejszej liczby bajtów niż żądana jako koniec pliku, przechodzi każdy test, który ich używa. Strumienie sieciowe, strumienie dekompresji i każdy potomek TStream napisany przez klienta potrafią zwrócić dwa bajty, gdy poprosisz o sześćdziesiąt cztery tysiące, i mieć za sobą gigabajty

TPLBuffer to czytnik, przez który przechodzi każdy parser w PDF Library for Delphi, i potrafi opakować AnsiString, wskaźnik, tablicę bajtów albo TStream. Jego cztery zapytania skanujące — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte i DistanceToOtherBytes, wszystkie zwracające Int64 — czytają źródło blokami po 64 KB w poszukiwaniu separatora i mówią, jak daleko jest, nie ruszając pozycji logicznej. Naturalny sposób zakończenia takiej pętli to Until ReadCount < BlockSize. Dla trzech źródeł w pamięci to poprawne, bo ReadIntoBuffer zawsze dostarcza pełny blok aż do ostatniego. Dla źródła strumieniowego znaczy to, że skan poddaje się przy pierwszym krótkim odczycie, zgłasza separator jako nieobecny, a tokenizer wyżej decyduje, że obiekt kończy się tam, gdzie się nie kończy — dlatego TPLBuffer pętli do momentu, aż odczyt zwróci 0

Obsługa krótkich odczytów w buforze strumienia PDFlibPas: DistanceToByte skanuje bloki po 64 KB, stara pętla traktowała Until ReadCount < BlockSize jako koniec danych i poddawała się przy pierwszym krótkim odczycie, a naprawiona pętla biegnie aż ReadCount równa się zero, znajduje separator i przywraca pozycję w bloku finally
Strumień może zwrócić dwa bajty, gdy poprosisz o sześćdziesiąt cztery tysiące, więc zero jest jedynym sygnałem końca danych, któremu skan może zaufać, a klauzula finally przywraca pozycję logiczną, gdy separator zostanie znaleziony i pętla wyjdzie wcześniej
// TPLBuffer.DistanceToByte, pętla skanująca.
// Zero to jedyny sygnał końca danych, jaki definiuje TStream.Read.
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;   // podgląd nie może ruszać czytnika
End;

Sposób, żeby to przypiąć, to potomek TMemoryStream, którego przeciążone Read ogranicza każde żądanie do dwóch bajtów. Owinięcie w nim łańcucha aaaaaX, ustawienie pozycji bufora na 1 — i wszystkie cztery zapytania muszą zgłosić odległość 4 do X, zostawić po sobie pozycję 1 i zwrócić -1 dla bajtu, którego tam nie ma. Skan, który staje przy pierwszym krótkim odczycie, widzi dwa bajty, uznaje strumień za wyczerpany i zwraca -1. finally znaczy tyle samo co warunek pętli: Exit z wnętrza skanu to normalna ścieżka sukcesu i pozycja logiczna musi być przywracana też na niej, nie tylko przy pełnym przebiegu pętli

Jeden kod źródłowy, dwa kompilatory, jeden zestaw asercji

Dyscyplina, która stoi za tymi pięcioma, mówi, że „build Delphi przechodzi” to dowód o Delphi, nie o źródłach. Sprawdzenie międzykompilatorowe warte swojej ceny puszcza jedną procedurę pod oboma kompilatorami: buduje dwustronicowy dokument ze skompresowaną treścią przez TPDFlib, zapisuje go, zapisuje ponownie jako QDF przez SaveQDFToFile, naprawia QDF przez RepairQDFFile, szyfruje zwykły plik AES-128 przez EncryptFile i maskę uprawnień z EncodePermissions, a potem wczytuje każdy artefakt na nowo i sprawdza na obu kompilatorach to samo: liczba stron to 2, tytuł przeżywa, tekst drugiej strony wyciąga się bez szwanku ze zwykłego, naprawionego i zaszyfrowanego pliku, błędne hasło jest odrzucane z niezerowym LastErrorCode, EncryptionStrength wynosi 128, EncryptionAlgorithm wynosi 2, a poszczególne bity uprawnień z GetUserPermissions wracają dokładnie takie, jakie zakodowano

Porównuj znormalizowaną semantykę, a nie bajty. Szyfrowanie losuje sole, a zapisujący przydziela identyfikatory dokumentu, więc nie oczekuje się, że oba buildy wypuszczą identyczne pliki; oczekuje się plików, które znaczą to samo, i sprawdzania powinny być sformułowane na tym poziomie. Noga z QDF zawdzięcza swoje miejsce pułapce przesunięcia: QDF z dwiema stronami i bez treści przechodzi sprawdzenie liczby stron, a pada na sprawdzeniu ekstrakcji tekstu — sprawdzaj więc to drugie. Każda zmiana, która jest no-opem pod jednym kompilatorem, a zmianą zachowania pod drugim — a opisuje to cztery z pięciu powyższych — musi przejść te same sprawdzenia na obu

Druga, linkowana połowa tego samego portu, czyli uzgodnienie obiektów OMF z Delphi z oczekiwaniami COFF Free Pascala, to osobna historia w artykule o linkowaniu obiektów OMF i COFF w FPC na Win32, a strukturalne utwardzenie tego samego czytnika TIFF przeciw BigTIFF i plikom kafelkowym jest w notatkach o wbudowanym dekoderze TIFF. Dekodery z tego artykułu są w PDF Library for Delphi dla Delphi, C++Buildera i Free Pascala, gdzie od tego samego źródła oczekuje się, że zarobi na ten sam wynik pod każdym obsługiwanym kompilatorem, zamiast dostawać go od jednego z nich