0x01 前言

在了解栈溢出后,我们再从原理和方法两方面深入理解基本ROP。

0x02 什么是ROP

ROP的全称为Return-oriented programming(返回导向编程),这是一种高级的内存攻击技术可以用来绕过现代操作系统的各种通用防御(比如内存不可执行和代码签名等)。通过上一篇文章栈溢出漏洞原理详解与利用,我们可以发现栈溢出的控制点是ret处,那么ROP的核心思想就是利用以ret结尾的指令序列把栈中的应该返回EIP的地址更改成我们需要的值,从而控制程序的执行流程。

0x03 为什么要ROP

探究原因之前,我们先看一下什么是NX(DEP) NX即No-execute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。随着 NX 保护的开启,以往直接向栈或者堆上直接注入代码的方式难以继续发挥效果。所以就有了各种绕过办法,rop就是一种

0x04 ret2shellcode

将返地址覆盖到我们插入shellcode的首地址。

利用IDA找到buf的地址在BSS段。BSS段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清0。既然可读写那么只要能够在栈内写入的payload,然后再转移到此处,并且执行权限就可以控制。通过strncpy函数达到这一目的

0x05 ret2text

利用点在原文件中寻找即可,控制程序执行程序本身已有的的代码 (.text)。

通常使用IDA字符串搜索到关键字例如 bin/sh 、command、system函数确定

0x06 ret2syscall

调用系统函数达到目的

在计算中,系统调用是一种编程方式,计算机程序从该程序中向执行其的操作系统内核请求服务。这可能包括与硬件相关的服务(例如,访问硬盘驱动器),创建和执行新进程以及与诸如进程调度之类的集成内核服务进行通信。系统调用提供了进程与操作系统之间的基本接口。

现在我们要做的是:让程序调用execve(“/bin/sh”,NULL,NULL)函数即可拿到shell 调用此函数的具体的步骤是这样的:因为该程序是 32 位,所以我们需要使得 系统调用号,即 eax 应该为 0xb 第一个参数,即 ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以。第二个参数,即 ecx 应该为 0 第三个参数,即 edx 应该为 0 最后再执行int 0x80触发中断即可执行execve()获取shell

系统调用:

系统调用的基本过程:开始时应用程序准备参数,发出调用请求,然后glibc中也就是c标准库封装函数引导,执行系统调用,这里我们只探讨到这两个过程。可以发现上述两个过程从用户态(第一步)过渡到内核态(第二步),系统调用就是中间的过渡件,我们能控制的地方就是用户态,然后通过系统调用控制到内核态。先看一个程序

可以发现该程序通过调用sys_write函数进行输出Hello World,那么sys_write()是什么?

image-20211026173457249

可以发现前三个mov指令是把该函数需要的参数放进相应寄存器中,然后把sys_write的系统调用号放在EAX寄存器中,然后执行int 0x80触发中断即可执行sys_call(),那么问题就来了:这几个寄存器有什么作用?为什么int 0x80?int 0x80后发生了什么?带着问题我们继续往下看


为何int 0x80?在系统文件中有这么一行代码

在系统启动的时候,系统会在sched_init(void)函数中调用set_system_gate(0x80,&system_call),设置中断向量号0x80的中断描述符,也就是说实现了系统调用 (处理过程system_call)和 int 0x80中断的对应,进而通过此中断号用EAX实现不同子系统的调用。经过初始化以后,每当执行 int 0x80 指令时,产生一个异常使系统陷入内核空间并执行128号异常处理程序,也就是绑定后的函数,即系统调用处理程序 system_call(),此时CPU完成从用户态到内核态切换,开始执行system_call()。

system_call()

当进入system_call()后,主要做了两件事,首先处理中断前设置环境的过程 然后找到实际处理在入口 规定:数值会放在eax,ebx,ecx,edx,参数一般为4个 所以ebx,ecx,edx会被压入栈中设置环境(也就是函数所需要的参数),当然ds、es等也要压入。然后就会调用call_sys_call_table(,%eax,4)来实现相应系统函数的调用。那么从大门进入后怎么知道进那个小门(系统函数)呢?存在这么一个数组——sys_call_table(对应的处理函数少部分在这里面进行处理),处理函数功能号对应sys_call_table[]的下标,sys_execve()函数的下标就是11,也就是0xb。此刻应该会明朗了,那么我们言归正传,回到ret2syscall来。

只要用户态栈空间能够控制成这样(只是举例其中的一种排列方式)就可以达到ret2syscall的目的 简单分析一下流程:1、成功溢出 2、通过ret指令使得EIP指向pop eax;的地址 3、执行pop eax;栈顶值0xb成功出栈,栈顶指针下移 4、通过ret指令使得EIP指向pop ebx;的地址

