PwnCollege - Program Security - Complex Corruption
Canary Conundrum (Easy)
1 | hacker@program-security~canary-conundrum-easy:~$ /challenge/canary-conundrum-easy |
Offsets: buf -> canary = 136, buf -> ret = 152
1 | hacker@program-security~canary-conundrum-easy:~$ r2 -A -q -c "pdf @ sym.challenge" /challenge/canary-conundrum-easy |
1 | #!/usr/bin/env python3 |
Canary Conundrum (Hard)
1 | hacker@program-security~canary-conundrum-hard:~$ /challenge/canary-conundrum-hard |
1 | # buf = rbp - 0x70 |
1 | #!/usr/bin/env python3 |
1 | hacker@program-security~canary-conundrum-hard:~$ python a.py |
A Crafty Clobber (Easy)
Write a full exploit involving injecting shellcode, and a method of tricking the challenge into executing your payload.
1 | hacker@program-security~a-crafty-clobber-easy:~$ /challenge/crafty-clobber-easy |
1 | hacker@program-security~a-crafty-clobber-easy:~$ r2 -A -q -c "pdf @ sym.challenge" /challenge/crafty-clobber-easy |
Payload 结构必须是这样:
[104 字节] 的垃圾填充数据(比如 b"A" * 104),这正好填满 buf 到 var_18h 之间的空隙。
[8 字节] value:p64(0x855cc253c479598b),覆盖 [var_18h],绕过 exit()。
[8 字节] 的垃圾填充数据(0x18 - 0x8 = 0x10,也就是 16 字节距离,减去刚刚写入的 8 字节魔法值,还需要 8 字节),用来走到 Canary 面前。
[8 字节] 的 Canary:原封不动地写回去,防止触发 __stack_chk_fail。
[8 字节] 的 Saved RBP:随便填,或者填入题干给你的 saved frame pointer。
[8 字节] 的 Saved RIP:填入题干给你的 input buffer starts at 的地址。
[N 字节] 的 Shellcode(既然题目说栈是可执行的,直接把 Shellcode 放在开头或者紧接着 RIP 后面跳过去执行就行)。
1 | #!/usr/bin/env python3 |
pwn.college{*******************************************}
A Crafty Clobber (Hard)
1 | # buf -> rbp - 0x60 |
1 | #!/usr/bin/env python3 |
pwn.college{******************************************}
Can It Fizz?
Exploit the binary to obtain the flag!
1 | hacker@program-security~can-it-fizz:~$ checksec /challenge/can-it-fizz |
1 | 0x0000126c imul rax, rax, 0xffffffff88888889 |
% 15 == 0(0x88888889 乘法逆元) -> 对应FizzBuzz\n% 3 == 0(0x55555556 乘法逆元) -> 对应Fizz\n% 5 == 0(0x66666667 乘法逆元) -> 对应buf + 0x44(即事先写好的"Buzz")- 都不满足 -> 对应
\n
1 | # check buf? |
[buf]位于rbp - 0x60- 输入起点
buf + 0x14=rbp - 0x4c - 循环计数器
var_14h=rbp - 0x14 - 源字符串指针
src=rbp - 0x10 - 目标字符串指针
dest=rbp - 0x8 - Saved RBP =
rbp - Saved RIP (Return Address) =
rbp + 0x8
从输入起点 (rbp - 0x4c) 到循环计数器
(rbp - 0x14),距离正好是
0x4c - 0x14 = 0x38,56 bytes
1 | #!/usr/bin/env python3 |
1. 利用 Buzz 泄露栈地址
1 | # 推进到第 5 次循环 (var_14h == 5) |
程序内置了 FizzBuzz 逻辑。当 var_14h == 5 时,满足
% 5 == 0,触发 Buzz 分支。此时,二进制文件会将
src 指针指向 buf + 0x44(这里提前写入了
"Buzz")。
1 | payload_leak_stack = b"A" * 56 + b"\xff\xff\xff\xff" |
- 填充 56 字节的
A,恰好抵达var_14h。 - 用
\xff\xff\xff\xff(-1) 覆盖var_14h。 read()函数不会自动补\x00(Null byte),而printf("You entered: %s")遇到\x00才会停。- 因为我们把
var_14h变成了非零的-1,printf会顺着读下去,直接把紧贴在它后面的src指针(位于rbp - 0x10)给打印出来! - 同时,由于
-1 + 1 = 0,循环变量更新后变成了 0,满足0 < 16,程序继续下一次循环,不会崩溃。
2. 利用 FizzBuzz 泄露程序基址
1 | payload_leak_pie = b"A" * 56 + b"\xff\xff\xff\xff" |
因为上一步把计数器改成了 -1,这一轮循环加一后
var_14h 变成了 0。
0 % 15 == 0,所以这一轮触发的是 FizzBuzz
分支。 此时,程序会将 src
指向只读数据段(.rodata)中的 "FizzBuzz\n"
字符串(地址为 PIE 偏移 0x4018)。
再次用 56 字节 + -1 覆盖,把此时的 src
指针打印出来。减去偏移
0x4018,就拿到了程序的真实基址(pie_base)
3. Code Execution
到了这一步,我们手里有栈的绝对地址(用来放 Shellcode),也有 PIE 基址。
1 | dest_addr = rbp_addr - 0x200 |
你可能会问,既然可以直接覆盖 RIP,为什么还要搞这么复杂的
payload,修复 src 和 dest 回去看反汇编,在
printf 之后,程序执行了一个操作:
call sym.imp.strcpy (把 src 复制到
dest)
如果只是用垃圾数据往后覆盖,把 src 和 dest
覆写成了无效地址(比如全 A),strcpy 就会触发
SIGSEGV (段错误)
56: p32(16): 把循环计数器改成 16。等这一轮结束16 + 1 = 17,不满足< 16的条件,程序会立刻跳出循环,执行leave; ret。60: p64(src_addr): 填入刚泄露的有效可读地址(只读段的"FizzBuzz")。68: p64(dest_addr): 填入栈上一个远离我们 shellcode 的空闲区(rbp_addr - 0x200)76: p64(rbp_addr): 还原 RBP。84: p64(shellcode_addr): 将 Saved RIP 劫持为栈上的 Shellcode 地址。92: asm(...): 注入 shellcode。
pwn.college{********************************************}
Does It Buzz?
1 | hacker@program-security~does-it-buzz:~$ r2 -A -q -c "pdf @ sym.challenge; pdf @ sym.win" /challenge/does-it-buzz |
仔细看汇编里的栈分配和 read() 调用:
0x000013d8 sub rsp, 0x90:开辟了 144 字节空间。canary位于rbp - 0x8。var_24h(循环计数器) 位于rbp - 0x24。src指针位于rbp - 0x20。dest指针位于rbp - 0x18。read()的输入起点在buf + 0x14,通过计算可以得出它位于rbp - 0x5c。
最关键的变化在 read() 的参数:
1 | 0x00001540 mov edx, 0x50 ; size_t nbyte = 80 |
现在的缓冲区溢出最多只能写 80 个字节。 buffer
address (rbp - 0x5c) 到 Canary (rbp - 0x8)
的距离是 0x5c - 0x8 = 0x54 (84 字节)。
最多只能写到 rbp - 0xc。
既然碰不到返回地址
(rbp + 0x8),我们怎么劫持控制流?破局点在于覆盖的局部变量:
距离起点 60 字节的地方是 src,68 字节的地方是
dest。这两个指针完全在 80 字节范围内。
1 | hacker@program-security~does-it-buzz:~$ checksec /challenge/does-it-buzz |
- SHSTK: Enabled (Hardware Shadow Stack / 硬件影子栈)
- IBT: Enabled (Indirect Branch Tracking / 间接分支追踪)
当我们用 strcpy 把后门地址写进 saved_rip
后,函数走到最后执行 ret 指令。 现代系统里,CPU
内部维护了一个只有硬件能访问的影子栈 (Shadow
Stack)。当你执行 call
时,返回地址会同时压入普通栈和影子栈;当你执行 ret 时,CPU
会把普通栈上的地址和影子栈里的地址做对比。对比失败 CPU 硬件直接抛出
Control Protection Exception (#CP)
- RELRO: Partial RELRO
Partial RELRO 意味着全局偏移表 (GOT) 是可写的 看一下
strcpy 之后程序干了什么:
1 | 0x00001580 call sym.imp.strcpy ; 我们控制的任意写入 |
如果我们在 strcpy 的时候,把目标地址 (dest)
设置为 printf 的 GOT 表地址,把写入内容设为
sym.win,那么下一条指令 call printf
就会直接跳进 win 函数
1 | #!/usr/bin/env python3 |
1 | hacker@program-security~does-it-buzz:~$ python a.py |
Make It FizzBuzz
1 | hacker@program-security~make-it-fizzbuzz:~$ r2 -A -q -c "pdf @ sym.challenge" /challenge/make-it-fizbuzz |
1 | hacker@program-security~make-it-fizzbuzz:~$ checksec /challenge/make-it-fizbuzz |
- 泄露 Stack & PIE:利用 FizzBuzz 机制泄露栈指针和程序基址。
- 泄露 Libc :将
src指向printf的 GOT 表,将dest指向栈上的安全区(比如buf)。strcpy会把 libc 地址复制过来,紧接着的printf("Correct answer: %s\n", dest)就会把 libc 地址泄露给终端 - 劫持 GOT 表:有了 libc 基址,算出
execve的真实地址。再次利用strcpy,把execve的地址写进strcpy自己的 GOT 表里
汇编代码里调用 strcpy 的传参过程,rsi 和 rdx 都被赋予了 src 的值。
这意味着,如果我们将 dest 指向字符串 "/bin/sh",将 src 指向一个指针数组 ["/bin/sh", "-p", NULL]:
rdi (可执行文件路径) = "/bin/sh"
rsi (命令行参数 argv) = ["/bin/sh", "-p", NULL]
rdx (环境变量 envp) = ["/bin/sh", "-p", NULL]
最核心的优势:使用 execve 并通过 argv 传入 -p 参数,可以直接让 /bin/sh 保持 SUID 的 Root 权限
1 | #!/usr/bin/env python3 |
1 | hacker@program-security~make-it-fizzbuzz:~$ python a.py |