CCITT、TIFF、PNG、Flate、ストリームバッファのコードを Free Pascal で動かすのは、「偶然 Delphi でしか動かない Pascal」を確実に見つける方法です。以下の5つのパターンのどれにもコンパイラのバグは関係ありません。どれも、実装の細部のおかげで Delphi がたまたま正しく実行してしまうコードです。呼び出し元の配列と別名になる隠れた result パラメータ、誰も読み越たことのない範囲外分岐、範囲チェックスイッチだけが唯一の防波堤になっている長さゼロのバッファ、1つのコード経路だけが 1 を渡してくる1ベースのオフセット、そしてメモリ内ストリームでは決して試されないTStream.Readの契約。コンパイラを変えるか、同じコードに不正なファイルを食わせれば、偶然は成り立たなくなります
以下では、各罠の具体的な形、PDF Library for Delphi がそれをどう扱うか、そして背後にある規律を述べます。同じソースが両方のコンパイラで同じ文書セマンティクスを生むこと、それを知る唯一の方法は両方でチェックすることです。姉妹記事の悪意あるファイルに対する Pascal PDF パーサーの強化では、整数の幅、再帰の深さ、未初期化バッファを扱っています。こちらは別の失敗クラスの話です。コンパイラが静かに肩代わりしている間、最初から間違っていたコードの話です
動的配列を返す関数がDelphiではSetLengthなしで動く理由
Delphi は呼び出し元の変数そのものを隠れた result パラメータとして渡すからです。結果を一切アロケートしない関数でも、呼び出し元が確保した配列に書き込めてしまいます。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArrayは、2次元の Group 3 および Group 4 復号の心臓部にある参照線ルックアップです。現在位置a0と現在の run の色を受け取り、前の走査線の変化点、つまり ITU-T T.4 / T.6 の2次元コーディングスキームでいう b1 と b2 を探し、2スロットの配列として返します。誘惑に駆られる実装はResult[0]とResult[1]に書き込んで、ResultへのSetLengthを一切呼ばないというものです
これは最初の書き込みで落ちられるはずの動作で、Free Pascal では実際に落ちます。Delphi では落ちません。ただし呼び出し側が次の形をしている限りです。b: TCCITTIntegerArrayを宣言し、走査線ループの前に一度SetLength(b, 2)を実行し、ループ内でb := GetNextChangingElement(a0, IsWhite)を代入してb[0]とb[1]を読む。Delphi の言語ガイドは、結果が long string、動的配列、その他の managed 型である関数は、その結果を追加のvarパラメータとして受け取ると述べており、実際にコンパイラは代入先のアドレスを渡します。したがって関数内のResultはbそのものであり、すでに2要素ぶんの長さがあり、すべての書き込みは呼び出し元が所有するメモリに着地します。Free Pascal は関数に新鮮な nil 配列を渡し、後でそれをbへ代入します。こちらが契約の正しい読み方で、コードは元々この読みに沿って書かれるべきものです
この別名には、デコーダーが依存しているセマンティクスも乗っています。Result[0]は走査がa0より大きい要素を見つけたときにだけ代入され、Result[1]はその後に要素があるときにだけ代入されます。見つからなかったとき、スロットは前のイテレーションがbに残したものを保つのです。見えすいた修正、つまり2スロットをアロケートして毎呼び出しゼロクリアするやり方は、この持ち越しを壊し、Delphi での復号出力を変えてしまいます。PDF Library for Delphi はリセットではなくガードを使います。Delphi では死にコードであり、復号経路はバイト単位でまったく同じまま。Free Pascal ではフォールトを意図した動作に変えます。この非対称性がすべての要点です。ガードは、コードがすでに検証済みの出力を出しているコンパイラ上では no-op でなければならないからです
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphiでは呼び出し側の2要素配列がResultとして
// エイリアスされた状態でここに来るので、ここは実質no-op。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はファイルからの値のまま残します
そこから2つのことが壊れます。まず、この関数は末尾に「Lengthがゼロなら、このエントリにゼロ値の要素を1つ与える」というフォールバックを備えており、呼び出し元が常に要素0を読めるようにするためのものです。範囲外の経路でLengthが一度もクリアされないので、このフォールバックは、存在理由であるあの1ケースでだけ決して発火しません。しかも呼び出し元は要素0を無条件に読みます。Width、Height、BitsPerSample、PhotometricInterpretation、FillOrder、SamplesPerPixel、RowsPerStripなど十数か所がE.IntegerValues[0]を取り、ストリップテーブルはMove(E.IntegerValues[0], StripOffsets[0], E.Length * 4)を実行し、何も持たない配列からLength×4バイトをコピーしようとします。生きたカウントを抱えたクリア済み配列は、未チェックの配列より厳密に危険です。未チェックの配列は少なくとも、主張するバイトを実際に持っているのですから
2つ目の罠は順序です。2つのSetLengthを範囲テストの前に、ファイルのカウントをサイズにして走らせると、敵意あるエントリが1つも妥当性チェックを受ける前に、ギガバイト級のアロケートを要求できます。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が規格にないカラータイプを主張したら何が起きるか
PDF Library for Delphi は、行フィルターが走るより前に画像を拒否します。ISO 15948 §11.2.2 が IHDR チャンクを定義し、Table 11.1 が正規のカラータイプとビット深度の6つの組み合わせを挙げています。1・2・4・8・16ビットのグレースケール、1・2・4・8ビットのインデックスカラー、そして 8 か 16 ビットのトゥルーカラー、アルファ付きグレースケール、アルファ付きトゥルーカラーです。罠は、IHDR の圧縮手法とフィルター手法のフィールドは検証するのに、FColorTypeとビット深度はそのまま素通りさせるリーダーです。不正なカラータイプが来ると、長さゼロの走査線が生まれ、アンフィルターのループは空のバッファを掴みます
行フィルターのコードは、たいてい各カラータイプをコンポーネント数に対応させるCase FColorType Ofからすべてのサイズを決めます。6つ以外のカラータイプはElse分岐に落ち、そこではSourceComponentsが 0、ゆえにScanlineByteCountが 0、ゆえにSetLength(PreviousScanline, 0)の直後にFillChar(PreviousScanline[0], ScanlineByteCount, 0)が続きます。空の動的配列の要素 0 を添字指定するのは、nil から計算されたアドレスです。範囲チェックがオフなら、そのアドレス経由のゼロバイト fill は音もない no-op で、デコーダーは存在しない行を通り過ぎて進み続けます。範囲チェックがオンなら、最初の画像でERangeErrorです。その後のMove呼び出しは、あと一歩でアクセス違反という距離にあります。どれになるかは、デコーダーが決めた何かではなく、コンパイラとビルドスイッチで決まります。そしてそれこそが、デコーダーがそもそも何も決めていなかったことを物語る痕跡です
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をクリアし、画像は診断の手がかりとして幅と高さをそのままに拒否されます。9バイトに満たない pHYs チャンクも同じ場所でチェックされるので、DPI リーダーが、短いチャンクが空のままにした文字列のS[1]からS[8]を添字指定することはありません
1ベースのオフセットを0ベースのポインタとして扱う
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiStringは1ベースのStartPosを取ります。入力がAnsiStringであり、Delphi 実装が zlib 入力を@Input[StartPos]としてアドレス指定するからです。両 Windows ターゲットが圧縮を静的リンクできるようpaszlibに対して書かれた Free Pascal 実装は、同じ入力をポインタ演算でアドレス指定しなければならず、ポインタ演算は0ベースです。next_inにPAnsiChar(Input) + StartPosを、avail_inにLength(Input) - StartPosを設定したまま、この関数にとっての「最初から始める」を意味する 1 を渡すと、FPC ビルドは2バイト目からインフレートを始め、終端の1バイト手前で止まります
これが容易に隠れるのは、大半のテストが届く呼び出し先が 0 を渡すInflateStrだからです。ゼロはたまたま正しい0ベースのオフセットなので、2つのビルドはすべての素のInflateStr呼び出しと、それを通るすべてのテストで一致します。TPDFDocument.DecodeAllStreams、つまりSaveQDFToFileとConvertFileToQDFが単一のFlateDecodeストリームを読める形に展開するのに使うルーチンは、1 を渡します。オフバイワンが居座ったままでは、スキップされた zlib ヘッダーのせいでインフレートは失敗しますが、zlib ストリームは検査したバイトに対して nonzero のConsumedを報告するので、Consumedを信用する呼び出し元は空のペイロードを成功したデコードと受け取り、すべてのコンテンツストリームを空文字列に置き換えます。結果としての QDF はページ数も正しく、構造も有効で、ページの中身だけがない。すべてのビューアーでエラーなく開き、何も見せないファイルです
// InflateStrFromPosition の FPC 分岐。
// StartPos は Delphi 分岐と同じく1ベース。まずクランプし、その後で
// 0ベースのポインタオフセットへ、境界でちょうど1回だけ変換する。
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;
これを捉えるチェックは、可能な限り最小のものです。ペイロードをデフレートし、位置 0 と位置 1 からインフレートし、両者が同じペイロードを返すこと、両者のConsumedがストリーム全長と等しいことを確認します。RFC 1950 のストリームは2バイトのヘッダーと4バイトの Adler-32 トレーラーを持ちます。どちらかの端でのオフバイワンは、微妙な破損ではなく、開始にも終了にも失敗するストリームです。教訓は境界についてであり、zlib についてではありません。関数のパラメータがあるインデックス基準で定義され、下の実装が別の基準を使うとき、変換はちょうど1行に置かれるべきで、テストは2つの基準を区別する値でその関数を呼ばなければなりません
短いTStream.Readはなぜストリームの終端ではないのか
TStream.Readは、どんな理由であれ要求より少ないバイト数を返すことが許されており、これ以上何もないことを意味するのは0が返ったときだけだからです。ローカルディスク上のTMemoryStreamとTFileStreamはほぼ常に要求どおり埋めてくれます。だから「要求より少なく返った」をファイル終端とみなすコードが、それらを使うあらゆるテストを通過してしまいます。ネットワーク越しのストリーム、伸長ストリーム、そして顧客が書いたあらゆるTStream派生クラスは、64000バイトを要求されて2バイトを返しながら、その背後にまだギガバイトを抱えていることがあります
TPLBufferは PDF Library for Delphi のすべてのパーサーが通るリーダーで、AnsiString、ポインタ、バイト配列、TStreamのどれでも包めます。4つの走査クエリ、DistanceToByte、DistanceToOtherByte、DistanceToAnyByte、DistanceToOtherBytesはいずれもInt64を返し、デリミタを探してソースを64 KB ブロックで読み、論理位置を動かさずにその距離を報告します。こうしたループの自然な終端はUntil ReadCount < BlockSizeです。3つのメモリ内ソースについてはそれで正しく、ReadIntoBufferは最後のブロックまでは常に完全なブロックを渡すからです。ストリームソースでは、これは走査が最初の短い読み取りで諦め、デリミタ不在と報告することを意味し、その上のトークナイザーが、そうでない場所でオブジェクトの終端を決めてしまいます。TPLBufferが読み取りが 0 を返すまでループするのは、このためです
// TPLBuffer.DistanceToByte の走査ループ。
// 0 だけが 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;
これを固定する方法は、Readオーバーライドがすべての要求を2バイトに切り詰めるTMemoryStreamの子孫を使うことです。文字列aaaaaXをそれに包み、バッファ位置を 1 に置くと、4つのクエリすべてがXまでの距離 4 を報告し、その後も位置を 1 のまま保ち、そこにないバイトに対しては -1 を報告しなければなりません。最初の短い読み取りで停止する走査は2バイトを見て、ストリームが尽きたと結論し、-1 を返します。finallyはループ条件と同じくらい重要です。走査の内側からのExitは正常な成功経路であり、論理位置はその経路でも復元されなければなりません。ループが最後まで回るときだけではだめです
1つのソース、2つのコンパイラ、1組のアサーション
この5つの背後にある規律は、「Delphi ビルドが通る」は Delphi についての証拠であって、ソースについての証拠ではない、ということです。持つ価値のあるクロスコンパイラチェックは、1つのルーチンを両コンパイラで走らせます。TPDFlibで圧縮コンテンツ付きの2ページ文書を組み立て、保存し、SaveQDFToFileで QDF としてもう一度保存し、RepairQDFFileで QDF を修復し、EncryptFileとEncodePermissionsの権限マスクで平文ファイルを AES-128 で暗号化してから、すべての成果物を再ロードし、両コンパイラで同じことを確認します。ページ数は 2、タイトルは生き残り、ページ2のテキストは平文・修復済み・暗号化済みの各ファイルからそのまま抽出でき、誤ったパスワードは nonzero のLastErrorCodeで拒否され、EncryptionStrengthは 128、EncryptionAlgorithmは 2、そしてGetUserPermissionsの個々の権限ビットは符号化どおりに返ってくる、という具合です
比べるのはバイトではなく、正規化したセマンティクスです。暗号化はランダムなソルトを引き、ライターは文書識別子を割り当てるので、2つのビルドが同一のファイルを吐くとは期待しません。同じ意味のファイルを吐くことを期待するのであり、チェックはそのレベルの言葉で書かれるべきです。QDF の脚がその席を得ているのはオフセットの罠のせいです。2ページで中身のない QDF はページ数チェックを通し、テキスト抽出チェックに落ちます。だから後者をチェックするのです。片方のコンパイラでは no-op でありながらもう片方では振る舞いの変更になる変更、上の5つのうち4つがこれに当たりますが、そうした変更は両方で同じチェックを通らなければなりません
同じ移植のリンク時の半分、つまり Delphi の OMF オブジェクトと Free Pascal の COFF 期待を和解させる話は、FPC Win32 OMF から COFF へのオブジェクトリンクに譲り、同じ TIFF リーダーの BigTIFF とタイルファイルに対する構造的強化は内蔵 TIFF デコーダーのノートにあります。この記事のデコーダーは、Delphi・C++Builder・Free Pascal 向けのPDF Library for Delphiに搭載されています。そこでは、同じソースが、あるコンパイラのおかげで結果を授かるのではなく、ターゲットとするどのコンパイラでもその結果を自力で稼ぐことが求められます