写在前面
关于我上一篇完篇之后就开始疯狂ISCC,然后休息的时候打野炊和王泪,结果在各种不同电子yw之间反复摩擦这件事情.pdf
关于小娜,别提了,她最近跑程序跑得很过载,估计连游戏都懒得打了。
也罢,到时候给她做个全面检查。
至于在程序的汇编表示之后,我们该探究些什么,想一想,直接扯硬件对于软工和逆向有点不大实际,谈网络又不可避免要请鲍师傅来扯Web Assembly,那只能是继续程序方面的内容了。
所以这一期,前有巨量文本,敬请留意——
来谈谈异常控制流
什么是异常控制流?从程序逆向来说,你可以试试对一个加了反调试的程序的main函数,或者是程序的起始点下断,然后单步步过,正常来讲,它并不会像我们在上一篇文章中提到的那样正常进入运行阶段,而是一头创进进程检查,然后让你的程序exit或者让你的ida崩溃,这就是一种异常控制流。
用专业一点的术语来讲,异常控制流是应对系统状态出现特殊情况而产生的控制流突变,这种特殊情况可能是一个附带多线程的程序的其中一个线程终止,也有可能是因为堆栈溢出导致整个程序崩溃,etc。
但是慢着,什么是控制流
从处理器开始上电到断电的时间里,有一个叫做“程序计数器”的寄存器(PC,又名指令指针IP、指令地址寄存器IAR等),一直在处理你下一个要处理的汇编指令的地址。汇编指令的地址在同一进程上是一段连续的序列(在堆栈上被抽象为数组),而就算是不同进程,在执行时也是一串以连续序列为元素的链表,串联着父进程的调用点与子进程的首尾。
假设整个地址序列为a,当前执行的指令的地址为ak,那么,每次从ak到ak+1的地址转移就被称为控制转移,而由一系列控制转移串联的如上序列则被称为控制流。
但是,我们之前也学过jmp和各种逻辑跳转,控制转移在内存中,不可能每次都是从上一个地址到下一个地址的平顺变化,由跳转、调用等导致的这些跨内存地址的控制转移,被称为突变,很多突变是必要且通常的,比方说正常的jnz call等逻辑指令组合,但如果部分突变导致了系统状态变化,比方说你程序运行的半路,又想将外源的数据写入当前进程的内存,那么这种突变就成为了“异常控制流”。
通常来讲,异常控制流包括如下部分:
异常
和“异常控制流”的定义一样,顾名不思义。异常在严格定义上来讲,只是控制流中的“突变”,用于响应处理器状态的某些变化,而并没有严格意义上界定它对于程序运行影响的正负。
系统中每种可能的异常都被分配了一个唯一的非负整数编号,即异常号,其中一部分由处理器设计者分配,另外一部分由操作系统内核设计者分配。这些编号组成一张异常表,这个表的起始地址存储在CPU的异常表基址寄存器中,在系统启动时由操作系统分配和初始化,异常号为异常表元素的索引,每个元素为每个异常的跳转地址,这就形成了异常号到异常的单射
这样,当系统欲抛出异常时,系统会先通过函数去抛出异常号,再对照异常表转到对应的异常处理程序,可以理解为一个间接的函数调用,只是函数调用时入栈的返回地址取决于你调用指令的位置,而异常抛出则以当前指令(事件发生时正在执行的指令)或下一条指令(事件不发生,将会在当前指令执行之后执行的指令)为返回值,视异常类型而定。且额外还会将处理器的一些状态入栈,在处理程序返回时,重新开始被中断的程序会需要这些状态。
上面这段话可能说的有点暧昧,别急,慢慢来。
比方说我们想要写一个阳间一点的高精度加法,但是我们分别将调用程序和高精度加法程序封装在了不同的文件里,按照我们之前提到过的,我们调用高精度加法时会产生一个call,此时除了传入的参数,压入栈中的也只剩下被调用函数的返回地址,其对应着我们call指令的下一条指令,而非下一条要执行的指令的地址。这是一般的过程调用。
可是,总有些CTF出题人想给你挖个坑,他们在你阳间的程序上加入了阴间的反调试,使得你在call的时候会进行一次进程检查,一旦发现你在用反编译程序进行进程调试就像上面说的那样,给你直接抛出异常中止运行,这时候,进程检查的返回值就是你进程栈上的下一条指令,而非你调用函数的返回值,倘若你真的没有用反编译器调试,这个返回指令就会和预先存储的处理器状态一同作用,使系统回到工作状态继续运行你的程序。这是一个异常作用的过程。
简而言之,异常是系统在给自己干活,调用是系统给你干活。
而关于内核和用户的交互部分,这里先不急着讲,下文再说,只需要知道我们平时执行程序用的都是用户层,而异常处理程序在内核层,因此拥有所有系统资源的访问权限就行了。
异常的类别
中断(interrupt)
中断是异步的,即动作的执行和异常的处理不同步,因为这里的系统动作来源于处理器外部的I/O设备,而非CPU自身的指令。
比方说你敲键盘就会产生一次中断,I/O设备向CPU的中断引脚传输一次电信号,提高引脚电压,产生电压差,从而在这一次的输入指令执行完成后触发一次对系统总线上异常的读取,进而调用相对应的中断处理程序执行中断。中断完成后,就跳转到下一条指令继续运行,中断过程并没有影响这个程序的运行。
而现在处理器的速度已经足够快,中断的处理也就在一瞬间,这就使得你在输入一长串文本,比方说写你那 * 中原粗口 * 的工程学老师留下的一堆报告时,并不会感觉到老式电脑,乃至于电子打字机(谁还用这玩意啊)那样的,中断带来的卡顿。
陷阱(trap)
如果说中断是一种程序设定好的,随时发生异常,那“陷阱”就是受控发生的异常。陷阱最重要的用途是用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。什么意思呢?
内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
当程序运行在0级特权级上时,就可以称之为运行在内核态。
运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态(比如操作硬件)。
这两种状态的主要差别是:
处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理器是可被抢占的
处于内核态执行时,则能访问所有的内存空间和对象,且所占有的处理器是不允许被抢占的。——摘自哪 吒 的文章
系统从0到3划分了三个特权级,3级为最低,0级为最高。要实现从低到高的调用,系统只提供了异常和一些特殊的中断作为窗口,令用户态主动切换到内核态,进而干一些只有系统内核能干的事情,比方说超管权限。但是,内核态到用户态的变化则是系统控制的,通过设置设置程序状态字PSW来控制。
当你在用户程序中向内核请求服务,处理器提供了一个叫 syscall 的指令,用于让你向系统内核请求服务,你使用某些形式使用了syscall,比方说C的 system 指令,以向系统内核请求去进行某些事项,比方说/bin/sh
进行系统文件查找,那么,syscall指令就会将控制权转移到到对应的异常处理程序,即对应的陷阱处理程序,以调用适当的内核程序。调用完毕后,系统会交回控制权,处理程序返回到syscall之后的命令。这就完成了一次“陷阱”的处理。
值得注意的是:
系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。
这也就意味着,以系统调用为代表的陷阱异常,通常来说,其本质都是中断。但是,这对于程序逆向基本没什么影响,暂且按下不表。
故障(错误,fault)
上述的两个异常都是可以预测和控制的,但是故障和中止并不能被预测,两者都是发生错误而非中断,区别在于这个错误是否可修复。如果一条指令导致了故障的发生,那它会相应地将控制权移交给内核的故障处理程序,处理程序要么成功修复错误,返回当前用户层指令地址继续运行,要么修复不能,直接abort中止程序运行。
比方说你在ida里单步步过动态调试的时候,因为你不小心把返回地址改大了,导致返回到了一些不该返回的语句,甚至是直接创到了符号表,那么就会发生一次故障,导致你的程序停转,这时候你还可以通过手动修改返回地址来补救,以再次继续运行,要是你不想修了,你也可以直接中止调试进程。这个时候,导致错误的是你动调patch过的返回指令,而故障处理程序是你自己。
中止(终止,abort)
通常是一些硬件问题导致的不可逆性错误,比方说你的RAM被日穿了,电容漏电,导致你写入的数据当场爆炸。详细的DRAM故障可以参考weixin_42238387的文章,本文不做赘述。
细心的逆向手可能会发现,这不就跟导致你ida当场爆炸的某些反调试函数一个功用吗?反调试有很多种,而且实际上,部分反调试就是利用调试器对异常的处理来进行对调试进程的破坏的。具体可以参考weakptr大佬的文章。
进程
从概念上讲,进程最普适的概念就是把进程对应一个程序的正在运行的实例,文件和实例的关系可以抽象地理解为放在存储空间上的原本和跑在内存上的副本的关系。
至于进程具体的实现细节,可以参考这篇文章。
进程带来的程序运行层面的两个抽象,也就是逻辑控制流和私有地址空间。这两个抽象导致了两个假象,即你的程序看似在独占地使用处理器和内存系统。关于独立使用内存这一点,在第一篇的虚拟内存那块也提到过。
逻辑控制流
众所周知,程序中下一条指令的值只会在执行到当前指令之后才会复制到PC中,需要的时候总是被算出来的。你在ida或者ghidra之类的反汇编软件中能直观地看到指令的PC值(前提是出题人没有丧心病狂地开PIE),这些PC值唯一对应于包含在程序的可执行目标文件中的指令。而这一连串的PC值构成的序列,就是逻辑控制流。在处理器的单个核心内,一个既定的物理控制流——你可以抽象地把它看作一个时间轴,这个物理控制流被多个进程分成了对应的多个逻辑控制流,也就是在一条既定的时间轴上安排进程的运行与否。
这也意味着每个进程占用处理器的时间轴区间是互不相交的,比方说你在使用word打字的时候,你想要用QQ回复一句消息,然后再回来打字,那么在处理器内核上,word的进程开始运行到你使用QQ,然后QQ的进程运行到你回完消息,接着,word的进程又占领控制权直到你把字码完。不难发现,整个过程中,不同进程在自己所排布的时间轴上,轮流使用处理器内核。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流。进程在处理器内核的使用上互不影响,在逻辑控制流上是互不相交的,但这不影响它们的整个流程,包括轮换时的中断,在时间轴上重叠,即逻辑控制流的重叠。还是上面的例子,你可以理解为逻辑控制流是割裂的,因为它确实导致了程序轮流占用处理器;但你也可以理解为它们是交叠的,因为QQ进程在占用与否上,其时间轴是被包含在word进程的时间轴内的。
这种多个流存在同时执行的时间区间的情况也就是所谓并发。区别于“并行”的概念,并发是排列于同一物理控制流上的进程的交替运行,在空间上统一;而并行是多个进程同时进行,在时间上同步。
对于并发的解释,还有多任务,也就是时间分片的概念,多任务简单来讲就是一堆进程轮流运行,每个进程执行它的控制流的一部分的每一时间段叫做 时间片。这与我们之前提到过的时间轴的思想不谋而合。
所以,在整个控制流上,每个进程轮流占用处理器内核,营造出一种整个处理器上只有你当前运行的进程,或者说你眼前的程序在好好运行的假象。
私有地址空间
顾名思义,进程在内存空间上是独立存在,且在一般情况下,某一进程无法被除自身外的其它进程读和写(也总有些例外情况,比方说windows的getThreadId)。具体而言,进程不但独占内存空间,将其与其它并行的进程隔离开来,每一个进程还有其独立的地址空间。
在同一系统架构的同一语言下,这个地址空间的构造几乎完全一致,所以出现相同的地址也很正常,比方说你linux的elf就是默认以0x400000作为代码段的起始地址,再往上,从上到下就是第一篇提到过的各种头啊表啊之类的,然后就是你的堆栈。
具体而言:
Linux的虚拟地址空间范围为0~4G,Linux内核将这4G字节的空间分为两部分,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
Linux使用两级保护机制:0级供内核使用,3级供用户程序使用,每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的,最高的1GB字节虚拟内核空间则为所有进程以及内核所共享。
通常32位Linux内核地址空间划分0
3G为用户空间,34G为内核空间——摘自张胜飞的文章
所以,在linux系统中,内核虚拟内存和用户区域分离的架构,使得进程在表面上是相互独立,在表现上是独占内存空间的,但实际上,所有的进程都由内核直辖,也就更方便内核与进程的交互和对进程的控制。
这种空间上的分离是通过模式位来实现的。通常情况下,模式位不设置,进程用户模式下,无法进行内核操作,比如停止处理器等,只能通过异常或系统接口的方式来间接调用内核指令和信息。但设置了模式位(最直观的就是kali的sudo等超管权限)的进程运行在内核模式,这时进程才能任意执行指令。
但也不一定,在linux的系统里有个叫/proc
的文件夹,里头存放着内核相关的数据结构,其中的信息能被用户层直接读取,也就意味着,进程可以反过来读取你的内核甚至硬件相关信息。
上下文
由上述内容可知,进程之间难以相互读取和操作,也就只能由内核层面的某种机制,控制它们对内核的占用状态,以方便逻辑控制流的实现和进程状态的恢复。这种时候,就需要上下文出手了。
系统的两种不同运行状态,才有了上下文的概念。用户空间的应用程序,如果想请求系统服务,比如操作某个物理设备,映射设备的地址到用户空间,必须通过系统调用来实现。(系统调用是操作系统提供给用户空间的接口函数)。
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的 地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
——摘自嵌入式与Linux那些事 的文章
内核为每一个进程维持一个上下文,即重启一个被抢占进程所需的状态。具体而言,上下文包括一个进程的通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(描述地址空间的页表、包含当前进程信息的进程表、包含进程已打开文件的信息表)等。
在进程执行的时候,内核可以决定让某个之前进行到一半被抢占的进程横插一刀,重新启动,这就叫调度,调度的过程由内核中叫调度器的代码处理。每当进行调度时,都会触发一次上下文切换,即:
1、保存当前进程的上下文
2、恢复某个先前被抢占的进程被保存的上下文
3、将控制传递给这个新恢复的进程
相当于你打游戏打到一半,你的导师突然让你改论文去,然后你无奈给游戏存了档,这是第一步;接着无奈地打开了你的word,把你之前那篇看上去还不错的论文给拿出来,这是第二步;然后被导师的要求摁着手去把论文改了,这是第三步。两个进程分别是游戏和写论文,导师是调度器,你是负责调度和处理的内核(悲)
上下文分为进程上下文和中断上下文等,进程上下文如前文所述,以及,没错,中断也可以是一种上下文,因为在中断的过程中,确实因为被中断的进程需要保存一些环境,而产生了一些用户向内核,以及内核向用户的数据传递。也就是说,内核对进程的控制,以及中断机制都会产生上下文切换。具体案例在原书中有提及,本文不多细说了。
综上所述,咱已经把一些基础的概念给理清楚了,接下来就是一些正式的进程操作了。
所以,还是那句话,前面的区域,以后再来……?!
这是什么绿皮散热法……算了,能活过来就行。下一期还指望你接着营业呢