汇编基础#
掌握程度:看懂汇编,分析 gadget,单步调试理解寄存器状态
寄存器#
常用的 x86 CPU
寄存器有 8 个:EAX
、EBX
、ECX
、EDX
、EDI
、ESI
、EBP
、ESP
CPU 优先读写寄存器,再通过寄存器、缓存跟内存来交换数据,达到缓冲的目的,因为可以通过名称访问寄存器,这样访问速度是最快的,因此也被称为零级缓存
存取速度从高到低分别是: 寄存器 > 1级缓存 > 2级缓存 > 3级缓存 > 内存 > 硬盘
通用寄存器及用途#
上面提到大 8 个寄存器都有其特定的用途,我们以32位 CPU
为例简单说明下这些寄存器的作用,整理如下表:
寄存器 | 含义 | 用途 | 包含寄存器 |
---|---|---|---|
EAX | 累加 (Accumulator) 寄存器 | 常用于乘、除法和函数返回值 | AX(AH、AL) |
EBX | 基址 (Base) 寄存器 | 常做内存数据的指针,或者说常以它为基址来访问内存 | BX(BH、BL) |
ECX | 计数器 (Counter) 寄存器 | 常做字符串和循环操作中的计数器 | CX(CH、CL) |
EDX | 数据 (Data) 寄存器 | 常用于乘、除法和 I/O 指针 | DX(DH、DL) |
ESI | 来源索引 (Source Index) 寄存器 | 常做内存数据指针和源字符串指针 | SI |
EDI | 目的索引 (Destination Index) 寄存器 | 常做内存数据指针和目的字符串指针 | DI |
ESP | 堆栈指针 (Stack Point) 寄存器 | 只做堆栈的栈顶指针;不能用于算术运算与数据传送 | SP |
EBP | 基址指针 (Base Point) 寄存器 | 只做堆栈指针,可以访问堆栈内任意地址,经常用于中转 ESP 中的数据,也常以它为基址来访问堆栈;不能用于算术运算与数据传送 | BP |
在上面的图标中每个常用寄存器后面还有其他的名字,rax,eax,ax,ah,al 其实是表示同一个寄存器,只是包含不同的范围
下面为 64 位寄存器的对照关系:
|63..32|31..16|15-8|7-0|
|AH. |AL.|
|AX......|
|EAX............|
|RAX...................|
指令指针寄存器#
指令指针寄存器(RIP
)包含下一条将要被执行的指令的逻辑地址。
通常情况下,每取出一条指令后,RIP 会自增指向下一条指令。在 x86_64 中 RIP 的自增也即偏移 8 字节。
但是 RIP 并不总是自增,也有例外,例如call
指令和ret
指令。call
指令会将当前 RIP 的内容压入栈中,将程序的执行权交给目标函数;ret
指令则执行出栈操作,将之前压入栈的 8 个字节的 RIP 地址弹出,重新放入 RIP。
标志寄存器(EFLAGS)#
汇编语言指令#
常用的汇编指令:mov
、je
、jmp
、call
、add
、sub
、inc
、dec
、and
、or
数据传送指令#
指令 | 名称 | 示例 | 备注 |
---|---|---|---|
MOV | 传送指令 | MOV dest, src | 将数据从 src 移动到 dest |
PUSH | 进栈指令 | PUSH src | 把源操作数 src 压入堆栈 |
POP | 出栈指令 | POP desc | 从栈顶弹出字数据到 dest |
算术运算指令#
指令 | 名称 | 示例 | 备注 |
---|---|---|---|
ADD | 加法指令 | ADD dest, src | 在 dest 基础上加 src |
SUB | 减法指令 | SUB dest, src | 在 dest 基础上减 src |
INC | 加 1 指令 | INC dest | 在 dest 基础上加 1 |
DEC | 减 1 指令 | DEC dest | 在 dest 基础上减 1 |
逻辑运算指令#
指令 | 名称 | 示例 | 备注 |
---|---|---|---|
NOT | 取反运算指令 | NOT dest | 把操作数 dest 按位取反 |
AND | 与运算指令 | AND dest, src | 把 dest 和 src 进行与运算之后送回 dest |
OR | 或运算指令 | OR dest, src | 把 dest 和 src 进行或运算之后送回 dest |
XOR | 异或运算 | XOR dest, src | 把 dest 和 src 进行异或运算之后送回 dest |
循环控制指令#
指令 | 名称 | 示例 | 备注 |
---|---|---|---|
LOOP | 计数循环指令 | LOOP label | 使 ECX 的值减 1,当 ECX 的值不为 0 的时候跳转至 label,否则执行 LOOP 之后的语句 |
转移指令#
指令 | 名称 | 示例 | 备注 |
---|---|---|---|
JMP | 无条件转移指令 | JMP lable | 无条件地转移到标号为 label 的位置 |
CALL | 过程调用指令 | CALL labal | 直接调用 label |
JE | 条件转移指令 | JE lable | zf =1 时跳转到标号为 label 的位置 |
JNE | 条件转移指令 | JNE lable | zf=0 时跳转到标号为 label 的位置 |
linux 和 windows 下汇编的区别#
linux
和 windows
下的汇编语法是不同的,其实两种语法的不同和系统不同没有绝对的关系,一般在 linux
上会使用 gcc/g++
编译器,而在 windows
上会使用微软的 cl
也就是 MSBUILD
,所以产生不同的代码是因为编译器不同,gcc
下采用的是 AT&T 的汇编语法格式,MSBUILD
采用的是 Intel 汇编语法格式。
差异 | Intel | AT&T |
---|---|---|
引用寄存器名字 | eax | %eax |
赋值操作数顺序 | mov dest, src | movl src, dest |
寄存器、立即数指令前缀 | mov ebx, 0xd00d | movl $0xd00d, %ebx |
寄存器间接寻址 | [eax] | (%eax) |
数据类型大小 | 操作码后加后缀字母,“l” 32 位,“w” 16 位,“b” 8 位(mov dx, word ptr [eax]) | 操作数前面加 dword ptr, word ptr,byte ptr 的格式 (movb % bl % al) |
寻址方式#
直接寻址
内存寻址:[ ]
溢出(有无符号 & 上下溢出)#
- 存储位数不够
- 溢出到符号位
整数溢出配合别的漏洞使用
个人认为,有符号位数进位代表溢出
LINUX 文件基础#
保护层级:0-3
0 - 内核
3 - 用户
虚拟内存:物理内存经过 MMU 转换后的地址 系统给每个用户进程分配一段虚拟内存空间
大端序小端序#
大端序:数据高位 -> 计算机地址低位(更符合人类阅读习惯)
小端序:数据低位 -> 计算机地址低位(反直觉,但更符合存储逻辑、运算规律)
计算机输出字符串:低地址到高地址
linux 数据存储格式为小端序,arm 架构为大端序
以字符串形式输入数字时,要注意格式,linux 从低到高读入数据,可用 pwntools 进行转换
文件描述符#
每个文件描述符与一个打开的文件相对应
- 0
- 1
- 2
stdin->buf->stdout
例如:
read(0,buf,size)
write(1,buf,size)
栈(stack)#
阉割版数组,只能在一头操作
数据结构:后进先出(LIFO),与函数调用顺序相同
函数开始执行:main->funA->funB
函数完成顺序:funB->funA->main
基本操作:push 压入,pop 弹出
函数调用指令 call,返回指令 ret
操作系统为每个程序都设置了一个栈,程序每个独立的函数都有独立的栈帧
linux 中的栈由高地址(栈顶)向低地址(栈底)生长
很多算法如 DFS 都是利用栈,以递归形式实现
调用约定 (Calling Convention)#
什么是调用约定#
函数的调用过程中有两个参与者,一个是调用方 caller,另一个是被调用方 callee。
调用约定规定了 caller 和 callee 之间如何相互配合来实现函数调用,具体包括的内容如下:
- 函数的参数存放在哪的问题。是放在寄存器中?还是放在栈中?放在哪个寄存器中?放在栈中的哪个位置?
- 函数的参数按何种顺序传递的问题。是从左到右将参数入栈,还是从右到左将参数入栈?
- 返回值如何传递给 caller 的问题。是放在寄存器里面,还是放在其他地方?
- 等等
那么,为什么需要调用约定呢?
举个例子,如果我们用汇编语言编写代码没有一个统一的规范来遵守的话。那么A
习惯将参数放在栈中,B
习惯将参数放在寄存器中,C
习惯 …,每个人编写的代码都按照自己的想法来。这样,当 A
尝试调用其他人的代码时,就不得不遵循其他人的习惯,比如说调用B
的,那么A
需要将参数放入 B 规定好的寄存器中;调用C
的,又是另一个样子…
调用约定就是为了解决上述问题,它对函数调用的细节作出了规定,这样的话,每个人都遵守一个约定,当我们想调用别人编写的代码时,就不需要做啥修改了。
函数调用栈#
- 函数调用:当一个函数被调用时,程序将在调用栈上为其分配一个新的栈帧。 栈帧中包含函数的参数、局部变量、返回地址等信息。
- 参数传递:在函数调用过程中,参数通过压栈操作传递给被调用的函数。 这些参数存储在栈帧中,供函数内部使用。
- 执行函数:被调用的函数开始执行,使用栈帧中的参数和局部变量。 函数的执行过程可能涉及复杂的逻辑和计算。
- 返回值处理:当函数执行完毕后,程序将返回到调用该函数的代码位置。 这个位置由栈帧中的返回地址指定。 如果函数有返回值,该值将被推送到调用者的栈帧中。
- 栈帧销毁:当函数调用完成后,其对应的栈帧将从调用栈中弹出并销毁,释放所占用的内存资源。
具体的函数调用流程#
- pop
pop rax 的作用:
mov rax [rsp];
// 栈顶数据弹出到寄存器
add rsp 8;
// 栈顶指针下移一个单位
- push
push rax 的作用:
sub rsp 8;
// 栈帧上移一个单位
mov [rsp] rax;
// 将一个寄存器的值放在栈顶
- jmp
立即跳转,不涉及函数调用,用于循环,if-else
如 call 1234h 的作用:
mov rip 1234h;
- call
函数调用,需要保存返回地址
如 call 1234h 的作用:
push rip;
mov rip 1234h;
- ret
pop rip
实例:main call funB , funB call funA ,逐步分析栈帧变化:
函数调用过程中:
- 调用函数:
- 将
rip
压入栈中,作为返回地址。(call)
- 将
- 被调用函数:
- 将
rbp
压入栈中,作为当前栈帧的基址。 - 将
rsp
的值赋给rbp
,使rbp
指向当前栈帧的底部。 - 为局部变量和临时数据分配栈空间,将
rsp
减去相应的大小。 - 使用
rsp
作为基址指针来访问函数参数和局部变量。
- 将
函数返回时:leave;ret;
- 被调用函数:
- 将栈中分配的局部变量和临时数据弹出。
- 将
rsp
恢复到函数调用时的值。
- 调用函数:
- 从栈中弹出返回地址。
- 将
rip
更新为返回地址。
栈帧变化示意图:
+----------------------------+
| main 函数栈帧 |
+----------------------------+
| 返回地址 |
| rbp (main 函数的基址指针) |
+----------------------------+
| funB 即调用函数栈帧 |
+----------------------------+
| 返回地址 |
| rbp (funB 函数的基址指针) |
+----------------------------+
| funA 即被调用函数栈帧 |
+----------------------------+
| rbp (funA 函数的基址指针) |
| 局部变量 |
+----------------------------+
如何传参#
函数返回值给 RAX
x86-64 函数的调用约定为:
-
从左至右参数依次传递给
RDI
,RSI
,RDX
,RCX
,R8
,R9
-
如果一个函数的参数 > 6 个,则从右至左压入栈中传递
系统调用#
syscall 指令#
用于调用系统函数,调用时指明系统调用号(可网上查询 64 位 Linux 系统调用表)
系统调用号存在 RAX 寄存器,然后布置好参数,执行 syscall 即可
示例:调用 read (0,buf,size)
mov rax 0;
mov rdi 0;
mov rsi buf;
mov rdx size;
syscall;
ELF 文件结构#
ELF 文件格式#
ELF (Executable and Linkable Format) 为 linux 中二进制可执行文件格式。
ELF 文件头(ELF Header)#
readelf -h 命令可以读取 ELF 文件的文件头,。ELF 头包括了程序的入口点(Entry Point Address)、段信息和节信息。从 ELF 头的Start of program headers和Start of section headers可以定位段表和节表的在文件中的位置。
节表(Section Header Table)#
使用readelf -S 命令读取二进制 ELF 文件的节信息(sections)。程序 test 中共有 31 一个节。汇编语言是按照节来编写程序的,例如.text 节、.data 节。汇编代码与机器代码是一一对应的,汇编程序被转换成二进制代码时保留了节的信息。
readelf -S test
段表(Program Header Table)#
ELF 程序执行时(加载进入内存时),装载器(Loader)根据程序的段表创建进程的内存镜像(Image)。使用readelf -l 命令读取二进制 ELF 文件的段信息(segments)。程序 test 共有 13 个段,段的数量大于节的数量,因此存在多个节映射到同一个段的情况。
根据节的权限:可读可写的节被映射入一个段,只读的节被映射入一个段,等等。
readelf -l test
链接视图 / 执行视图#
Segment 和 Section 是从不同角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图(View),
从 Section 的角度来看 ELF 文件就是链接视图(Linking View)。
从 Segment 的角度来看就是执行视图(Execution View)。
当我们在谈到ELF 装载时,段专门指 Segment;而在其他情况下,段指的是 Section。
libc#
glibc:GNU C Library ,glibc 本身是 GNU 旗下的 C 标准库,后来逐渐成为了 Linux 的标准 c 库
其后缀为 libc.so ,本质也是 ELF 文件,可以单独执行,通常 pwn 题接触到的动态链接库就是 libc.so 文件
linux 基本所有程序都依赖 libc,所以 libc 中的函数至关重要
延迟绑定机制#
静态编译与动态编译#
动态编译的可执行文件需要附带一个的动态链接库,在执行时,需要调用其对应动态链接库中的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点一是哪怕是很简单的程序,只用到了链接库中的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。
静态编译就是编译器在编译可执行文件的时候,将可执行文件需要调用的对应动态链接库 (.so) 中的部分提取出来,链接到可执行文件中去,使可执行文件在运行的时候不依赖于动态链接库。所以其优缺点与动态编译的可执行文件正好互补。
延迟绑定(Lazy Binding)#
使用延迟绑定是基于这样一个前提:在动态链接下,程序加载的模块中包含了大量的函数调用。
延迟绑定通过将函数地址的绑定推迟到第一次调用这个函数时,从而避免动态链接器在加载时处理大量函数引用的重定位。
延迟绑定的实现使用了两个特殊的数据结构:全局偏移表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。
全局偏移表 GOT#
在库函数第一次调用后,程序才将其地址保存在 got 表中。
全局偏移表在 ELF 文件中以独立的节区存在,共包含两类,对应的节区名为.got
和.got.plt
,其中,.got
存放所有对于外部变量引用的地址;.got.plt
保存所有对于外部函数引用的地址,对于延迟绑定主要使用.got.plt
表。.got.plt
表的基本结构如下图所示:
其中,.got.plt
的前三项存放着特殊的地址引用:
- GOT[0]:保存
.dynamic
段的地址,动态链接器利用该地址提取动态链接相关的信息; - GOT[1]:保存本模块的 ID;
- GOT[2]:存放了指向动态链接器
_dl_runtime_resolve
函数的地址,该函数用来解析共享库函数的实际符号地址。
过程链接表 PLT#
为了实现延迟绑定,当调用外部模块的函数时,程序并不会直接通过 GOT 跳转,而是通过存储在 PLT 表中的特定表项进行跳转。对于所有的外部函数,在 PLT 表中都会有一个相应的项,其中每个表项都保存了 16 字节的代码,用于调用一个具体的函数。过程链接表的通用结构如下:
过程链接表中除了包含编译器为调用的外部函数单独创建的 PLT 表项外,还有一个特殊的表项,对应于 PLT [0],它用于跳转到动态链接器,进行实际的符号解析和重定位工作:
PLT 与 GOT#
无论第几次调用外部函数,程序真正调用的是 PLT 表,PLT 表其实是一段段汇编指令构成。
那么这里可能会有疑问:为什么要存在 PLT,存在过度,而不是直接到达 GOT 呢?
这就比如说,你是一个有很多亲戚的人,你每周都需要拜访这些亲戚,所以你将这些亲戚的地址都记在了一个本子上,等你要去拜访的时候就翻出来查找,那么这个本子就是一个 PLT 表,它里面每一个地址都会跳转到对应的 GOT 表地址(你的亲戚家)
假如有一天,你觉得每天跑来跑去好麻烦,于是把你所有亲戚全都接到了你家里住,每周只要到对应的房间去拜访就可以了,那个本子也就没用了,你扔掉了它。这时,就是在没有 PLT 表的情况下,直接把 GOT 表弄来了。
你觉得是一个登记本占地面积小,还是一屋子亲戚占地面积小呢。
这就是 PLT 表存在的原因之一,为了更高效的利用内存。
另一个原因就是可以增加安全性。
LINUX 安全防护机制#
gcc 安全编译选项详解(NX (DEP)、RELRO、PIE (ASLR)、CANARY、FORTIFY)_gcc pie-CSDN 博客
CANARY#
Canary 是一种针对栈溢出攻击的防护手段,其基本原理是从内存中fs: 0x28处复制一个开头字节为 \x00 的长度为 8 字节的随机数 canary ,该随机数会在创建栈帧时紧跟着 rbp 入栈(紧挨 ebp 的上一个位置)。当攻击者想通过缓冲区溢出覆盖 ebp 或者 ebp 下方的返回地址时,一定会覆盖掉 canaary 的值;当程序结束时,程序会检查 CANARY 这个值和之前的是否一致,如果不一致,则不会往下运行,从而避免了缓冲区溢出攻击。
绕过方法:
- 修改 canary。
- 泄露 canary。
canary bypass#
- 格式化字符串绕过 canary
- 通过格式化字符串读取 canary 的值
- Canary 爆破(针对有 fork 函数的程序)
- fork 作用相当于自我复制,每一次复制出来的程序,内存布局都是一样的,当然 canary 的值也一样,那我们就可以逐位爆破,如果程序崩溃了就说明这一位不对,如果程序正常就可以接着跑下一位,直到跑出正确的 canary
- Stack smashing(故意触发 canary_ssp leak)
- 劫持__stack_chk_fail
- 修改 got 表中__stack_chk_fail 函数的地址,在栈溢出后执行该函数,但由于该函数的地址被修改,所以程序会跳转到我们想要执行的地址
NX#
栈上的数据没有执行权限(not executable),开启后,程序中的堆、栈、bss 段等可写段就不能执行。
绕过方法:
用 mprotect 函数改写段权限,且 nx 保护对于 rop 或劫持 got 表利用方式不影响。
PIE 和 ASLR#
ASLR 是什么?#
ASLR 是 Linux 操作系统的功能选项,作用于程序(ELF)装入内存运行时。是一种针对缓冲区溢出的安全保护技术,通过对加载地址的随机化,防止攻击者直接定位攻击代码位置,到达阻止溢出攻击的一种技术。
开启、关闭 ASLR#
查看当前系统 ASLR 的打开情况:
sudo cat /proc/sys/kernel/randomize_va_space
ASLR 有三个安全等级:
- 0: ASLR 关闭
- 1:随机化栈基地址(stack)、共享库(.so\libraries)、mmap 基地址
- 2:在1基础上,增加随机化堆基地址(chunk)
PIE 是什么?#
PIE 是 gcc 编译器的功能选项,作用于程序(ELF)编译过程中。是一个针对代码段( .text )、数据段( .data )、未初始化全局变量段( .bss )等固定地址的一个防护技术,如果程序开启了 PIE 保护的话,在每次加载程序时都变换加载地址,从而不能通过 ROPgadget 等一些工具来帮助解题。
开启 PIE#
在使用 gcc 编译时加入参数-fPIE
。
PIE 开启后会随机化代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss )的加载地址。
PIE bypass#
程序的加载地址一般都是以内存页位单位的,所以程序的基地址最后三个数字一定是 0,这也就是说那些地址已知的最后三个数就是实际地址的最后三个数。知道这一点后我们就有了绕过 PIE 的思路,虽然我并不知道完整的地址,但我知道最后三个数,那么我们可以利用栈上已有的地址,只修改它们最后两个字节(最后四个数)即可。
所以绕过 PIE 的核心思想就是partial writing(部分写地址)
RELRO#
ReLocation Read-Only,堆栈地址随机化, 是一种用于加强对 binary 数据段的保护的技术。