0day安全:软件漏洞分析技术(第二版)读书笔记(3-4)
漏洞利用技术才是实施有效攻击的最核心技术,才是突破安全边界、实施深度入侵的关键所在。ʕ •ᴥ•ʔ (๑˃̵ᴗ˂̵) ପ( ˘ᵕ˘ ) ੭
第三章 开发shellcode的艺术
3.1 shellcode概述
3.1.1 shellcode | exploit | poc
shellcodeexploitpoc
shellcode:通称缓冲区溢出攻击中植入进程的代码。shellcode往往需要用汇编语言编写,并转换成二进制机器码,其内容和长度经常还会受到很多苛刻限制,故开发和调试的难度很高。
exploit:植入代码之前需要做大量的调试工作,例如
- 弄清楚程序有几个输入点;
- 这些输入将最终会当作哪个函数的第几个参数读入到内存的哪个区域;
- 哪一个输入会造成栈溢出;
- 在复制到栈区的时候对这些数据有没有额外的限制等。
调试之后还要计算函数返回地址距离缓冲区的偏移并淹没之,选择指令的地址,最终制作出一个有攻击效果的“承载”着shellcode的输入字符串。这个代码植入的过程就是漏洞利用,也就是exploit,简称EXP。
Exploit负责将程序导向shellcode,shellcode又称为payload。
poc:PoC(Proof of Conecpt),概念性证明,是证明漏洞存在而提供的一段代码或方法,只要能够触发漏洞即可。用其对漏洞进行验证。
shellcode 这段代码可以是出于恶作剧目的的弹出一个消息框,也可以是出于攻击目的的删改重要文件、窃取数据、上传木马病毒并运行,甚至是出于破坏目的的格式化硬盘等。请注意本章讨论的shellcode是这种广义上的植入进程的代码,而不是狭义上的仅仅用来获得shell的代码。
exploit一般以一段代码的形式出现, 用于生成攻击性的网络数据包或者其他形式的攻击性输入。expliot的核心是淹没返回地址劫持进程的控制权,之后跳转去执行shellode,与shellcode具有一定的通用性不同,exploit往往是针对特定漏洞而言的。exploit关心的是怎样淹没返回地址,获得进程控制权,把EIP传递给shellcode让其得到执行并发挥作用,而不关心shellcode到底是何用途。
3.1.2 shellcode需要解决的问题
在上节的代码植入实验中,我们直接用 OllyDbg 查出了栈中shellode的起始地址。而在实际调试漏洞时,尤其是在调试 IE 中的漏洞时,我们经常会发现有缺陷的函数位于某个动态链接库中,且在程序运行过程中被动态装载。这时的栈中情况将会是动态变化着的,也就是说,这次从调试器中直接抄出来的shellcode起始地址下次就变了。要编写出比较通用的shellcode就必须找到一种途径让程序能够自动定位到shellode的起始地址。
要编写出比较通用的shellcode就必须找到一种途径让程序能够自动定位到shellode的起始地址。
不同的机器、操作系统中同一个API函数的入口地址往往会有差异,必须让shellcode自己运行时动态地获得当前系统的API地址。
3.2 定位shellcode
3.2.1 栈帧移位与jmp esp
栈帧移位
第二章修改返回地址的代码植入实验,用越界的字符完全控制返回地址后,需要将返回地址改写成 shellode 在内存中的起始地址。在实际的漏洞利用过程中,由于动态链接库的装入和卸载等原因,Windows 进程的函数栈帧很有可能会产生“移位”,即 shellcode 在内存中的地址是会动态变化的,因此如果单纯地将返回地址简单地覆盖成一个定值的做法往往不能让 exploit 奏效,如图3.2.1所示。所以必须想出一种方法能够在程序运行时动态定位栈中的shellcode的起始地址。
jmp esp
一般情况下,ESP寄存器中的地址总是指向系统栈中且不会被溢出的数据破坏。函数返回时,ESP所指的位置恰好是我们所淹没的返回地址的下一个位置(32位系统返回地址+4),即栈顶。如图3.2.3左图所示。
提示:函数返回时,ESP 所指位置还与函数调用约定、返回指令等有关。
对于jmp esp 原理理解:
- 程序先去执行
jmp esp
这条指令,这条指令的含义是跳转到寄存器 ESP 里面的地址去,jmp esp
指令在进程中有独立地址,可查找。 - ESP寄存器的内容刚好是函数返回时调用者函数的栈顶,如果将 shellcode 的起始地址紧跟在
返回地址+4
的区间里面去,则指令执行完被淹没的返回地址里面jmp esp
指令后继续执行 shellcode。
动态定位shellcode
由于ESP寄存器在函数返回后不被溢出数据干扰,且始终指向返回地址之后的位置,我们可以使用图3.2.3所示的这种定位 shellcode 的方法来进行动态定位。这里的jmp esp
被称作跳板。
JMP ESP 原理
用内存中任意一个
jmp esp
指令的地址覆盖函数返回地址,而不是原来用手工查出的 shellcode 起始地址直接覆盖。函数返回后被重定向去执行内存中的这条
jmp esp
指令,而不是直接开始执行shellcode。由于 esp 在函数返回时仍指向栈区(函数返回地址之后),
jmp esp
指令被执行后,处理器会到栈区函数返回地址之后的地方取指令执行。重新布置shellcode。在淹没函数返回地址后,继续淹没一片栈空间。将缓冲区前边一段地方用任意数据填充,把 shellcode 恰好摆放在函数返回地址之后。这样,
jmp esp
指令执行过后会恰好跳进 shellcode。
3.2.2 获取“跳板”的地址
必须首先获得进程空间内一条jmp esp
指令的地址作为 “跳板”。程序运行时,除了 PE 文件的代码被读入内存空间,一些经常被用到的动态链接库也将会同被映射到内存。其中,诸如kernel32.dll
、user32 dll
之类的动态链接库会被几乎所有的进程加载,且加载基址始终相同。
实验
执行 shellcode 章节(2.7)实验中的有漏洞的密码验证程序已经加载了user32dll
,所以我们准备使用user32.dll
中的jmp esp
作为跳板。获得user32.dll
内跳转指令地址最直观的方法就是编程序搜索内存。
1 |
|
jmp esp
对应的机器码是0xFFE4
,上述程序的作用就是从user32.dll
在内存中的基地址开始向后搜索0xFFE4
,如果找到就返回其内存地址(指针值)。同理如果想使用别的动态链接库中的地址(如“kernel32.dI”、“mfc42.dIl” 等),或者使用其他类型的跳转地址(如call esp,jmp ebp等),也可以通过对上述程序稍加修改而轻易获得。
3.3 缓冲区的组织
3.3.1 缓冲区的组成
送入缓冲区的数据可以分为以下几种:
- 填充物:可以是任何值,但是一般用
NOP
指令对应的0x90
来填充缓冲区,并把 shellcode 布置于其后(NOP
的高地址)。这样即使不能准确地跳转到 shellcode 的开始,只要能跳进填充区,处理器最终也能顺序执行到 shellcode。 - 淹没返回地址的数据:可以是跳转指令的地址、shellcode 起始地址,甚至是一个近似 shellcode 的地址(通过
nop
滑入)。 - shellcode:可执行的机器代码。
在缓冲区中如何布置 shellcode 对 exploit 的成功至关重要,当 shellcode 较缓冲区小很多时,可直接将其置于返回地址之前(地址较返回地址小),当 shellcode 较大时,将其置于返回地址之后(地址较返回地址大),然后可以使用 jmp esp
等指令来定位 shellcode。但是需要注意的问题就是这样的 shellcode 布置于前一个栈帧中,当 shellcode 较大时,这样就会大范围地破坏前栈帧数据可能引发一些其他问题。
3.3.2 抬高栈顶保护shellcode
将shellcode布置在缓冲区中虽然有不少好处,但是也会产生问题。函数返回时,当前栈帧被弹出,这时缓冲区位于栈顶ESP之上的内存区域。在弹出栈帧时只是改变了ESP寄存器中的值,逻辑上,ESP以上的内存空间的数据已经作废;物理上,这些数据并没有被销毁。
当前栈逻辑上销毁后,栈帧返回到调用函数,如果shellode被再次调用时,若shellcode中没有压栈指令向栈中写入数据还没有太大影响;但如果使用push
指令在栈中暂存数据,压栈数据很可能会破坏到shellcode本身,压栈的数据会增高当前栈帧的高度,然后覆盖逻辑上已被销毁的栈帧中的shellcode。这里假设调用函数的栈帧是紧挨着被调函数的,位于被调函数的高地址,这个过程如图3.3.2所示。
当缓冲区相对shellcode较大时,把shellcode布置在缓冲区的“前端”(内存低址方向), 这时shellcode离调用函数栈顶较远,几次压栈可能只会破坏到一些填充值nop
;但是,如果缓冲区已经被shellcode占满,则shellcode离调用函数栈顶比较近,这时的情况就比较危险了。
为了使shellcode具有较强的通用性,我们通常会在shellode开始就大范围抬高被调函数栈顶,如下第三图jmp esp-X
后定位一条指令sub esp,N
,这里的N足够大以覆盖被调函数的栈,将逻辑上已经销毁的被调函数的栈变成当前函数的栈,把shellcode“藏”在栈内保护起来,从而达到保护自身安全的目的。这个过程如图3.3.3所示。
【0day安全 | Chapter 3 开发shellcode的艺术】
3.3.3 使用NOP雪橇
个别有苛刻的限制条件的漏洞不允许我们使用跳转指令精确定位shellcode,而使用 shellcode的静态地址来覆盖又不够准确,这时我们可以做一个折中:如果能够淹没大片的内存区域,可以将shellcode 布置在一大段nop
之后。这时定位shellcode时,只要能跳进这一大片nop
中,shellcode就可以最终得到执行,如图3.3.4所示。
NOP
指令除了对程序计数器加一,使之指向下一条指令之外,没有其他任何效果。这种将跳转定位在一片nop
指令之中一直执行到shellcode(nop
比shellcode更靠近栈顶),就好像是在滑行,所以也称之为“空操作雪橇”(nop sled)。浏览器的漏洞利用采用Heap Spray也是相同思想。
3.3.4 函数返回地址移位
- 使用大片返回地址来覆盖真正的返回地址。
- 地址覆盖时可能会发生字节错位,可以使用堆中的地址来放置shellcode,进行绕过。
如果函数返回地址的偏移按双字(DWORD)不定,可以用一片连续的跳转指令的地址来覆盖函数返回地址,只要其中有一个能够成功覆盖,shellcode就可以得到执行。这个过程如图3.3.5所示。
在覆盖返回地址时可能会发生字节错位的情况如strcat
产生的漏洞:
1 | strcat(程序安装目录, 输入的字符串); |
在不同主机上程序安装目录可能不同,如:
1 | C:\xxx\ |
假如把返回地址覆盖为0x77DC965B
,则理论上只有25%的成功率。上面提到的使用一片连续的跳转指令地址的方法不起作用,因为多出的字节只要不是地址长度的倍数,就一定会引起地址的错位:
解决办法是:使用堆的地址来解决,使用0x0a0a0a0a
、0x0c0c0c0c
之类的返回地址。当然这种情况下,shellcode位于堆上。
3.4 开发通用的shellcode
3.4.1 定位API的原理
前面解决了shellcode地址不定的问题,本节解决不同操作系统版本、不同补丁环境下动态链接库中导出函数地址不定的问题。方法很简单:在shellcode中对函数先定位,再调用。
影响动态链接库中的导出函数的主要因素有:
- 不同的操作系统版本。
- 不同的补丁版本:很多安全补丁会修改这些动态链接库中的函数,使得不同版本补丁对应的动态链接库的内容有所不同,包括动态链接库文件的大小和导出函数的偏移地址。由于这些因素,我们手工查出的API地址很可能会在其他计算机上失效。
在shellcode中使用静态函数地址来调用API会使exploit的通用性受到很大限制。所以,实际中使用的shellcode必须还要能动态地获得自身所需的API函数地址。
Windows的API是通过动态链接库中的导出函数来实现的,例如:
- 内存操作等的数在
kernel32.dll
中实现 - 大量的图形界面相关的API则在
user32.dll
中实现。
Win_32平台下的shellcode使用最广泛的方法,就就是从进程环境块(PEB)中找到动态链接库的导出表,并搜索出所需的API地址,然后调用。
所有Win_32程序都会加载 ntdll.dll 和 kernel32.dll 这两个最基础的动态链接库。如果想要在Win_32平台下定位kernel.dll中的API地址,可以采用如下方法:
- 首先通过段选择字FS在内存中找到当前的线程环境块TEB。
- 线程环境块偏移位置为
0x30
的地方存放着指向进程环境块PEB的指针。 - 进程环境块中偏移位置为
0x0C
的地方存放着指向PEB_LDR_DATA
结构体的指针,其中,存放着已经被进程装载的动态链接库的信息。 PEB_LDR_DATA
结构体偏移位置为0x1C
的地方存放着指向模块初始化链表的头指针InInitializationOrderModuleList
。- 块初始化链表
InInitializationOrderModuleList
中按顺序存放着PE装入运行时初始化模块的信息,第一个链表结点是ntdll.dll,第二个链表结点就是kernel32.dll。 - 找到属于kernel32.dll的结点后,在其基础上再偏移
0x08
就是kenel32.dlI在内存中的加载基地址。 - 从kernel32.dll的加载基址算起,偏移
0x3C
的地方就是其PE头。 - PE头偏移
0x78
的地方存放着指向函数导出表的指针。 - 至此,我们可以按如下方式在函数导出表中算出所需函数的入口地址,如图3.4.1
- 导出表偏移
0x1C
处的指针指向存储导出函数偏移地址(RVA)的列表。 - 导出表偏移
0x20
处的指针指向存储导出函数函数名的列表。 - 函数的RVA地址和名字按照顺序存放在上述两个列表中,我们可以在名称列表中定位到所需的函数是第几个,然后在地址列表中找到对应的RVA。
- 获得RVA后,再加上前边已经得到的动态链接库的加载基址,就获得了所需API此刻在内存中的虚拟地址,这个地址就是我们最终在shellcode中调用时需要的地址。
- 导出表偏移
按照上面的方法,我们已经可以获得kernel32.dll中的任意函数。类似地,我们已经具备了定位ws2_32.dlI中的winsock函数来编写一个能够获得远程shell的真正的shellcode了。
kernel32.dll
中有两个导出函数:LoadLibrary
和GetProcAddress
,可以通过这两个函数帮助定位其他API。由于MessageBox
位于user32.dll
库中,后面我们将借助LoadLibrary
为shellcode加载user32.dll
,从而调用MessageBox
。