Tehnički članak

Delphi kod koji radi slučajno: pet grešaka pri FPC portu

PDF Library for Delphi je pronašla pet defekata u dekoderima dok je svoj CCITT, TIFF, PNG, Flate i stream-buffer kod dizala na Free Pascal, i svaki od njih godinama je prolazio celokupan test suite na Delphi-ju. Nijedan nije bio bug u kompajleru. Svaki je bio Pascal koji je Delphi slučajno izvršavao ispravno zbog jedne pojedinosti implementacije: skriveni Result parametar koji se alijasirao na niz pozivaoca, grana van opsega koju niko nikada nije čitao dalje, bafer nulte dužine čiji je jedini čuvar bio prekidač za proveru opsega, offset sa bazom 1 koji je samo jedna putanja koda ikada prosledila kao 1, i ugovor TStream.Read koji stream-ovi u memoriji nikada ne ispituju. Promenite kompajler, ili istoj kodi podmetnite neispravan fajl, i slučaj prestaje da važi

Ono što sledi je konkretan oblik svakog od njih, popravka, i disciplina koja je iz toga proizašla: isti izvorni kod sada mora da proizvede istu semantiku dokumenta na oba kompajlera, a test include proverava da je tako. Srodni članak o očvršćavanju Pascal PDF parsera protiv zlonamernih fajlova obradio je širinu celih brojeva, dubinu rekurzije i neinicijalizovane bafere. Ovaj tekst pokriva drugu klasu kvara: kod koji je oduvek bio pogrešan i imao kompajler koji ga tiho pokriva

Zašto funkcija koja vraća dinamički niz radi bez SetLength na Delphi-ju?

Zato što Delphi prosleđuje sopstvenu promenljivu pozivaoca kao skriveni Result parametar, pa funkcija koja nikada ne alocira svoj rezultat ipak može da piše u niz koji je alocirao pozivalac. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray je pretraga referentne linije u srcu dvodimenzionalnog Group 3 i Group 4 dekodiranja: za datu trenutnu poziciju a0 i boju tekućeg niza, traži elemente promene prethodne scanline, b1 i b2 iz ITU-T T.4 i T.6 dvodimenzionalne šeme kodiranja, i vraća ih kao niz od dva slota. Originalna funkcija je pisala Result[0] i Result[1] i nikada nije pozvala SetLength nad Result

To bi trebalo da pukne pri prvom upisu, i na Free Pascal-u puca. Na Delphi-ju nikada nije, jer oba mesta poziva u dekoderu izgledaju ovako: deklariši b: TCCITTIntegerArray, pozovi SetLength(b, 2) jednom pre petlje po scanline-ovima, pa unutar petlje dodeli b := GetNextChangingElement(a0, IsWhite) i čitaj b[0] i b[1]. Delphi priručnik za jezik kaže da funkcija čiji je rezultat long string, dinamički niz ili drugi managed tip prima taj rezultat kao dodatni var parametar, a u praksi kompajler prosleđuje adresu cilja dodele. Znači Result unutar funkcije je sam b, već dužine dva elementa, i svaki upis sleće u memoriju koju poseduje pozivalac. Free Pascal predaje funkciji svež nil niz i posle ga dodeljuje u b, što je i čitanje ugovora prema kojem je kod od početka trebalo pisati

Divergencija u CCITT dekodiranju u PDFlibPas-u: Delphi prosleđuje niz b pozivaoca kao skriveni var Result funkcije GetNextChangingElement, pa upisi sleću u memoriju koju pozivalac poseduje i promašena pretraga čuva prethodne vrednosti, dok Free Pascal predaje funkciji svež nil niz koji Length guard mora da dimenzioniše kroz SetLength pre prvog upisa
Delphi alijasira niz pozivaoca na skriveni Result parametar pa nezaštićeni upisi ipak sleću u sopstvenu memoriju, dok Free Pascal dolazi sa nil-om i guard od jednog reda pretvara kvar u namerno ponašanje bez promene Delphi putanje dekodiranja

Alijasiranje je nosilo i semantiku na koju se dekoder oslanja. Result[0] se dodeljuje samo kada skeniranje nađe element veći od a0, a Result[1] samo kada posle njega postoji element, pa pri promašaju slotovi zadržavaju ono što je prethodna iteracija ostavila u b. Očigledna popravka, alociraj dva slota i nuliraj ih pri svakom pozivu, uništila bi to prenošenje i promenila dekodirani izlaz na Delphi-ju. Popravka koja je isporučena je guard umesto reseta: na Delphi-ju je mrtav kod i putanja dekodiranja ostaje bajt po bajt ista, a na Free Pascal-u pretvara kvar u namerno ponašanje. Ta asimetrija je cela poenta, jer je popravka morala da bude no-op na kompajleru na kojem je kod već davao verifikovan izlaz

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi ovamo stiže sa dvoelementnim nizom pozivaoca koji je
  // alijasiran kao Result, pa je ovo tamo no-op. FPC stiže sa nil-om.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] se i dalje pišu samo pri pogotku, pa promašaj
  // čuva vrednosti prethodne iteracije tačno kao i pre
