版权声明:本文为CSDN博主「ashimida@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lidan113lidan/article/details/122547854
更多内容可关注微信公众号
信号是事件发生时对进程的通知机制,其与中断类似,在到达时都会打断程序的正常执行流程。一个进程(若具有权限则)可以向另一个进程或向自身发送信号,其可以作为一种同步技术或进程间通信的原始形式。发往进程的诸多信号通常都源于内核,引发内核为进程产生信号的事件包括:
硬件异常: 如用户态的访问异常/除零异常等,其首先都是由硬件捕获并通知内核的,再由内核通过信号传递给用户态。
用户输入的中断字符: 如ctrl-c, ctrl-z。
软件事件的发生: 如针对文件描述符的输出变为有效,终端大小调整,定时器到期,cpu执行时间到期,子进程退出等。
每个信号在系统中都有唯一的编码,其编号随着系统的不同而不同,故程序中应该总是使用符号名来代表这些信号。
信号分为标准信号和实时信号, 在linux中编号1~31为标准信号 >31(<=64)的为实时信号。
信号在产生后可能会经历一段时间才会真正被处理(到达),在此过程中信号则处于pending(等待状态), 在内核返回用户态时才会检查信号是否到来,故:
若进程向其他进程发送信号,则通常总要有一段(极短的)pending的时间, 直到目标进程被调度到或目标进程正在运行时产生了el0异常.
若进程向自身发送信号,则通常在如*kill系统调用返回时此信号立即被处理。
有时为了确保一段代码不被打断,可以通过掩码来屏蔽部分信号,被屏蔽的信号会一直处于等待状态,直到接触屏蔽。
通过/proc/pid/status接口可以查看当前进程的信号:
linux中各信号的定义可参考[0], 这里需要注意的是:
1. 标准信号不排队,实时信号需排队处理
2. 内核线程也可以接收信号
虽然信号处理是为用户进程设计的,但在linux中内核线程也是可以接受信号的。和用户进程不同的是:
内核线程的信号处理可以参考内核线程函数 jffs2_garbage_collect_thread.
3. init进程不接受SIGKILL/SIGSTOP信号
见内核sig_task_ignored函数
从内核角度看,一个线程的信号可能保存在两个队列中:
通常信号是发送到线程组共享的信号队列的,此队列中的信号被线程组中的任意线程处理(一次)即可,而在用户态看来则是一个信号可能被线程组中的任一线程处理。而通过如tkill系统调用也可以将信号直接发送给线程的私有信号队列,此时虽然线程组中所有线程的信号处理函数是同一个,但可以确保此信号只会由某个具体的线程来处理。在内核中信号相关的结构体定义如下:
各结构体关系如下图:
linux 用户态可以通过signal/sigaction函数设置信号处理函数,二者系统调用接口如下:
二者最终均调用了do_sigaction函数,这里以简单的sys_signal函数为例:
do_sigaction:
这里以用户态入口系统调用sys_kill为例,其定义如下:
其中prepare_signal定义如下:
其中complete_signal函数定义如下:
由前可知,信号发送操作除了将具体信号设置到线程的共享/私有队列外,还会为此线程标记TIF_SIGPENDING flags(若当前线程block了信号,则有可能会发送给线程组其他线程), 不论线程收到了多少个信号都会通过这一个flag标记, 只要线程的tsk->flags 标记了 TIF_SIGPENDING则就说明此线程收到了信号。
内核在检查是否有信号到达时同样检查的也是线程的TIF_SIGPENDING flag, 内核中的信号处理可以分为两种场景:
1) 内核返回用户态时检查并处理信号
由于信号在多大多数情况下是给用户态进程使用的,故比较常见的是此场景, 通常从EL0异常入口进入内核并返回到用户态之前都会检查当前线程是否有要处理的信号,如:
EL0同步异常, 如EL0发起的系统调用, 指令/数据访问错误.
2) 内核线程通过循环检查自身的信号
这种场景比较少见, 偶尔出现在一些需要与用户态交互的内核线程中, 此时内核线程可以决定只接受内核发送的信号(SIG_KTHREAD),也可以接受用户态信号(SIG_KTHREAD). 和用户态显著的区别在于, 内核信号更类似一个个case,其不能指定信号处理函数,内核线程中需要自己实现代码来循环检测是否有信号出现(内核线程不会执行到1的流程,故若收到信号必须主动处理) 在情景1/2中内核均通过类似signal_pending的逻辑判断当前线程是否有需要处理的信号.
内核在返回用户态之前检查并处理信号, 当前线程首先会无视(不忽略也不处理)其自身阻塞的信号,对于未被阻塞的信号分为三种情况:
1) 使用默认行为的信号(SIG_DFL)
2) 直接忽略的信号(SIG_IGN)
3) 需要执行handler的信号
需要注意的是:
1) 阻塞是以线程为单位的:
线程组共享的信号(常规信号)被一个线程阻塞并不代表其不能被处理, 同一线程组的任一其他线程均可处理此信号。
2) 用户态上下文:
用户态代码执行到任意位置时都有可能有异常触发, 不论是同步/异步异常在返回时都会检查当前进程是否有信号要处理, 对于需要执行信号处理函数的信号其被中断前的用户态上下文必须得以保存,否则信号处理函数执行完毕后无法恢复原有运行环境. 在linux中此用户态上下文是被在异常发生时存储,在信号处理函数执行的过程中被保存在用户/信号栈中的,在信号处理完毕后同样需要从栈中恢复。
3) 信号上下文:
信号上下文指的是信号处理函数的上下文,主要包括:
4) 当异常返回前发现多个信号处理函数时:
异常返回前会循环遍历所有未被当前线程block的信号,如果有多个信号均需要执行信号处理函数, 那么内核会在进程栈中依次堆叠多个信号栈帧, 如某线程依次收到了需要执行handler的 sig1/sig2两个信号, 则内核会先为sig1设置栈帧,然后为sig2设置栈帧。而最终用户态的执行流程是 sig2_handler => sys_rt_sigreturn => sig1_handler => sys_rt_sigreturn => 用户态上下文, 即后到的信号被优先处理(举例见备注)。
5) 当信号处理函数执行过程中再次被信号中断时:
若用户态正在执行信号处理函数,此时没有被当前线程block的信号可能导致此信号处理程序被再次中断,同样是后到的信号被优先处理,但不同的是此时可能导致竞态死锁问题(信号处理函数需要设计为可重入).
6) 安全性分析:
这里的安全性分析仅针信号处理过程中是否会导致权限提升,并不针对如SROP等利用信号处理的利用方式。信号处理的过程中在用户栈中保存了用户态上下文,在信号处理完成后需要内核(sys_rt_sigreturn)为用户态恢复此上下文,用户态上下文的数据包括:
用户态跳转到/修改任何用户态数据均不会有权限问题,其中唯一的问题就是内核在信号返回时会从用户态读取pstate并恢复到内核的CPSR_EL1; pstate中记录了一些需要恢复的如比较j结果,是否溢出等,但同时也记录了一些安全相关的如当前异常级别等bit位,故如果不加检查的直接从用户态恢复此值则攻击者可以轻易利用信号处理来提升异常级别(如用户态由EL0=>EL1), 内核处理的方式则是恢复用户态上下文之前添加了一个检查函数valid_user_regs,以确保pstate的正确性,代码如下:
测试代码:
输出结果:
整个信号处理的代码执行流程如下图所示:
EL0中不论是同步/异步异常,最终均会调用函数 exit_to_user_mode返回用户态, 此函数中负责检查当前线程是否有需要处理的信号,定义如下:
其中do_signal是信号处理的入口,此函数一次处理一个信号,其定义如下:
其中do_signal=>get_signal负责从信号队列中获取一个信号,其定义如下:
其中do_signal=>get_signal=>dequeue_synchronous_signal/dequeue_signal逻辑类似,这里仅以dequeue_signal为例:
在确定信号需要执行handler后, do_signal=>handle_signal负责将handler函数指针设置到用户态执行上下文中,其代码如下:
其中setup_rt_frame负责在用户态栈上保存异常前的执行环境,并设置用户态返回后直接跳转到信号处理函数:
由上可知,当内核发现一个信号需要执行信号处理函数时, 会保存当前用户态上下文并重置为信号处理的上下文。
当前用户态上下文是保存在用户态栈中, 信号处理函数执行完毕后需要恢复到原有用户态上下文执行, 这一步上下文恢复操作是通过sys_rt_sigreturn系统调用完成的, 在设置信号处理上下文时setup_rt_frame会设置其返回地址为__kernel_rt_sigreturn, 这样信号处理函数执行完毕后即可以无感知的通过正常函数返回(ret)跳转并执行sys_rt_sigreturn系统调用,sys_rt_sigreturn定义如下:
其中restore_sigframe用来从用户态栈帧恢复原本的用户态上下文,其定义如下:
这里以内核线程 jffs2_garbage_collect_thread为例:
在DEP(Data Execution Prevention)普遍部署之后, 控制流劫持后跳转到数据执行shellcode(如栈溢出后跳转到栈执行)的方式就基本无法使用了。攻击者通常只能通过ret2xxx(如ret2libc/ROP/JOP)的方式执行其所需要的代码逻辑,但构建这样的执行序列通常并不容易,以ROP(Return Orientend Programming)为例,攻击者通常需要满足以下前提:
1) 攻击者可以在目标应用中收集到足够多的gadgets且可以确定其运行时地址
2) 攻击者可以在程序运行时将适合的数据布局到栈中
3) 攻击者可以利用漏洞劫持控制流并跳转到第一个gadget
对于 1) 来说能否满足条件:
而 SROP(Sigreturn Orientend Programming)[1,2]的出现则可以解决1)中遇到的绝大多数问题:
以AArch64为例, 由前面可知在信号处理的过程中:
这也就意味着只要攻击者控制了当前栈帧并执行一个sys_rt_sigreturn后,既可以控制当前用户态的所有通用硬件寄存器,包括:
简单说sigreturn实际上就是一个能力极其强大的gadget,其可以完成任何参数设置,控制流转移,返回地址设置,堆栈指针修改操作。且由于sigreturn中本身就存在一条svc指令,攻击者同时还可以复用此gadget执行一次execve来获取本地shell。利用SROP执行execve的流程如下图:
但需要注意的是如果使用SROP来chain多个系统调用时,则还需一个额外的 syscall& ret; gadget, 这是因为 __kernel_rt_sigreturn中虽然有一个可用的svc指令,但其后面通常没有ret指令,因此此svc指令其不能用来链接gadget chain。 如当攻击者需要执行如 mprotect + shellcode时, 则需要找到一个 svc 0; ret; 指令序列来确保 mprotect系统调用执行完毕后会有一条ret指令可以返回到sigreturn 设置的lr寄存器位置。以下代码可用来简单测试SROP和基于SROP的syscall chain:
test case 输出如下:
PS:
vdso的地址是通过mmap分配出来的,故用户态ASLR可以对攻击者增加一个infoleak的难度, randomize_va_space >=1 时用户态进程开启VDSO(mmap)随机化[4]:
参考资料:
[0] 《Linux/Unix 系统编程手册》
[1] Framing Signals -- A Return to Portable Shellcode
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2022-2-19 15:34
被ashimida编辑
,原因: