快速入门

千里之行,始于足下。

请读者自行根据需求自由跳转对应的章节。

点击各级标题旁边的「列表」图样按钮,即可开启目录快速跳转;目录窗口右上角「大头针」图样按钮可以使之成为独立浮动窗口。

搭建调试环境

  • 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 常用库
  • ROPgadgetropper 也可以):检索 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 环境变量配置问题。

更新 libc 库时出现问题参见这篇文章,安装时出现问题参见这篇文章

对于 Mac ARM 用户,使用 UTM 虚拟机搭建共享文件夹是必要的,在 UTM 虚拟机管理页面唤出对应虚拟机的右键菜单(确保虚拟机已经关闭!),选择编辑(Edit,并进行如下操作:

image

其中 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 启动时自动执行的命令,直接追加上述指令即可。

image

更多 GDB 基础与使用技巧,详见 @uf4te 的文章

x86/x64 汇编基础

GCC 从源文件到可执行文件主要包含四个阶段:

  • 预处理preprocess):处理源代码中以 # 开始的预处理指令,将其转换后直接插入程序文本中,通常以 .i 作为文件扩展名。
  • 编译compile):对预处理文件进行语义分析和优化,生成汇编代码。
  • 汇编assemble):根据汇编指令与机器指令的对照表进行翻译,通常以 .o 作为文件扩展名;此时对象文件中符号的虚拟地址无法确定。
  • 链接link):可分为静态链接动态链接两种,此时无法确定的符号地址已经被修正为实际的符号地址,程序也就可以被加载到内存中正常执行了。

其中,汇编语言作为 C 语言代码到可被计算机执行的机器码的媒介,是我们重点研究的对象。

image

指令集架构(Instruction Set Architecture, ISA)简称指令集,包含了一系列的操作码(opcode),以及由特定 CPU 执行的基本命令。根据指令集的特征,通常可分为 CISC 和 RISC 两大阵营

汇编语言(Assembly language)只是机器码的一个助记符,两者是几乎等价的,每条汇编指令都有对应的操作码。换言之,汇编语言利用类似人类语言的方式对指令集进行描述。

语法风格

x86 汇编语言主要的语法风格有两种:

  • Intel 风格
  • AT&T 风格

image

我们一般使用 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 个十六进制数组成。

image

部分寄存器的功能

  • 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 & eaxeax == 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位)。

image

image

使用栈保存函数返回地址

当一个函数被调用时,会开辟一个独立的存放函数状态和所使用的变量的栈空间,即栈帧。一个运行中的函数,其栈帧区域被栈基址寄存器( bp )和栈顶寄存器( sp )所限定。

调用某个子函数时,需要先将该函数的实参按一定的规范压入栈中,再执行 CALL 指令,下一条指令的地址作为返回地址被保存到栈中,被调用函数结束时,程序将执行 RET 指令跳转到这个返回地址,将控制权交还给调用函数。因此无论调用了多少层子函数,由于栈后入先出的特性,程序控制权最终会回到 main 函数。

所有的 x86 汇编程序中都包含标识符为 main 的函数,这是程序的入口点,main 函数不需要使用 RET指令,但其他的被调用函数结束时都需要通过 RET 指令将控制权交还调用函数。

当一个函数结束调用时,为避免因开辟新的栈空间后未正确恢复而造成栈空间上溢或下溢,需要清除它使用的栈空间,关闭栈帧。这一过程称为栈平衡

image

使用栈传递函数参数、局部变量

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 命令,直到执行到如图所示。

image

分析源码可以得知,用户输入函数 gets() 传入参数 a ,结合反汇编代码可知,[rbp - 0x18]a 对应的相对地址;相应地,[rbp - 0x10]b 对应的相对地址。距离栈底( rbp 指针指向的)更远的地址后入栈,则 b 先入栈,a 再入栈,满足后定义先入栈原则。

继续单步执行程序(箭头指向、标记为绿色的指令即 RIP 寄存器的值,即下一次要执行的指令;执行 call gets@plt 后需要自行输入任意数据),如图,出现条件分支。

image

  • 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 以获得题目 flagx/20bx $rbp - 0x10 以字节分块查看字符串 b 的十六进制形式,如果将字符串 b 的第一个字节篡改为 0x61,我们就可以执行 system 函数,完成攻击。

image

执行 GDB 指令 stack 10 来查看前 10 行的栈,上方为 sp 栈顶指针,下方为 bp 栈底指针。栈空间是一段连续的内存地址,而 ab 紧邻且 a 处于相对 b 低的地址,则可以向 a 输入足够多的垃圾数据,gets() 函数不限制输入数据大小,向高地址溢出,从而覆盖 b[0] 篡改为字符 a ,构成 b[0] == 'a' 的情形。

image

当前主流计算机系统多数采用小端序的数据格式,即低地址存放数据低位,高地址存放数据高位,例如 0x12345678 会存储为 78 56 34 12;逐个发送逆序的地址字节串,才能在栈上存储为正确的地址格式,从而保证寻址准确。

image

payload 大致如下图:

image

溢出字符串 a 需要 8 个字符,只要输入 8 个任意字符加上一个字符 a 即可。

