验证调用约定,分析堆栈图,实现汇编和c的相互调用,实现ROP
前往git拉取代码
玩转执行流
这里研究的是机器码遵循一个什么样的约定去读数据,执行指令
x86和x64有不同的约定,x86又分为多种约定,具体参考函数调用约定
关于64位执行流,由于64位调用约定使用寄存器传参,所以无所谓平栈操作,就不展示了
执行流指令
push |
sp--;mov [sp],par |
参数入栈 |
pop |
mov par,[sp];sp++ |
参数出栈 |
enter |
push bp;mov bp,sp |
开启栈帧 (gcc专用) |
leave |
mov sp,bp;pop bp |
关闭栈帧 (gcc专用) |
call |
push next;jmp lable |
调用子函数 |
ret |
pop tmp;jmp tmp |
函数返回 |
手绘流程(cdecl)
有如下代码,添加gcc选项 -no-pie -fno-pic
#include <string.h> int add(int a,int b) { return a*b; }
int main(int argc,char** data) { char* file =data[0]; char str[8]; strcpy(str,file); return add(argc,23); }
|
linux下32位反汇编如下
08049176 <_Z3addii>: ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------开始函数体 mov eax,DWORD PTR [ebp+0x8] imul eax,DWORD PTR [ebp+0xc] ------------------------------------------恢复栈帧 pop ebp ------------------------------------------函数返回 ret
08049182 <main>: ------------------------------------------边界对齐 lea ecx,[esp+0x4] and esp,0xfffffff0 push DWORD PTR [ecx-0x4] ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------保护现场 push ebx push ecx ------------------------------------------分配栈内存 sub esp,0x20 ------------------------------------------开始main逻辑 mov ebx,ecx ;对齐前esp+4,相当于掉过ip,指向main第一个参数 mov eax,DWORD PTR [ebx+0x4] ;获取main第二参数data mov DWORD PTR [ebp-0x1c],eax ;定义局部变量data ------------------------------------------str溢出标识符 mov eax,gs:0x14 mov DWORD PTR [ebp-0xc],eax ; ------------------------------------------定义局部变量 xor eax,eax mov eax,DWORD PTR [ebp-0x1c] ;取出data mov eax,DWORD PTR [eax] ;获取data[0] mov DWORD PTR [ebp-0x18],eax ;定义file ------------------------------------------strcpy调用 sub esp,0x8 ;填充 push DWORD PTR [ebp-0x18] ;第二参数file地址入栈 lea eax,[ebp-0x14] ;获取str地址 push eax ;第一参数str入栈 call 8049060 <strcpy@plt> ;函数调用 add esp,0x10 外平栈(包含两个参数,以及填充) ------------------------------------------addii调用 sub esp,0x8 ;填充 push 0x17 ;第二参数23入栈 push DWORD PTR [ebx] ;第一参数argc参入入栈 call 8049186 <_Z3addii> ;调用addii add esp,0x10 ;外平栈(包含两个参数,以及填充) ------------------------------------------str溢出检测 nop mov edx,DWORD PTR [ebp-0xc] sub edx,DWORD PTR gs:0x14 je <main+0x60> ------------------------------------------销毁内存 lea esp,[ebp-0x8] ------------------------------------------恢复现场 pop ecx pop ebx ------------------------------------------关闭栈帧 pop ebp ------------------------------------------恢复边界 lea esp,[ecx-0x4] ------------------------------------------函数返回 ret
|
手绘流程(thiscall)
如下++代码
struct Base{ public: int funa(int tmp){ return tmp+pub++; };
int funb(){ int tmp=++prot; return tmp; } int get(){ return priv; } public: int pub; protected: int prot; private: int priv; };
int main() { Base stack; stack.funa(10); Base* heap=new Base(); heap->funb(); int ret = stack.get()+heap->get(); delete heap; return ret; }
|
<main>: ------------------------------------------边界对齐 lea ecx,[esp+0x4] and esp,0xfffffff0 push DWORD PTR [ecx-0x4] ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------保护现场 push ebx push ecx ------------------------------------------分配栈内存 sub esp,0x20 ------------------------------------------stack.funa(10) sub esp,0x8 //填充 push 0xa //第二参数10入栈 lea eax,[ebp-0x1c] push eax //第一参数this入栈 call 8049222 <_ZN4Base4funaEi> add esp,0x10 //外平栈(4+4+0x8=0x10) ------------------------------------------new Base() sub esp,0xc //填充 push 0xc //new操作数入栈 call 8049040 <_Znwj@plt> add esp,0x10 //外平栈(4+0xc=0x10) ------------------------------------------heap初始化 mov DWORD PTR [eax],0x0 //heap->pub=0 mov DWORD PTR [eax+0x4],0x0 //heap->prot=0 mov DWORD PTR [eax+0x8],0x0 //heap->priv=0 mov DWORD PTR [ebp-0xc],eax //赋值heap ------------------------------------------heap->funb() sub esp,0xc push DWORD PTR [ebp-0xc] //this(heap)入栈 call 8049234 <_ZN4Base4funbEv> add esp,0x10 //外平栈(4+0xc=0x10) ------------------------------------------stack.get() sub esp,0xc lea eax,[ebp-0x1c] push eax //this(stack)入栈 call 804924a <_ZN4Base3getEv> add esp,0x10 //外平栈(4+0xc=0x10) mov ebx,eax //保存stack.get() ------------------------------------------heap->get() sub esp,0xc push DWORD PTR [ebp-0xc] //this(heap)入栈 call 804924a <_ZN4Base3getEv> add esp,0x10 //外平栈(4+0xc=0x10) add eax,ebx //stack.get()+heap->get() ------------------------------------------ret=stack.get()+heap->get() mov DWORD PTR [ebp-0x10],eax //ret 赋值 ------------------------------------------heap空指针判断 mov eax,DWORD PTR [ebp-0xc] //取出 test eax,eax je 8049212 <main+0x8c> ------------------------------------------delete heap sub esp,0x8 push 0xc //delete 参数二入栈(大小) push eax //delete 参数一入栈(heap) call 8049050 <_ZdlPvj@plt> add esp,0x10 //外平栈(4+4+0x8=0x10) ------------------------------------------清理内存 mov eax,DWORD PTR [ebp-0x10] //main+0x8c 取出ret返回 lea esp,[ebp-0x8] //销毁栈内存 ------------------------------------------恢复现场 pop ecx pop ebx ------------------------------------------关闭栈帧 pop ebp ------------------------------------------恢复边界 lea esp,[ecx-0x4] ------------------------------------------main返回 ret nop
<_ZN4Base4funaEi>: ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------函数逻辑 mov eax,DWORD PTR [ebp+0x8] //取出this mov eax,DWORD PTR [eax] //先引用 lea ecx,[eax+0x1] //后加 mov edx,DWORD PTR [ebp+0x8] //先引用 mov DWORD PTR [edx],ecx //自加 mov edx,DWORD PTR [ebp+0xc] //取出tmp add eax,edx //ret=tmp+pub+1 ------------------------------------------关闭栈帧 pop ebp ret nop
<_ZN4Base4funbEv>: ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------梦开始 sub esp,0x10 //分配内存
//先自加 mov eax,DWORD PTR [ebp+0x8] //取出this mov eax,DWORD PTR [eax+0x4] //取出this->prot lea edx,[eax+0x1] mov eax,DWORD PTR [ebp+0x8] //取出this mov DWORD PTR [eax+0x4],edx //保存自加
//后引用 mov eax,DWORD PTR [ebp+0x8] //取出自加 mov eax,DWORD PTR [eax+0x4] mov DWORD PTR [ebp-0x4],eax //赋值tmp mov eax,DWORD PTR [ebp-0x4] //返回tmp ------------------------------------------关闭栈帧 leave ------------------------------------------函数返回 ret nop
<_ZN4Base3getEv>: ------------------------------------------开启栈帧 push ebp mov ebp,esp ------------------------------------------梦开始 mov eax,DWORD PTR [ebp+0x8] //取出this mov eax,DWORD PTR [eax+0x8] //返回this->priv ------------------------------------------关闭栈帧 pop ebp ------------------------------------------函数返回 ret
|
玩转汇编和C
32位汇编调用c
对于callFormAsm这个c函数而言,它使用cdecl外平栈,所以需要add esp,8
对于callFromC来说,由于调用它的c函数是外平栈,所以直接ret
返回,由c函数平栈
section .text
extern callForAsm
global callFromC callFromC: push ebp; mov ebp,esp
mov eax,dword[ebp+12];第二个参数 push eax mov eax,dword[ebp+8];第一个参数 push eax
call callForAsm
add esp,8 ;callForAsm外平栈
mov esp,ebp pop ebp ret ;callFromC外平栈
|
64位汇编调用c
对于callFormAsm这个c函数而言,它使用64位约定,不使用堆栈传参,所以不需要平栈
对于callFromC来说,由于调用它的c函数不使用堆栈传参,所以直接ret
返回
section .text
extern callForAsm
global callFromC callFromC:
endbr64
;mov edi,edi ;第一个参数 ;mov rsi,rdi;第二个参数 call callForAsm;不需要平栈
ret ;不需要平栈
|
c调用汇编
extern int callFromC(int a,int*b);
int main() { int a = 20; int b = 30; int c = callFromC(a,&b);
return c*c; }
int callForAsm(int par1,int* par2) { *par2 = par1+6; return par1+*par2; }
|
fPIC和fPIE
从get_pc_thunk说起
在把C语言反汇编时,经常会看到这样的代码<__x86.get_pc_thunk.ax>
__get_pc_thunk.ax: movel eax,[esp] ret
|
看起来是把esp处的东西复制到eax,仔细看,因为就在函数入口处,所以esp里面放的就是caller的下一条指令地址
我们在编译选项中添加-no-pie -fno-pic
然后在编译,就可以去除这条指令。
为什么要这么整?下一条指令难道还不知道吗?
这就涉及到PIE了
Position-Independent-Executable是Binutils,glibc和gcc的一个功能,能用来创建介于共享库和通常可执行代码之间的代码–能像共享库一样可重分配地址的程序,这种程序必须连接到Scrt1.o。标准的可执行程序需要固定的地址,并且只有被装载到这个地址时,程序才能正确执行。PIE能使程序像共享库一样在主存任何位置装载,这需要将程序编译成位置无关,并链接为ELF共享对象。
为什么要PIE
引入PIE的原因是让程序能装载在随机的地址,通常情况下,内核都在固定的地址运行,如果能改用位置无关,那攻击者就很难借助系统中的可执行码实施攻击了。类似缓冲区溢出之类的攻击将无法实施。而且这种安全提升的代价很小.除了Grub和Glibc中无法位置无关的汇编码。
gcc中的-fpic选项,使用于在目标机支持时,编译共享库时使用。编译出的代码将通过全局偏移表(Global
Offset
Table)中的常数地址访存,动态装载器将在程序开始执行时解析GOT表项(注意,动态装载器操作系统的一部分,连接器是GCC的一部分).
而gcc中的-fPIC选项则是针对某些特殊机型做了特殊处理,比如适合动态链接并能避免超出GOT大小限制之类的错误。而Open64仅仅支持不会导致GOT表溢出的PIC编译。
gcc中的-fpie和-fPIE选项和fpic及fPIC很相似,但不同的是,除了生成为位置无关代码外,还能假定代码是属于本程序。通常这些选项会和GCC链接时的-pie选项一起使用。fPIE选项仅能在编译可执行码时用,不能用于编译库。所以,如果想要PIE的程序,需要你除了在gcc增加-fPIE选项外,还需要在ld时增加-pie选项才能产生这种代码。即gcc
-fpie -pie来编译程序。单独使用哪一个都无法达到效果。
gcc选项
gcc链接以下四种链接模式四选一,控制输出文件的类型(可执行档/shared
object/relocatable object)
- -no-pie (default): 生成position-dependent executable
(ET_EXEC)。要求最宽松,源文件可用-fno-pic,- -fpie,-fpic编译
- -pie: 生成position-independent executable
(ET_DYN)。源文件须要用-fpie,-fpic编译
- -shared: 生成position-independent shared object
(ET_DYN)。最严格,源文件须要用-fpic编译
- -r: relocatable link,保留relocations
-pie可以和-shared都是position-independent的链接模式。-pie也可以和-no-pie都是可执行档的链接模式。
-pie和-shared
-Bsymbolic很相似,但它毕竟是可执行档,以下行为和-no-pie贴近而与-shared不同:
- 允许copy relocation和canonical PLT
- 允许relax General Dynamic/Local Dynamic TLS models和TLS
descriptors到Initial Exec/Local Exec
- 会链接时解析undefined weak,(LLD行为)不生成dynamic relocation。GNU
ld是否生成dynamic relocation有非常复杂的规则,且和架构相关
容易产生混淆的是,编译器提供了几个同名选项:-no-pie,-pie,-shared,-r。
GCC
6引入了configure-time选项--enable-default-pie:启用该选项的GCC预设-pie和-fPIE。现在,很多Linux发行版都启用了该选项作为基础的security
hardening。
玩转ROP
PLT和GOT
#include <stdio.h>
void print_banner() { printf("Welcome to World of PLT and GOT\n"); }
int main(void) { print_banner();
return 0; }
|
以上代码经过编译后会是下面这个样子
00000000 <print_banner>: 0: 55 push %ebp 1: 89 e5 mov %esp, %ebp 3: 83 ec 08 sub $0x8, %esp 6: c7 04 24 00 00 00 00 movl $0x0, (%esp) d: e8 fc ff ff ff call e <print_banner+0xe> 12: c9 leave 13: c3 ret
|
可以看出call指令的操作数是fc ff ff
ff,翻译成16进制数是0xfffffffc(x86架构是小端的字节序),看成有符号是-4。
这里应该存放printf函数的地址,但由于编译阶段无法知道printf函数的地址,所以预先放一个-4在这里,然后用重定位项来描述:
这个地址在链接时要修正,它的修正值是根据printf地址(更确切的叫法应该是符号,链接器眼中只有符号,没有所谓的函数和变量)来修正,它的修正方式按相对引用方式。
这个过程称为链接时重定位:链接器生成一段额外的小代码片段,通过这段代码支获取printf函数地址,并完成对它的调用。
.text ...
// 调用printf的call指令 call printf_stub ...
printf_stub: mov rax, [printf函数的储存地址] // 获取printf重定位之后的地址 jmp rax // 跳过去执行printf函数
.data ... printf函数的储存地址: 这里储存printf函数重定位后的地址
|
链接阶段发现printf定义在动态库时,链接器生成一段小代码print_stub,然后printf_stub地址取代原来的printf。
因此转化为链接阶段对printf_stub做链接重定位,而运行时才对printf做运行时重定位。
存放函数地址的数据表,称为全局偏移表(GOT, Global Offset
Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link
Table)。
如何阻止栈溢出
- 随机栈地址,让攻击者无法知道shellcode地址,无处跳转.
典型地,ASLR和PIE技术可以一定地阻止攻击
- 检测栈溢出,检测到则报错退出. 典型地,有stack
cannary技术,在函数开始执行前往堆栈插入一个识别码,执行完之后再检测识别码是否被改变
- gcc的
-fstack-protector
可以开启cannary机制,增加溢出检测.如果编译时没有指定-fno-fstack-protector那么是默认启用的
- 设置栈上不能运行代码.
典型地,有ND技术,设置内存权限精细划分,可写/可执行二者不可兼得
- alexander给出了一个linux补丁(1997),实现了栈不可执行
ASLR,最早由pax研究组提出,通过提交linux内核补丁方式安装,包括用户栈随机(2001),内核栈随机(2002),堆随机化(2003)
/proc/sys/kernel/randomize_va_space
,=0
表示关闭随机,=1
表示栈随机,=2
表示栈堆随机
开启ASLR后,堆栈共享库都会随机,但是程序本身代码固定加载在0x804800处.
可以使用pie技术随机化程序地址,gcc -fPIC -pie
即可开启
在gdb环境执行info proc mappings
可查看程序加载地址,库加载地址,堆地址;开启之后,每次都不一样
如何绕过阻止措施
- 针对栈上不能运行代码,可以使用rop技术,即复用已有包含ret的代码片段,代替自己手写shellcode
- 以上措施同时也绕过了随机化,可以return to
libc等,比如直接跳转到libc里的system函数
- 如果没有开启PIE,可以使用return to
PLT,直接通过PLT调用,无需知道实际地址
- 布置一长串nop指令增加随机化命中概率
可以在gdb中直接print出来system的函数地址,然后放入ROP即可.
也可以ldd查看动态库起始地址,结合readelf -s xxx.so |grep 函数名
中找到的函数偏移地址,加起来也就是函数地址了
对于有些参数字符串,我们也要从已有代码里去找,可以使用strings -tx xxx.so | grep 字符串
去查找地址