/Indexed查找表存的是基础颜色空间的各分量,不是RGB三元组。DeviceGray基础的每个条目打包一个字节,DeviceCMYK四个,Lab三个(需要范围缩放),ICCBased则是profile的/N声明几个就几个。HotPDF在解析时把所有这些规范化成THPDFColorSpace暴露的公开RGB调色板
这一句话就是整类bug。几乎每个调色板渲染器起步时都假设三字节步长,因为现实世界里的/Indexed颜色空间绝大多数坐在/DeviceRGB上,这个假设成立好多年。然后一张扫描发票带着[/Indexed /DeviceCMYK 15 <...>]来了,读取器每次按三字节走查找字符串,于是每个大于零的索引都落在别人颜色的中间。输出不是空白,也不崩溃。它是看起来合理的乱码,这糟得多,因为没人会为只是看起来不对的颜色报bug
Indexed调色板为什么会出错?
步长错是因为步长不是常量。ISO 32000-1 §8.6.6.3把查找表定义为m × (hival + 1)字节,其中m是基础空间的组件数,它根本没说基础空间必须是RGB。同一节只明确禁止两种基础:/Indexed本身和/Pattern。其他一切都可以,包括§8.6.5.2到§8.6.5.5的全部四个CIE系列家族
所以真正的工作不是读字节,而是先解析基础空间,问它一个条目有多宽。HotPDF的ResolveIndexedPalette做的正是这件事:它把基础解析成一个THPDFColorSpace,从解析结果里取ComponentCount,如果基础返回的是csfIndexed、csfPattern或csfUnsupported就整个拒绝这个数组。只有在那之后,它才把字节预算定为PaletteCount * Count,按hival 255的合法上限和四组件基础计算,顶多1024字节。预算计算里的两个细节正是恶意文件会试探你的地方。HotPDF按§8.6.6.3的限制在使用前把hival钳制到0..255,所以声称hival 100000的查找数组不会变成吉字节级分配;它还把预算截断到查找数据实际包含的量,然后在第一个字节越界的条目处停止填充调色板。短表产出短调色板,而不是越过缓冲区的读取
把四种不同的基础空间规范化成一张RGB表
HotPDF在解析时而不是绘制时把调色板转成RGB,这个决定驱动了其余的设计。公开的THPDFColorSpace.Palette字段是长度为3 * PaletteCount的RGB三元组TBytes,早在支持CIE基础空间之前它就是这副形状。改写记录布局去携带原生基础组件,会打破这个字段的每一个消费者。在解析时转换保持布局不动,顺带让Gray、CMYK、Lab、CalGray、CalRGB和ICCBased条目都走渲染器对直接颜色算子已经在用的同一条HPDFResolveColor路径
缩放步骤是基础空间停止互换的地方。查找字节总是0..255,但这个字节意味着什么取决于基础声明的分量范围。Device和校准分量映射到0..1。Lab不是:L*分量跑0..100,而a*和b*跑空间声明的/Range。HotPDF从THPDFCIEParams的AMin、AMax、BMin和BMax字段取这些边界,Lab解析器从字典的/Range条目填充它们,条目缺失时默认−108..101,所以别凭记忆硬编码一个对称范围。ICCBased基础加了第三个事实来源:组件数来自profile流的/N,以ICCN呈现,ICCBased流本身可能携带一个/Range数组,HotPDF通过HPDFReadNumericArrayUpTo把它读进同一个逐分量边界表。因此三组件ICC调色板和三组件Lab调色板以相同步长走查找字符串,算术却完全不同,这正是固定RGB步长无法产生的结局
var
CS: THPDFColorSpace;
Entry: Integer;
R, G, B: Byte;
begin
// ColorSpaceObj 取自图像 XObject 的 /ColorSpace 条目,或
// resource dictionary; it may be a name, an array, or an indirect ref
CS := HPDFResolveColorSpace(ColorSpaceObj);
if CS.Family <> csfIndexed then
Exit;
// 无论基础色彩空间是什么,调色板本身已是 RGB
for Entry := 0 to CS.PaletteCount - 1 do
begin
if (Entry * 3 + 2) >= Length(CS.Palette) then
Break; // 查找表很短,直接干净地收尾
R := CS.Palette[Entry * 3];
G := CS.Palette[Entry * 3 + 1];
B := CS.Palette[Entry * 3 + 2];
UseColor(Entry, R, G, B);
end;
end;
查找表住在流里时
ISO 32000-1 §8.6.6.3允许查找表是字符串或流,咬人的正是流形式。流可以带/FlateDecode,可以是与文件无关部分共享的间接对象,而且你的解析器到达它时,它的TStream可能停在任意位置。HotPDF通过委托给HPDFSetColorStreamReader注册的解码字节回调来处理压缩情形,这个回调正是Type 0采样函数引擎用的那个
没有注册读取器时,回退是刻意狭窄的而不是尽力而为。HotPDF检查流字典里有没有/Filter,有就整个拒绝调色板,而不是把压缩字节喂进查找、产出一张自信的错误表。没有filter时它读原始字节,上限是按hival和基础组件数算出的字节预算。整个流分支坐在一个try..finally里,包括拒绝在内的每条出口路径都恢复原来的Stream.Position,因为这个对象是共享的,下一个消费者不会感谢你把游标挪走了。回归套件里的LookupStreamUsesDecodedReaderAndRestoresPosition把这两个行为都钉死了
// 在解析任何色彩空间之前先注册两个回调,否则
// 间接基础空间和压缩查找流都会被直接拒绝
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
CS := HPDFResolveColorSpace(ColorSpaceObj);
if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
LogWarning('lookup table unreadable: filtered stream or no reader');
finally
HPDFSetColorStreamReader(nil);
HPDFSetColorSpaceResolver(nil);
end;
什么阻止Indexed颜色空间无限递归?
拒绝/Indexed作为直接基础是必要的,但远远不够。有意思的攻击走侧路。拿一个合法的[/Indexed /Separation ...],回想§8.6.6.4允许Separation命名一个alternate空间,§8.6.6.5允许DeviceN做同样的事。把那个alternate做成指向原Indexed数组的间接引用。链里没有任何一跳违反直接基础规则:基础是Separation,Separation的alternate是数组,而这个数组碰巧就是你出发的那个。天真地解析它,解析器会在循环里走到栈耗尽。这些空间依赖的色调变换(tint-transform)机制是单独的话题,渲染Separation和DeviceN专色那篇文章讲了;在这里它们只是循环里的一环
HotPDF在每条路径都必经的唯一入口处堵住它。HPDFResolveColorSpace给内部解析器包一层深度计数器,上限是HPDFColorSpaceMaxDepth,即64。越过这个界限,守卫置一个标记,而这个标记才是关键:在回卷时,任何看到它被置位的嵌套层级都会丢弃自己部分解析的记录、返回csfUnsupported,所以整张图被拒绝而不是保留一半,最外层调用返回时两个计数器都复位。这些计数器声明为threadvar而不是单元全局变量,这并非顺手的整洁:GResolveObj和GReadStream住在线程本地存储里也是同一个原因,因为页面是并行渲染的。一个共享全局变量会让一个深陷嵌套DeviceN链的线程,为另一个正在解析完全普通[/Indexed /DeviceRGB 255 <...>]的线程触发上限,产生无法确定、依赖负载的失败——这是最没法调试的那种。64对合法内容来说是舒适的余量,合法内容很少嵌套超过四五层,所以把结构上看起来合法的数组返回的csfUnsupported当作值得记日志的事:在实践中它意味着一个循环,或者一个真实生成方不会产生的深度。IndirectAlternateCycleIsUnsupported(对Separation和DeviceN参数化)和ExcessivePatternDepthIsUnsupported覆盖了两种情况
function TryResolveImageSpace(Obj: THPDFObject;
out CS: THPDFColorSpace): Boolean;
begin
CS := HPDFResolveColorSpace(Obj);
// 这里的 csfUnsupported 涵盖真正未知的色彩族、
// 经由间接替代空间形成的循环,以及超过 64 层的深度
Result := CS.Family <> csfUnsupported;
if not Result then
CS := HPDFDefaultColorSpace; // DeviceGray,PDF 的初始色彩空间
end;
一个值得为你的调用方写进文档的API边界
HotPDF在构建Indexed调色板时应用解析时刻生效的ICC转换配置,之后调用HPDFSetColorSpaceRenderingIntent不会重新生成已解析的调色板。那个函数会走你交给它的记录的ICCBased和alternate变换并重建那些管线;Palette里展平的RGB字节是解析时的产物,保持原样。这是提前规范化的结果,不是疏忽,值得写进你自己的API文档,而不是留给客户去发现。如果你的应用允许用户在会话中途切换渲染意图,切换后要重新解析颜色空间;如果意图只在管线搭建时设置一次——这是压倒性的常见情形——就没什么可做的。同样的顺序适用于通过HPDFSetICCColorPipeline安装的校样profile:在你开始解析之前配置它们,而不是之后
写入侧的镜像bug
上面讲的都是读取。写入侧的对称缺陷更容易发布,也更容易漏掉。HotPDF从Windows 8位位图构建/Indexed数组时调用GetPaletteEntries,这个API只填调色板实际持有的条目数并返回那个数量。原来的代码只把结果测为= 0,然后在一个栈数组上迭代固定的256个条目。对16色位图,这就把240个未初始化栈内存条目直接写进你要交给客户的PDF的查找字符串,而hival硬编码为255,每个读取器都尽职地把残留物当成真实颜色数据。这是一个正确性bug和换着衣服的信息泄露bug。修复方式不起眼但完整:调用前把数组清零,然后对循环边界和hival都尊重返回的数量。如果你在自己的代码里任何地方生成/Indexed图像,现在就去检查那个hival
两个相邻话题补全了这幅图:Indexed调色板和Type 0采样函数共享解码流读取器和同样的钳制纪律,见Type 0函数颜色查找表那篇;管辖查找流的filter规则和经解码filter提取已加载图像里管辖图像数据的规则是同一套。这里描述的颜色空间解析器作为HotPDF Delphi组件的一部分发布,面向Delphi和C++Builder,产品页上有完整的THPDFColorSpace参考