把 CCITT、TIFF、PNG、Flate 與串流緩衝區程式碼帶上 Free Pascal,是找出「只在 Delphi 上碰巧能跑」的 Pascal 程式碼的可靠方法。以下五種模式沒有一個涉及編譯器 bug。每一個都是 Delphi 因為某個實作細節而碰巧執行正確的程式碼:一個把呼叫端陣列化為別名的隱藏 result 參數、一個從沒有人讀過頭的越界分支、一個唯一防護是範圍檢查開關的零長度緩衝區、一個只有一條程式路徑會以 1 傳入的 1 基底位移,還有一個 in-memory 串流從不會行使的 TStream.Read 契約。換個編譯器,或餵同一份程式碼一份畸形檔案,這個巧合就不再成立
接下來是每個陷阱的具體樣貌、PDF Library for Delphi 的應對方式,以及背後的紀律:同一份原始碼必須在兩個編譯器上產生相同的文件語意,而唯一能確定的辦法就是在兩邊都查。姊妹作強化 Pascal PDF 解析器以抵禦惡意檔案涵蓋整數寬度、遞迴深度與未初始化緩衝區。這篇講的是另一類失敗:程式碼從頭到尾都是錯的,而某個編譯器一直悄悄替它掩護
為什麼回傳動態陣列的函式在 Delphi 上不呼叫 SetLength 也能跑
因為 Delphi 會把呼叫端自己的變數當成隱藏的 result 參數傳進去,所以一個從不配置自己回傳值的函式,仍然可以寫進呼叫端配置好的陣列。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray 是二維 Group 3 與 Group 4 解碼核心的參考行查找:給定目前位置 a0 與目前游程的顏色,它搜尋前一條掃描線的變化元素——ITU-T T.4 與 T.6 二維編碼方案裡的 b1 與 b2——並以兩槽陣列回傳。誘人的寫法是直接指定 Result[0] 與 Result[1],而從不對 Result 呼叫 SetLength
這在第一次寫入時就該爆掉,而在 Free Pascal 上確實如此。在 Delphi 上則不會,只要呼叫點長這樣:宣告 b: TCCITTIntegerArray,在掃描線迴圈之前先跑一次 SetLength(b, 2),接著在迴圈裡指定 b := GetNextChangingElement(a0, IsWhite) 並讀取 b[0] 與 b[1]。Delphi 語言指南寫明:回傳型別為長字串、動態陣列或其他受管型別的函式,會以額外的 var 參數接收該回傳值,實務上編譯器傳的是指派目標的位址。所以函式裡的 Result 就是 b 本身,已經有兩個元素,每次寫入都落在呼叫端擁有的記憶體裡。Free Pascal 則交給函式一個全新的 nil 陣列,事後才指派給 b——這才是這份程式碼一開始就該對準的契約讀法
這個別名還承載了一個解碼器所依賴的語意。Result[0] 只在掃描找到大於 a0 的元素時才被指定,Result[1] 只在它後面還有元素時才被指定,所以落空時兩個槽位保留前一輪留給 b 的值。最直覺的修法——每次呼叫都配置兩個槽位並歸零——會摧毀這個接續效果,改變 Delphi 上的解碼輸出。PDF Library for Delphi 用防護而非重置:在 Delphi 上它是死碼,解碼路徑逐位元組保持原樣;在 Free Pascal 上它把崩潰變成預期行為。這個不對稱正是重點所在,因為防護在「程式碼已產生已驗證輸出」的那個編譯器上必須是無操作
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi 進來時,呼叫端的兩元素陣列已別名為 Result,
// 所以這裡對它而言是無作用的陳述。FPC 進來時是 nil。
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] 仍然只在中命中時寫入,所以落空時
// 保留前一輪的值,與過去完全一致
End;
比資料活得久的計數:TIFF 目錄項目
把一個陣列作廢時,必須在同一個陳述式裡把它的計數一起作廢,否則那個計數會被根本看不到陣列的程式碼採信。TIFF 影像檔案目錄項目(TIFF 6.0 §2,tag、type、count 與 value-or-offset 的 12 位元組佈局)帶著一個直接來自檔案的 32 位元計數,而 PDF Library for Delphi 透過 PopDE: TTIFFEntry 讀取每一項——這個記錄有 Tag、TagType、Length、Offset,以及解碼後的 IntegerValues 與 DoubleValues 陣列。天真的範圍檢查測試 Offset + TypeSize * Length 是否跑出檔案結尾,若超出,就把兩個陣列設為零長度,卻讓 Result.Length 保持檔案裡來的值
從那裡開始有兩件事出錯。函式結尾有個後備邏輯寫著「若 Length 為零,就給這個項目一個值為零的元素」,好讓呼叫端永遠讀得到元素零。由於越界路徑從未清掉 Length,這個後備邏輯在它唯一存在的那個情境裡從來沒觸發過。而呼叫端確實會無條件讀取元素零:Width、Height、BitsPerSample、PhotometricInterpretation、FillOrder、SamplesPerPixel、RowsPerStrip 等十幾個欄位都取 E.IntegerValues[0],strip 表則執行 Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4),從一個空陣列裡複製 Length 乘四個位元組。清空的陣列配上還活著的計數,比未檢查的還危險,因為未檢查的那個至少還握著它宣稱有的位元組
第二個陷阱是順序。把兩個 SetLength 呼叫跑在範圍測試之前、大小取自檔案的計數,一個帶惡意的項目就能在任何有效性檢查之前要求好幾 GB 的配置。在 Delphi 上,隨之而來的例外通常被影像載入路徑上層的處理器接走,檔案就只是載入失敗,這正是問題容易被錯過的原因;實際發生的是一個由檔案自己挑選的 out-of-memory 事件。正確的順序是先測試再配置,並讓計數跟著資料一起走
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // 計數跟著值一起走
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // 到這裡才配置
SetLength(Result.DoubleValues, Result.Length);
End;
// ……稍後,後備邏輯終於遇到它存在的理由:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
這一項沒有任何地方是編譯器專屬的,而這正是它該被列進這份清單的原因。這個陷阱在 Delphi 上潛伏的理由,跟在 Free Pascal 上潛伏的理由一模一樣:它需要一個指到檔案結尾之外的目錄項目,而一般的測試檔案不會有。移植並沒有把它暴露出來。帶著「Delphi 在這裡替我做了什麼我自己沒做的事」這個問題去讀程式碼,才是把它翻出來的東西
當 PNG IHDR 宣稱一個格式沒定義的 color type 時會發生什麼事
PDF Library for Delphi 會在 row filter 跑之前就把影像擋掉。ISO 15948 §11.2.2 定義了 IHDR chunk,Table 11.1 列出六種合法的 color type 與 bit depth 組合:灰階 1、2、4、8 或 16 位元,索引色 1、2、4 或 8 位元,真彩、灰階加 alpha、真彩加 alpha 則是 8 或 16 位元。陷阱在於一個驗證了 IHDR 的 compression method 與 filter method 欄位、卻讓 FColorType 與 bit depth 原樣通過的讀取器:不合法的 color type 於是產出零位元組的掃描線,unfilter 迴圈拿到一個空緩衝區
row filter 程式碼的一切尺寸通常都來自一個 Case FColorType Of,它把每個 color type 對應到一個 component 數量。六種之外的 color type 落進 Else 分支,在那裡 SourceComponents 是 0,於是 ScanlineByteCount 是 0,於是 SetLength(PreviousScanline, 0) 之後緊跟著 FillChar(PreviousScanline[0], ScanlineByteCount, 0)。對空動態陣列的元素零做索引,得到的是一個從 nil 算出來的位址。範圍檢查關閉時,經那個位址做的零位元組填充是靜默的無操作,解碼器照樣在不存在列上前進;範圍檢查開啟時,第一張影像就是一個 ERangeError;而它後面的 Move 呼叫離 access violation 只差一步。你得到哪一種,取決於編譯器與建置開關,而不是解碼器自己的任何決定——這正是「解碼器從未做過決定」的鐵證
PDF Library for Delphi 把規格裡的那張表,套用在其他 IHDR 欄位早就已經在做檢查的地方:COLOR_GRAYSCALE 接受 FSourceBitDepth in [1, 2, 4, 8, 16],COLOR_PALETTE 接受 [1, 2, 4, 8],COLOR_RGB、COLOR_GRAYSCALEALPHA 與 COLOR_RGBALPHA 接受 [8, 16];其餘一律清掉 ValidImage,影像連同完整的寬高資訊一起被拒收,方便診斷。少於九位元組的 pHYs chunk 也在同一處檢查,DPI 讀取器因此不會去索引被短 chunk 留空的字串的 S[1] 到 S[8]
被當成 0 基底指標處理的 1 基底位移
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString 收的是 1 基底的 StartPos,因為它的輸入是 AnsiString,而 Delphi 實作以 @Input[StartPos] 定址 zlib 輸入。Free Pascal 實作是對著 paszlib 寫的,讓兩個 Windows 目標都能靜態連結壓縮庫,它必須用指標算術定址同一份輸入,而指標算術是 0 基底。把 next_in 設成 PAnsiChar(Input) + StartPos、avail_in 設成 Length(Input) - StartPos,再傳入 1——對這個函式而言「從頭開始」的意思——FPC 建置就會從第二個位元組開始 inflate,並在結尾前一位元組停住
這一項很容易藏,因為多數測試會碰到的呼叫端是 InflateStr,而它傳 0。零剛好就是正確的 0 基底位移,所以兩個建置在每一次單純的 InflateStr 呼叫、以及每一個走那條路的測試上都一致。TPDFDocument.DecodeAllStreams——SaveQDFToFile 與 ConvertFileToQDF 用來把單一 FlateDecode 串流展開成可讀形式的常式——傳的是 1。off-by-one 在位時,被跳過的 zlib 標頭讓 inflate 失敗,但 zlib 串流仍為它檢查過的位元組回報非零的 Consumed,於是信任 Consumed 的呼叫端把空 payload 當成解碼成功,把每一條 content stream 都換成空字串。產出的 QDF 頁數正確、結構有效、頁面內容全無——一份在每個閱讀器裡都不報錯開啟、卻什麼都沒有的檔案
// InflateStrFromPosition 的 FPC 分支。
// StartPos 與 Delphi 分支一樣是 1 基底;先夾住,然後
// 在邊界處轉成 0 基底指標位移,恰好一次。
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;
抓到它的檢查小到不能再小:把一份 payload deflate,再從位置 0 與位置 1 各 inflate 一次,確認兩者回傳相同的 payload,且都回報 Consumed 等於完整串流長度。RFC 1950 串流有兩位元組標頭與四位元組 Adler-32 尾端,所以任何一端的 off-by-one 都不是微妙的損壞,而是要嘛起不了頭、要嘛收不了尾的串流。教訓關於邊界,與 zlib 無關:當函式的參數以一種索引基底定義、底層實作用另一種時,轉換應該只出現在恰好一行裡,而且測試必須用能區分兩種基底的值去呼叫它
為什麼 TStream.Read 讀得比要求少不等於串流結束
因為 TStream.Read 有權用任何它高興的理由少回一些位元組,而只有回傳 0 才代表後面沒有東西了。本機磁碟上的 TMemoryStream 與 TFileStream 幾乎總是補滿要求,這就是為什麼把「回傳得比我要求的少」當成檔案結束的程式碼,能通過每一個用到它們的測試。網路後端串流、解壓縮串流,以及客戶自己寫的任何 TStream 後裔,都可能在你要六萬四千位元組時回你兩個位元組,而後面還躺著好幾 GB
TPLBuffer 是 PDF Library for Delphi 每個剖析器都會經過的讀取器,它可以包住 AnsiString、指標、位元組陣列或 TStream。它的四個掃描查詢——DistanceToByte、DistanceToOtherByte、DistanceToAnyByte 與 DistanceToOtherBytes,全部回傳 Int64——以 64 KB 區塊讀取來源、尋找分隔符,並在不移動邏輯位置的前提下回報它有多遠。這種迴圈最自然的結束方式是 Until ReadCount < BlockSize。對三種 in-memory 來源這是對的,因為 ReadIntoBuffer 在最後一個區塊之前總是補滿整個區塊。對串流來源,這代表掃描在第一次短讀取就放棄,回報分隔符不存在,於是上面的 tokenizer 把物件結尾判在不該結束的地方——這正是 TPLBuffer 一直迴圈到讀取回傳 0 為止的原因
// TPLBuffer.DistanceToByte 的掃描迴圈。
// 零是 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; // 窺看不得移動讀取器
End;
把這件事釘死的方法,是一個 TMemoryStream 後裔,它覆寫的 Read 把每次要求都限制在兩個位元組。把字串 aaaaaX 包進去,把緩衝區位置設成 1,四個查詢都必須回報到 X 的距離是 4、事後位置停在 1,並對不存在的位元組回報 -1。在第一次短讀取就停下的掃描只看到兩個位元組,斷定串流耗盡,回傳 -1。finally 跟迴圈條件一樣重要:從掃描內部 Exit 是正常的成功路徑,邏輯位置在這條路上也必須還原,而不是只在迴圈跑完時才還原
一份原始碼、兩個編譯器、一套斷言
這五個背後的紀律是:「Delphi 建置通過」是關於 Delphi 的證據,不是關於原始碼的證據。值得做的跨編譯器檢查,是讓同一個常式在兩個編譯器下都跑一遍:透過 TPDFlib 建一份兩頁、內容壓縮的文件,存檔,再用 SaveQDFToFile 存成 QDF,以 RepairQDFFile 修復該 QDF,透過 EncryptFile 與 EncodePermissions 給的權限遮罩以 AES-128 加密明文檔,然後把每個成品重新載入,在兩個編譯器上檢查同樣的事:頁數為 2、標題存活、第二頁文字從明文、修復與加密檔都能完整擷出、錯誤密碼以非零 LastErrorCode 拒收、EncryptionStrength 為 128、EncryptionAlgorithm 為 2,而 GetUserPermissions 回來的各個權限位元與編碼時完全一致
比較的是正規化後的語意,不是位元組。加密會抽取隨機 salt,寫入器也會指派文件識別碼,所以不預期兩個建置產出位元組相同的檔案;預期的是它們產出意思相同的檔案,檢查就應該寫在那個層次上。QDF 這一段的存在是為了位移陷阱:一份有兩頁卻沒有內容的 QDF 會通過頁數檢查、卻在文字擷取檢查上失敗,所以要查的是後者。任何「在一個編譯器上是無操作、在另一個上是行為改變」的變更——上面五個裡有四個符合這個描述——都必須在兩邊通過同一組檢查
同一場移植的連結期那一半——讓 Delphi 的 OMF 目的檔與 Free Pascal 的 COFF 期待達成一致——在FPC Win32 的 OMF 到 COFF 目的檔連結裡自成一個故事;同一個 TIFF 讀取器對 BigTIFF 與 tiled 檔案的結構強化,則見內建 TIFF 解碼器筆記。本文的解碼器隨 PDF Library for Delphi 出貨,供 Delphi、C++Builder 與 Free Pascal 使用——同一份原始碼應該在它瞄准的每個編譯器上憑本事贏得同樣的結果,而不是被其中一個編譯器白白送分