0%

Bufbomb栈溢出炸弹实验

本文是对计算机系统基础第三次实验的复现,以此为基础讲解栈溢出的相关知识。

基本逻辑逆向

main函数

这里比较引人注目的是signal函数,先执行了三个该函数,又引入标准输入的文件描述符为infile,以下链接比较深入讲解了signal函数,读者可详细了解。

C library function - signal()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

void sighandler(int);

int main () {
signal(SIGINT, sighandler);

while(1) {
printf("Going to sleep for a second...\n");
sleep(1);
}
return(0);
}

void sighandler(int signum) {
printf("Caught signal %d, coming out...\n", signum);
exit(1);
}

以下是一个可能的输出

1
2
3
4
5
6
Going to sleep for a second...
Going to sleep for a second...
Going to sleep for a second...
Going to sleep for a second...
Going to sleep for a second...
Caught signal 2, coming out...

总之,这段利用sign函数来实现了一些违规输入的提示。包括段溢出,进程繁忙和错误指令。例如下面是段溢出错误的提示。

接下来,程序会去走一个循环。

getopt()function in C to parse command line arguments

getopt()函数原型:

1
getopt(int argc, char *const argv[], const char *optstring)
  • 如果选项带有一个值,那么该值将由optarg 指向

  • 当没有更多选项可以处理时,它将返回-1

  • 返回“?”表明这是一个无法识别的选项,它将其存储到 optopt 中。

  • 有时某些选项需要一些值,如果选项存在但值不存在,那么它也会返回“?”。

  • 我们可以使用 ‘:’ 作为 optstring 的第一个字符,这样,如果没有给出值,它将返回 ‘:’ 而不是 ‘?’。
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
#include <stdio.h>
#include <unistd.h>

main(int argc, char *argv[]) {
int option;
// put ':' at the starting of the string so compiler can distinguish between '?' and ':'
while((option = getopt(argc, argv, ":if:lrx")) != -1){ //get option from the getopt() method
switch(option){
//For option i, r, l, print that these are options
case 'i':
case 'l':
case 'r':
printf("Given Option: %c\n", option);
break;
case 'f': //here f is used for some file name
printf("Given File: %s\n", optarg);
break;
case ':':
printf("option needs a value\n");
break;
case '?': //used for some unknown options
printf("unknown option: %c\n", optopt);
break;
}
}
for(; optind < argc; optind++){ //when some extra arguments are passed
printf("Given extra arguments: %s\n", argv[optind]);
}
}

以下是输出

1
2
3
4
5
Given Option: i
Given File: test_file.c
Given Option: l
Given Option: r
Given extra arguments: hello

因此,很容易为这段循环设置下面这段注释

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
while ( 1 ) // 无限循环
{
v4 = getopt(argc, (char *const *)argv, "gsnhu:"); // 解析命令行参数
if ( v4 == -1 ) // 如果没有更多的参数
break; // 退出循环
switch ( v4 ) // 根据参数类型进行处理
{
case 'g': // 如果参数是 'g'
autograde = 1; // 设置自动评分标志
break;
case 'h': // 如果参数是 'h'
usage(); // 调用 usage 函数
case 'n': // 如果参数是 'n'
v10 = 1; // 设置 v10 标志
v3 = 5; // 设置 v3 的值为 5
break;
case 's': // 如果参数是 's'
puts("This is a quiet bomb. Ignoring -s flag."); // 输出一条消息
notify = 0; // 设置通知标志
break;
case 'u': // 如果参数是 'u'
userid = __strdup(optarg); // 复制用户 ID
cookie = gencookie(userid); // 生成 cookie
break;
default: // 如果参数不是上述任何一种
usage(); // 调用 usage 函数
}
}
1
2
3
4
5
6
7
8
9
void __usercall __noreturn usage(const char *a1@<eax>)
{
__printf_chk(1, "Usage: %s -u <userid> [-nsh]\n", a1);
puts(" -u <userid> User ID");
puts(" -n Nitro mode");
puts(" -s Submit your solution to the grading server");
puts(" -h Print help information");
exit(0);
}