End;

Brojač koji je nadživeo svoje podatke: TIFF direktorijumski ulaz

Kada poništite niz, morate u istoj naredbi poništiti i njegov brojač, inače će u brojač verovati kod koji niz nikada ne vidi. TIFF ulaz u direktorijum slike (TIFF 6.0 §2, raspored od 12 bajtova: tag, tip, brojač i vrednost-ili-offset) nosi 32-bitni brojač direktno iz fajla, a PDF Library for Delphi svaki čita kroz PopDE: TTIFFEntry, record sa Tag, TagType, Length, Offset i dekodiranim nizovima IntegerValues i DoubleValues. Originalni kod je proveravao da li Offset + TypeSize * Length prelazi kraj fajla, i ako prelazi, postavljao je oba niza na nultu dužinu. Ostavljao je Result.Length na vrednosti iz fajla

Dve stvari su odatle krenule naopako. Funkcija se završava fallback-om koji kaže „ako je Length nula, daj ulazu jedan element sa vrednošću nula“, da bi pozivaoci uvek mogli da pročitaju element nula. Pošto Length nikada nije bio obrisan na putanji van opsega, taj fallback se nikada nije aktivirao za jedini slučaj zbog kojeg je i postojao. A pozivaoci čitaju element nula, bezuslovno: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip i još desetak njih uzimaju E.IntegerValues[0], a strip tabele rade Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), kopirajući Length puta po četiri bajta iz niza kojih nema. Obrisan niz sa živim brojačem je strogo opasniji od neproverenog, jer neprovereni bar drži bajtove koje tvrdi da ima

Drugi problem je bio redosled. Dva poziva SetLength izvršavala su se pre provere opsega, dimenzionisana brojačem iz fajla, pa je zlonamerni ulaz mogao da zatraži alokaciju od više gigabajta pre ijedne provere validnosti. Na Delphi-ju je nastali izuzetak hvatao handler dalje uz putanju učitavanja slike i fajl bi jednostavno pao pri učitavanju, zbog čega niko nije primetio; ono što se stvarno desilo bio je out-of-memory događaj koji je izabrao fajl. Popravka pomera alokaciju posle provere i tera brojač da putuje zajedno sa podacima

Očvršćavanje TIFF direktorijumskog ulaza u PDFlibPas-u: ulaz od 12 bajtova nosi brojač koji dolazi iz fajla, pokvareni redosled alocirao je nizove po tom brojaču pre provere opsega i ostavljao Result.Length živim posle brisanja, a ispravljeni redosled prvo testira Int64 aritmetiku prema dužini fajla pa se brojač briše zajedno sa nizovima
Alokacija pre provere opsega pustila je zlonamerni brojač da zatraži gigabajte i ostavila živ brojač na ispražnjenom nizu, pa popravka prvo testira offset a Result.Length briše u istoj naredbi u kojoj i nizove
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // brojač ide zajedno sa vrednostima
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // tek sada
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... kasnije, postojeći fallback konačno stiže do slučaja za koji je i napravljen:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Na ovoj popravci ništa nije specifično za kompajler, i upravo zato joj je mesto na ovom spisku. Defekt je bio latentan na Delphi-ju iz istog razloga iz kojeg je bio latentan i na Free Pascal-u: nijedan test fajl nije imao direktorijumski ulaz koji pokazuje iza kraja fajla. Port ga nije razotkrio. Čitanje koda uz pitanje „šta to Delphi radi umesto mene“ jeste

Šta se dešava kada PNG IHDR navede tip boje koji format ne definiše?

PDF Library for Delphi sada odbija sliku pre nego što se pokrenu row filter-i; pre verzije v3.539.2 računala je scanline od nula bajtova i predavala unfilter petljama prazan bafer. ISO 15948 §11.2.2 definiše IHDR chunk, a tabela 11.1 navodi šest legalnih kombinacija tipa boje i dubine bita: grayscale na 1, 2, 4, 8 ili 16 bita, indeksirana boja na 1, 2, 4 ili 8, i truecolor, grayscale sa alfom i truecolor sa alfom na 8 ili 16. TPNGReader je validirao polja compression method i filter method u IHDR, a FColorType i dubinu bita propuštao netaknute

