PDF Library for Delphi 在把 CCITT、TIFF、PNG、Flate 與串流緩衝區程式碼帶上 Free Pascal 的過程中,找出五個解碼器缺陷,而它們每一個都已經通過完整的 Delphi 測試套件好幾年。這些都不是編譯器 bug。每一個都是 Delphi 因為某個實作細節而碰巧執行正確的 Pascal:一個把呼叫端陣列別名進來的隱藏 result 參數、一個沒人讀超過的越界分支、一個只靠範圍檢查開關才守得住的零長度緩衝區、一個只有一條程式路徑會傳 1 的 1 基底位移,以及一個記憶體內串流從未觸發過的 TStream.Read 契約。換掉編譯器,或把同樣的程式碼餵給畸形檔案,這個巧合就不再成立
接下來是每一個缺陷的具體樣貌、修法,以及從中長出來的紀律:同一份原始碼現在必須在兩個編譯器上產生相同的文件語意,而一個測試 include 會檢查它確實做到。姊妹作強化 Pascal PDF 剖析器以對抗惡意檔案談的是整數寬度、遞迴深度與未初始化緩衝區。本文講的是另一種失效類型:那些從頭到尾就寫錯、只是有編譯器默默替它撐著的程式碼
為什麼回傳動態陣列的函式在 Delphi 上不呼叫 SetLength 也能跑
因為 Delphi 會把呼叫端自己的變數當成隱藏的 result 參數傳進去,所以一個從不配置自己回傳值的函式,仍然可以寫進呼叫端配置好的陣列。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray 是二維 Group 3 與 Group 4 解碼核心的參考線查表:給定目前位置 a0 與目前 run 的顏色,它會搜尋上一條掃描線的 changing element,也就是 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 上的解碼輸出。實際出貨的修法是守衛而不是重置:在 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 陣列的 record。原始程式碼會檢查 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 上,隨之而來的例外被影像載入路徑上層的處理器接走,檔案就只是載入失敗,這就是沒人注意到的原因,實際上發生的是由檔案決定的記憶體耗盡事件。修法把配置移到測試之後,並讓計數跟著資料一起移動
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 跑之前就把影像擋掉,在 v3.539.2 之前,它會算出零位元組的掃描線,然後把空緩衝區交給 unfilter 迴圈。ISO 15948 §11.2.2 定義了 IHDR chunk,而 Table 11.1 列出六種合法的 color type 與 bit depth 組合:1、2、4、8 或 16 位元的灰階,1、2、4 或 8 的索引色,以及 8 或 16 的 truecolor、帶 alpha 的灰階與帶 alpha 的 truecolor。TPNGReader 驗證了 IHDR 的 compression method 與 filter method 欄位,卻讓 FColorType 和 bit depth 原封不動地通過
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 呼叫距離存取違規只差一步。你拿到哪一種,取決於編譯器與建置開關,而不是解碼器決定過的任何事,而這正是解碼器從頭到尾沒做過決定的破綻
修法就是把規格裡的那張表,套用在其他 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 目標都能靜態連結壓縮功能,它把 next_in 設成 PAnsiChar(Input) + StartPos、avail_in 設成 Length(Input) - StartPos。那是指標運算,而指標運算是 0 基底的。傳 1,對這個函式來說就是「從頭開始」的意思,FPC 建置就會從第二個位元組開始 inflate,並在結尾前一個位元組停下
它能活下來的原因是,大多數測試會碰到的唯一呼叫端是 InflateStr,而它傳 0。零剛好就是正確的 0 基底位移,所以兩個建置在每一次單純的 InflateStr 呼叫、以及每一個走那條路的測試上都一致。TPDFDocument.DecodeAllStreams,也就是 SaveQDFToFile 與 ConvertFileToQDF 用來把單一 FlateDecode 串流展開成可讀形式的常式,傳的是 1。在 FPC 建置上,被跳過的 zlib 標頭讓 inflate 失敗,但 zlib 串流仍然為它檢查過的位元組回報了非零的 Consumed,於是 DecodeAllStreams 把空的 payload 當成解碼成功,並把每一個內容串流都換成空字串。產出的 QDF 頁數正確、結構有效、頁面內容全無,這種檔案在每個檢視器裡開啟都不報錯,然後什麼都不顯示
// InflateStrFromPosition 的 FPC 分支,v3.539.16 之後。
// 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 收尾。對三種記憶體來源來說這是對的,因為 ReadIntoBuffer 在最後一塊之前總是交付完整區塊。對串流來源來說,這代表掃描在第一次短讀取就放棄、回報分隔符不存在,而上層的 tokenizer 就判定物件在它其實還沒結束的地方結束
// TPLBuffer.DistanceToByte,v3.539.6 之後的迴圈。
// 零是 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 的證據,不是關於原始碼的證據。從 v3.539.16 起,Delphi DUnitX 套件與 Free Pascal 主控台套件都 include 同一份 Tests\CrossCompilerSemantics.inc,裡頭只有一個常式 RunCrossCompilerFileSemantics:它透過 TPDFlib 建出一份帶壓縮內容的兩頁文件、存檔、再用 SaveQDFToFile 另存成 QDF、用 RepairQDFFile 修復該 QDF、用 EncryptFile 搭配 EncodePermissions 產生的權限遮罩以 AES-128 加密未加密檔,然後重新載入每一份產物並在兩個編譯器上斷言同樣的事:頁數是 2、標題存活、第二頁文字能從未加密、修復後與加密後檔案中完整取出、錯誤密碼被拒並帶非零的 LastErrorCode、EncryptionStrength 是 128、EncryptionAlgorithm 是 2,而 GetUserPermissions 的各個權限位元回來的結果與編碼時完全一致
這個比較刻意採正規化而非逐位元組比對。加密會抽取隨機 salt,寫入器也會指派文件識別碼,所以不預期兩個建置產出完全相同的檔案,預期的是它們產出的檔案意思相同,而斷言就寫在那個層次上。QDF 這一段之所以存在,正是因為那個位移 bug:一份有兩頁卻沒有內容的 QDF 會通過頁數檢查、卻在文字取出檢查上失敗,而這個矩陣斷言的正是後者。任何未來「在一個編譯器上無作用、在另一個上改變行為」的修法,上面五個裡有四個都是這種,現在都得先把同一套斷言通過兩次才能出貨
同一場移植的連結期那一半,也就是讓 Delphi 的 OMF 目的檔與 Free Pascal 的 COFF 期待達成一致,在FPC Win32 的 OMF 到 COFF 目的檔連結裡有自己的故事,而同一個 TIFF 讀取器針對 BigTIFF 與分塊檔案所做的結構強化,記在內建 TIFF 解碼器筆記裡。本文這些解碼器,以及現在墊在它們底下的跨編譯器測試,都在 PDF Library for Delphi 出貨,支援 Delphi、C++Builder 與 Free Pascal,在那裡同一份原始碼應該要在它瞄準的每個編譯器上掙得同樣的結果,而不是被其中一個施捨