欢迎各位来到今天的技术讲座,我们将深入探讨JavaScript应用中一个狡猾且危险的漏洞类型——原型链污染(Prototype Pollution),以及如何利用静态分析技术,特别是基于控制流图(CFG)的方法,来精准探测这些潜伏的攻击路径。
在现代JavaScript应用中,无论是前端还是Node.js后端,对象的动态性、原型继承机制以及大量第三方库的使用,都为原型链污染提供了温床。这类漏洞一旦被利用,轻则导致拒绝服务(DoS),重则可能引发远程代码执行(RCE),对应用的安全性构成严重威胁。
传统的动态测试手段,如模糊测试(fuzzing),在发现原型链污染方面有其局限性,尤其是在复杂的代码库中,难以穷尽所有执行路径。因此,我们需要一种更系统、更全面的方法来识别潜在的风险,这就是静态分析的优势所在。通过深入分析代码结构而非执行代码,我们可以提前在开发阶段捕获这些漏洞,从而显著提升应用的安全韧性。
今天的讲座将围绕以下几个核心议题展开:
- 原型链污染的本质与危害: 理解JavaScript原型机制,以及攻击者如何利用它。
- 静态分析基础: 从抽象语法树(AST)到控制流图(CFG)的构建。
- CFG在原型链污染探测中的应用: 如何结合数据流分析,追踪污点数据到污染源。
- 挑战与对策: JavaScript动态特性带来的复杂性及应对策略。
- 实践与展望: 简化的探测算法和缓解措施。
一、原型链污染的本质与危害
1.1 JavaScript原型与继承机制
在JavaScript中,每个对象都有一个内部属性[[Prototype]],通常通过__proto__或Object.getPrototypeOf()访问。这个属性指向该对象的原型(prototype)。当我们尝试访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript会沿着__proto__链向上查找,直到找到该属性或者到达原型链的顶端null。
Object.prototype是原型链的顶端之一,几乎所有JavaScript对象都直接或间接地继承自它。这意味着,如果你能向Object.prototype添加一个属性,那么所有继承自它的对象(几乎所有对象)都将“拥有”这个新属性,除非它们自己定义了同名属性。这就是原型链污染的根源。
// 示例:原型链基础
const obj1 = {};
const obj2 = Object.create(obj1); // obj2的原型是obj1
const obj3 = {}; // obj3的原型是Object.prototype
console.log(Object.getPrototypeOf(obj2) === obj1); // true
console.log(Object.getPrototypeOf(obj3) === Object.prototype); // true
// obj3没有toString属性,但可以通过原型链访问到Object.prototype.toString
console.log(obj3.toString()); // "[object Object]"
1.2 污染的发生机制
原型链污染通常发生在以下几种场景:
-
通过用户可控的键名进行属性赋值: 当应用程序接收到用户输入(如JSON数据、URL查询参数等),并使用这些输入作为键名来设置对象的属性时,如果键名被构造为
__proto__、constructor.prototype或prototype,就可能导致原型链被修改。// 场景一:直接通过__proto__污染 function merge(target, source) { for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { if (typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null) { merge(target[key], source[key]); } else { target[key] = source[key]; // 污染点 } } } } const userControlledInput = JSON.parse('{"__proto__": {"isAdmin": true}}'); const config = {}; // 初始配置 merge(config, userControlledInput); // 此时,任何新创建的或继承自Object.prototype的对象都可能拥有isAdmin: true const newUser = {}; console.log(newUser.isAdmin); // true (如果攻击成功)在这个例子中,
merge函数递归地将source的属性合并到target中。如果source包含__proto__作为键,它会尝试修改target的__proto__。 -
库或框架中的深合并(Deep Merge)函数: 许多JavaScript库(如Lodash, jQuery等)提供了深合并功能,用于将多个对象的属性递归合并到一个目标对象中。如果这些函数没有正确地过滤或处理
__proto__等特殊键,就可能成为污染的入口。// 场景二:深合并函数中的污染(简化版,实际库会更复杂) function deepMerge(target, source) { if (target === null || typeof target !== 'object' || source === null || typeof source !== 'object') { return source; } for (const key in source) { // 关键:这里没有对key进行特殊处理,如过滤__proto__ if (Object.prototype.hasOwnProperty.call(source, key)) { if (key === '__proto__' || key === 'constructor') { // 实际攻击可能更隐蔽,例如通过constructor.prototype // 理想情况下应该阻止或警告 console.warn(`Attempted to modify sensitive key: ${key}`); continue; } if (Object.prototype.hasOwnProperty.call(target, key) && typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null) { deepMerge(target[key], source[key]); } else { target[key] = source[key]; } } } return target; } const baseConfig = { enabled: false, port: 8080 }; const maliciousPayload = JSON.parse('{"__proto__": {"debugMode": true}}'); deepMerge(baseConfig, maliciousPayload); // 尽管baseConfig自身没有被直接污染,但Object.prototype可能被污染 const anotherObject = {}; console.log(anotherObject.debugMode); // true (如果攻击成功) -
通过
constructor.prototype进行污染: 一些环境中,constructor属性也可能被利用。例如,obj['constructor']['prototype']['key'] = value。// 场景三:通过constructor.prototype污染 const payload = JSON.parse('{"constructor": {"prototype": {"evilMethod": "console.log('Evil!')"}}}'); const emptyObj = {}; // 假设一个不安全的函数处理payload function unsafeProcess(obj, data) { for (const k in data) { if (typeof obj[k] === 'object' && obj[k] !== null && typeof data[k] === 'object' && data[k] !== null) { unsafeProcess(obj[k], data[k]); } else { obj[k] = data[k]; // 这里就可能触发污染 } } } unsafeProcess(emptyObj, payload); const testObj = {}; // eval(testObj.evilMethod); // 潜在的RCE
1.3 危害与影响
原型链污染的危害是多方面的:
- 拒绝服务 (DoS): 攻击者可以覆盖或删除
Object.prototype上的关键方法(如toString、hasOwnProperty),导致应用崩溃。 - 权限绕过: 覆盖像
isAdmin这样的标志位,从而绕过认证或授权检查。 - 数据篡改: 修改应用程序中广泛使用的默认配置或数据。
- 远程代码执行 (RCE): 这是最严重的后果。通过污染原型链,攻击者可以注入恶意代码,例如在某些模板引擎或反序列化库中,通过修改
Object.prototype上的特定属性,触发任意代码执行。例如,一些库在处理对象时可能会查找并执行obj.exec或obj.run等方法。
二、静态分析基础:从AST到CFG
静态分析是指在不执行代码的情况下对代码进行分析。它通过检查代码的结构、语法和语义来发现潜在的错误或漏洞。
2.1 抽象语法树(AST)
在进行任何深层分析之前,我们需要将源代码转换成一种更易于程序处理的结构,这就是抽象语法树(AST)。AST是源代码的树形表示,它抽象了源代码的细节,只保留了对其语义分析至关重要的信息。
AST的构建过程:
- 词法分析(Lexical Analysis): 将源代码分解成一系列的词法单元(tokens),如关键字、标识符、运算符、字面量等。
- 语法分析(Syntactic Analysis): 根据语言的语法规则,将词法单元流组织成一个树形结构,即AST。
示例:JavaScript代码及其AST片段
// 示例JavaScript代码
let user = { name: "Alice" };
user.isAdmin = true;
其AST的简化表示可能如下:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "user" },
"init": {
"type": "ObjectExpression",
"properties": [
{
"type": "Property",
"key": { "type": "Identifier", "name": "name" },
"value": { "type": "Literal", "value": "Alice" }
}
]
}
}
],
"kind": "let"
},
{
"type": "ExpressionStatement",
"expression": {
"type": "AssignmentExpression",
"operator": "=",
"left": {
"type": "MemberExpression",
"object": { "type": "Identifier", "name": "user" },
"property": { "type": "Identifier", "name": "isAdmin" },
"computed": false // user.isAdmin (false) vs user[someVar] (true)
},
"right": {
"type": "Literal",
"value": true
}
}
}
]
}
在JavaScript生态中,有许多工具可以生成AST,例如Esprima、Acorn、Babel Parser等。AST是后续构建控制流图和进行数据流分析的基础。
2.2 控制流图(CFG)
控制流图(Control Flow Graph, CFG)是一种抽象的程序表示,它描述了程序执行的可能路径。CFG由节点(Nodes)和边(Edges)组成:
- 节点 (Nodes): 通常代表一个基本块(Basic Block)。基本块是一段程序代码序列,其中控制流只能从入口点进入,从出口点离开,并且在块内部没有任何分支或跳转。
- 边 (Edges): 表示控制流从一个基本块转移到另一个基本块。边可以是顺序执行、条件分支(if/else)、循环(for/while)、函数调用或函数返回等。
CFG的构建过程:
从AST开始,遍历代码并识别基本块。然后,根据条件语句、循环、函数调用和异常处理等控制流结构,添加连接基本块的边。
示例:一个简单的JavaScript函数及其CFG
// 示例JavaScript函数
function processData(data, isAdmin) {
let result = {}; // 1. 基本块 A
if (data && typeof data === 'object') { // 2. 基本块 B
if (isAdmin) { // 3. 基本块 C
result.adminProp = data.secret; // 4. 基本块 D
} else { // 5. 基本块 E
result.publicProp = data.info; // 6. 基本块 F
}
} else { // 7. 基本块 G
result.error = "Invalid data"; // 8. 基本块 H
}
return result; // 9. 基本块 I (所有路径最终汇合)
}
CFG的基本块划分与边:
| 基本块ID | 语句 | 后继基本块 |
|---|---|---|
| A | let result = {}; |
B |
| B | if (data && typeof data === 'object') |
C (true), G (false) |
| C | if (isAdmin) |
D (true), E (false) |
| D | result.adminProp = data.secret; |
I |
| E | result.publicProp = data.info; |
F |
| F | result.publicProp = data.info; |
I |
| G | result.error = "Invalid data"; |
H |
| H | result.error = "Invalid data"; |
I |
| I | return result; |
(函数出口) |
CFG的可视化表示(概念图):
[A]
|
V
[B] --(true)--> [C]
| |
(false) |--(true)--> [D]
| | |
V |--(false)--> [E]
[G] | |
| V V
V [F] [D] (简化,D和F都指向I)
[H] | |
| V V
V [I] [I]
[I]
更准确地,D和F应该汇聚到I,而E只是一个标签,实际语句在F。我们修正一下CFG的表示:
修正后的CFG基本块划分与边:
| 基本块ID | 语句 | 后继基本块 |
|---|---|---|
| B0 | let result = {}; |
B1 |
| B1 | if (data && typeof data === 'object') |
B2 (true), B4 (false) |
| B2 | if (isAdmin) |
B3_T (true), B3_F (false) |
| B3_T | result.adminProp = data.secret; |
B5 |
| B3_F | result.publicProp = data.info; |
B5 |
| B4 | result.error = "Invalid data"; |
B5 |
| B5 | return result; |
(函数出口) |
修正后的CFG的可视化表示(概念图):
[B0: let result = {};]
|
V
[B1: if (data && typeof data === 'object')]
/
/ (true) (false)
V V
[B2: if (isAdmin)] [B4: result.error = "Invalid data";]
/ |
/ (true) (false) |
V V |
[B3_T: result.adminProp = data.secret;] [B3_F: result.publicProp = data.info;]
/ |
/ |
V V |
[B5: return result;]
CFG是进行数据流分析和污点分析的关键。通过遍历CFG,我们可以模拟程序在不同路径上的数据流,从而追踪变量的值、属性的修改以及数据来源。
三、CFG在原型链污染探测中的应用
基于CFG探测原型链污染的核心思想是:识别从不受信任的输入(源,Source)到可能修改原型链的赋值操作(汇,Sink)的控制流和数据流路径。
3.1 核心概念
- 污点(Taint): 标记来自不受信任来源的数据。
- 污点源(Taint Source): 应用程序中接收外部输入的地方,如
window.location.search、document.cookie、HTTP请求体/查询参数等。 - 污染汇(Pollution Sink): 能够修改对象属性的赋值操作,特别是当目标对象是
Object.prototype或其子孙,并且键名是用户可控的敏感值时。
3.2 探测算法概述
-
代码解析与CFG构建:
- 使用工具将JavaScript源代码解析为AST。
- 从AST构建每个函数和顶级作用域的CFG。
-
识别污点源(Sources):
- 遍历CFG,识别所有从外部接收数据的API调用或变量声明。
- 例如:
new URLSearchParams(window.location.search)、req.query、req.body(在Node.js中)、localStorage.getItem()等。 - 将这些API的返回值或相关变量标记为“污点”。
// 示例污点源识别 // 假设我们有一个AST节点代表这个表达式 const urlParams = new URLSearchParams(window.location.search); // 那么urlParams变量及其所有派生数据都将被标记为污点 -
识别污染汇(Sinks):
- 遍历CFG,查找所有可能导致原型链污染的赋值操作。
- 关注点:
obj[key] = value;Object.assign(target, source);- 特定库的深合并函数调用(如
_.merge(target, source))。
- 对于这些操作,我们需要进一步分析
obj、key和value的属性。
// 示例污染汇点识别 // AST节点类型为AssignmentExpression,其中left是MemberExpression // 目标:检查 obj[key] = value // 1. obj是否是 Object.prototype 或其派生对象? // 2. key是否是污点数据且值为 '__proto__' 或 'constructor.prototype' 等敏感键? // 3. value是否是污点数据? (虽然通常key是关键,但value的恶意内容也可能导致RCE) -
数据流分析(Taint Tracking on CFG):
-
从污点源开始,沿着CFG的边进行前向数据流分析。
-
传播规则:
- 赋值:
a = b;如果b是污点,那么a也是污点。 - 函数调用: 如果参数是污点,函数内部对参数的任何操作都可能传播污点;函数的返回值也可能是污点。
- 对象属性:
obj.prop = taintedValue;那么obj.prop是污点。taintedObj.prop那么taintedObj.prop是污点。 - 敏感键名:
obj[taintedKey] = value;如果taintedKey的值可能导致原型污染(如__proto__),则这是一个潜在的污染点。 - 对象创建/合并:
Object.assign({}, taintedSource)或merge(target, taintedSource)会将污点从taintedSource传播到目标对象。
- 赋值:
-
核心挑战——别名分析(Alias Analysis): 确定不同的变量或表达式是否指向同一个内存位置或对象。这对于识别
obj是否最终指向Object.prototype至关重要。- 例如:
let a = {}; let b = a;那么a和b是别名。let c = Object.prototype; let d = c;那么c和d是别名。 - 更复杂的是:
function foo(x) { x.__proto__ = ...; } foo(someObj);这里需要追踪someObj是否可能成为Object.prototype的别名。
- 例如:
-
-
原型对象识别:
- 在进行数据流分析时,需要一个机制来识别一个对象是否可能指向
Object.prototype。 - 这通常涉及追踪对象在程序中的创建和引用。例如,
{}或new Object()创建的对象默认继承自Object.prototype。 - 一个更高级的分析会尝试追踪
obj.__proto__或Object.getPrototypeOf(obj)的返回值,以判断其是否指向Object.prototype。
- 在进行数据流分析时,需要一个机制来识别一个对象是否可能指向
-
报告攻击路径:
- 如果数据流分析发现一条路径,其中污点数据作为键名或值,流向一个污染汇点,并且该汇点的目标对象被识别为可能指向
Object.prototype,那么就报告一个潜在的原型链污染漏洞。 - 报告应包含从源到汇的完整代码路径,以及相关的变量和操作。
- 如果数据流分析发现一条路径,其中污点数据作为键名或值,流向一个污染汇点,并且该汇点的目标对象被识别为可能指向
3.3 详细的数据流分析步骤(概念性)
让我们考虑一个简化的数据流分析器,它在一个CFG上进行前向分析。我们维护一个程序状态,其中包含:
TaintedVariables: 存储所有被污点标记的变量名集合。TaintedProperties: 存储对象属性的污点信息,例如{ objectName: { propertyName: isTainted } }。ObjectPrototypeAliases: 存储所有可能引用Object.prototype的变量或表达式的集合。
在CFG的每个节点(基本块)中,我们更新这些状态:
-
处理声明语句 (
let,const,var):// let x = source(); // source() 是一个污点源 // 状态更新:TaintedVariables.add('x') -
处理赋值语句 (
obj[key] = value;或obj.prop = value;):- 识别污点传递:
// y = x; // 如果 x in TaintedVariables, 则 TaintedVariables.add('y') // obj.prop = x; // 如果 x in TaintedVariables, 则 TaintedProperties.set(obj, 'prop', true) // obj[key] = x; // 如果 x in TaintedVariables, 则 TaintedProperties.set(obj, key, true) - 识别原型污染风险:
// 关键检测逻辑: // obj[key] = value; // 1. 检查 'obj' 是否在 ObjectPrototypeAliases 中。 // 2. 检查 'key' 是否在 TaintedVariables 中,并且其值可能为 '__proto__', 'constructor', 'prototype'。 // 3. 如果满足以上条件,则报告原型链污染漏洞。
- 识别污点传递:
-
处理函数调用:
- 参数污点传播: 如果函数参数是污点,那么函数内部对这些参数的任何操作都可能导致污点传播。
- 返回值污点传播: 如果函数内部操作了污点数据,其返回值也可能成为污点。
- 库函数特殊处理: 对于像
Object.assign、深合并函数等,需要有专门的规则来处理它们的污点传播和潜在的污染风险。
// 假设有一个函数 taint_check_merge(target, source) // 如果 source 是污点,且 target 是 Object.prototype 的别名,则报告。 // 如果 key 是污点且为敏感键,则报告。
表:数据流分析中的状态更新示例
| CFG节点操作 | 描述 | 状态更新示例 |
|---|---|---|
let x = getUntrustedInput(); |
getUntrustedInput()被识别为污点源。 |
TaintedVariables.add('x') |
let y = x; |
x的值赋给y。 |
如果x在TaintedVariables中,则TaintedVariables.add('y')。 |
obj.prop = x; |
x的值赋给obj的prop属性。 |
如果x在TaintedVariables中,则TaintedProperties.set('obj', 'prop', true)。 |
target[key] = value; |
关键污染点。 | 检测逻辑: 1. isObjPrototypeAlias(target)? (通过别名分析确定target是否可能为Object.prototype) 2. isTainted(key) 且 isSensitiveKey(key_value)? (例如__proto__, constructor, prototype) 如果1和2都为真,报告漏洞。 |
Object.assign(a, b); |
b的属性合并到a。 |
遍历b的所有属性。如果b.prop是污点,则TaintedProperties.set('a', 'prop', true)。 检测逻辑: 1. isObjPrototypeAlias(a)? 2. b的某个属性prop的键名被检测为敏感键(如__proto__)且prop是污点。 如果满足,报告漏洞。 |
_.merge(target, source); |
类似Object.assign,但通常是深合并。需要对库函数进行特殊建模。 |
类似Object.assign,但需要递归处理。对target和source的深层结构进行污点传播和污染检测。 |
function call(param1, param2); |
函数调用。需要进行过程间分析(Interprocedural Analysis),或者保守地假设所有参数的污点都会传播到函数的返回值。 | 如果param1是污点,则在函数call内部将param1(在函数作用域内)标记为污点。 如果函数 call的返回值可能包含污点,则将调用点接收返回值的变量标记为污点。 |
return expr; |
函数返回值。 | 如果expr是污点,则函数的返回值被标记为污点。 |
3.4 示例:检测一个简化的污染路径
考虑以下代码:
// app.js
const http = require('http');
const querystring = require('querystring');
function safeMerge(target, source) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 简单过滤敏感键
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
console.warn(`Blocked sensitive key: ${key}`);
continue;
}
if (typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null) {
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
const defaultConfig = {
port: 3000,
debug: false
};
http.createServer((req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const queryParams = querystring.parse(parsedUrl.search.substring(1)); // 污点源
let userConfig = {};
Object.assign(userConfig, defaultConfig); // 1. userConfig继承自Object.prototype
// 2. 关键点:这里使用了Object.assign,而不是safeMerge
// 如果queryParams包含__proto__,则会污染Object.prototype
Object.assign(userConfig, queryParams); // 污染汇?
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Debug mode: ${userConfig.debug}n`);
}).listen(defaultConfig.port, () => {
console.log(`Server running at http://localhost:${defaultConfig.port}/`);
});
CFG与数据流分析的视角:
- 污点源识别:
queryParams = querystring.parse(...)。parsedUrl.search来自req.url(用户输入),因此queryParams被标记为污点。 - 对象初始化:
userConfig = {}。userConfig是一个普通对象,其原型链指向Object.prototype。 - 第一次合并:
Object.assign(userConfig, defaultConfig)。defaultConfig不是污点,userConfig保持非污点状态,但其原型链不变。 -
第二次合并(污染汇检测):
Object.assign(userConfig, queryParams)。- 目标对象检查:
userConfig此刻是一个普通对象,其原型链指向Object.prototype。我们假设我们的别名分析能够识别出这一点,或者至少保守地认为任何普通对象都可能存在原型污染风险。 - 源对象检查:
queryParams被标记为污点。 - 操作检查:
Object.assign会遍历源对象的属性并赋值给目标对象。 - 风险评估:
queryParams中的任何属性都会被直接复制到userConfig。如果queryParams包含__proto__作为键,那么Object.assign将尝试将queryParams.__proto__的值赋给userConfig.__proto__。由于userConfig继承自Object.prototype,这将直接修改Object.prototype。
结论: 发现一条从
req.url(污点源)到Object.assign(userConfig, queryParams)(污染汇)的攻击路径,其中污点数据queryParams的键名可能被恶意构造为__proto__,从而污染Object.prototype。 - 目标对象检查:
四、挑战与对策:JavaScript动态特性
JavaScript的动态性和灵活性给静态分析带来了显著挑战。
-
别名分析(Alias Analysis):
- 挑战: 确定两个不同的表达式是否引用同一个内存位置或对象。在JavaScript中,由于引用传递、对象字面量、函数参数传递等,精确的别名分析非常困难。
let a = {}; let b = a; function modify(obj) { obj.x = 1; } modify(b); // a.x 也被修改了 - 对策: 通常采用保守的(over-approximating)分析,即如果不能确定两个表达式不是别名,就假设它们是。这可能导致更多的误报(False Positives),但能减少漏报(False Negatives)。可以采用点对(points-to)分析来跟踪变量指向的对象。
- 挑战: 确定两个不同的表达式是否引用同一个内存位置或对象。在JavaScript中,由于引用传递、对象字面量、函数参数传递等,精确的别名分析非常困难。
-
原型链的动态性:
- 挑战:
__proto__属性本身是可写的(尽管不推荐),并且Object.setPrototypeOf()、Object.create()等方法可以动态修改原型链。 - 对策: 需要建模这些操作对原型链的影响,并将其纳入别名分析和对象识别的范畴。如果一个污点数据能够控制
__proto__的赋值,即使不是直接赋值给Object.prototype,也可能间接导致污染。
- 挑战:
-
动态属性访问:
- 挑战:
obj[key]中的key可以是变量,其值只有在运行时才能确定。静态分析难以精确判断key的最终值。const userControlledKey = getQueryParam('prop'); // 污点 obj[userControlledKey] = value; // userControlledKey可能是'__proto__' - 对策:
- 污点传播: 如果
key是污点,我们必须假设它可能取任何值,包括敏感键。 - 常量传播: 如果
key是常量,可以精确判断其值。 - 值集分析: 尝试推断
key可能取值的一个有限集合。
- 污点传播: 如果
- 挑战:
-
this上下文的动态性:- 挑战:
this关键字的值在JavaScript中是动态绑定的,取决于函数如何被调用。这使得追踪对象属性的访问和修改变得复杂。 - 对策: 采用上下文敏感分析(Context-Sensitive Analysis),根据函数调用时的上下文(caller)来分析函数内部的
this。
- 挑战:
-
高阶函数与回调:
- 挑战: 函数作为参数传递,回调函数等使得控制流和数据流跳跃,难以追踪。
- 对策: 过程间分析(Interprocedural Analysis)是必需的,需要构建一个调用图(Call Graph)来连接不同函数之间的控制流。
-
eval()、with语句和Proxy:- 挑战:
eval()执行任意字符串作为代码,with语句修改作用域链,Proxy拦截对象操作。这些使得静态分析几乎不可能准确地预测程序行为。 - 对策: 通常对这些构造采取保守策略:要么直接报告为潜在风险(即便没有明确的污染路径),要么将其视为分析的边界,不再深入。
- 挑战:
为了应对这些挑战,实际的静态分析工具会结合多种分析技术:
- 流敏感分析(Flow-Sensitive Analysis): 考虑程序语句的执行顺序。
- 路径敏感分析(Path-Sensitive Analysis): 尝试区分不同的执行路径,以获得更精确的结果。
- 上下文敏感分析(Context-Sensitive Analysis): 区分不同调用站点对同一函数的分析结果。
这些技术虽然增加了分析的复杂性和计算成本,但能显著提高分析的准确性,减少误报和漏报。
五、实践与展望
5.1 简化的探测算法实现构想
我们可以设想一个简化的静态分析器,其核心组件包括:
- AST解析器: 例如使用
acorn库。 - CFG构建器: 遍历AST,识别基本块和控制流。
- 污点追踪模块:
- 维护一个
Map<Node, Set<TaintSource>>来记录每个AST节点产生的污点源。 - 在CFG上进行迭代数据流分析,传播污点。
TaintedState对象: 包含当前程序点上的所有污点信息(变量、对象属性)。
- 维护一个
- 别名/原型识别模块: 尝试识别哪些变量可能引用
Object.prototype。- 可以通过简单的规则:
{}、new Object()、Object.create(null)除外,默认为Object.prototype的后代。
- 可以通过简单的规则:
- Sink检测模块: 检查赋值语句、
Object.assign、_.merge等。
伪代码示例:核心分析循环
// Global state for taint and alias information
const globalTaintState = new Map(); // Map<CFGNode, AnalysisState>
class AnalysisState {
constructor() {
this.taintedVariables = new Set(); // e.g., ['x', 'userConfig']
this.taintedProperties = new Map(); // e.g., { objectRef: { propName: true } }
this.objectPrototypeAliases = new Set(); // e.g., ['userConfig']
// ... more state for value sets, types, etc.
}
// Merge state from another path
merge(otherState) {
// Union sets, merge maps
otherState.taintedVariables.forEach(v => this.taintedVariables.add(v));
otherState.objectPrototypeAliases.forEach(v => this.objectPrototypeAliases.add(v));
// ... merge taintedProperties (more complex, might require union of keys/values)
}
// Deep copy for branching paths
clone() {
const newState = new AnalysisState();
newState.taintedVariables = new Set(this.taintedVariables);
newState.objectPrototypeAliases = new Set(this.objectPrototypeAliases);
// ... deep copy taintedProperties
return newState;
}
}
function analyzeCFG(cfg) {
// Initialize entry node state
globalTaintState.set(cfg.entryNode, new AnalysisState());
let worklist = [cfg.entryNode];
while (worklist.length > 0) {
const node = worklist.shift();
const currentState = globalTaintState.get(node).clone(); // Start with a fresh state for this node
for (const statement of node.statements) {
// Process each statement in the basic block
processStatement(statement, currentState);
}
// Propagate state to successors
for (const successor of node.successors) {
let successorState = globalTaintState.get(successor);
if (!successorState) {
successorState = new AnalysisState();
globalTaintState.set(successor, successorState);
}
// Merge current state into successor's state
// If the state changed, add successor to worklist
if (successorState.merge(currentState)) { // merge returns true if state changed
worklist.push(successor);
}
}
}
}
function processStatement(statement, state) {
// ... logic to update state based on statement type ...
if (isTaintSource(statement)) {
const variableName = extractAssignedVariable(statement);
state.taintedVariables.add(variableName);
}
if (isObjectCreation(statement)) { // e.g., let obj = {};
const variableName = extractAssignedVariable(statement);
state.objectPrototypeAliases.add(variableName); // Assume it's an alias to Object.prototype initially
}
if (isAssignmentToMemberExpression(statement)) { // e.g., obj[key] = value;
const targetObjectRef = getObjectReference(statement.left); // e.g., 'obj'
const propertyKeyRef = getPropertyKeyReference(statement.left); // e.g., 'key' or string literal 'prop'
const assignedValueRef = getAssignedValueReference(statement.right);
// 1. Check for Prototype Pollution Sink
if (state.objectPrototypeAliases.has(targetObjectRef)) { // Is 'obj' an alias to Object.prototype?
// If propertyKeyRef is a tainted variable, assume it can be '__proto__'
if (state.taintedVariables.has(propertyKeyRef)) {
reportVulnerability(statement, "Prototype Pollution via tainted key", {
target: targetObjectRef,
key: propertyKeyRef,
value: assignedValueRef
});
}
// If propertyKeyRef is a literal like '__proto__'
if (isSensitiveKeyLiteral(propertyKeyRef)) {
reportVulnerability(statement, "Prototype Pollution via literal sensitive key", {
target: targetObjectRef,
key: propertyKeyRef,
value: assignedValueRef
});
}
}
// 2. Propagate taint
if (state.taintedVariables.has(assignedValueRef)) {
// Mark the property as tainted
if (!state.taintedProperties.has(targetObjectRef)) {
state.taintedProperties.set(targetObjectRef, new Map());
}
state.taintedProperties.get(targetObjectRef).set(propertyKeyRef, true);
}
}
// ... handle Object.assign, deepMerge, function calls, etc.
}
function isTaintSource(statement) { /* ... */ }
function extractAssignedVariable(statement) { /* ... */ }
function isObjectCreation(statement) { /* ... */ }
function isAssignmentToMemberExpression(statement) { /* ... */ }
function getObjectReference(memberExpression) { /* ... */ }
function getPropertyKeyReference(memberExpression) { /* ... */ }
function getAssignedValueReference(expression) { /* ... */ }
function isSensitiveKeyLiteral(keyRef) {
return keyRef === '__proto__' || keyRef === 'constructor' || keyRef === 'prototype';
}
function reportVulnerability(statement, message, details) {
console.log(`Vulnerability found: ${message} at ${statement.loc.start.line}:${statement.loc.start.column}`);
console.log(details);
}
5.2 缓解措施与最佳实践
除了通过静态分析发现原型链污染,我们还应该在开发和部署阶段采取预防措施:
- 输入验证: 对所有来自外部的输入进行严格的验证和净化。使用白名单机制,只允许已知的安全键名和值通过。
- 避免深合并: 尽量避免使用深合并函数处理来自不可信源的数据。如果必须使用,确保库函数经过安全审计,或者实现自己的合并逻辑并严格过滤
__proto__、constructor、prototype等敏感键。// 安全的合并函数示例(简化) function safeMergeStrict(target, source) { for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') { // 严格过滤,直接跳过或抛出错误 continue; } if (typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null) { // 递归调用 safeMergeStrict(target[key], source[key]); } else { target[key] = source[key]; } } } return target; } - 使用
Object.create(null): 当需要创建一个纯净的、不继承自Object.prototype的对象时,使用Object.create(null)。这对于存储用户可控键值对的映射非常有用。const cleanMap = Object.create(null); cleanMap[userControlledKey] = userControlledValue; // 即使userControlledKey是'__proto__'也无害 - 冻结
Object.prototype: 在应用程序启动时,可以通过Object.freeze(Object.prototype)来阻止对Object.prototype的任何修改。但这可能会破坏某些依赖于修改原型的旧代码或库。// 在应用入口点执行 Object.freeze(Object.prototype); // 尝试污染将抛出错误 // {}.__proto__.test = 1; // TypeError: Cannot add property test, object is not extensible - 安全的反序列化: 当从JSON或其他格式反序列化数据时,应警惕可能存在的原型链污染载荷。使用
JSON.parse()是安全的,但如果在此基础上进行自定义处理,就需要格外小心。
通过今天的讲座,我们深入探讨了JavaScript原型链污染的机理、危害,以及如何利用控制流图进行静态分析来探测这些复杂的攻击路径。这项技术的核心在于构建代码的CFG,并在此基础上进行精细的数据流和别名分析,以追踪来自不可信源的数据,并识别其是否可能在程序执行过程中修改Object.prototype。尽管JavaScript的动态特性带来了诸多挑战,但通过结合多种静态分析技术,我们能够显著提升对原型链污染漏洞的检测能力,从而在软件开发生命周期的早期阶段增强应用程序的安全性。