Kod row filter-a sve dimenzioniše iz Case FColorType Of koji svaki tip boje mapira na broj komponenti. Tip boje van tih šest pada u Else granu, gde je SourceComponents 0, pa je ScanlineByteCount 0, pa posle SetLength(PreviousScanline, 0) odmah sledi FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indeksiranje elementa nula u praznom dinamičkom nizu je adresa izračunata iz nil-a. Sa isključenom proverom opsega, popunjavanje nula bajtova kroz tu adresu je tihi no-op i dekoder maršira kroz redove koji ne postoje; sa uključenom proverom opsega to je ERangeError pri prvoj slici; a Move pozivi koji slede su korak od access violation-a. Koje od toga dobijete zavisi od kompajlera i od build prekidača, a ne od bilo čega što je dekoder odlučio, i to je znak da dekoder nije odlučio ništa

Popravka je tabela iz specifikacije, primenjena tamo gde su se i ostala IHDR polja već proveravala: COLOR_GRAYSCALE prihvata FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE prihvata [1, 2, 4, 8], a COLOR_RGB, COLOR_GRAYSCALEALPHA i COLOR_RGBALPHA prihvataju [8, 16]; sve ostalo briše ValidImage i slika se odbija sa netaknutom širinom i visinom zbog dijagnostike. pHYs chunk kraći od svojih devet bajtova zatvoren je u istom prolazu, jer je DPI čitač indeksirao S[1] do S[8] u string-u koji je kratki chunk ostavio praznim

Offset sa bazom 1 tretiran kao pointer sa bazom 0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString prima StartPos sa bazom 1, jer je njegov ulaz AnsiString a Delphi implementacija adresira zlib ulaz kao @Input[StartPos]. Free Pascal implementacija, pisana prema paszlib-u da bi oba Windows targeta statički linkovala kompresiju, postavljala je next_in na PAnsiChar(Input) + StartPos i avail_in na Length(Input) - StartPos. To je pointer aritmetika, i ona ima bazu 0. Prosledite 1, što za ovu funkciju znači „počni od početka“, i FPC build počinje inflate od drugog bajta i staje bajt pre kraja

Razlog zbog kojeg je preživeo je to što je jedini pozivalac do kojeg većina testova stigne InflateStr, koji prosleđuje 0. Nula je slučajno ispravan offset sa bazom 0, pa su se dva build-a slagala pri svakom običnom InflateStr pozivu i svakom testu koji je išao kroz njega. TPDFDocument.DecodeAllStreams, rutina koju SaveQDFToFile i ConvertFileToQDF koriste da razviju stream-ove sa pojedinačnim FlateDecode-om u čitljiv oblik, prosleđuje 1. Na FPC build-u je preskočen zlib header učinio da inflate padne, ali je zlib stream i dalje prijavio Consumed različit od nule za bajtove koje je pregledao, pa je DecodeAllStreams uzeo prazan payload kao uspešno dekodiranje i zamenio svaki content stream praznim string-om. Dobijeni QDF imao je ispravan broj stranica, validnu strukturu i nikakav sadržaj stranice, što je fajl koji se otvara bez greške u svakom pregledaču i ne prikazuje ništa

// FPC grana InflateStrFromPosition, posle v3.539.16.
// StartPos ima bazu 1 kao i Delphi grana; ograniči ga, pa prevedi
// u pointer offset sa bazom 0 tačno jednom, na granici.
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;

Regresija koja to čuva je najmanja moguća: deflate-uj payload, inflate-uj ga sa pozicije 0 i sa pozicije 1, i tvrdi da oba vraćaju isti payload i da oba prijavljuju Consumed jednak punoj dužini stream-a. RFC 1950 stream ima header od dva bajta i Adler-32 trailer od četiri bajta, pa off-by-one na bilo kojem kraju nije suptilna korupcija, već stream koji ili ne počinje ili ne završava. Pouka je o granici, ne o zlib-u: kada je parametar funkcije definisan u jednoj brojnoj bazi a implementacija ispod koristi drugu, konverzija pripada tačno jednom redu, i test mora da je pozove sa vrednošću koja razlikuje te dve baze

Zašto kratko čitanje iz TStream.Read nije kraj stream-a?

Zato što TStream.Read sme da vrati manje bajtova nego što je zatraženo iz kog god razloga, i samo povratna vrednost 0 znači da dalje nema ničega. TMemoryStream i TFileStream na lokalnom disku skoro uvek napune zahtev, i zato kod koji „vratio je manje nego što sam tražio“ tretira kao kraj fajla prolazi svaki test koji ih koristi. Stream-ovi preko mreže, stream-ovi za dekompresiju i bilo koji TStream naslednik koji je napisao korisnik mogu da vrate dva bajta kada se traži šezdeset četiri hiljade i da i dalje imaju gigabajte za sobom

