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 | 外中断:是指来自处理器和内存以外的部件引起的中断,包括I/O设备发出的I/O中断,外部信号中断,以及各种计时器引起的时钟中断等(外中断在狭义上一般被称为中断) |
2.硬件中断和软件中断
1 | 硬件中断:通过外部的硬件产生的中断(硬件中断属于外中断) |
3.非屏蔽中断和可屏蔽中断(全是外中断)
1 | 非屏蔽中断:非屏蔽中断是一种硬件中断,此种中断通过不可屏蔽中断请求NMI控制,不受中断标志位IF的影响,即使关中断(IF=0)的情况下也会被响应 |
参考: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 | typedef struct ucontext { |
结构体ucontext用于保存上下文信息
有了它,许多上下文切换的操作都可以完成,linux也为它提供了一组api:
1 | int getcontext(ucontext_t *ucp); |
这里就不继续展开了
siginfo:
1 | typedef struct { |
结构体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 | struct sigcontext |
x64
1 | struct _fpstate |
每一次在请求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需要以下条件:
- 攻击者可以通过stack overflow等漏洞控制栈上的内容
- 需要知道栈的地址(比如需要知道自己构造的字符串
/bin/sh
的地址) - 需要知道
syscall
指令在内存中的地址 - 需要知道
sigreturn
系统调用的内存地址
接着我们就看一下那道设计巧妙的题目吧:
1 | signed __int64 start() |
1 | .text:00000000004000B0 ; signed __int64 start() |
原汇编代码就相当于“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 | 1、如果读取成功,则返回实际读到的字节数。这里又有两种情况:一是如果在读完count要求字节之前已经到达文件的末尾,那么实际返回的字节数将小于count值,但是仍然大于0;二是在读完count要求字节之前,仍然没有到达文件的末尾,这是实际返回的字节数等于要求的count值。 |
那么,先要rax为“1”,函数“read”的实际写入长度必须为“1”
1 | .text:00000000004000B0 xor rax, rax |
而且这段汇编代码会把rax中的值改为“0”,所以必须让过……
所以“send(‘\xb3’)”是最佳选择,覆盖“0x4000B0”最后一字节为“0xb3”,跳过了它,并且长度为“1”
接下来就会执行“write(1,rsp,0x400)”,泄露stack_addr
1 | start_addr = 0x00000000004000B0 |
第一次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 | from pwn import * |
总结:
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的条件非常苛刻)