技術記事

偶然動いていたDelphiコード:FPC移植で露見した5つのバグ

PDF Library for Delphiは、CCITT、TIFF、PNG、Flate、およびストリームバッファのコードをFree Pascalへ移行する過程で、5つのデコーダ不具合を見つけました。しかもそのどれもが、長年にわたってDelphiのテストスイート全体を通過していたものです。コンパイラのバグは1つもありません。いずれも、Delphiの実装上の都合でたまたま正しく実行されていたPascalコードです。呼び出し側の配列をエイリアスしてしまう隠れたresultパラメータ、誰も範囲外まで読まなかった分岐、唯一の防御がレンジチェックのスイッチだけだった長さ0のバッファ、1つのコードパスだけが1を渡していた1ベースのオフセット、そしてメモリ上のストリームでは決して踏まれないTStream.Readの契約です。コンパイラを変えるか、同じコードに壊れたファイルを食わせるか、それだけでこの偶然は成り立たなくなります

以下では、それぞれの具体的な形、修正内容、そしてそこから生まれた規律を順に見ていきます。すなわち、同じソースが両方のコンパイラで同じ文書セマンティクスを生み出さなければならないということで、それを検査するテストincludeも用意しました。姉妹記事の悪意あるファイルに対するPascal PDFパーサの堅牢化では、整数幅、再帰の深さ、未初期化バッファを扱いました。今回の記事は別の失敗クラスの話です。ずっと間違っていたのに、コンパイラが黙って庇ってくれていたコードです

動的配列を返す関数がDelphiではSetLengthなしで動く理由

Delphiは呼び出し側の変数そのものを隠れたresultパラメータとして渡すため、resultを一度も確保しない関数でも、呼び出し側が確保した配列に書き込めてしまうからです。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArrayは、2次元のGroup 3/Group 4デコードの中心にある参照ライン検索です。現在位置a0と現在のランの色を受け取り、前の走査線のchanging element、つまりITU-T T.4/T.6の2次元符号化方式におけるb1とb2を探して、2要素の配列として返します。元の関数はResult[0]とResult[1]に書き込むだけで、Resultに対してSetLengthを一度も呼んでいませんでした

最初の書き込みで例外になるはずで、実際Free Pascalではそうなります。Delphiでは決してそうなりませんでした。デコーダ内の2か所の呼び出しがどちらも次の形になっているからです。b: TCCITTIntegerArrayを宣言し、走査線ループの前にSetLength(b, 2)を1回実行し、ループの中でb := GetNextChangingElement(a0, IsWhite)を代入してb[0]とb[1]を読みます。Delphiの言語ガイドにも、結果がlong stringや動的配列などの管理型である関数は、その結果を追加のvarパラメータとして受け取ると書かれています。実際、コンパイラは代入先のアドレスを渡します。つまり関数内のResultはbそのもので、すでに2要素あり、書き込みはすべて呼び出し側が所有するメモリに着地します。一方Free Pascalは、関数にまっさらなnil配列を渡し、その後でそれをbに代入します。本来このコードが最初から前提にすべきだった契約の読み方です

PDFlibPasのCCITTデコードの分岐。Delphiは呼び出し側の配列bをGetNextChangingElementの隠れたvar Resultとして渡すため、書き込みは呼び出し側が所有するメモリに着地し、検索が外れた場合は前回の値がそのまま残ります。Free Pascalはまっさらなnil配列を渡すので、Lengthのガードが最初の書き込み前にSetLengthでサイズを確保しなければなりません
Delphiは呼び出し側の配列を隠れたResultパラメータとしてエイリアスするので、ガードのない書き込みも所有メモリに着地します。Free Pascalではnilで到着するため、1行のガードがDelphiのデコード経路を変えずに、例外を意図した動作へと変えます

