参考资料:
4.12 利用 __stack_chk_fail - CTF 竞赛权威指南 Pwn 篇 - 开发文档 - 文江博客 (wenjiangs.com)
星盟 Pwn 公开课
攻击概要
expoit 攻击脚本,一整套攻击方案
payload 攻击载荷,构造的恶意数据
shellcode 调用攻击目标shell的机器码
一道题目,会提供服务器的IP和对应的端口,计网知识
nc 是什么命令?
是”Netcat”的缩写,用于网络通信的工具,可以在不同的网络层级上进行数据传输和操作。可以用于TCP/IP和UDP套接字的创建和连接、监听端口和处理传入连接、发送和接收数据流,进行网络调试和测试。
nc [options] host(目标主机的名称) port(目标主机的端口号)
buu第一道pwn
运行exp脚本攻击远程服务器的逻辑是什么?
把payload顺着网线送过去了(具体是个什么过程 ),获得了shell的控制权,然后控制服务器得到flag。
shellcode
通过软件漏洞利用过程中使用一小段机器代码
作用包括但不限于 启用shell进行交互、打开服务器端口等待连接、反向连接端口(?)
shellcode 编写
简单的实验:
1 |
|
gcc 编译后用 gdb 调试:

当执行到 system(“/bin/sh”) 时,先 call plt表,再根据 plt 表找到 system 函数。
当可容纳 shellcode 的空间较小时,以上方法不再成立。
问题及解决
解题过程中,shellcode 的大小被控制在几十个字节以内,而且由于地址未知,我们无法直接调用系统函数。
- 触发中断(int 0x80 或者 syscall),进行系统调用
- 使用 system 的底层调用 execve(“/bin/sh”,0,0)
syscall 调用表:https://publicki.top/syscall.html
32位步骤:
- 设置 ebx 指向 /bin/sh
- ecx = 0,edx = 0
- eax = 0xb
- int 0x80 触发中断调用
用汇编做一个简单实现:
1 | ;通过nasm编译汇编文件并生成可执行文件,需要使用链接器(ld)将生成的目标文件与C runtime库链接起来。 |
将汇编编译后得到的可执行文件用 gdb 调试:

可以不再使用不知道地址的函数。
64位步骤:
- 设置 rdi 指向 /bin/sh
- rsi = 0,rdx= 0
- rax = 0x3b
- syscall 进行系统调用
1 | ;nasm -f elf64 shellcode64.asm -o shellcode64.o |
一样可以得到shell。
使用工具快速生成
使用pwntools:
- 设置目标架构
- 生成shellcode
32位:
1 | from pwn import* |
64位:
1 | from pwn import* |
使用pwntools直接生成的 shellcode 不存在 00 字符。
PS:一般工具直接生成的 shellcode 会比手写的长一点。
例题1:mrctf2020_shellcode

向缓冲区 buf 中读入 400h 内容,eax(输入的字节个数)与0比较,不为0时调用执行读入的内容。
这里使用了 call rax,所以 IDA F5失效了。(?)

查看保护,无 NX,所以可以实现栈上的代码执行。且拥有可读写执行的段。
很明显直接输入shellcode即可,不管是用pwntools直接生成还是用手写的代码都行。
exp:
1 | from pwn import * |
这题卡死在一个很蠢的点上一直打不通。rdi 中本来应该存储 “/bin/sh”字符串的地址,我把它直接传给 rdi 了。正确的操作是把它放到栈上,然后把 rsp 的值传给 rdi。
当然用 pwntools 直接生成更简单。两种都可以打通。
例题2:ciscn_2019_s_9