最后一段,要求必须有userid,即执行bufbomb程序的时候必须添加-u参数。

后面初始化bomb炸弹,打印UseridCookie。重点看下下面这段的汇编,cookieeax寄存器以后,放到栈顶,作为seed执行_srandom函数和_random函数,该函数的返回值与0xFF0按位与运算后将结果加上256,比较符合C伪代码,并将结果放到esp+18的内存地址,后面将4edi里存的数据作为参数入栈,查看edi(v3)的引用信息,发现默认为1,在传入-n参数时修改为5。

使用_calloc函数分配好堆空间以后,把分配空间的内存地址放到eax寄存器中,并把堆空间中首元素置零,ebx寄存器赋1,跳转到loc_80491BB。后面分析下来基本和C伪代码保持一致。重点在于-n参数会把v10(默认为0)参数改为1,会把v3(默认为1)参数改为5,从而影响到分配到分配到的v5的大小,这里我将其改名为arry,为了便于理解,将其他参数也改名。

1
launcher(launcher_flag, arry[j] + ran_cookie);

这是修改后的各个参数的名字,后面我们重点分析launcher函数即可。看下launcher函数的C风格的伪代码

可以看到,launcher_flag实际上是开启nitro这一关的全局变量,arry[j] + ran_cookie随机数当作全局偏移量量出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int __cdecl launcher(int a1, int a2) // 定义一个名为 launcher 的函数,它接受两个整数参数 a1 和 a2
{
int v3; // 在栈上定义一个整数变量 v3 [esp+0h] [ebp-28h] BYREF

global_nitro = a1; // 将 a1 的值赋给全局变量 global_nitro
global_offset = a2; // 将 a2 的值赋给全局变量 global_offset

// 使用 mmap 函数尝试在内存中预留一块空间,大小为 0x100000 字节,权限为 7(即可读、可写、可执行),并将其映射到进程的地址空间
// 如果映射失败(即 mmap 的返回值不等于预期的地址 reserved),则向标准错误输出流 stderr 写入错误信息,并退出程序
if ( mmap(&reserved, 0x100000u, 7, 306, 0, 0) != &reserved )
{
fwrite("Internal error. Couldn't use mmap. Try different value for START_ADDR\n", 1u, 0x47u, stderr);
exit(1);
}

stack_top = (int)&unk_55685FF8; // 将一个未知的内存地址赋给全局变量 stack_top
global_save_stack = (int)&v3; // 将 v3 的地址赋给全局变量 global_save_stack

launch(global_nitro, global_offset); // 调用名为 launch 的函数,参数为 global_nitro 和 global_offset

return munmap(&reserved, 0x100000u); // 使用 munmap 函数取消之前通过 mmap 函数预留的内存空间,并返回 munmap 的结果
}

重点看下mmap分配的空间,应该注意到,(int)&unk_55685FF8实际上是mmap()函数分配的内存空间的reserved的最后一段。下面是程序头表(Program Header Table,PHT)的条目,它是在可执行和链接格式(ELF)文件中找到的。这个条目提供了加载(LOAD)段到内存中的必要信息。注意这里的 _reserved 物理地址这刚好是0x5586000,分配的权限为6(写和读),大小是0x100000

mmap函数说明文档

有关信息与上图所示,重点是对一些变量的赋值和分配堆空间。重点函数是launch,看下C风格的伪代码。下面是入栈信息,可以看到参数并未放栈上,而是放在了eaxedx寄存器中了。

看下函数本体。

C伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned int __usercall launch@<eax>(int global_nitro@<eax>, int global_offset@<edx>)
{
void *v3; // esp,栈指针
int v5; // [esp+10h] [ebp-58h] BYREF,局部变量v5
unsigned int v6; // [esp+5Ch] [ebp-Ch],局部变量v6
int savedregs; // [esp+68h] [ebp+0h] BYREF,保存寄存器的值

v6 = __readgsdword(0x14u); // 读取线程局部存储(Thread Local Storage,TLS)中的值
v3 = alloca((((unsigned __int16)&savedregs - 76) & 0x3FF0) + global_offset + 15); // 分配一块内存,大小取决于savedregs的地址、global_offset和15
memset(&v5, 244, ((unsigned __int16)&savedregs - 76) & 0x3FF0); // 将v5的内存区域设置为244
__printf_chk(1, "Type string:"); // 打印字符串"Type string:"
if ( global_nitro ) // 如果global_nitro为真
testn(); // 调用函数testn
else
test(); // 否则调用函数test
if ( !success ) // 如果success为假
{
puts("Better luck next time"); // 打印字符串"Better luck next time"
success = 0; // 将success设置为0
}
return __readgsdword(0x14u) ^ v6; // 返回TLS中的值与v6的异或结果
}

进去testn()函数和test()函数看一下,大体可以分辨这两个函数为漏洞函数。testn()需要-n参数触发,test()函数不需要。大体知道这些就够了,具体的漏洞原理流程在每个部分具体说明。

Smoke

可以看到,Gets函数最多能读取1024个字节,而数组大小仅仅为40个字节,因此,可以传入44个字节装满数组并覆盖rbp,4个字节覆盖返回地址,使得跳转到指定的返回地址,本题要求是跳转到Smoke函数,仅仅跳转过去就完成了目标。

原理主要是因为leave指令相当于

1
2
mov esp, ebp
pop ebp

ret指令相当于

1
pop eip

注意:这里的eip是不能直接操作的,所以上述代码只是为了解释ret的功能,并不能直接在代码中使用。

返回点在如图所示的高亮部分。

Fizz

本题还要求cookie要与栈上一个值相等。

这是修改后的各个参数的名字,后面我们重点分析launcher函数即可。

这段除了launch以外都是分配内存与变量赋值的操作,重点看下launch函数,逻辑也很简单,开-n参数执行testn,不开-n执行test函数

核心原理

leave指令

1
2
push esp,ebp
pop ebp

ret指令

1
pop eip

Smoke

函数在执行getbuf后不返回1,而是转向Smoke函数

容易知道,Gets函数最多能写入1024个字节,而v1仅仅开辟了40(0x28)个字节的空间。因此,输入可以用44个字节覆盖整个v1ebp,再用4个字节覆盖返回地址,使函数跳转到Smoke执行。

实操:生成cookie

二进制文件Smkoe

验证

Fizz

跳转思路与Smoke一致,不过这里还需要与栈上数据比较一下

那就在后面再加上几位cookie即可

执行结果

Bang

要求与全局变量进行比较,修改下全局变量

在跳转bang之前需要执行的汇编指令如下:

1
2
3
push 0x3962b26d,%eax
mov %eax,0x0804d100
ret

Nitro

通过第一阶段的基本逻辑分析容易知道:只有在执行-n程序的时候添加-n参数,该阶段才会被开启。

这里testn函数如果想正常返回,需要满足两个条件

  • 栈帧不被破坏
  • getbufn函数返回cookie

我们现在来寻找漏洞点来注入我们的攻击指令。

我们前面分析了Gets函数的实现,容易知道,这里是存在栈溢出漏洞的。依照前面的思路,我们用520个字符填满v1数组,用4个字符覆盖ebp,用四个字符覆盖getbufn的返回地址。由前面的基本逻辑逆向,我们知道,该函数实际上需要执行5次,每次执行时的栈状态不一致,因此,我们选择使用nop指令来填充输入中除了攻击指令其他位置,以此保证只要返回地址跳到任意一个nop指令上,程序都会执行我们的攻击指令。

以上的分析结束以后,是时候来生成攻击指令了。

先写出汇编代码

1
2
3
4
lea 0x28(%ebp),%esp
mov cookie,%eax
push 返回地址
ret