玩转系统执行流

验证调用约定,分析堆栈图,实现汇编和c的相互调用,实现ROP

前往git拉取代码learnNasm

玩转执行流

这里研究的是机器码遵循一个什么样的约定去读数据,执行指令

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);
}

20221023191436

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;
}

20221026234511

<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即可开启

20221023161052

在gdb环境执行info proc mappings可查看程序加载地址,库加载地址,堆地址;开启之后,每次都不一样

如何绕过阻止措施

  • 针对栈上不能运行代码,可以使用rop技术,即复用已有包含ret的代码片段,代替自己手写shellcode
  • 以上措施同时也绕过了随机化,可以return to libc等,比如直接跳转到libc里的system函数
  • 如果没有开启PIE,可以使用return to PLT,直接通过PLT调用,无需知道实际地址
  • 布置一长串nop指令增加随机化命中概率

20221023162710

可以在gdb中直接print出来system的函数地址,然后放入ROP即可.

也可以ldd查看动态库起始地址,结合readelf -s xxx.so |grep 函数名中找到的函数偏移地址,加起来也就是函数地址了

对于有些参数字符串,我们也要从已有代码里去找,可以使用strings -tx xxx.so | grep 字符串去查找地址