JavaScript 引擎漏洞挖掘:Fuzzing 的艺术
各位靓仔靓女,老少爷们,欢迎来到今天的 JavaScript 引擎漏洞挖掘讲座!今天我们要聊聊如何用一些“不正经”的手段,来发现 JavaScript 引擎里那些藏得深深的 bug。
别害怕,我说的“不正经”指的是 Fuzzing (模糊测试)。这是一种让程序自己找 bug 的黑科技,简单来说,就是给程序喂各种各样的“奇怪”输入,看看它会不会崩溃、卡死或者做出一些“不正常”的行为。
今天,我们重点关注两种 Fuzzing 技术:Grammar Fuzzing 和 Coverage-Guided Fuzzing。它们就像两把不同的宝剑,可以帮助我们砍掉 JavaScript 引擎里的各种“妖魔鬼怪”。
一、Grammar Fuzzing:按剧本演出的“捣蛋鬼”
想象一下,你想测试一个编译器。如果随机生成一堆二进制数据喂给它,那效果肯定不好,因为编译器压根就看不懂。这时候,Grammar Fuzzing 就派上用场了。
Grammar Fuzzing 的核心思想是:按照目标语言的语法规则,生成有效的、但又可能包含各种边界情况的输入。 就像给演员一个剧本,让他们按照剧本演戏,但允许他们即兴发挥,加一些“捣蛋”的台词和动作。
1. 语法 (Grammar) 的定义
首先,我们需要定义 JavaScript 的语法。这听起来很吓人,但不用担心,我们不需要完全复刻 JavaScript 规范。只需要定义一些关键的语法规则,就足够进行有效的 Fuzzing 了。
例如,我们可以用 Backus-Naur Form (BNF) 来描述 JavaScript 的语法:
<program> ::= <statement>*
<statement> ::= <variable_declaration> | <expression_statement> | <if_statement>
<variable_declaration> ::= "var" <identifier> "=" <expression> ";"
<expression_statement> ::= <expression> ";"
<if_statement> ::= "if" "(" <expression> ")" "{" <statement>* "}"
<expression> ::= <literal> | <identifier> | <binary_expression>
<literal> ::= <number> | <string> | <boolean>
<binary_expression> ::= <expression> <operator> <expression>
<operator> ::= "+" | "-" | "*" | "/" | "==" | "!="
<identifier> ::= [a-zA-Z_][a-zA-Z0-9_]*
<number> ::= [0-9]+
<string> ::= """ [^"]* """
<boolean> ::= "true" | "false"
这个简单的语法描述了 JavaScript 程序的基本结构,包括变量声明、表达式语句和 if 语句。
2. Grammar Fuzzer 的实现
有了语法定义,我们就可以编写一个 Grammar Fuzzer,根据这些规则生成 JavaScript 代码。下面是一个简单的 Python 实现:
import random
class GrammarFuzzer:
def __init__(self, grammar):
self.grammar = grammar
def generate(self, symbol):
# 如果 symbol 是终结符,直接返回
if symbol not in self.grammar:
return symbol
# 从 symbol 对应的产生式中随机选择一个
production = random.choice(self.grammar[symbol])
# 递归生成非终结符
return ''.join([self.generate(s) for s in production])
# 定义 JavaScript 的语法 (简化版)
js_grammar = {
'<program>': [['<statement>', '<program>'], []],
'<statement>': [['<variable_declaration>'], ['<expression_statement>'], ['<if_statement>']],
'<variable_declaration>': [['var', '<identifier>', '=', '<expression>', ';']],
'<expression_statement>': [['<expression>', ';']],
'<if_statement>': [['if', '(', '<expression>', ')', '{', '<statement>', '}']],
'<expression>': [['<literal>'], ['<identifier>'], ['<binary_expression>']],
'<literal>': [['<number>'], ['<string>'], ['<boolean>']],
'<binary_expression>': [['<expression>', '<operator>', '<expression>']],
'<operator>': [['+'], ['-'], ['*'], ['/'], ['=='], ['!=']],
'<identifier>': [['x'], ['y'], ['z']],
'<number>': [['1'], ['2'], ['3']],
'<string>': [['"hello"'], ['"world"']],
'<boolean>': [['true'], ['false']]
}
# 创建 Grammar Fuzzer
fuzzer = GrammarFuzzer(js_grammar)
# 生成 JavaScript 代码
for i in range(10):
js_code = fuzzer.generate('<program>')
print(js_code)
这段代码定义了一个 GrammarFuzzer
类,它接受一个语法定义作为输入,并可以根据这个语法生成代码。generate
函数递归地展开非终结符,直到生成最终的代码。
3. 高级技巧:增加“捣蛋”的概率
仅仅生成符合语法的代码是不够的,我们需要让生成的代码更“奇怪”,更有可能触发 bug。可以考虑以下技巧:
- 边界值测试: 在生成数字和字符串时,可以尝试生成一些极值,比如
2**53
(JavaScript 的最大安全整数),Number.MAX_VALUE
,Number.MIN_VALUE
,空字符串,超长字符串等等。 - 类型混淆: JavaScript 是一种动态类型语言,可以尝试将不同类型的值进行运算,比如
"1" + 1
,true * "hello"
。 - 特殊字符: 在字符串中插入特殊字符,比如
,
n
,r
,t
,uFFFF
。 - 原型链污染: 尝试修改
Object.prototype
,影响所有对象的行为。
例如,我们可以修改 js_grammar
,增加一些生成 NaN 和 Infinity 的概率:
js_grammar = {
# ... 之前的定义 ...
'<number>': [['1'], ['2'], ['3'], ['NaN'], ['Infinity']],
# ... 之后的定义 ...
}
4. 缺陷与优势
- 优势: Grammar Fuzzing 可以生成结构化的输入,更容易触发语法和语义相关的 bug。可以针对特定的语言特性进行 Fuzzing。
- 缺陷: 需要手动定义语法,比较耗时。生成的代码可能过于简单,无法覆盖到所有代码路径。
总结: Grammar Fuzzing 就像一个按照剧本演出的“捣蛋鬼”,它知道程序的规则,并利用这些规则来制造混乱,试图找到程序里的漏洞。
二、Coverage-Guided Fuzzing:拿着地图的“探险家”
Coverage-Guided Fuzzing 就像一个拿着地图的“探险家”,它会根据代码的覆盖率来调整 Fuzzing 的策略,尽可能地覆盖到更多的代码路径。
1. 代码覆盖率 (Code Coverage)
代码覆盖率是指测试用例执行的代码占总代码的比例。常见的代码覆盖率指标包括:
- 语句覆盖率 (Statement Coverage): 每一行代码是否被执行到。
- 分支覆盖率 (Branch Coverage): 每一个分支 (if/else) 是否被执行到。
- 路径覆盖率 (Path Coverage): 所有可能的执行路径是否被执行到。
Coverage-Guided Fuzzing 的目标是最大化代码覆盖率,因为覆盖率越高,发现 bug 的概率就越大。
2. AFL (American Fuzzy Lop)
AFL 是一个非常流行的 Coverage-Guided Fuzzer,它通过以下步骤来最大化代码覆盖率:
- 种子输入 (Seed Input): AFL 首先需要一些种子输入,作为 Fuzzing 的起点。
- 变异 (Mutation): AFL 会对种子输入进行各种变异,比如位翻转、加减常数、插入删除字节等等。
- 执行 (Execution): AFL 会执行变异后的输入,并监测代码覆盖率。
- 反馈 (Feedback): 如果变异后的输入能够覆盖到新的代码路径,AFL 会将这个输入加入到种子队列中,作为后续 Fuzzing 的起点。
这个过程不断循环,直到达到预定的时间或覆盖率目标。
3. 使用 AFL Fuzz JavaScript 引擎
使用 AFL Fuzz JavaScript 引擎需要以下步骤:
-
编译目标程序: 使用 AFL 提供的编译器 (afl-gcc, afl-clang) 编译 JavaScript 引擎。需要注意的是,为了让 AFL 能够有效地监测代码覆盖率,需要在编译时插入一些插桩代码。
例如,使用 afl-clang 编译 V8 引擎:
./tools/dev/gm.py x64.release export CC=/path/to/afl-clang-fast export CXX=/path/to/afl-clang-fast++ gn gen out.gn/x64.release --args='target_cpu="x64" is_debug=false' ninja -C out.gn/x64.release d8
-
准备种子输入: 准备一些有效的 JavaScript 代码作为种子输入。可以从现有的 JavaScript 代码库中提取,也可以手动编写一些简单的测试用例。
-
运行 AFL: 使用 AFL 运行编译后的 JavaScript 引擎,并指定种子输入和输出目录。
afl-fuzz -i seeds -o findings ./out.gn/x64.release/d8 @@
-i seeds
: 指定种子输入目录。-o findings
: 指定输出目录,AFL 会将发现的崩溃和挂起的输入保存在这个目录中。./out.gn/x64.release/d8 @@
: 指定要 Fuzz 的程序和输入文件,@@
会被 AFL 替换为实际的输入文件名。
-
分析结果: AFL 会持续运行,并实时显示 Fuzzing 的状态,包括代码覆盖率、执行速度、发现的崩溃数量等等。如果 AFL 发现了崩溃,会在输出目录中生成相应的崩溃文件。需要分析这些崩溃文件,找出导致崩溃的原因。
4. AFL 的局限性与改进
AFL 擅长发现内存错误和控制流相关的 bug,但对于一些需要深入理解语言语义的 bug,效果可能不太好。
一些研究人员提出了改进 AFL 的方法,比如:
- Context-Sensitive Fuzzing: 考虑代码的上下文信息,生成更有效的变异。
- Type-Aware Fuzzing: 根据变量的类型信息,生成更合理的输入。
- Hybrid Fuzzing: 结合 Grammar Fuzzing 和 Coverage-Guided Fuzzing 的优点,先用 Grammar Fuzzing 生成结构化的输入,再用 Coverage-Guided Fuzzing 优化这些输入。
5. 缺陷与优势
- 优势: 可以自动化地探索代码路径,不需要手动编写大量的测试用例。可以发现一些意想不到的 bug。
- 缺陷: 对于一些需要深入理解语言语义的 bug,效果可能不太好。需要大量的计算资源和时间。
总结: Coverage-Guided Fuzzing 就像一个拿着地图的“探险家”,它会根据代码的覆盖率来调整 Fuzzing 的策略,尽可能地覆盖到更多的代码路径,寻找隐藏的宝藏 (bug)。
三、实战案例:利用 Fuzzing 发现 V8 引擎漏洞
V8 引擎是 Google Chrome 浏览器使用的 JavaScript 引擎,也是一个非常流行的 JavaScript 引擎。有很多研究人员利用 Fuzzing 技术发现了 V8 引擎的漏洞。
案例 1:类型混淆漏洞
V8 引擎在处理一些类型转换时,可能会出现类型混淆的漏洞。例如,将一个浮点数转换为整数时,如果没有进行正确的类型检查,可能会导致整数溢出或者类型错误。
可以使用 Grammar Fuzzing 生成一些包含类型转换的代码,来测试 V8 引擎的类型转换逻辑。
案例 2:JIT 编译器漏洞
V8 引擎使用 JIT (Just-In-Time) 编译器来优化 JavaScript 代码的执行速度。JIT 编译器会将 JavaScript 代码编译成本地机器码,但在这个过程中,可能会出现一些编译错误,导致漏洞。
可以使用 Coverage-Guided Fuzzing 来覆盖 JIT 编译器的代码路径,寻找编译错误。
代码示例:利用 Grammar Fuzzing 发现类型混淆漏洞
import random
class GrammarFuzzer:
def __init__(self, grammar):
self.grammar = grammar
def generate(self, symbol):
if symbol not in self.grammar:
return symbol
production = random.choice(self.grammar[symbol])
return ''.join([self.generate(s) for s in production])
js_grammar = {
'<program>': [['<statement>', '<program>'], []],
'<statement>': [['<expression_statement>']],
'<expression_statement>': [['<expression>', ';']],
'<expression>': [['<literal>'], ['<binary_expression>'], ['parseInt(<expression>)']],
'<literal>': [['<number>'], ['<string>'], ['<boolean>']],
'<binary_expression>': [['<expression>', '<operator>', '<expression>']],
'<operator>': [['+'], ['-'], ['*'], ['/'], ['=='], ['!=']],
'<number>': [['1'], ['2'], ['3'], ['1.1'], ['2.2'], ['3.3'], ['NaN'], ['Infinity'], ['-Infinity']],
'<string>': [['"hello"'], ['"world"'], ['""']],
'<boolean>': [['true'], ['false']]
}
fuzzer = GrammarFuzzer(js_grammar)
for i in range(100):
js_code = fuzzer.generate('<program>')
print(js_code)
# 将生成的代码保存到文件
with open('test.js', 'w') as f:
f.write(js_code)
# 使用 V8 引擎执行代码
import subprocess
try:
subprocess.run(['./out.gn/x64.release/d8', 'test.js'], timeout=1, check=True, capture_output=True)
except subprocess.TimeoutExpired:
print("Timeout!")
except subprocess.CalledProcessError as e:
print("Crash!")
print(e.stderr.decode())
这段代码会生成包含 parseInt
函数的 JavaScript 代码,并尝试使用 V8 引擎执行这些代码。如果 V8 引擎在执行过程中崩溃,说明可能存在类型混淆的漏洞。
四、Fuzzing 的注意事项
- 选择合适的 Fuzzer: 不同的 Fuzzer 适用于不同的目标程序和漏洞类型。需要根据实际情况选择合适的 Fuzzer。
- 准备充分的种子输入: 种子输入越多样化,Fuzzing 的效果就越好。
- 监控 Fuzzing 的状态: 实时监控 Fuzzing 的状态,包括代码覆盖率、执行速度、发现的崩溃数量等等。
- 分析 Fuzzing 的结果: 认真分析 Fuzzing 发现的崩溃,找出导致崩溃的原因,并修复漏洞。
- 持续 Fuzzing: Fuzzing 是一个持续的过程,需要不断地改进 Fuzzing 的策略,才能发现更多的漏洞。
五、总结
今天我们聊了 JavaScript 引擎 Fuzzing 的一些基本概念和技术,包括 Grammar Fuzzing 和 Coverage-Guided Fuzzing。Fuzzing 是一种非常有效的漏洞挖掘技术,可以帮助我们发现 JavaScript 引擎里那些藏得深深的 bug。
希望今天的讲座对大家有所帮助。记住,Fuzzing 是一门艺术,需要不断地学习和实践,才能掌握其中的精髓。
祝大家 Fuzzing 愉快,早日发现 JavaScript 引擎的漏洞!
下次再见!