0x07 ret2libc

含义

我们知道,操作系统通常使用动态链接的方法来提高程序运行的效率。那么在动态链接的情况下,程序加载的时候并不会把链接库中所有函数都一起加载进来,而是程序执行的时候按需加载。也就是控制执行 libc(对应版本) 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行 system(“/bin/sh”)(或者execve(“/bin/sh”,NULL,NULL)),故而此时我们需要知道 system 函数的地址,具体可以移步深入理解GOT表和PLT表。

初探ret2libc

上面已经提到了,我们只要可以执行类似system(“/bin/sh”)的函数即可获取shell,在存在溢出的程序中我们在一般怎么去执行此函数呢?大致可以分为三类:

一、"/bin/sh"字符串和system函数都可以在程序找到

二、二者其一找不到(一般为"/bin/sh"字符串找不到)

三、二者都没有

无论是哪一种情况,我们需要找到"/bin/sh"字符串和system()函数,并且堆栈位置如下:

当然还需了解一下x86对于形参的处理,就可以知道上图的“任意四字符”处为返回地址,因为我们不用考虑程序后续怎去正常运行,达到getshell的目的即可,程序的具体执行过程可以参照栈溢出漏洞原理详解与利用

再探ret2libc

先看一个简单的例子, 也就是我们说的第一种情况。检查保护机制,程序为32位并且开了NX保护,继续反编译从伪代码可以发现gets()处导致栈溢出,对于以上步骤,本文已经详细讲述过,不再赘述,以下两种情况的分析也直接省去该过程。按照上述的理论,我们在IDA的Stings中可以找到"/bin/sh",在Functions中可以找到system()函数

三探ret2libc

在IDA中只能找到system()函数的plt地址,却没有看到"/bin/sh"字符串的踪影,没有了"/bin/sh"字符串,就没办法获取shell,那么我们就得创造条件。除了现成的内容,我们也可以人工输入,那么就需要gets()函数来实现这一目的,因此目前的结构应该如下图所示。

当然也可以进行堆栈平衡,在执行完gets()函数后提升堆栈(add esp, 4),堆栈位置如下:程序在读写数据的时候是通过地址查找的,如果函数调用之前的堆栈与函数调用之后的堆栈不一致,就可能导致找不到数据或找到的数据错误,那么久有可能导致程序崩溃。

继续来看第三种情况,如果什么都没有,我们怎么去一个一个去创造条件?对于’/bin/sh’字符串的构造已经知道了,剩下的就是怎么找到system函数 这里需要事先了解下动态链接时GOT表和PLT表的作用。可以发现,GOT表的第三项调用_dl_runtimw_resolve将真正的函数地址,也就是glibc运行库中的函数的地址,回写到代码段,就是got[n](n>=3)中。也就是说在函数第一次调用的时,才通过连接器动态解析并加载到.got.plt中,而这个过程称之为延时加载或者惰性加载。目前的思路就是,通过栈溢出泄露某函数(一般为泄露 __libc_start_main 地址,这里选择泄露put函数)的GOT表地址,然后根据偏移量(libc中函数与函数之间的距离时固定的)来计算出system()的地址,有了’/bin/sh’也有了system,shell自然就有了,如下图所示。

可以发现通过相应的模块可以顺利获取puts函数的真实地址(也就是GOT表中存储的地址)

那么问题来了?此处的溢出用来获取put函数的真实地址,怎么再去进行执行system(‘bin/sh’)呢?如果存在两个溢出点就完美了,可惜只有一个。不过刚才提到的返回地址,在这里就有了用武之地了,它可以让我们有“两个”溢出点。如果put函数的返回地址可以回到函数的入口,不就可以再执行一遍gets(溢出点)了吗?怎么构造之前简单了解用户代码的入口和系统代码的入口,在一个程序运行中有两个入口,一个是main(),另一个是_start(),简单来说,main()函数是用户代码的入口,是对用户而言的;而_start()函数是系统代码的入口,是程序真正的入口。这里以main()函数作为入口为例,如下图所示:

梳理一下我们需要知道什么条件:

一、puts函数的地址和真实地址

二、main函数的真实地址

三、system函数的真实地址

四、'/bin/sh’字符串的位置

条件一我们已经具备了,那么怎么搞定剩下的条件,以及堆栈位置。怎么获取main、system和’/bin/sh’的真实地址呢?当然与获取put的真实地址一样

另外也可以根据第二种情况的思路,引入gets和buf来获取字符串’/bin/sh’,如下图所示