TPLBuffer je čitač kroz koji prolazi svaki parser u PDF Library for Delphi, i može da obavije AnsiString, pointer, niz bajtova ili TStream. Njegova četiri upita za skeniranje, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte i DistanceToOtherBytes, svi vraćaju Int64, čitaju izvor u blokovima od 64 KB tražeći delimiter i prijavljuju koliko je daleko bez pomeranja logičke pozicije. Svaka petlja se završavala sa Until ReadCount < BlockSize. Za tri izvora u memoriji to je ispravno, jer ReadIntoBuffer uvek isporuči pun blok do poslednjeg. Za stream izvor to znači da skeniranje odustaje pri prvom kratkom čitanju, prijavljuje delimiter kao odsutan, i tokenizer iznad njega odlučuje da se objekat završava tamo gde se ne završava

Rukovanje kratkim čitanjem u PDFlibPas stream baferu: DistanceToByte skenira blokove od 64 KB, stara petlja je Until ReadCount < BlockSize tretirala kao kraj podataka i odustajala pri prvom kratkom čitanju, dok ispravljena petlja radi dok ReadCount ne postane nula, nalazi delimiter i vraća poziciju u finally bloku
Stream može da vrati dva bajta kada se traži šezdeset četiri hiljade, pa je nula jedini signal kraja podataka u koji skeniranje sme da veruje, a finally klauzula vraća logičku poziciju kada se delimiter nađe i petlja izađe ranije
// TPLBuffer.DistanceToByte, petlja posle v3.539.6.
// Nula je jedini signal kraja podataka koji TStream.Read definiše.
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;   // peek ne sme da pomera čitača
End;

Test koji ovo prikiva je TMemoryStream naslednik čiji Read override ograničava svaki zahtev na dva bajta. Obavijte string aaaaaX u njega, postavite poziciju bafera na 1, i sva četiri upita moraju da prijave razdaljinu 4 do X, da posle toga ostave poziciju na 1, i da prijave -1 za bajt kojeg nema. Pre popravke prvi upit je video dva bajta, zaključio da je stream iscrpljen i vratio -1. finally je jednako važan kao i uslov petlje: Exit iz unutrašnjosti skeniranja je normalna putanja uspeha, i logička pozicija mora da se vrati i na toj putanji, ne samo kada petlja prođe do kraja

Jedan izvorni kod, dva kompajlera, jedan skup tvrdnji

Disciplina koja je proizašla iz ovih pet je da „Delphi build prolazi“ jeste dokaz o Delphi-ju, ne o izvornom kodu. Od verzije v3.539.16 i Delphi DUnitX suite i Free Pascal konzolni suite uključuju isti Tests\CrossCompilerSemantics.inc, jednu rutinu, RunCrossCompilerFileSemantics, koja gradi dvostranični dokument sa komprimovanim sadržajem kroz TPDFlib, čuva ga, pa ga ponovo čuva kao QDF kroz SaveQDFToFile, popravlja QDF kroz RepairQDFFile, šifruje običan fajl AES-128 kroz EncryptFile i masku dozvola iz EncodePermissions, a zatim ponovo učitava svaki artefakt i tvrdi iste stvari na oba kompajlera: broj stranica je 2, naslov preživljava, tekst druge stranice se izvlači netaknut iz običnog, popravljenog i šifrovanog fajla, pogrešna lozinka se odbija uz LastErrorCode različit od nule, EncryptionStrength je 128, EncryptionAlgorithm je 2, a pojedinačni bitovi dozvola iz GetUserPermissions vraćaju se tačno onako kako su kodirani

Poređenje je namerno normalizovano, a ne bajt po bajt. Šifrovanje izvlači slučajne salt-ove a pisac dodeljuje identifikatore dokumenta, pa se od dva build-a ne očekuje da emituju identične fajlove; od njih se očekuje da emituju fajlove koji znače istu stvar, i tvrdnje su formulisane na tom nivou. QDF krak je tu upravo zbog offset bug-a: QDF sa dve stranice i bez sadržaja prolazi proveru broja stranica a pada na proveri izvlačenja teksta, a matrica tvrdi ovo drugo. Svaka buduća popravka koja je no-op na jednom kompajleru a promena ponašanja na drugom, što opisuje četiri od pet gore, sada mora dvaput da prođe iste tvrdnje pre nego što ode u isporuku

Link-time polovina istog porta, usklađivanje Delphi OMF objekata sa očekivanjima Free Pascal COFF-a, ima svoju priču u tekstu linkovanje FPC Win32 OMF u COFF objekte, a strukturno očvršćavanje istog TIFF čitača protiv BigTIFF i tiled fajlova je u beleškama o ugrađenom TIFF dekoderu. Dekoderi iz ovog članka, i cross-compiler test koji sada stoji ispod njih, isporučuju se u PDF Library for Delphi za Delphi, C++Builder i Free Pascal, gde se od istog izvornog koda očekuje da zaradi isti rezultat na svakom kompajleru koji targetira, a ne da mu ga jedan pokloni