PDF Library for Delphi 在把 CCITT、TIFF、PNG、Flate 和流缓冲区代码搬到 Free Pascal 上的过程中挖出五个解码器缺陷,而每一个都在 Delphi 上跑了好几年完整测试套件。这些都不是编译器 bug。每一处都是 Delphi 恰好执行正确的 Pascal,靠的是某个实现细节:一个隐藏的结果参数恰好别名到调用方的数组,一个谁也没读过界的分支,一个只有范围检查开关守着的零长度缓冲区,一个只有一条代码路径传过 1 的 1 基偏移,以及一份内存流从来不会去考验的 TStream.Read 契约。换个编译器,或者拿同一个格式错误的文件喂给同一段代码,这个意外就不再成立
下面写的是每一个缺陷的具体形态、修法,以及由此定下来的纪律:同一份源码现在必须在两个编译器上产出同样的文档语义,并且有一个测试 include 负责检查它确实如此。讲给 Pascal PDF 解析器加固、防御恶意文件的姊妹篇覆盖的是整数宽度、递归深度和未初始化缓冲区。这篇讲的是另一类失败:代码从一开始就是错的,只是有个编译器悄悄替它兜着
为什么在 Delphi 上返回动态数组的函数不调用 SetLength 也能跑?
因为 Delphi 把调用方自己的变量当作隐藏的结果参数传进去,所以一个从不给返回值分配内存的函数,照样能往调用方已经分配好的数组里写。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray 是二维 Group 3 和 Group 4 解码核心的参考行查找:给定当前位置 a0 和当前 run 的颜色,它在前一条扫描线的 changing elements(也就是 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 上它把故障变成预期行为。这个不对称正是关键,因为修法必须在那个本来就产出已验证输出的编译器上是个 no-op
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi 走到这里时,调用方的两元素数组已经别名成 Result,
// 所以这里在 Delphi 上是 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 留在了文件给的值上
接下来有两件事出错。函数末尾有个兜底写着「如果 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 声称了一个格式没定义的颜色类型时会怎样?
PDF Library for Delphi 现在会在行过滤器运行之前就拒掉这张图;在 v3.539.2 之前,它会算出一条零字节扫描线,然后把空缓冲区交给反过滤循环。ISO 15948 §11.2.2 定义了 IHDR 块,Table 11.1 列出了六种合法的颜色类型与位深组合:灰度 1、2、4、8 或 16 位,索引色 1、2、4 或 8 位,以及真彩色、带 alpha 的灰度和带 alpha 的真彩色各 8 或 16 位。TPNGReader 校验了 IHDR 的压缩方法字段和过滤方法字段,却把 FColorType 和位深原样放了过去
行过滤代码的一切尺寸都来自一个把每种颜色类型映射到分量数的 Case FColorType Of。这六种之外的颜色类型落进 Else 分支,那里 SourceComponents 是 0,于是 ScanlineByteCount 是 0,于是紧接着 SetLength(PreviousScanline, 0) 的就是 FillChar(PreviousScanline[0], ScanlineByteCount, 0)。对空动态数组取元素零,算出来的地址是从 nil 起步的。关掉范围检查,这个零字节填充透过该地址就是一次静默的 no-op,解码器继续大步走过根本不存在的行;打开范围检查,它在第一张图上就是 ERangeError;而紧跟其后的那些 Move 调用离 access violation 只有一步。你拿到的是哪一种,取决于编译器和编译开关,而不是取决于解码器做出的任何决定,这就是解码器其实根本没做决定的证据
修法就是规范里的那张表,加在原本就在检查其他 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 块,因为 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]。Free Pascal 实现是照着 paszlib 写的,好让两个 Windows 目标都静态链接压缩库,它把 next_in 设成 PAnsiChar(Input) + StartPos,把 avail_in 设成 Length(Input) - StartPos。那是指针算术,而指针算术是 0 基的。传 1——对这个函数来说就是「从头开始」——FPC 构建就从第二个字节开始解压,并在末尾前一个字节停下
它能活下来,是因为大多数测试能碰到的那唯一一个调用方是 InflateStr,它传 0。零恰好就是正确的 0 基偏移,所以两个构建在每一次普通的 InflateStr 调用上、以及在每一个经由它的测试上都一致。TPDFDocument.DecodeAllStreams——SaveQDFToFile 和 ConvertFileToQDF 用来把单个 FlateDecode 流展开成可读形式的那段例程——传的是 1。在 FPC 构建上,被跳过的 zlib 头让解压失败,但 zlib 流仍然为它检查过的那些字节报了一个非零的 Consumed,于是 DecodeAllStreams 把空载荷当成解码成功,把每个内容流都替换成了空字符串。得到的 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;
守住它的回归测试是最小的那种:把一份载荷 deflate 掉,从位置 0 和位置 1 分别 inflate,断言两者返回同一份载荷,并且都报告 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 在最后一块之前总是交付完整的块。对流源来说,这意味着扫描在第一次短读时就放弃、报告分隔符不存在,而它上面的分词器判定对象结束的位置根本不是真正结束的位置
// 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 的控制台套件都包含同一份 Tests\CrossCompilerSemantics.inc,一个例程 RunCrossCompilerFileSemantics:它通过 TPDFlib 构建一个带压缩内容的两页文档,保存它,再通过 SaveQDFToFile 存成 QDF,用 RepairQDFFile 修复该 QDF,用 AES-128 通过 EncryptFile 和一个来自 EncodePermissions 的权限掩码加密明文文件,然后重新加载每一份产物,并在两个编译器上断言同样的事情:页数是 2,标题保留,第二页的文本能从明文、修复后和加密后的文件里完整提取出来,错误密码被拒绝并给出非零的 LastErrorCode,EncryptionStrength 是 128,EncryptionAlgorithm 是 2,而 GetUserPermissions 返回的各个权限位与编码时完全一致
这个比较是刻意做归一化而不是逐字节的。加密会抽随机 salt,写入器会分配文档标识符,所以并不指望两个构建产出完全相同的文件;指望的是它们产出意义相同的文件,而断言就是按这个层次写的。QDF 这一段专门存在,就是因为那个偏移 bug:一个两页但没有内容的 QDF 能通过页数检查而通不过文本提取检查,而这个矩阵断言的正是后一项。以后任何一个在一个编译器上是 no-op、在另一个编译器上改变行为的修法——上面五个里有四个都是这样——现在都得在发布前把同一套断言过两遍
同一次移植中链接期的那一半——让 Delphi 的 OMF 目标文件和 Free Pascal 的 COFF 预期谈得拢——是另一个故事,写在FPC Win32 的 OMF 到 COFF 目标文件链接里;同一个 TIFF 读取器针对 BigTIFF 和分块文件的结构加固写在内置 TIFF 解码器笔记里。本文中的解码器,以及现在垫在它们下面的跨编译器测试,随PDF Library for Delphi 一起发布,面向 Delphi、C++Builder 和 Free Pascal——同一份源码应当在它瞄准的每个编译器上挣到同样的结果,而不是由其中一个赏给它