接下来,我们来看关闭栈溢出保护机制的。同理,GDB 调试发现:

image

  • a 先入栈,b 后入栈
  • 在栈上, a 地址高于 b ,无论怎么写入 a 都无法覆盖到 b ,之前对于开启栈溢出保护机制的攻击方法就行不通了

程序的内存布局模型

一个 C 程序被载入到内存中,所构成的空间布局模型大致如下图,自上而下,从高地址到低地址:

image

对于各个内存区域:

  • text 段:存放程序执行代码
  • bss 段:存放未初始化的全局变量
  • data 段:存放已初始化的全局变量
  • heap 段:堆,存放程序运行时动态分配的内存,向上增长
  • stack 段:栈,存放程序临时创建的局部变量,以及函数体的栈帧,向下增长
  • 动态链接库:夹在 heap 段和 stack 段中间,存放程序运行期间加载的外部符号链接

Linux 安全机制

定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法内存区域,即缓冲区溢出,主要分为如下三类:

  • 栈溢出stack overflow):最常见、漏洞比例最高、危害最大的二进制漏洞,也是 CTF 中漏洞利用的基础,我们之前举出的程序案例就属于这一类
  • 堆溢出heap overflow):堆管理器复杂,利用花样繁多,是 CTF 中常见题型
  • Data 段溢出:攻击效果依赖于 data 段上存放了何种控制数据

针对缓冲区溢出,现代操作系统(Linux)采用了一些漏洞缓解措施。通过 checksec 这一工具,我们可以便捷地查看可执行文件的相关保护机制是否开启。

image

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 的地址有变化

image

而在完全开启时,栈、堆和 libc 都有变化,但程序本身以及 PLT 不变

image

PIE

ASLR 只是提供了程序运行内存空间的随机化加载,而二进制程序本身是不支持的,程序代码对应的地址是相对固定的,存在可被利用的安全风险。2003 年,人们引入了位置无关可执行文件Position-Independent Executable, PIE),在应用层的编译器上实现,将程序编译为位置无关代码Position-Independent Code, PIC),使程序可以被加载到任意位置-no-pie 编译选项可以关闭这一机制。

通过添加参数 -pie -fpie 将上面的程序编译为 PIE 程序,可以看到打印出来的每一项都已经随机化。

image

在增加安全性的同时,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 查看一下相关架构与保护措施:

image

拖入 IDA 查看反汇编代码。程序先将 fp 置 0,再验证 fp == 0 与否,是则跳转到主函数出口 return 0 ,否则将 fp 赋值给 RAX 寄存器,传入 /bin/sh 字符串,直接 call rax ,跳转到 fp 所指向的地址。

image

Tab 键生成的伪代码如下:

image

进入 GDB 调试,发现溢出输入点。

image

不论从上述的 IDA 伪代码,还是从下面 GDB 调试输出显示(GDB 输出stack 窗口中,栈地址的左侧一列是相对偏移,根据反汇编结果可知,此时 fp 地址为 rbp - 0x10a 输入 4 个 0x61 后溢出)来看,输入点 a 只需 4 个字节(字符)即可溢出到 fp

image

pwndbg 的反汇编界面已经显示出条件判断后程序执行流, fp 被对整型a 的输入覆盖,指向的地址为 0x61616161 ,是无效地址。
image

在 IDA 中,我们发现后门函数 func() ,恰好接受一个 /bin/sh 字符串执行 system() 函数。我们只需要fp 覆盖为这个后门函数的入口地址,即可直接 CALL func() 函数,执行 shell 调用。

image

Tab 返回到初始的反汇编界面,按下空格键切换为文本视图,获得 func() 地址:

image

或者,在 GDB 调试界面,输入 p &func 指令也可:

image

输入 p $rbp - 0x10 后获得 fp 的实际地址,fp 赋值给 RAX 的汇编语句 mov rax, qword ptr [rbp - 0x10] 之前使用 set 命令将 fp 地址存储的值改为 func() 函数地址:

image

set *<address> 是 GDB 常用的地址修改指令(long long) 将需要修改的地址强制转换为长整型,确保完全覆盖之前的值,空余的位全部置 0.

继续 ni ,可以看到 RAX 存储的地址已经指向 func() 函数,CALL 指令调用 func() 后将执行 system 函数。

image

成功打通,只是由于 GDB 动态调试环境的原因,直接正常退出了。

image

然而,在远程做题环境,我们无法进行 GDB 调试,可以通过 Python 脚本自动发送 payload,覆盖 afp 的地址,再发送 func() 地址覆盖在其上即可。

from pwn import *
file = './hello'
io = process(file)

func_addr = 0x40121f
payload = b'a' * 4 + p64(func_addr)

io.sendline(payload)
io.interactive()

image

推荐安装 python-is-python3 工具,可以直接将 python3 命令映射到 python 上,apt install 即可。

其中,p64() 函数将数据按 64 位打包,按小端序的形式逆序发送。我们可以在脚本开头加入 context(log_level='debug') 来跟踪 p64() 打包发送的格式。

image

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 参数。

posted @ 2025-05-03 15:05  孤独者的夜空  阅读(105)  评论(0)    收藏  举报