fflush(stdin):刷新缓冲区,将缓冲区内的数据清空并丢弃。 fflush(stdout):刷新缓冲区,将缓冲区内的数据输出到设备。
查看函数列表,可以发现一个 hint:
1 | void hint() |
fgets 处可以用于实现溢出,查看保护,栈可执行。如果直接构造 ROP 链可能这里能溢出的长度不是很够。
思路:通过 fgets 将 shellcode 写入栈,再将 return 的地址覆盖为 jmp esp,即可执行栈上的内容。
1 | from pwn import* |
汇编还是非常重要的,要经常复习。
二进制基础
程序的编译和链接
对C语言代码进行预处理——>预处理之后的C代码——>编译——>汇编代码——>汇编——>生成机器码
链接——> 将多个机器码的目标文件链接成一个可执行文件(指令的执行需要用到操作系统的一些链接库)
可执行文件
不同的操作系统不同的名称,本质还是二进制文件
可执行程序(exe/out),动态链接库(dll/so),静态链接库(lib/a)
ELF 文件结构
头、节、段
一个段可以包含多个节,段是用来规定程序的可读写执行权限的。
节视图用于 ELF 文件 编译链接时 与 在磁盘上存储时 的文件结构的组织
代码段包含了代码与只读数据
数据段包含了可读可写的数据
查看ELF文件结构
objdump -s elf
cat /程序对应的进程号/pid/maps 查看内存中的内容
显示进程的内存映射信息,包括内存地址范围、权限、偏移量、设备号、inode号和映射的文件路径等。每一行表示一个内存映射区域。
磁盘中的 ELF(可执行文件)和 内存中的 ELF(进程内存映像)
ELF 文件到虚拟地址空间的映射,在物理内存中是不连续的,虚拟内存(抽象层)中是连续的
可执行文件和源代码均存在磁盘上,当需要执行时,需要为可执行文件分配一段虚拟内存,将可执行文件映射到虚拟内存中供CPU读取使用。
地址以字节编码,常以16进制表示
虚拟内存用户空间每个进程一份
虚拟内存内核空间所有进程共享一份
虚拟内存mmap段中的动态链接库仅在物理内存中装载一份
前面两个加起来是 4GB (32位)
操作系统的基础由 gnu 和 操作系统内核组成
再加上软件源、用户态软件
内核和驱动起到的是管理硬件的作用
内存中的数据的写入是从低地址写到高地址
人类视觉:由上到下
程序视觉:由下到上
程序数据是如何在内存中组织的?
未初始化的全局变量存储在 Bss
字符串如果是只读数据,依旧会被放在代码段(rodata)
如果分配的是堆上的内存,那读取的内容存放在堆内存中,例如malloc这种
大端序和小端序
小端序比较常见,指低地址存放数据低位、高地址存放数据高位(LSB)
大端序和小端序相反,低地址存放数据高位、高地址存放数据低位(MSB)
CPU 和 内存配合执行数据
内存将对应的数据和指令机器码送到 CPU ,CPU执行指令的过程中将一些数据再返还到内存中。CPU 通过内部的寄存器暂存数据(比如参数值或中间计算结果)。
动态链接的程序的执行过程(?)
我们运行程序时,操作系统的内核会创建一个新的进程,为程序提供运行环境。新的进程通过执行系统调用 execve
进入内核(内核负责管理计算机的底层资源和提供一些功能的接口)。进入内核后,内核执行一些初始化操作,准备好运行环境。其中涉及一些底层的函数和操作,可以简单理解为内核在为程序做一些准备工作。
接着使用动态链接器(如 ld.so)来加载和链接所需的动态链接库。ld.so 是一个系统级的库文件,它在程序运行时负责加载动态链接库,并将其与程序进行动态连接, 还提供符号解析、重定位等功能,确保库函数的正确调用和运行。
然后,进程会开始执行可执行文件中的 _start
标签所对应的代码。这段代码通常是由编译器生成的,它会执行一些底层的初始化操作,例如设置堆栈、加载寄存器等。在 _start
代码执行的过程中,会调用名为 __libc_start_main
的函数。负责进行动态链接的初始化工作。
__libc_start_main
函数的最后一步即调用 main
函数。
编写程序代码 ——> 编译生成可执行文件 ——> 运行可执行文件 ——> 内核初始化 ——> 动态链接器加载和链接所需的动态链接库——>执行 _start
代码 ——> 调用 __libc_start_main
函数 ——> 执行 main
函数
这个过程中,计算机的内核负责提供运行环境和执行所需的底层操作,程序通过和内核提供的接口进行交互,实现所需的功能。
动态链接的程序在执行过程中,并不在开始时将所有的库函数代码和数据嵌入到可执行文件中,而是在需要时进行动态加载和链接。动态链接器负责在程序运行时加载和链接所需的动态链接库,这使得多个程序可以共享同一个动态链接库,节省磁盘空间和内存。动态链接的程序具有更好的可扩展性和灵活性,因为库的更新和替换可以独立于可执行文件的重新编译和发布。相较于静态链接的程序,动态链接的程序在启动时会经过动态链接器的初始化过程,包括加载动态链接库和设置环境等操作,可能在执行过程中引入一些额外的开销,例如加载和链接动态链接库的时间。但由于多个程序可以共享同一个动态链接库,整体上可以提供更好的内存利用率。
ld.so的功能和调用时机与__libc_start_main是否有重合?以及重定位等功能具体怎么理解,是怎么实现的?
汇编
主要是x86
amd64向下兼容x86
rax(8bytes)——>取低四位——>eax(4bytes)——>ax(2bytes)——>al+ah(1bytes)
主要的几个寄存器:rip、rsp、rbp、rax
查看文件所用 libc 版本
1 | giantbranch@ubuntu:~/Desktop/buu/wustctf2020_easyfast$ ldd '/home/giantbranch/Desktop/buu/wustctf2020_easyfast/wustctf2020_easyfast' |
栈溢出基础
函数调用栈是一块连续的内存区域,用于保存程序运行过程中的状态信息(函数参数、局部变量……)
函数调用栈在内存中由高地址向低地址生长,栈顶对应的内存地址在压栈时变小,退栈时变大。
主要的寄存器
esp:存储栈顶地址
ebp:存储栈底地址
eip:存储即将执行的程序指令的地址
函数调用开始
核心任务:保存调用函数的状态,创建被调用函数的状态。
将被调用函数的参数逆序入栈 ——> 将被调用函数进行调用后的下一条地址入栈(被调用函数的返回地址) ——> 将当前 ebp 寄存器的值入栈 ——> 将 ebp 的值更新为当前栈顶的地址(mov ebp,esp)——> 将被调用函数的局部变量等数据入栈
发生调用时,程序还会将被调用函数的指令地址存放在 eip 内,以便函数依次执行被调用函数的指令。
函数调用结束
核心任务:丢弃被调用函数的状态,将栈顶恢复为调用函数的状态。
被调用函数的局部变量弹出 ——> 栈顶指向被调用函数的基地址 ——> 将调用函数的基地址弹出,保存在 ebp 内(pop ebp)——> ebp 恢复到调用被调用函数之前的位置 ——> 将调用函数的返回地址弹出,保存在 eip 中(pop eip)
要点
函数调用栈中,eip 中存储的值完全是由栈中的返回地址决定的,栈溢出的原理就在于修改这个返回地址,使 eip 指向我们希望的位置,控制整个程序的执行流。
缓冲区溢出
向定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法的内存区域。
常见的有栈溢出、堆溢出、Data段溢出
canary绕过
canary
一种用来防护栈溢出的保护机制,原理是在一个程序的入口处,先从fs(32)/gs(64)寄存器中取出一个四字节(eax)或八字节(rax)的值存到栈上(缓冲区的后面),函数结束时会检查这个栈上的值是否和存进去的值一致。(?)
canary 如果被篡改,会触发 __Stack_chk_fail函数并直接报错。
绕过姿势
格式化字符串泄露canary
通过溢出将 canary 的最后一位改为 /00,通过 printf 输出 canary。
stack smash
以一个程序为例:
1 |
|
编译运行这个程序,简单溢出,会报出如下错误:
1 | giantbranch@ubuntu:~/Desktop/test$ ./test |
用 gdb 调试:

当 canary 被篡改时会触发 __Stack_chk_fail。
查看__Stack_chk_fail 函数源码:
1 | // debug/stack_chk_fail.c |
调用函数 __fortify_fail:
1 | // debug/fortify_fail.c |
__fortify_fail 会负责打印出错误信息和文件名。
如果将 flag 地址覆盖到 libc_argv[0],就可以利用 __fortify_fail 打印出 flag。
但是仅限于 libc-2.23 版本之前可以这么干,之后的版本就对 __fortify_fail 进行了修改。
如2.27:
1 | void |
need_backtrace && __libc_argv[0] 必须同时为空才能输出理想的内容。
如2.31:
1 | void |
直接砍掉了第二个参数。
例题:wdb2018_guess(网鼎杯2018)
64位,NX and Canary

