前言
在先前其实我已经写过一篇ret2dl-runtime-resolve了。不过在那篇文章中并没有提到当RELRO
的等级为FULL
的情况下该如何进行利用方法。又因为这类题目一般来说就是模板题目的缘故所以我也就一直没有放在心上,直到这一次巅峰极客遇到了这样一道题目。事先需要提到的是,这道题其实是具有更简单的解题方法的,那就是将got表中read的函数地址写到bss中,随后修改便宜直接调用syscall即可。不过既然取名叫link_map
也导致我头铁到一直尝试_dl_runtime_resolve
的方式去解决,所以后续的题目都没看。
因为内核玩的比较多,所以下来看了那一道内核题,题目给的驱动是没有漏洞的,但是因为没有加任何锁的缘故并且内核版本为5.10.x所以可以直接使用堆占位技术直接造成UAF,还算是比较简单,所以不会单独写文章进行复现。
利用原理
Full和Partial的区别
首先最直接的区别就是在Full的情况下got表是不可写的,并且所有符号的在在开始时就会被解析,.got.plt
段会被完全初始化为目标函数的最终地址。这也就导致link_map
和_dl_runtime_resolve
不会被加载。所以首先需要的就是泄漏出link_map
和_dl_runtime_resolve
函数。
利用的必要条件
一、栈溢出
二、存在一个任意地址读取并且能写到任意地址
其实有了如上条件之后依旧可以选择的更简单的方式就是读取got表中read
函数的地址,并且进行partial write
使其最终指向syscall
。
获取link_map
1 | LOAD:0000000000600E10 0C 00 00 00 00 00 00 00 B0 04+Elf64_Dyn <0Ch, 4004B0h> ; DT_INIT |
在入口处存在一个.dynmic
叫做DT_DEBUG
,由调试器使用。
1 | typedef struct |
这里的d_ptr
位置只想的是r_debug
结构体。
1 | struct r_debug |
可以看到中间的r_map
就是指向link_map
的地址了。
获得_dl_runtime_resolve
1 | struct link_map |
可以看出来link_map
其实是一个双向链表的形式,而且其中存在一个相当有用的成员l_info
。而其中的DT_PLTGOT
的d_tag
存放的是got表的地址,那么可以经过多次的l_next
的查找得到libc的got表地址进而获得_dl_runtime_resolve
1 | pwndbg> p/x *((struct link_map*)0x7fd64bb592e0)->l_next->l_next->l_info[3] |
可以看到_dl_runtime_resolve_xsave
函数就在偏移为0x10
的位置了。
例题
题目分析
1 | __int64 __fastcall main(__int64 a1, char **a2, char **a3) |
题目的代码很简单,main
函数就只有这几行代码,并且没有任何输出函数。
1 | __int64 __fastcall sub_400606(unsigned int a1, int a2, int a3) |
这一段函数则是刚好满足第二个函数,能够实现任意地址读取之后写入到任意地址。
exp
因为前面已经提到了利用原理,这里就不多赘述直接上exp吧。
因为我的exp是边打边写的,所以写得像一坨shit所以我在每一步都加了注释方便理解。
1 | from pwn import * |
重点:与Partial利用方式的区别
Partial利用方式重谈
( 在下面讨论老版本时,默认只存在栈溢出,不存在上述题目中的gadget )
在以前的文章,在64位的partial保护中我写的非常粗糙,所以在这里也趁此机会详细谈一下。
1 | DL_FIXUP_VALUE_TYPE |
首先则是关于_dl_fixup
函数的重新分析,通过字符串进行查找对应函数时我们需要进入到底这条if语句
1 | if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0) |
而最终真正找到函数地址的函数其实是_dl_lookup_symbol_x
函数。然而其中存在一条调用关系是do_lookup_x
=>check_match
。
1 | static const ElfW(Sym) * |
而在check_match
函数中通过这条if (version != NULL)
语句又分为了两条分支,肉眼可见的是上面的分支是较为严格的一条,而下面的则是较为简单的一条。不过显而易见的是这条分支的走向是由version
变量决定的。
1 | if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) |
而version
的由来就是上面的这段代码赋予的,所以如果我们可以控制
1 | l->l_info[VERSYMIDX (DT_VERSYM)] == NULL |
或者可以控制ndx
都是可以解决的。
首先思考第二种方式,首先则是可以看到这里ndx
中的计算方式ELFW(R_SYM) (reloc->r_info)
和前面获取sym
时是同一种运算方式。所以这一算式中的各个参数我们是优先保证sym的正确性的。不过DT_SYMTAB
所处的段和DT_VERSYM
所处的段是不一样的,这也是我们能够修改这里的依据之一,另一个依据则是&l->l_versions[0]
的内容是NULL。所以我们只需要让他为NULL即可。
DT_VERSYM
节的位置其实就是.gnu.version
节的位置,所以首先通过readelf
查看一下节的位置
1 | Version symbols section '.gnu.version' contains 7 entries: |
随后看一下程序在运行时的内存布局
1 | pwndbg> vmmap |
可以看到DT_VERSYM
所处的位置就是第一页中。
而在linux存在这样一种分页机制,如果当前页的使用不到0x1000
其实也会返回一页,所以在没有使用完的情况下,页内可能存在空白数据也就是\x00
。而这个段的结束位置则在.eh_frame
可以看到地址为0000000000400860
,那么当这个结束地址减去.gnu.version
的地址则表示所取的ndx
的偏移到0x400860 ~ 0x401000
之间到最小值。
在x64和x32上ElfW(Half)
的结构都是2个字节。所以上述的最小偏移是:(0x400860-0x4003dc) / sizeof(ElfW(Half)) = 0x242
而我们需要把伪造的内容放到bss段上,随后计算最大偏移则是根据sym的获取进行计算(0x602000-0x601000) /sizeof (Elf64_Sym) = 0xaa
。最终得到0xaa < 0x242
所以也就导致无法在满足ndx的同时拿到伪造的sym结构。**(不过32位是可以的,只需要在bss靠后的位置写sym结构体即可)**。
那么接着思考第一种方式,在只有栈溢出的情况下我们无法直接泄漏或修改link_map
结构体中的内容,那么唯一可行的方法就是进行栈迁移在bss中伪造link_map
,但是如果是走第一个分支就会出现一种情况了。
1 | lookup_t |
在_dl_lookup_symbol_x
函数中搜索的方法是根据scope
这个范围搜索的,而这个范围是通过l->l_scope
获取的,所以如果我们在没有任何地址泄漏的情况下要想伪造l_scope
是不现实的。那么唯一的方法是什么呢?
当然是走else语句了。
1 | value = DL_FIXUP_MAKE_VALUE (l, SYMBOL_ADDRESS (l, sym, true)); |
这里直接返回的是link_map->l_addr + sym->d_val
。
1 | from pwn import * |
这里再次审视这段exp,可以发现l_addr
其实就是read
函数和system
函数之间的差值,而sym->d_val
就是read
函数的地址,并且可以将这篇文章的代码进行编译会发现他的got表中read
函数不是首位,第一个函数是_libc_start_main
这也刚好可以让sym->st_other
不为0从而进入else。
与Full的差别
可以很明显的看出来,这道题目所利用的方式其实是走的if分支而不是else,因为这道题压根没给libc所以无法计算偏移。虽然我在构造sym结构体的时候选择了read@got - 8
的位置,但是属于是瞎猫碰到死耗子这个题目中的read
就是got表中的第一个函数,所以才没有导致sym->st_other
为非0。
可能大家会疑惑为什么这里能够走上面的if语句,又是因为运气好(可能是做题运气用完了,国赛啥都没抽到😭),在进行时ndx又是为0。当然如果不为0我们可以采取上面的第二种办法,直接覆盖0x1d0
为0即可。
参考链接:
https://inaz2.hatenablog.com/entry/2014/07/29/020112
https://elixir.bootlin.com/glibc/latest/source/elf/dl-runtime.c#L41