一、操作系统
操作系统是管理计算机硬件资源,控制其他程序运行并为用户提供交互操作界面的系统软件的集合。操作系统是计算机系统的关键组成部分,负责管理与配置内存、决定系统资源供需的优先次序、控制输入与输出设备、操作网络与管理文件系统等基本任务。操作系统的种类很多,各种设备安装的操作系统可从简单到复杂,可从手机的嵌入式操作系统到超级计算机的大型操作系统。目前流行的现代操作系统主要有Android、BSD、iOS、Linux、Mac OS X、Windows、Windows Phone和z/OS等,除了Windows和z/OS等少数操作系统,大部分操作系统都为类Unix操作系统。
操作系统可以将函数调用栈的起始地址设为随机化(这种技术被称为内存布局随机化,即Address Space Layout Randomization (ASLR) ),这样程序每次运行时函数返回地址会随机变化。反之如果操作系统关闭了上述的随机化(这是技术可以生效的前提),那么程序每次运行时函数返回地址会是相同的,这样我们可以通过输入无效的溢出数据来生成core文件,再通过调试工具在core文件中找到返回地址的位置,从而确定 shellcode 的起始地址。
操作系统打开了 ASLR,程序每次运行时动态库的起始地址都会变化,也就无从确定库内函数的绝对地址。在 ASLR 被关闭的前提下,我们可以通过调试工具在运行程序过程中直接查看 system() 的地址,也可以查看动态库在内存的起始地址,再在动态库内查看函数的相对偏移位置,通过计算得到函数的绝对地址。
现代操作系统内存通常是以分段的形式存放不同类型的信息的。
二、缓存区
1.栈,由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。
2.堆,由new分配的内存块,编译器不能控制其释放,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
3.自由存储区,由malloc等分配的内存块,和堆是十分相似,不过它是用free来结束自己的生命的。
4.全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
5.常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(可通过非正当手段修改)
三、寄存器
通用寄存器和特殊寄存器
通用寄存器:
包括一般寄存器(eax、ebx、ecx、edx),索引寄存器(esi、edi),以及堆栈指针寄存器(esp、ebp)。
一般寄存器用来存储运行时数据,是指令最常用到的寄存器,除了存放一般性的数据,每个一般寄存器都有自己较为固定的独特用途。eax 被称为累加寄存器(Accumulator),用以进行算数运算和返回函数结果等。ebx 被称为基址寄存器(Base),在内存寻址时(比如数组运算)用以存放基地址。ecx 被称为记数寄存器(Counter),用以在循环过程中记数。edx 被称为数据寄存器(Data),常配合 eax 一起存放运算结果等数据。
索引寄存器通常用于字符串操作中,esi 指向要处理的数据地址(Source Index),edi 指向存放处理结果的数据地址(Destination Index)。
堆栈指针寄存器(esp、ebp)用于保存函数在调用栈中的状态。
Intel 格式,寄存器名称和数值前无符号:
AT&T 格式,寄存器名称前加“%”,数值前加“$”:
特殊寄存器:
包括段地址寄存器(ss、cs、ds、es、fs、gs),标志位寄存器(EFLAGS),以及指令指针寄存器(eip)。
四、内存
内存分段还包括堆(Heap Segment)、数据段(Data Segment),BSS段,以及代码段(Code Segment)。代码段存储可执行代码和只读常量(如常量字符串),属性可读可执行,但通常不可写。数据段存储已经初始化且初值不为0的全局变量和静态局部变量,BSS段存储未初始化或初值为0的全局变量和静态局部变量,这两段数据都有可写的属性。堆用于存放程序运行中动态分配的内存,例如C语言中的 malloc() 和 free() 函数就是在堆上分配和释放内存。
五、栈
EIP寄存器里存储的是CPU下次要执行的指令的地址
EBP寄存器里存储的是是栈的栈底指针,通常叫栈基址
ESP寄存器里存储的是在调用函数fun()之后,栈的栈顶
函数调用时,调用函数(caller)的状态被保存在栈内,被调用函数(callee)的状态被压入调用栈的栈顶;在函数调用结束时,栈顶的函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态。函数调用栈在内存中从高地址向低地址生长,所以栈顶对应的内存地址在压栈时变小,退栈时变大。
1.被调用函数(callee)的参数按照逆序依次压入栈内(保存在调用函数(caller)的函数状态内,之后压入栈内的数据都会作为被调用函数(callee)的函数状态来保存)
2.调用函数(caller)进行调用之后的下一条指令地址作为返回地址压入栈内
3.将被调用函数的返回地址压入栈内,并将 ebp 寄存器的值更新为当前栈顶的地址(调用函数(caller)的 ebp(基地址)信息得以保存。同时,ebp 被更新为被调用函数(callee)的基地址。)
4.将调用函数的基地址(ebp)压入栈内,并将当前栈顶地址传到 ebp 寄存器内
5.将被调用函数(callee)的局部变量等数据压入栈内
6.将被调用函数的局部变量弹出栈外
7.将调用函数(caller)的基地址(ebp)弹出栈外,并存到 ebp 寄存器内
8.将被调用函数(callee)的返回地址弹出栈外,并存到 eip 寄存器内
四、基础栈溢出
发生条件:1.程序要有向栈内写入数据的行为。2.程序并不限制写入数据的长度。
基本案例:
1.利用栈溢出执行攻击指令
(一)发生原理:当溢出数据中包括攻击指令的内容或地址,并且攻击指令获得程序的控制权时。
(二)控制程序执行指令最关键的寄存器就是 eip,目标就是让 eip 载入攻击指令的地址。让溢出数据用攻击指令的地址来覆盖返回地址。(在溢出数据内包含一段攻击指令,或在内存其他位置寻找可用的攻击指令。)
(三)将原本指定的函数在调用时替换为其他函数。
技术:
修改返回地址,让其指向溢出数据中的一段指令(shellcode)
修改返回地址,让其指向内存中已有的某个函数(return2libc)
修改返回地址,让其指向内存中已有的一段指令(ROP)
修改某个被调用函数的地址,让其指向另一个函数(hijack GOT)
第一项技术:
Padding (填充)属性定义元素边框与元素内容之间的空间
padding1 + address of shellcode + padding2 + shellcode
(padding1 处的数据可以随意填充(注意如果利用字符串程序输入溢出数据不要包含 “\x00” ,否则向程序传入溢出数据时会造成截断),长度应该刚好覆盖函数的基地址。address of shellcode 是后面 shellcode 起始处的地址,用来覆盖返回地址。padding2 处的数据也可以随意填充,长度可以任意。shellcode 应该为十六进制的机器码格式。)
1.求返回地址之前的填充数据(padding1)长度
查看汇编代码来确定这个距离,也可以在运行程序时用不断增加输入长度的方法来试探
(cyclic 100就是输出100个乱码字符)
2.求shellcode起始地址
在调试工具里查看返回地址的位置(可以查看 ebp 的内容然后再加4(32位机)64位机则加8)
在调试工具里的这个地址和正常运行时并不一致,这是运行时环境变量等因素有所不同造成的。所以这种情况下我们只能得到大致但不确切的 shellcode 起始地址,解决办法是在 padding2 里填充若干长度的 “\x90”(表示NOP)。
3.求 shellcode 所用溢出数据的最终构造
这种方法生效的一个前提是在函数调用栈上的数据(shellcode)要有可执行的权限(另一个前提是上面提到的关闭内存布局随机化)。
第二项技术:
padding1 + address of system() + padding2 + address of “/bin/sh”
address of system() 是 system() 在内存中的地址,用来覆盖返回地址。padding2 处的数据长度为4(32位机),对应调用 system() 时的返回地址。因为我们在这里只需要打开 shell 就可以,并不关心从 shell 退出之后的行为,所以 padding2 的内容可以随意填充。address of “/bin/sh” 是字符串 “/bin/sh” 在内存中的地址,作为传给 system() 的参数。
1.求返回地址之前的填充数据(padding1)长度
同上
2.求system() 函数地址
当函数被动态链接至程序中,程序在运行时首先确定动态链接库在内存的起始地址,再加上函数在动态库中的相对偏移量,最终得到函数在内存的绝对地址。
3.求“/bin/sh” 的地址
在动态库里搜索这个字符串,如果存在,就可以按照动态库起始地址+相对偏移来确定其绝对地址。如果在动态库里找不到,可以将这个字符串加到环境变量里,再通过 getenv() 等函数来确定地址。
第三项技术:
针对程序栈溢出所要实现的效果,找到若干段以 ret 作为结束的指令片段,按照上述的构造将它们的地址填充到溢出数据中。
padding + address of gadget 1 + address of gadget 2 + ...... + address of gadget n
padding + address of gadget 1 + param for gadget 1 + address of gadget 2 + param for gadget 2 + ...... + address of gadget n + shellcode
1.栈溢出之后要实现什么效果?
ROP 常见的拼凑效果是实现一次系统调用,Linux系统下对应的汇编指令是 int 0x80。执行这条指令时,被调用函数的编号应存入 eax,调用参数应按顺序存入 ebx,ecx,edx,esi,edi 中。
2.如何寻找对应的指令片段?
若干开源工具可以实现搜索以 ret 结尾的指令片段,著名的包括 ROPgadget、rp++、ropeme 等,甚至也可以用 grep 等文本匹配工具在汇编指令中搜索 ret 再进一步筛选。
3.如何传入系统调用的参数?
可以用 pop 指令将栈顶数据弹入寄存器。如果在内存中能找到直接可用的数据,也可以用 mov 指令来进行传输(用pop更简单)
注意:
第一,很多时候并不能一次凑齐全部的理想指令片段,这时就要通过数据地址的偏移、寄存器之间的数据传输等方法来“曲线救国”。
第二,第二,要小心 gadget 是否会破坏前面各个 gadget 已经实现的部分,比如可能修改某个已经写入数值的寄存器。另外,要特别小心 gadget 对 ebp 和 esp 的操作,因为它们的变化会改变返回地址的位置,进而使后续的 gadget 无法执行。
第四项技术:
GOT 全称是全局偏移量表(Global Offset Table),用来存储外部函数在内存的确切地址。GOT 存储在数据段(Data Segment)内,可以在程序运行中被修改。PLT 全称是程序链接表(Procedure Linkage Table),用来存储外部函数的入口点(entry),程序总会到 PLT 这里寻找外部函数的地址。PLT 存储在代码段(Code Segment)内,在运行之前就已经确定并且不会被修改, PLT 并不会知道程序运行时动态链接库被加载的确切位置。 PLT 表内存储的入口点就是 GOT 表中对应条目的地址。
1.如何确定函数 A 在 GOT 表中的条目位置?
程序调用函数时是通过 PLT 表跳转到 GOT 表的对应条目,所以可以在函数调用的汇编指令中找到 PLT 表中该函数的入口点位置,从而定位到该函数在 GOT 中的条目。
2.如何确定函数 B 在内存中的地址?
知道了函数 A 的运行时地址(读取 GOT 表内容),也知道函数 A 和函数 B 在动态链接库内的相对位置,就可以推算出函数 B 的运行时地址。
3.如何实现 GOT 表中数据的修改?
找到若干条 gadget,改写 GOT 表中数据,从而实现函数的伪装。
防御措施:
首先,通常情况下程序在默认编译设置下都会取消栈上数据的可执行权限,这样简单的 shellcode 溢出攻击就无法实现了。其次,可以在操作系统内开启内存布局随机化(ASLR),这样可以增大确定堆栈内数据和动态库内函数的内存地址的难度。编译程序时还可以设置某些编译选项,使程序在运行时会在函数栈上的 ebp 地址和返回地址之间生成一个特殊的值,这个值被称为“金丝雀”。这样一旦发生了栈溢出并覆盖了返回地址,这个值就会被改写,从而实现函数栈的越界检查。