这个程序首先将 flag 读入 buf 缓冲区中,然后让用户猜测 flag 并输入,与 buf 进行比对。
猜是不可能猜的,所以可以尝试 stack smash 直接把 flag 泄露出来。

sub_400A11() 处可以 fork。子进程中,循环终止,继续向下执行 gets()。父进程中,由于 if 中判断值为假,程序会不断地 wait,不断地 fork。直到 v6 等于 v7 等于 3,结束循环。一共有三次 fork 机会。
先泄露 libc。
多进程下的爆破
pid_t fork(void) 函数
创建一个新进程,操作系统会复制父进程的地址空间中的内容给子进程,调用 fork() 后,子进程与父进程的执行顺序是无法确定的。子进程无法通过 fork() 来创建子进程。
它有三个返回值:
- 父进程中,fork 返回新创建子进程的 ID
- 子进程中,fork 返回 0
- 如果出现错误,fork 会返回一个负值
如题:
1 |
|
canary 的第一位永远是 ‘\x00’,因此只需要爆破剩下七位即可。
exp 如下:
1 | from pwn import* |
PIE
针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一种防御技术,当程序开启了 PIE 保护,在每次加载程序时都变换加载地址,从而不能通过 ROPgadget 等一些工具帮助解题。
开了 PIE 保护的程序,所有代码段的地址都只有最后三个数是已知的。程序的加载地址一般以内存页为单位,所以程序的基地址的最后三个数一定是 0,因此开了 PIE 后显示的后三位地址也就是实际地址的后三位。
partial writing
利用栈上已有的地址,只修改它们的最后两个字节(4个数字)。第四个数字通过爆破得到正确的结果。
开了PIE之后 gdb 通过 *$rebase(0xxxx)
下断点
泄露 PIE 基地址
ret2xxx
ROP
参考资料:http://github.com/zhengmin1989/ROP_STEP_BY_STEP
在栈缓冲区溢出的基础上,利用程序中已有的小片段(gadgets)来改变某些寄存器或变量的值,从而控制程序的执行流程。
gadgets 即以 ret 结尾的指令序列。
ROP 攻击一般需要满足以下条件:
- 程序存在溢出,并且可以控制返回地址
- 可以找到满足条件的 gadgets 以及相应 gadgets 的地址
gadgets 的地址如果不固定,则需要想办法动态获取其地址。
ret2text
控制程序执行程序本身已有的代码(.text)
例题1:jarvisoj_level2
1 | ssize_t vulnerable_function() |
read 存在溢出。有现成的 system 函数和 “/bin/sh” 字符串。
32 位的情况下,使用栈传递参数,可以画出正常调用时的栈图。

只需要控制返回地址为 system 函数地址并传入对应参数即可。
exp:
1 | from pwn import* |
例题2:jarvisoj_level2_x64
检查保护,只开了 NX。
1 | ssize_t vulnerable_function() |
漏洞和 32 位也是一样的。
主要是传参过程和 32 位有差异。32 位传参在栈上进行。64 位优先使用寄存器(依次为rdi,rsi,rdx,rcx,r8,r9),当参数超过六个时再使用栈。
那这题比较大的不同就是要把 “/bin/sh” 字符串的地址存在 rdi 中。
寻找 pop rdi;ret;
1 | giantbranch@ubuntu:~/Desktop/buu/jarvisoj_level2_x64$ ROPgadget --binary level2_x64 --only "pop|ret" |
找到目标 0x4006b3。
exp:
1 | from pwn import* |
ret2shellcode
控制程序执行 shellcode 代码,一般由我们自己去填充。
例题1:jarvisoj_level1
检查保护,啥也没开。尤其是堆栈可执行。
1 | ssize_t vulnerable_function() |
泄露 buf 的地址,在 buf 地址处写入 shellcode(使用pwntools自动生成),并把返回地址改为 buf 地址。
exp:
1 | from pwn import* |
ret2syscall
控制程序执行系统调用获取 shell。
例题1:inndy_rop
方法一、自己实现系统调用
32位,开了NX保护。
1 | int overflow() |
没有直接可用的后门函数,也没有现成的 /bin/sh 字符串,需要自己实现系统调用。
1 | giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ file rop |
statically linked 意味着静态编译,因此程序一定存在 int 0x80,可以自己使用中断调用系统函数。
1 | read(0,bss+0x100,8) //读入/bin/sh 设置eax为0x03 |
用 ROPgadget 找到以下 gadgets:
1 | 0x080b8016 : pop eax ; ret |
搜索 int 0x80 需要用到 Ropper 工具。(这个工具必须在python3环境下使用)
Rop gadgets搜索工具 Ropper 的安装与使用 - robotech_erx - 博客园 (cnblogs.com)
1 | giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ ropper --file rop --search "int 0x80" |
bss的地址选取任意可读可写的地址即可,这里的地址是在动调时随意取的:

构造 ROP 链,写出 exp:
1 | from pwn import* |
方法二、对于静态链接的程序使用工具直接生成ropchain
使用 ropper 生成:
1 | giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ ropper --file rop --chain execve |
使用 ROPgadgets 生成:
1 | ROPgadget --binary rop --ropchain |
我用 ROPgadgets 总是生成不成功,怪。
直接在自动生成的 ROP 前面加上溢出量即可。
ret2libc
控制程序执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置(即函数对应的 got 表项的内容)。一般选择执行 system(”/bin/sh”)。
例题1:jarvisoj_level1 二解
1 | ssize_t vulnerable_function() // 32bit没有开任何保护 |
这次直接利用溢出,构造 rop 链调用 write(1, xx_got, 4) 泄露 libc 基地址 ——> 再次调用 main 函数 ——> 根据 libc 基址得到 system 和 /bin/sh 字符串地址 ——> 再次利用漏洞构造 rop 调用 system(‘/bin/sh’)
1 | from pwn import* |
ret2csu
在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候我们很难找到每个寄存器对应的 gadgets。此时,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。

loc_4006A6 作为 gadget1 ,利用栈对一系列寄存器进行赋值。
loc_400690 作为 gadget2。
二者组合可以实现rdi,rsi,rdx寄存器的赋值,并通过 r12 和 rbx 实现系统调用。
如何构造 ROP 链?
假设要利用 csu 调用 write(1,write_got,8) 来达到泄露 libc。
使用 call 调用write 需要 [r12+rbx*8] = write_got
设置参数 edi = r15d =1, rsi = r14 = write_got, rdx = r13 = 8
不能让 jnz 跳转,继续向下执行(继续构造 ROP 链),需要 rbx + 1 = rbp
rbx = 0, rbp = 1, r12 = write_got
布置好的栈表如下:

由于再一次执行 gadget1 时 rsp 依旧会 +8,因此当我们需要构造 ROP 覆盖返回地址时需要先填充 0x8 的 padding。
例题1:蒸米 linux_x64 level5
64 位程序,只开了 NX。
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
一个思路是自己构造 ROP 实现系统调用。

很明显可以用的 gadgets 不够。
查看 csu:

overflow = b’a’*128
fake_ebp = b’b’*8
stuff = b’c’*0x38
fake_ret_addr = p64(0)
gadget_1 = 0x400606
gadget_2 = 0x4005f0
利用 csu :
write(rdi =1, rsi=write_got, rdx = 8) 泄露 libc 基址
rdi = r13 = 1, rsi = r14 = write_got, rdx = r15 = 8, rbx = 0, r12 = write_got, rbp = 1
1
2
3
4
5payload1 = overflow + fake_ebp + p64(gadget_1) + fake_ret_addr
payload1 += p64(0) + p64(1) + p64(write_got) + p64(1) + p64(write_got) + p64(8)
payload1 += p64(gadget_2)
payload1 += stuff
payload1 += p64(main)read(rdi=0, rsi=bss_addr, rdx=16) 把 system_addr 和 /bin/sh 字符串写入可读写段
rdi = r13 = 0, rsi = r14 = bss_addr, rdx = r15 = 16, rbx = 0, r12 = read_got, rbp = 1
1
2
3
4
5payload2 = overflow + fake_ebp + p64(gadget_1) + fake_ret_addr
payload2 += p64(0) + p64(1) + p64(read_got) + p64(0) + p64(bss_addr) + p64(16)
payload2 += p64(gadget2)
payload2 += stuff
payload2 += p64(main)system(rdi = bss_addr+8 = “/bin/sh”) get shell
rdi = r13 = bss_addr+8, rsi = r14 = 0, rdx = r15 = 0, rbx =0, r12 = bss_addr, rbp = 1
1
2
3
4
5payload3 = overflow + fake_ebp + p64(gadget1) + fake_ret_addr
payload3 += p64(0) + p64(1) + p64(bss_addr) + p64(bss_addr+8) + p64(0) + p64(0)
payload3 += p64(gadget2)
payload3 += stuff
payload3 += p64(main)
写出整体的 exp:
1 | from pwn import* |
这里有一个很奇怪的点,padding 处只使用字符 ‘\x00’ ,否则无法正常 getshell,也不知道是为什么。
总结
ret2xxx 根据 ROP 的 gadgets 来源进行分类。
ret2text:利用程序本身的 gadgets
ret2shellcode:利用输入的 gadgets,栈可执行可以把shellcode写在栈上,有可读可写可执行的段也行。
ret2syscall:利用 syscall 的 gadgets,一般用于静态链接的程序
ret2libc:利用 libc 中存在的 gadgets,适用于程序调用了 libc 中的函数但没有现成的后门函数
ret2csu:程序编译时存在的 gadgets 存在通用性
栈迁移-stack pivot
参考文章:栈迁移原理介绍与应用_Max1z的博客-CSDN博客
条件:
存在栈溢出且大小至少能覆盖一个返回地址
存在可以控制内容的内存(栈、堆、bss),并且它们的地址可以被泄露,最简单的就是利用 printf 和 puts 这类函数。
SROP
参考:SROP - CTF Wiki (ctf-wiki.org)
全称为 Sigreturn Oriented Programming。Sigreturn 是一个系统调用,在 unix 系统发生 signal 的时候会被间接地调用。
Unix系统的Signal机制是一种用于进程间通信和处理异步事件的重要机制。Signal是在Unix操作系统中用来通知进程发生了特定事件或异常情况的一种软件中断。当发生这些事件时,操作系统会向目标进程发送一个信号,该进程可以选择捕获、处理或忽略信号。
Sigreturn调用是一个较为特殊的系统调用,用于恢复进程的上下文状态。它通常由信号处理函数中的恢复操作使用,以确保进程在信号处理完成后能够正确返回到原始状态。Sigreturn调用会根据传递给信号处理函数的上下文信息来还原寄存器、堆栈等状态,从而继续执行进程的正常流程。
Sigreturn调用本身不是常规的API函数,而是由操作系统内部处理信号时使用的。在处理信号时,内核会根据信号上下文的保存信息调用sigreturn函数。这确保了进程在接收到信号后可以正确地恢复到之前的状态。
原理
1.内核向进程发起一个 signal,该进程被暂时挂起,进入内核
2.内核为该进程保存相应的上下文,跳转到 signal handler 中处理相应的 signal
3.signal handler 执行完毕,内核为进程恢复之前保存的上下文
4.恢复进程的执行
Linux 下,内核会帮助用户进程将其上下文保存在该进程的栈上,然后在栈顶填上一个地址 rt_sigreturn,这个地址指向一段代码,在这段代码中会调用 sigreturn 系统调用。当 signal handler 执行完之后,ESP/RSP 就指向 rt_sigreturn,这样 signal handler 函数的最后一条指令 ret 会使得执行流跳转到这段 sigreturn 代码,执行 sigreturn 系统调用。

ucontext 和 siginfo 两块合起来就是 Signal Frame。
SROP 利用基于两点:
- Signal Frame 被保存在用户的地址空间中,所以用户是可以读写的。
- 用户进程上下文保存在栈上,且内核恢复上下文时不校验。
32 位的 sigreturn 的调用号为 119(0x77),64 位的系统调用号为 15(0xf)。
利用 pwntools 可以直接 改写 Signal Frame:pwnlib.rop.srop — Sigreturn Oriented Programming — pwntools 4.10.0 documentation
例题:ciscn_2019_s_3
64 位,开启了 NX。
查看 vuln 函数:

这里直接通过 syscall 调用了 read 和 write。read 处的可溢出量远超 buf 的大小。

gadgets 处将系统调用号设置为了 0xF,明示可以进行 sigreturn 调用。
利用 write 泄露出栈上大小为 0x30 的数据,可以通过动调算出泄露出的地址与存储 buf 的起始地址之间的偏移。这里是 def8 - dee0 = 0x118。即 泄露得到的地址 - 0x118 = buf 首地址。在 buf 中写入 /bin/sh ,这样它的地址就变为已知。
1 | search aaaaaaaa |
直接利用现有的 gadgets 进行 signal 调用,通过 SROP 得到 shell。
1 | from pwn import* |
格式化字符串
基本格式
1 | %[parameter][flags][field width][.precision][length]type |
参数 | 作用 |
---|---|
parameter(参数) | 通常用于指定要格式化的参数的索引。如果在字符串中有多个参数需要格式化,可以使用参数来指定格式应该应用于哪个参数。一般的用法是通过 n$ 来获取格式化字符串中的指定参数。 |
flags(标志 | 用于指定各种格式化选项。一些常见的标志包括:- :左对齐输出。 + :显示正数的正号。 0 :用零填充字段宽度。 # :用于不同类型的格式化(例如,八进制或十六进制前缀)。 |
field width(字段宽度) | 用于指定输出字段的最小宽度。如果要格式化的内容不足宽度,可以使用空格或零进行填充,具体取决于标志。例如,%5d 表示输出字段的最小宽度为5个字符。 |
.precision(精度) | 用于指定浮点数或字符串的小数位数或最大字符数。例如,%.2f 表示要输出的浮点数保留2位小数。 |
length(长度) | 用于指定要格式化的参数的长度或大小。通常用于整数类型,例如%ld 表示格式化一个长整数。常见的长度标识符包括 hh (短短整数 1byte)、h (短整数 2byte)、l (长整数 4byte)、ll (长长整数 8byte)等。 |
type(类型) | 指定要格式化的参数的数据类型。一些常见的类型包括:d:整数。f:浮点数。s:字符串。c:字符。x 或 X:十六进制整数。o:八进制整数。 |
一些举例
%c
通常用来输出单个字符,结合 field width 这个参数,就可以输出大量字符。
例如:
1 | printf("%100c",'a'); |
会输出大量空格:

如果这里没有后面的 ‘a’,依旧可以输出大量空格。
与 %c 同样效果的还有 %d 和 %s。
%p
在格式化字符串漏洞中用来泄露信息。
如下代码:
1 |
|
输出:

可以分别将 a 以地址的形式输出 、输出 a 的地址。
我们常用 %n$p
来泄露栈上的数据,%n$x
也可以。
%s
可以获取变量对应地址的数据,即将栈中的数据当作一个地址,获取这个地址中的数据,存在 0 截断。
以这个程序为例:
1 |
|
如果不断地输入 %s 程序肯定会崩溃,因为有的数据无法被解析为正常的地址。
输入aaaa,可以通过动调得出输入字符与格式化字符串的偏移,这里是 6:

pwndbg 自带的 fmtarg 指令可以直接计算出偏移量:
1 | fmtarg 0xffffd088 |
利用该特性可以泄露某个函数的 got 表地址。但是不常用,因为当 PIE 开启时就很难这么干了。一般可利用的数据都会在栈上,确认好偏移之后,利用 %n$p 泄露即可。
这里如果输入的是 aaaa%6$s ,程序就会崩溃,因为 aaaa 不能作为一个合法地址被解析。
%n,%hn,%hhn
最重要的几个。
%n 的作用是把已经成功输出的字符个数写入对应的整形指针参数所指的变量。
1 |
|
输出:

可以将 100 写入变量 a 中。而 %n
结合 n$
,以 %Xc%Y$n
这样的格式,可以将已经输入的字符数向指定的参数中写入,达成向任意地址写数据。%n 写入 4 字节,%hn 写入 2 字节,%hhn 写入 1 字节。
%a
以 double 型的 16 进制格式输出栈中的变量,当程序开启了 FORTIFY 机制后,printf 在编译时被 __printf_chk 函数替换。相比于 printf ,多了一些限制:1. 不能使用 %n$p
不连续地打印,比如说如果要使用 %3$p
,则需要同时使用 %1$p
和 %2$p
2. 在使用 n% 的时候会做一些检查
而此时,在可输入字符数量有限的情况下,利用 %a 就可以输出栈顶上方的数据。
原理
1 |
|
32位
输入5个%p,查看栈空间:

继续运行至输出:
1 | c |
可以看到它输出了自格式化字符串之下栈上的内容。
64位
由于 64 位的传参规则不同,参数会先依次存放在 rdi,rsi,rdx,rcx,r8,r9 六个寄存器中。输入 5 个 %p,会依次输出自 rsi 开始的5个寄存器中的值。(rdi 存放 格式化字符串本身)
1 | c |
也就是说,栈上的参数是从 %6$p 开始的。
例题:wdb_2018_2nd_easyfm
32位,NX。GOT表是可改的。

拥有无限循环的格式化字符串漏洞。
程序首先没有给现成的 system 和 /bin/sh ,需要自己通过泄露 libc 得到。利用格式化字符串漏洞修改 printf 函数的 GOT 表为 system 函数的地址,再次输入 ‘/bin/sh’,即可执行 system(‘/bin/sh’)。
首先泄露 printf 的 GOT 表值,再计算得到 system 地址,最后改写 GOT 表:
1 | from pwn import* |
可以直接利用pwntools的工具,也可以自己构造格式化字符串改写。
sandbox
参考:[原创]Seccomp BPF与容器安全-二进制漏洞-看雪-安全社区|安全招聘|kanxue.com
沙箱
沙箱是限制用户行为的计算环境,可以通过各种方式限制沙箱里执行的内容,以确保沙箱的安全性。且其中执行内容不会影响到外部,起到隔离的作用。
在计算机安全领域,沙箱是一种用于安全的运行程序的机制。它常常用来执行那些非可信的程序。非可信程序中的恶意代码对系统的影响将会被限制在沙箱内而不会影响到系统的其它部分。沙箱技术按照一定的安全策略,通过严格限制非可信程序对系统资源的使用来实现隔离。
Seccomp
secure computing mode:是linux kernel支持的一种安全机制。在Linux系统里,大量的系统调用(systemcall)直接暴露给用户态程序。但是,并不是所有的系统调用都被需要,而且不安全的代码滥用系统调用会对系统造成安全威胁。通过seccomp,我们限制程序使用某些系统调用,这样可以减少系统的暴露面,同时是程序进入一种“安全”的状态。
SECCOMP_SET_MODE_STRICT
最早(2.2.16版本)被添加进入内核,只允许使用 read, write, _exit, sigreturn 四种系统调用。除了已打开的文件描述符和允许的系统调用,如果发起其它系统调用,内核会使用 SIGKILL 或 SIGSYS 终止该进程。
SECCOMP_SET_MODE_FILTER
Seccomp - Berkley Packet Filter(BPF)
允许用户使用可配置的策略来过滤系统调用
使用 BPF 规则自定义测量 ???
可对任意系统调用及其参数进行过滤
BPF(Berkley Packet Filter)
https://zhuanlan.zhihu.com/p/636162422
工具:seccomp-bpf.h
http://outflux.net/teach-seccomp/step-3/seccomp-bpf.h
利用该工具快速构造 seccomp-bpf filter
使用库函数快速使用 seccomp
绕过沙箱
一般方法:
seccomp BPF与容器安全(上)-阿里云开发者社区 (aliyun.com)
seccomp BPF与容器安全(下)-阿里云开发者社区 (aliyun.com)
使用 shellcode 或 ROP 实现 ORW(Open/openv,Read/readv,Write/writev)
首先使用 开源工具查看 seccomp 规则:https://github.com/david942j/seccomp-tools
特殊思路:
未检查架构:
i386 和 x86-64 下的系统调用号不同,可以利用 retq 指令修改 cs 寄存器为 0x23
cs == 0x23 (32 bit) cs == 0x33(64 bit)
1 | mov DWORD [rsp+4],0x23 |
未检查范围:
在 x64 下还可以直接使用 x32-abi绕过
x32为x86-64下的一种特殊的模式,使用64位的寄存器和32位的地址,只需要直接加__X32_SYSCALL_BIT(0x4000000),即原本的 syscall number + 0x4000000
更多:
根据具体规则,结合 syscall 调用表找没被过滤的替代行数,如:execveat openv readv writev
堆利用
堆
是虚拟地址空间的一块连续的线性区域。由低地址向高地址增长。
提供动态分配的内存,允许程序申请大小未知的内存。
在用户与操作系统之间,作为动态内存管理的中间人。响应用户的申请内存请求,向操作系统申请内存,然后将其返回给用户进程。管理用户所释放的内存,适时归还给操作系统。
堆的实现有很多种,最常见的 glibc 中堆主要由 ptmalloc2 实现。
堆的基本操作有 malloc,free 等。背后的系统调用主要是 (s)brk 函数以及 mmap, munmap 函数。
虽然程序可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序。这样的话,就避免了多次内核态与用户态的切换,提高了程序的效率。
我们称这一块连续的内存区域为 arena(可以理解为堆管理器所持有的内存池)。此外,我们称由主线程申请的内存为 main_arena。后续的申请的内存会一直从这个 arena 中获取,直到空间不足。当 arena 空间不足时,它可以通过增加 brk 的方式来增加堆的空间。类似地,arena 也可以通过减小 brk 来缩小自己的空间。
数据结构
chunk
用户申请内存的单位,也是堆管理器管理内存的基本单位。我们称由 malloc 申请的内存为 chunk。malloc() 返回的指针指向一个 chunk 的数据区域。
分类:
按状态 | 按大小 | 按特定功能 |
---|---|---|
malloced | fast | top chunk |
free | small | last remainder chunk |
large | ||
tcache |
malloced_chunk: 已被分配且填写了相应数据的 chunk
free_chunk: 被释放掉的 malloced_chunk
top_chunk: arena中从未被使用过的内存区域
last_remainder_chunk: malloc分割原 chunk 后剩余的部分
用户区域的大小不等于 chunk_head.size,chunk_head.size = 用户区域大小 + 2 * 字长。
微观结构
malloc_chunk 的结构如下:
1 | struct malloc_chunk { |
INTERNAL_SIZE_T是用于内部管理块大小的字大小。默认版本与size_t相同。
一般来说,size_t 在 64 位中是 64 位无符号整数,32 位中是 32 位无符号整数。
prev_size:如果该 chunk 的物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk 。
**size(堆大小对齐)*:堆的大小必须是 2SIZE_SZ 的整数倍,如果不是这样会自动转换成整数倍。32位下,SIZE_SZ=4。64位下,SIZE_SZ=8。32 位系统堆大小为 8 的倍数,64 位为 16 的倍数。由于 8 对应的 2 进制为1000,所以该字段的低三个比特位对 chunk 的大小没有影响,它们从高到低分别表示的是:
- NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
- IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的。
- PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
fd、bk:chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中。
- fd:指向下一个(非物理相邻)空闲的 chunk
- bk:指向上一个(非物理相邻)空闲的 chunk(仅为处于双向链表bin中的free chunk时生效)
- 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
fd_nextsize, bk_nextsize:仅为 large free chunk 时生效。
- fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
- bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
- 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。
一个已被分配的 chunk 中,我们称前两个字段称为 chunk header,后面的部分称为 user data。每次 malloc 申请得到的内存指针,其实指向 user data 的起始处。
1 | chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
被释放的 chunk 被记录在链表中(可能是循环双向链表,也可能是单向链表):
1 | chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
总结:如果一个chunk是空闲的,那有两处会记录它的大小。首先它本身的 size 字段会记录,其次下一个 chunk 也会记录。一般情况下,物理相邻的两个空闲 chunk 会被合并为一个 chunk 。堆管理器会通过 prev_size 字段以及 size 字段合并两个物理相邻的空闲 chunk 块。
bin
管理 arena 中空闲 chunk 的结构,以数组的形式存在,数组元素为相应大小的 chunk 链表的链表头,存在于 arena 的 malloc_state 中。
用户释放掉的 chunk 不会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk。当用户再一次请求分配内存时,ptmalloc 分配器会试图在空闲的 chunk 中挑选一块合适的给用户。这样可以避免频繁的系统调用,降低内存分配的开销。
fast bin
当用户需要的 chunk 的大小小于 fastbin 的最大大小时, ptmalloc 会首先判断 fastbin 中相应的 bin 中是否有对应大小的空闲块,如果有的话,就会直接从这个 bin 中获取 chunk。如果没有的话,ptmalloc 才会做接下来的一系列操作。
特点:
LIFO
glibc 采用单向链表对其中的每个 bin 进行组织
支持的 chunk 大小一般为 64 字节,最大为 80 字节
fastbin 最多可以支持的 bin 的个数为 10 个,从数据空间为 8 字节开始一直到 80 字节
ptmalloc 默认情况下会调用 set_max_fast(s) 将全局变量 global_max_fast 设置为 DEFAULT_MXFAST,也就是设置 fast bins 中 chunk 的最大值。当 MAX_FAST_SIZE 被设置为 0 时,系统就不会支持 fastbin 。
small bin
每个 chunk 的大小与其所在的 bin 的 index 的关系为:chunk_size = 2 * SIZE_SZ *index。
small bin 中共有 62 个循环双向链表,每个链表中存储的 chunk 大小是一致的。
chunk 的 大小从 (32)16-504 字节,(64)32-1008 字节
特点:
- 每个链表都有链表头节点
- 采用循环双向链表结构,每个链表都遵循 FIFO 规则。
- fast bin 中的 chunk 是有可能被放到 small bin 中去的(大小重合的部分)
large bin
一共包括 63 个 bin,每个 bin 中的 chunk 的大小不一致,而是处于一定区间范围内。此外,这 63 个 bin 被分成了 6 组,每组 bin 中的 chunk 大小之间的公差一致。
unsorted bin
unsorted bin 可以视为空闲 chunk 回归其所属 bin 之前的缓冲区。
unsorted bin 处于我们之前所说的 bin 数组下标 1 处。故而 unsorted bin 只有一个链表。且其中的 chunk 处于乱序状态。
其中的 chunk 有三个来源:
- 一个较大的 chunk 被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中。
- 释放一个不属于 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中。
- 当进行 malloc_consolidate 时,可能会把合并后的 chunk 放到 unsorted bin 中,如果不是和 top chunk 近邻的话。
使用情况:
- Unsorted Bin 在使用的过程中,采用的遍历顺序是 FIFO,即插入的时候插入到 unsorted bin 的头部,取出的时候从链表尾获取。
- 在程序 malloc 时,如果在 fastbin,small bin 中找不到对应大小的 chunk,就会尝试从 Unsorted Bin 中寻找 chunk。如果取出来的 chunk 大小刚好满足,就会直接返回给用户,否则就会把这些 chunk 分别插入到对应的 bin 中。
top chunk
程序第一次进行 malloc 的时候,heap 会被分为两块,一块给用户,剩下的那块就是 top chunk。其实,所谓的 top chunk 就是处于当前堆的物理地址最高的 chunk。这个 chunk 不属于任何一个 bin,它的作用在于当所有的 bin 都无法满足用户请求的大小时,如果其大小不小于指定的大小,就进行分配,并将剩下的部分作为新的 top chunk。否则,就对 heap 进行扩展后再进行分配。在 main arena 中通过 sbrk 扩展 heap,而在 thread arena 中通过 mmap 分配新的 heap。
需要注意的是,top chunk 的 prev_inuse 比特位始终为 1,否则其前面的 chunk 就会被合并到 top chunk 中。
初始情况下,我们可以将 unsorted chunk 作为 top chunk。
last remainder
在用户使用 malloc 请求分配内存时,ptmalloc2 找到的 chunk 可能并不和申请的内存大小一致,这时候就将分割之后的剩余部分称之为 last remainder chunk ,会存放在 unsort bin 里。top chunk 分割剩下的部分不会作为 last remainder。
arena
arena 的数量和系统的核数有关:
1 | For 32 bit systems: |
当线程数大于核数的二倍(超线程技术)时,就必然有线程处于等待状态,所以没有必要为每个线程分配一个 arena。
与 thread 不同的是,main arena 作为一个全局变量存在于 libc.so 数据段。
heap_info
用来记录线程所申请的 heap 区域信息的结构。
一般申请的 heap 是不连续的,因此需要记录不同 heap 之间的链接结构。
1 |
|
该数据结构是专门为从 Memory Mapping Segment 处申请的内存准备的,即为非主线程准备的。
malloc_state
用于管理堆,记录每个 arena 当前申请的内存的具体状态(是否有空闲 chunk,有什么大小的空闲 chunk )。无论是 thread arena 还是 main arena,它们都只有一个 malloc state 结构。由于 thread 的 arena 可能有多个,malloc state 结构会在最新申请的 arena 中。
main arena 的 malloc_state 同样作为一个全局变量 存储在 libc.so 数据段。
堆的实现
可以从宏观或微观的角度去考虑:
从宏观上,堆经历了 创建 ——> 初始化——> 销毁 的过程。
从微观上来说,这些操作对应着内存块的申请和释放。
UAF
use-after-free 原理:释放一个堆块后,由于程序有漏洞(比如指针没置零),可以对释放后的堆块进行改写。随即利用堆分配算法进行攻击。
原理以一段 C 代码为例:
1 |
|
编译运行,劫持成功:

例题:actf_2019_babyheap
64位,除了 pie 保护全开。运行一下是标准的菜单题,提供了现成的 system 和 /bin/sh 字符串。
首先根据运行情况对 main 函数进行一些整理,如下:

查看 delete 函数:

free 前有检查,free 后却没有将指针设置为空值。
查看 create:

当选择create选项时,会先创建 0x10 大小的 结构体,将它的后八位设置为 print_content 函数的地址。接着输入 content 的大小和内容,将 content 的地址存放在结构体的前八位。
再查看 print:

分析到这里发现是很明显的 UAF 了,我们通过画图理一理思路:

当我们两次调用 create 函数,首先分配的是 chunk0 和 chunk1 的结构体本身,再次会根据我们输入的 size 为 content 分配 chunk 空间。
当我们输入的 size 足够大远大于 0x10 时,根据堆空间的分配规则,只有两个大小为 0x10 的结构体本身会被放到 fast bin。
我们首先 free 结构体 0 和对应的 content 再 free 结构体 1。
再次申请 content 大小为 0x10 的堆块。则结构体本身与其 content 各占 0x10 个字节。
fast bin 遵循 LIFO 的原则,因此,对于率先分配的两块大小为 0x10 的空间,原先结构体 0 的空间对应新分配的 content,结构体 1 的空间对应新分配的结构体本身。而我们输入新的 content,就相当于在修改原先 结构体 0 的内容。
此时再调用 print(1),即可 getshell。
exp:
1 | from pwn import * |
unlink
原理
俗称脱链,将链表头处的 free 堆块从 unsorted bin 中脱离出来,然后和物理地址相邻的新 free 的堆块合并成大堆块(向前或向后合并),再次放到 unsorted bin 里。

通过伪造 free 状态的 fake_chunk,伪造 fd 指针和 bk 指针,通过绕过 unlink 的检测实现 unlink。向 p 所在的位置写入 p - 0x18,实现任意地址写漏洞。
产生原因:offbynull、offbyone、堆溢出,修改了堆块的使用标志位。
查看 molloc.c 中的 _int_free 部分源码,这里以 glibc-2.28 为例:
1 | /* consolidate backward */ |
主要的检查代码:
1 | // 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致(size检查) |
伪造过程(以64位为例)
获取将要合并到的堆地址,P 一般存在于 bss 段的堆管理列表中,相当于 *chunk = P。
假设 chunk = 0x602280
通过溢出等手段改写 chunk 的 fd 与 bk 指针的值,令:
1 | P_fd = chunk - 0x18 = 0x602268 |
- 绕过
1 | //为临时变量赋值 |
相当于往 0x602280(chunk)地址对应的内存中写入了 0x602268(即 chunk - 0x18)的值。
再查看检查:
1 | // 检查 fd 和 bk 指针,确实都指向 P |
例题:hitcontraining_unlink
64 位,开启了 NX 和 cananry。
用 IDA 查看发现是标准的菜单题。
1 | int menu() |
还给了 一个可以直接读出 flag 的 magic 函数:
1 | void __noreturn magic() |
查看函数的大致情况。add:

没有开 PIE ,且change函数处存在溢出漏洞,满足 unlink 的条件,推测是通过 unlink 修改 got 表。

show 处则可以 泄露 libc。
利用过程:
创建堆块
构造 fake_chunk ,利用溢出篡改 chunk 1 的 prev_size 标志位为 0
free chunk 1,触发 unlink,使得 堆块 0 和 1 合并
把 chunk 移到存储 chunk 指针的内存处
覆盖 chunk 0 指针为 atoi 的 got 表地址并泄露
覆盖 atoi 的 got 表为 system 函数地址。
输入 /bin/sh getshell
参考:buuctf pwn hitcontraining_unlink unlink堆溢出利用 - Nemuzuki - 博客园 (cnblogs.com)
exp:
1 | from pwn import* |
off by xxx
参考:Off by Null的前世今生-安全客 - 安全资讯平台 (anquanke.com)
off by one:非预期地溢出一个字节
off by null:非预期地溢出 ‘\x00’
一般利用 off by 溢出进行堆叠和布局。
Fastbin attack
fastbins 的特点:
- fast bin chunk 的 prev_IN_USE 标志位永远为 1。
- 通过 fd 连接的单向链表
- LIFO
- 管理 0x20,0x30,0x40,0x50,0x60,0x70,0x80 大小的 free chunk,可以通过修改 global_max_fast 来更改最大范围
方法分类:
fast bin double free
house of spirit(在目标位置处伪造 fastbin chunk,并将其释放,从而达到分配指定地址的 chunk 的目的)
alloc to stack(劫持 fastbin 链表中 chunk 的 fd 指针,把 fd 指针指向我们想要分配的栈上,从而实现控制栈中的一些关键数据)
arbitrary alloc(Arbitrary Alloc 其实与 Alloc to stack 是完全相同的,唯一的区别是分配的目标不再是栈中。 事实上只要满足目标地址存在合法的 size 域(这个 size 域是构造的,还是自然存在的都无妨),我们可以把 chunk 分配到任意的可写内存中,比如 bss、heap、data、stack 等等)
漏洞成因
fastbin 通过单链表连接,修改其 fd 即可控制下一个申请的地址。条件:size 位与 fastbin 管理的大小对应。
fast bin double free
以一段 c 代码为例:
1 |
|
target 指向的就是用户使用的内存区域,data 对应 fake_chunk 的 header。
如图所示:
大致就是通过构造循环链表将堆块申请到我们想要的位置。
例题:wustctf2020_easyfast
一道64位的堆菜单题,首先还是逆向整理和函数编写。

要使得选择 4 可以执行 system,需要将 key 从 1 改为 0。

查看 target 地址附近,在 size 位正好存在 0x50 帮助我们通过 size 验证。那我们申请的堆块大小为 0x40 即可

write可更改的数据要从 0x602090 开始,那我们将上一个 chunk 的 fd 改为 0x602080 即可。
需要注意的是,本题对可申请的堆块数量有一定限制,因此还要利用 UAF。
exp 如下:
1 | from pwn import* |
原理如图所示:
Unsorted bin leak
small bin 大小的堆块被释放后会进入 unsorted bin。而 unsorted bin 中只有一个 chunk 时,该 chunk 的 fd 和 bk 指针指向 main_arena + 88。(如有多个则 fd 与 bk 有一个会指向 main_arena + 88)main_arena 作为一个全局变量存在于 libc.so 数据段。因此如果可以泄露该 chunk 的指针,就相当于泄露了 libc。
而且 main_arena 和 __malloc_hook
的偏移是固定的。可以通过偏移计算出 __malloc_hook
函数的地址,以此确定 libc。
Unsorted bin attack
前提:控制 Unsorted Bin Chunk 的 bk 指针
目的:修改任意地址值为一个较大的数值
(通常是为了配合fastbin attack而使用的)盲猜是为 fastbin attack 绕过 size 位检查
当一个 free chunk 从 unsorted bin 中取出的时候,会执行如下代码:
1 | /* remove from unsorted list */ |
要实现修改任意地址,需要先利用 UAF 等漏洞修改待取出 chunk 的 bk 指针为 target 地址,如下图:
与 fastbin attack 配合使用
fastbin attack 中构造堆块时,需要将目标地址的 size 数值处写入一个 0x7f 的数值才能够过滤掉系统的检测,如果没有方法写入,就可以利用 unsortedbin attack,将构造堆块的地址作为 unsortedbin attack 的 target 地址,那么就可以实现在指定位置处写入 0x7f 的数值了。
Tcache attack
Tcache 是 libc-2.26 加入的新机制,本意上是为了加快内存的分配,但是安全性有所降低。