单字节延迟绑定实现rop
1673799454684900 历史精选 245浏览 · 2025-03-20 13:13

单字节延迟绑定实现rop

之前遇到过一个有意思的题目,在只有一个字节的情况下,利用延迟绑定机制,最终实现shell或者orw,记录一下,便于后续复习

题目分析

先看一下保护措施



题目反汇编代码大概是这个样子,稍微重命名了一下



题目还给了一个txt文件



可能是想让大家知道,malloc申请这个大小的堆块是会贴着libc,然后输入偏移量,输入数据,相当于任意地址写一个字节的数据,毕竟是贴着libc,我们也完全是有偏移的,这一点很容易想到,但是当时比赛完全想不到怎么利用,想过各种hook,但是一个字节实在是太少了,什么都不够做的。

仔细观察可以发现



main函数里面其实只有一个exit,而我们的漏洞函数,暂且就重命名为vuln函数



这个函数其实是在init函数里面被调用,而如果了解基础知识,应该是知道我们的init函数其实是位于start函数



start函数里面调用mian函数,而其实mian函数的调用是在后面的函数之后,也就是说,程序在启动的时候是先进行init函数调用的,那这有什么用呢,我们暂且放下这一个知识点,来介绍一些别的

ret2dl_reslove

这其实算是一个高级栈知识,而事实上,在堆里面的house of banana就是以这个为基础的,我们以64位程序为例

延迟绑定基础简略来说,就是函数第一次调用的时候,程序先通过plt表,检索到got表,got表会寻找函数真实地址并写入got表当中,而第二次及以后的函数调用,则是从plt到got再直接去真实地址,中间会略过寻找的过程,而我们现在要做的,就是把这个过程展开说

我们来看看调用write函数



第一次调用的时候,程序会call这个函数的plt表

我们可以很轻易的发现,plt里面会有一个jmp语句

这个位置正是got表



后续会调用到dl_runtime_resolve_xsavec,而这个函数里面,又会调用到dl_fixup函数



这个函数就很有意思了



在源码里面涉及到很多结构体

Dyn 结构体用于描述动态链接时需要使用到的信息,其成员含义如下:

d_tag 表示标记值,指明了该结构体的具体类型。比如,DT_NEEDED 表示需要链接的库名,DT_PLTRELSZ 表示 PLT 重定位表的大小等。

d_un是一个联合体,用于存储不同类型的信息。具体含义取决于d_tag的值。

如果 d_tag 的值是一个整数类型,则用 d_val 存储它的值。

如果 d_tag 的值是一个指针类型,则用 d_ptr 存储它的值。

Sym 结构体用于描述 ELF 文件中的符号(Symbol)信息,其成员含义如下:

st_name:指向一个存储符号名称的字符串表的索引,即字符串相对于字符串表起始地址的偏移

st_info:如果 st_other 为 0 则设置成 0x12 即可。

st_other:决定函数参数link_map 参数是否有效。如果该值不为 0 则直接通过 link_map 中的信息计算出目标函数地址。否则需要调用 _dl_lookup_symbol_x 函数查询出新的 link_mapsym 来计算目标函数地址。

st_value:符号地址相对于模块基址的偏移值。

Rel 结构体用于描述重定位(Relocation)信息,其成员含义如下:

r_offset:加上传入的参数link_map->l_addr 等于该函数对应 got 表地址。

r_info :符号索引的低 8 位(32 位 ELF)或低 32 位(64 位 ELF)指示符号的类型这里设为 7 即可,高 24 位(32 位 ELF)或高 32 位(64 位 ELF)指示符号的索引即 Sym 构造的数组中的索引。

link_map 是存储目标函数查询结果的一个结构体,我们主要关心 l_addrl_info 两个成员即可。

l_addr:目标函数所在 lib 的基址。

l_info:Dyn结构体指针,指向各种结构对应的Dyn。

l_info[DT_STRTAB]:即 l_info 数组第 5 项,指向 .dynstr 对应的 Dyn

l_info[DT_SYMTAB]:即 l_info 数组第 6 项,指向 Sym 对应的 Dyn

l_info[DT_JMPREL]:即 l_info 数组第 23 项,指向 Rel 对应的 Dyn

视线回到 _dl_fixup函数,重点关注这句话

if (__builtin_expect(ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)

根据这个判断决定了重定位的策略。_dl_fixup 函数在计算出目标函数地址并更新 got 表之后会回到 _dl_runtime_resolve 函数,之后 _dl_runtime_resolve 函数会调用目标函数



大概是这样的

link_map 访问 .dynamic ,取出 .dynstr.dynsym.rel.plt 的指针。

.rel.plt + 第二个参数 求出当前函数的重定位表项 Elf32_Rel 的指针,记作 rel

rel->r_info >> 8 作为 .dynsym 的下标,求出当前函数的符号表项 Elf32_Sym 的指针,记作 sym

.dynstr + sym->st_name 得出符号名字符串指针。

在动态链接库查找这个函数的地址,并且把地址赋值给 *rel->r_offset ,即 GOT 表。

调用这个函数。

改写 .dynamic 的 DT_STRTAB

这个只有在 checksec 时 NO RELRO 可行,即 .dynamic 可写。因为 ret2dl-resolve 会从 .dynamic 里面拿 .dynstr 字符串表的指针,然后加上 offset 取得函数名并且在动态链接库中搜索这个函数名,然后调用。而假如说我们能够改写这个指针到一块我们能够操纵的内存空间,当 resolve 的时候,就能 resolve 成我们所指定的任意库函数。

操纵第二个参数,使其指向我们所构造的 Rel

由于 _dl_runtime_resolve 函数各种按下标取值的操作都没有进行越界检查,因此如果 .dynamic 不可写就操纵 _dl_runtime_resolve 函数的第二个参数,使其访问到可控的内存,然后在该内存中伪造 .rel.plt ,进一步可以伪造 .dynsym.dynstr ,最终调用目标函数。

而上述的这些就是我们的ret2dl_reslove攻击方式

了解这些有什么用呢,上述循环存在exit函数,而这个函数在程序开始并没有被调用,刚好我们的got表可写,我们又知道,got表回填地址,是通过link_map->l_addr来确定的,那如果我们把write的回填改到exit,exit调用就不在是退出,而我们init函数里面又是循环,而且由于write并没有被正确填写,每一次都会执行一次延迟绑定,这就意味这我们可以循环写入数据,这比正常一字节要好太多了。

exp编写

改写exit

那么首先,我们需要完成这个改写exit的操作,那第一步就是找link map相对于libc的偏移量,事实上这个数据并不好找,它其实位于ld文件里面,而在缺失符号表的情况下,找到它就更加艰难了,只能硬解析,从偏移出发,在_rtld_global里面找的对应偏移的位置(演示为2.35的环境)



里面的ns_loaded段指向的就是link map,我这里是0x7ffff7ffe2e0,我们需要修改link_map->l_addr,



把这个数据改成exit的got表与write的got表的偏移



计算写入位置和link map的偏移,我们就能写出如下代码

我们来看看写入情况



在执行完_dl_runtime_resolve_xsavec函数之后,我们来看看got表



我们可以神奇的发现,write的got并没有被写入,反而写到exit上面了,那这样的话,我们就不会exit,而由于write也没有正确写入,每次都会重新覆写寻找

再之后的思路就比较简单了,我们覆写stdout去泄露libc

泄露libc

通过无限次修改,改stdout的flag字段和write指针,就可以在调用write的时候触发io,泄露libc出来,可是我们一开始看的时候,会发现里面是空的



原因也很简单,我们需要刷新一次stdout,不然是不会存在libc的,所以我们可以通过修改_IO_flush_all进行刷新,那这怎么办呢,上面讲dlreslove的时候介绍过,索引函数的时候会根据函数名来定位,那么我们便可以去伪造这一部分

可能前面那一段有些师傅是跳着看的,所以这里我再写一下

如果你了解过 "House Of Blindness" 的分析文章,会知道通过 修改 Elf64_Dyn 指针的最低有效字节(LSB),可以让程序从 可写的内存区域(而非只读的二进制文件) 中读取关键资源(如符号表、字符串表等)。

关键结构解析

1 l_info 数组:

l_info 是一个紧凑的指针数组,每个指针指向 .dynamic 段中的一个 Elf64_Dyn 结构体。

通过覆盖 l_info 中某个指针的最低有效字节(LSB),可以改变其指向的Elf64_Dyn。例如:

l_info[DT_SYMTAB](符号表指针)的 LSB 改为 0x78,使其指向 DT_STRTAB(字符串表)对应的 Elf64_Dyn

利用核心:劫持 DT_DEBUG_r_debug

1 DT_DEBUG 的特殊性:

DT_DEBUG 对应的 Elf64_Dyn 中存储了一个指针 _r_debug,该指针位于 动态链接器(ld.so)的可写内存区域

由于 _r_debug 所在内存可写,我们可以 伪造任意的 Elf64_Dyn

2 攻击流程:

通过 LSB 覆盖,使 l_info 中的某个条目(如 DT_SYMTAB)指向 DT_DEBUG

此时,程序会从 _r_debug 指向的可写内存中读取伪造的 Elf64_Dyn 结构。

例如:将 字符串表(DT_STRTAB)的地址 篡改为指向 _r_debug,并在此区域伪造字符串 "system"。

_dl_fixup 尝试解析符号时,会读取伪造的字符串,从而将函数调用重定向到 system

所以我们现在可以修改l_info[DT_SYMTAB](符号表指针)的 LSB ,修改到DT_DEBUG,而提前在DT_DEBUG里面写上_IO_flush_all字符串,这样的话,解析别的函数的时候就会触发io刷新,所以现在问题就是,怎么找这些数据,还是使用gdb,我们先找到r_debug的地址



然后将其转换为 struct r_debug * 并访问 r_map

pwndbg> p/x ((struct r_debug *)0x7ffff7ffe118)->r_map

将上一步得到的r_map地址转换为 struct link_map *,并访问其 l_info[6]

pwndbg> p/x ((struct link_map *)0x7ffff7ffe2d0)->l_info[5]

当然也可以一步完成



像这样我两次得到的地址也是相同的,至于为什么是5,这是因为在GDB中直接使用DT_SYMTAB符号可能会未定义错误,因为它属于动态链接器的内部定义(需加载相关符号),而在ELF标准中,DT_SYMTAB的值为5。直接替换数值即可,这样我们就找到l_info[DT_SYMTAB]的地址了,里面存放的地址就是0x5开头的这个数据,我们要做的就是把这个里面的地址修改成DT_DEBUG的地址,现在第二个问题来了,DT_DEBUG的地址怎么办,DT_DEBUG.dynamic 段中的一个独立条目,不在 l_info 数组中。所以我们需要通过遍历 .dynamic 段找到它。

当然其实并不需要这么麻烦,我们可以从已知的 DT_SYMTAB 地址附近开始搜索



这个前面为0x15的,就是DT_DEBUG地址,那也就是说,我们只要修改



这个7e78为7eb8即可,其实也就是一个字节,当我们完成修改之后,我们可以调试看看什么情况



我们重点关注一下_r_debug的地址



在write函数进去,最终会进入_dl_lookup_symbol_x函数(其实名字就能看出来是和解析符号表有关系的)



注意看这一行,上面关注过_r_debug的地址,rdi的值就是r_debug加上0x3e,而我们把修改语句去掉可以对比看看



在没有修改之前,这里是一个elf文件地址,并且不可写,这个地址正是l_info[DT_SYMTAB],所以在修改成一个libc地址之后,我们又可以对libc进行修改,这样就可以在这里填上字符串,而在把rdi伪造成字符串地址也是有原因的,因为_dl_lookup_symbol_x函数的第一个参数就是待查找函数的函数名,也就是rdi

那接下来就应该在r_debug加上0x3e的位置写上_IO_flush_all(这个是修改前伪造,这样下一次修改完直接就运行到这个函数了,不然因为本来加0x3e的位置是空的,程序解析不了,会报错退出)



可以看到,我们解析的函数名就变成了_IO_flush_all,而调用write就会变成调用IO_flush_all,但是运行结束后依然没有libc地址在stdout上面,其实是因为需要提前布置一下flag字段和write的指针,把flag改成0xfbad3887,IO_write_ptr改成0xff,这里解释一下为什么,虽然大家io都挺了解的,但是这个参数用的确实少

0x3887 = 0011 1000 1000 0111 (二进制)

_IO_USER_BUF (0x0001):用户分配的缓冲区。

_IO_UNBUFFERED (0x0002):无缓冲模式(直接写入)。

_IO_NO_READS (0x0004):禁止读操作(stdout仅用于写)。

_IO_LINKED (0x0080):结构体在全局文件链表中。

_IO_CURRENTLY_PUTTING (0x0800):当前正在写入数据。

_IO_IS_APPENDING (0x1000):以追加模式打开。

这意味着当前 stdout 处于 全缓冲模式,会刷新自己



可以看到,我们已经把对应libc的地址写到io里面了,然后就正常去修改结构体泄露



短期总结一下

伪造io结构体

有了libc,有了任意地址写,怎么打其实都行,这题最开始是2.31的环境,只不过我是2.35,就用io进行攻击好了,直接改io_list_all,就到这个大堆块上面,不过由于我们一次只能写一个字节,所以我们要先把write改回来,这样改完字节之后不会出问题

write(0x2C9338, 0x78)



这样就改好了,现在要做的就是伪造结构体,这一部分不再多说,直接套公式都行(严格来说,并非伪造,而是直接在上面改)

这里直接给出最后完整的exp



这样就拿到shell了,至于orw,和io的orw操作相同,这里不再赘述

0 条评论
某人
表情
可输入 255