このエイリアスには、デコーダが依存しているセマンティクスもありました。Result[0]はa0より大きい要素が見つかったときにだけ代入され、Result[1]はその次に要素があるときにだけ代入されます。したがって検索が外れると、各スロットには前の反復がbに残した値がそのまま入っています。ありがちな修正、つまり毎回2スロットを確保してゼロで埋める方法では、この引き継ぎが壊れ、Delphiでのデコード結果が変わってしまいます。実際に出荷した修正は、リセットではなくガードです。Delphiではデッドコードとなりデコード経路は1バイトも変わらず、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のimage file directoryエントリ(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がファイル末尾を越えるかどうかを検査し、越える場合は両方の配列を長さ0にしていました。しかしResult.Lengthはファイルから読んだ値のまま残していました

ここから2つの問題が生じます。関数の末尾には「Lengthが0ならエントリにゼロ値の要素を1つ持たせる」というフォールバックがあり、呼び出し側が常に要素0を読めるようにしています。ところが範囲外の経路でLengthをクリアしていなかったため、そのフォールバックは、まさにそのために用意されたケースで一度も発動しませんでした。しかも呼び出し側は容赦なく要素0を読みます。Width、Height、BitsPerSample、PhotometricInterpretation、FillOrder、SamplesPerPixel、RowsPerStripなど十数か所がE.IntegerValues[0]を取り、ストリップテーブルはMove(E.IntegerValues[0], StripOffsets[0], E.Length * 4)で、要素が1つもない配列からLength×4バイトをコピーします。クリア済みの配列に生きているカウントが付いている状態は、検査なしの配列より明確に危険です。検査なしのほうは、少なくとも主張どおりのバイト数を持っているからです

2つ目の問題は順序でした。2つのSetLengthは範囲検査より前に、ファイル由来のカウントをサイズとして実行されていました。つまり悪意あるエントリは、妥当性検査を1つも受けないまま、数ギガバイトの確保を要求できました。Delphiではそこで発生した例外が画像読み込み経路の上位ハンドラに捕まり、単にファイルの読み込みに失敗するだけでした。だから誰も気づかなかったのです。実際に起きていたのは、ファイル側が選んだメモリ不足イベントでした。修正では確保を検査の後ろへ移し、カウントがデータと一緒に移動するようにしました

PDFlibPasにおけるTIFFディレクトリエントリの堅牢化。12バイトのエントリはファイル由来のカウントを持ち、壊れた順序では範囲検査の前にそのカウントで配列を確保し、配列をクリアした後もResult.Lengthを生かしたままでした。修正後の順序はInt64演算を先にファイル長と比較し、カウントを配列と一緒にクリアします
範囲検査の前に確保すると、悪意あるカウントがギガバイトを要求でき、空にした配列に生きたカウントが残ってしまいます。そこで修正ではオフセットを先に検査し、配列と同じステートメントでResult.Lengthをクリアします
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;

この修正にはコンパイラ固有の要素は一切ありません。だからこそ、このリストに入るのです。この不具合がFree Pascalで潜在していたのと同じ理由で、Delphiでも潜在していました。ファイル末尾を越えるディレクトリエントリを持つテストファイルが存在しなかったからです。移植が暴いたわけではありません。「ここでDelphiは、自分がやっていない何を代わりにやってくれているのか」という問いを持ってコードを読んだことで露見しました

PNGのIHDRが規格にないカラータイプを主張したら何が起きるか

PDF Library for Delphiは現在、行フィルタが動く前に画像を拒否します。v3.539.2より前は、0バイトの走査線を計算し、アンフィルタ処理のループに空のバッファを渡していました。ISO 15948 §11.2.2がIHDRチャンクを定義し、Table 11.1が合法なカラータイプとビット深度の組み合わせを6つ挙げています。1、2、4、8、16ビットのグレースケール、1、2、4、8ビットのインデックスカラー、そして8または16ビットのトゥルーカラー、アルファ付きグレースケール、アルファ付きトゥルーカラーです。TPNGReaderはIHDRのcompression methodとfilter methodのフィールドだけを検証し、FColorTypeとビット深度は手つかずのまま通していました

行フィルタのコードは、カラータイプごとにコンポーネント数を対応付けるCase FColorType Ofから、すべてのサイズを決めています。この6つ以外のカラータイプはElse分岐に落ち、そこでSourceComponentsが0になり、ScanlineByteCountも0になり、SetLength(PreviousScanline, 0)の直後にFillChar(PreviousScanline[0], ScanlineByteCount, 0)が続きます。空の動的配列の要素0をインデックスすることは、nilから計算されたアドレスを指すことです。レンジチェックがオフなら、そのアドレスを通した0バイトのFillCharは静かなno-opで、デコーダは存在しない行をそのまま進んでいきます。レンジチェックがオンなら最初の画像で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をクリアし、画像は幅と高さを診断用に保ったまま拒否されます。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実装では、next_inをPAnsiChar(Input) + StartPosに、avail_inをLength(Input) - StartPosに設定していました。これはポインタ演算であり、0ベースです。この関数にとって「先頭から開始」を意味する値である1を渡すと、FPCビルドは2バイト目から展開を始め、末尾の1バイト手前で止まります

これが生き残った理由は、ほとんどのテストが到達する唯一の呼び出し元がInflateStrで、そちらは0を渡すからです。0はたまたま正しい0ベースのオフセットなので、素のInflateStr呼び出しと、それを経由するすべてのテストで両ビルドの結果が一致していました。SaveQDFToFileとConvertFileToQDFが、単一のFlateDecodeストリームを読める形に展開するために使うTPDFDocument.DecodeAllStreamsは、1を渡します。FPCビルドでは、読み飛ばされたzlibヘッダのせいでinflateが失敗しましたが、zlibストリームは調べたバイト数として非ゼロのConsumedを報告しました。そのためDecodeAllStreamsは空のペイロードをデコード成功と見なし、すべてのコンテンツストリームを空文字列に置き換えてしまいました。出来上がったQDFはページ数も構造も正しいのに、ページ内容がありません。これはどのビューアでもエラーなく開けて、何も表示されないファイルです

// v3.539.16以降の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;

これを守るリグレッションテストは、可能なかぎり小さなものです。ペイロードをdeflateし、位置0と位置1からそれぞれinflateして、どちらも同じペイロードを返し、Consumedがストリーム全体の長さに等しいことをアサートします。RFC 1950のストリームは2バイトのヘッダと4バイトのAdler-32トレーラを持ちます。したがって前後どちらかの1バイトずれは微妙な破損ではなく、開始に失敗するか終了に失敗するかのどちらかです。教訓はzlibではなく境界についてです。ある関数のパラメータが一方のインデックス基準で定義され、その下の実装がもう一方を使うなら、変換はちょうど1行に収めるべきであり、テストは両基準を区別できる値でそれを呼び出さなければなりません

短いTStream.Readはなぜストリームの終端ではないのか

TStream.Readは、どんな理由であれ要求より少ないバイト数を返すことが許されており、これ以上何もないことを意味するのは0が返ったときだけだからです。ローカルディスク上のTMemoryStreamとTFileStreamはほぼ常に要求どおり埋めてくれます。だから「要求より少なく返った」をファイル終端とみなすコードが、それらを使うあらゆるテストを通過してしまいます。ネットワーク越しのストリーム、伸長ストリーム、そして顧客が書いたあらゆるTStream派生クラスは、64000バイトを要求されて2バイトを返しながら、その背後にまだギガバイトを抱えていることがあります

TPLBufferはPDF Library for Delphiのすべてのパーサが通るリーダーで、AnsiString、ポインタ、バイト配列、またはTStreamを包むことができます。DistanceToByte、DistanceToOtherByte、DistanceToAnyByte、DistanceToOtherBytesという4つのスキャン用クエリはいずれもInt64を返し、区切り文字を求めてソースを64 KBブロック単位で読み、論理位置を動かすことなくその距離を報告します。各ループはUntil ReadCount < BlockSizeで終わっていました。3つのメモリ上のソースにとってはこれで正しく、ReadIntoBufferは最後のブロックまでは常にブロック全体を返します。ストリームソースでは、最初の短い読み取りでスキャンが諦め、区切り文字は存在しないと報告され、その上のトークナイザが、実際には終わっていない場所でオブジェクトが終わったと判断してしまいます

PDFlibPasのストリームバッファにおける短い読み取りの扱い。DistanceToByteは64 KBブロック単位でスキャンし、旧ループはUntil ReadCount < BlockSizeをデータ終端とみなして最初の短い読み取りで諦めていましたが、修正後のループはReadCountが0になるまで回り、区切り文字を見つけてfinallyブロックで位置を復元します
ストリームは64000バイトを要求されて2バイトを返すことがあるため、スキャンが信頼してよいデータ終端の合図は0だけです。そして区切り文字が見つかってループが途中で抜ける場合も、finally句が論理位置を復元します
// v3.539.6以降のTPLBuffer.DistanceToByteのループ
// TStream.Readが定義するデータ終端の合図は0だけ
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はリーダーを動かしてはならない
End;

これを固定するテストは、Readのオーバーライドですべての要求を2バイトに制限するTMemoryStream派生クラスです。文字列aaaaaXをそれで包み、バッファ位置を1に設定すると、4つのクエリすべてがXまでの距離4を報告し、その後も位置を1のまま残し、存在しないバイトに対しては-1を報告しなければなりません。修正前は、最初のクエリが2バイトを見ただけでストリームは尽きたと判断し、-1を返していました。finallyはループ条件と同じくらい重要です。スキャン中からのExitは通常の成功経路であり、論理位置はループが最後まで回ったときだけでなく、その経路でも復元されなければなりません

1つのソース、2つのコンパイラ、1組のアサーション

この5件から導かれた規律は、「Delphiビルドが通った」という事実はDelphiについての証拠であって、ソースについての証拠ではないということです。v3.539.16以降、DelphiのDUnitXスイートとFree Pascalのコンソールスイートはどちらも同じTests\CrossCompilerSemantics.incを取り込みます。単一のルーチンRunCrossCompilerFileSemanticsが、TPDFlibを通じて圧縮コンテンツ付きの2ページ文書を組み立てて保存し、SaveQDFToFileでQDFとしてもう一度保存し、RepairQDFFileでそのQDFを修復し、EncryptFileとEncodePermissionsの権限マスクで平文ファイルをAES-128で暗号化し、その後ですべての成果物を再読み込みして、両方のコンパイラで同じことをアサートします。ページ数は2、タイトルは保持され、2ページ目のテキストは平文・修復済み・暗号化済みの各ファイルからそのまま抽出でき、誤ったパスワードは非ゼロのLastErrorCodeで拒否され、EncryptionStrengthは128、EncryptionAlgorithmは2、そしてGetUserPermissionsが返す個々の権限ビットはエンコードどおりに戻る、という内容です

比較は意図的に、バイト単位ではなく正規化した形で行っています。暗号化はランダムなソルトを引き、ライターは文書IDを割り当てるので、両ビルドが同一のファイルを出力することは期待されていません。期待されているのは同じ意味を持つファイルを出力することであり、アサーションもそのレベルで書かれています。QDFの工程が入っているのは、まさにこのオフセットのバグのためです。2ページあって内容のないQDFはページ数の検査を通り、テキスト抽出の検査で落ちます。このマトリクスは後者をアサートします。片方のコンパイラではno-op、もう片方では動作変更になるような今後の修正――上の5件のうち4件がまさにこれです――は、出荷前に同じアサーションを2回通過しなければならなくなりました

同じ移植のうちリンク時の半分、DelphiのOMFオブジェクトとFree Pascalが期待するCOFFを一致させる話はFPC Win32のOMFからCOFFへのオブジェクトリンクに、同じTIFFリーダーをBigTIFFやタイル化ファイルに対して構造的に堅牢化した話は内蔵TIFFデコーダの記録にあります。本記事で扱ったデコーダ群と、その下に置かれたクロスコンパイラテストは、Delphi、C++Builder、Free Pascal向けのPDF Library for Delphiに含まれています。そこでは、同じソースが対象とするすべてのコンパイラで同じ結果を勝ち取ることが求められ、どれか1つに結果を与えてもらうことはありません