参考资料:
实例文件为boss题的attachment,见github
https://zhuanlan.zhihu.com/p/37572651
https://ctf-wiki.org/executable/elf/structure/basic-info/
https://deepunk.icu/dl%E7%9B%B8%E5%85%B3%E6%94%BB%E5%87%BB%E6%B1%87%E6%80%BB/
https://www.soinside.com/question/AENBEApAgMMbfzPviVeoBc
动态链接程序的装载
当程序使用动态链接时,才会存在延迟绑定技术。
一个动态链接的程序,除了要将程序本身加载进内存之外,还需要加载对应使用的libc,这一步由ld动态链接器实现。
由于动态链接信息与程序的形成和加载由莫大关系,所以在linux系统下,这些信息必须在二进制文件中明确写出,而不是存放在某个PATH中。
1 | 首先,我们来关注一下链接视图。 |
来自CTFwiki
这里谈及的是Linking View(链接视图),也就是程序没有加载时的结构,Header table中有关链接的信息在装载时被读取,作为构建Executing View(执行视图)的依据。如下,IDA也读取到了这些信息。
1 | LOAD:0000000000000000 |
不难发现,即使是在桌面中的文件,IDA依然可以正确读取Interpreter的位置,因为这些信息已经写死在二进制文件中。
常用的工具patchelf也是通过直接修改文件达成Interpreter和libc的更换。
延迟绑定系统
对于动态链接库的使用,主要关注点在于外部函数的使用
当程序和库被装载在内存之后,.text段的指令就可以通过call来实现对外部函数的调用,对于内部的函数call指令相当于是push和jmp,然后到达对应地址之后开始压栈、执行等。而call外部函数时,对应地址是另外一条jmp,它会跳转到该函数的plt的位置。
如果这个外部函数已经被调用过至少一次,那么plt处第二次跳转会到达该函数的got表项的位置,这个got表项又是另一个jmp指令,这次终于到达了外部函数的真正地址,然后开始压栈、执行。这是外部函数大多数情况下的调用过程。
众所周知,我们在打ret2libc时,需要先泄露出libc中某一个函数在内存中的真实地址,然后根据已知的偏移找到我们需要的东西,即使是-no-pie也是一样。所以说由于种种原因,即使程序本身的地址可以通过静态分析获得确切地址,也无法预先找到libc的加载地址。
那么问题来了,由于.text肯定是没法跟着libc加载地址一起变化的,那么在使用外部函数时,怎样才能保证外部函数地址的正确呢?这就是第一次调用外部函数时需要解决的,也就是对外部函数进行重定位.
首先解决一个疑惑,为什么是在第一次调用时才重定位呢?实际上,不一定是第一次调用才重定位,也可能在main()之前就被处理好了,但在具体实现(尤其是有大量外部函数的调用时)上,还是第一次调用时重定位居多。很简单,因为重定位是一个比较消耗时间的过程,而有些函数(比如异常时结束进程的exit())很可能根本就用不上,所以就延迟绑定(lazy load),没ddl绝不干活。
由于延迟绑定的存在,所以之前所说的got表那一内存页在完成所有重定位之前,一直都要保持可写。这就是got表篡改这一漏洞的实现逻辑,既在所有重定位完成之前篡改一个或多个got表项。这个办法在partial RELRO和no RELRO时可用,在full RELRO时,函数被提前重定位,然后内存页变成只读,就没办法改了。
延迟绑定 detail
先来一个一个demo,这个是最原始纯真的延迟绑定,后面会来一个带-fcf-protection=none的demo。
1 |
|
在main()中第一次调用puts(),可以看到是puts@plt
1 | ► 0x401140 <main+26> call puts@plt <puts@plt> |
然后进去看看
1 | 0x401030 <puts@plt>: jmp QWORD PTR [rip+0x2fe2] # 0x404018 <puts@got.plt> |
再到puts@got.plt看一眼,这一块有些不知所云,网上收集的资料倒是比较容易,一致的说法是,这里是存放的是puts@plt + 6的指令,也就是又跳转回去,到了下面的0x401036的位置。
1 | 0x404018 <puts@got.plt>: ss adc BYTE PTR [rax+0x0],al |
再回来,+6位置push一个0x0到栈上,然后又跳
1 | 0x401036 <puts@plt+6>: push 0x0 |
不难看到,这块儿正好在puts@plt上,具体来说是它在plt头部,所以也叫plt[0]
又向栈上push,然后jmp到0x404010
1 | 0x401020: push QWORD PTR [rip+0x2fe2] # 0x404008 |
0x404010是一个函数的地址,这个函数就是_dl_runtime_resolve(),用于运行时进行外部函数重定位,而刚才的两次push,是为该函数提供了参数,第一个push的是rel_arg,是一个偏移值,第二个是link_map结构体。
1 | 0x404008: 0x00007ffff7ffe2e0 0x00007ffff7fd8d30 |
需要注意的是,无论是32还是64位都是这一套模式,64位在这里不会用寄存器传递这两个参数。
_dl_runtime_resolve()如何重定位
在具体讨论之前,补充一些关于Segment的东西
.dynamic,存储很多关于动态链接的信息的结构体(ELF64_Dyn),结构体内包含的是信息的种类以及地址。
1 | LOAD:0000000000403E20 ; ELF Dynamic Information |
注意关注(来自deepunk.icu)
DT_REL 动态链接重定位表地址
DT_SYMTAB 动态链接符号表地址
DT_STRTAB 动态链接字符串表地址
DT_INIT 初始化代码地址
DT_FINI 结束代码地址
.dynstr,动态链接中的字符串,可以从上面的结构体可以寻址。可以看到我们使用的puts()
我们主要关注函数名字符串,比如说在no RELRO时,可以篡改.dynamic中指向该段结构的地址指向提前伪造好的.dynstr,然后触发某函数的重定位,这个函数就被重定位到了伪造段中包含的system字样。partial RELRO 或者 full RELRO时,这段内存不可写,这种方法就使用不了。
1 | LOAD:0000000000400420 ; ELF String Table |
.dynsym,这里是一堆符号表结构体,还是主要关注函数的结构体
1 | LOAD:00000000004003C0 ; ELF Symbol Table |
1 | typedef struct |
.rel.dyn(DT_RELA)和.rel.plt(DT_JMPREL),被称为动态链接重定位表
.rel.dyn,用于修正.data和.got中的数据引用,函数的信息不在这里,一般也不是很关注这个
.rel.plt这个段和之前的rel_arg直接相关,并且用于修正.got.plt(俗称的got表)。在32位中rel_arg是用于计算它的偏移,64位里直接就是下标(deepunk.icu);
1 | LOAD:00000000004004A0 ; ELF RELA Relocation Table |
64位和32位的结构体不一样,结构体示例对比一下。(deepunk.icu)
1 | typedef struct |
rel_arg了解之后,再来解决一下上面遗留的link_map结构体。可以看到存储的是被链接的文件,以及它们对应的.dynamic以及加载地址偏移(由于-no-pie所以执行文件加载地址是0x0),之前push的是执行文件的linkmap,也就是第一个
1 | Node Objfile Load Bias Dynamic Segment |
以其中的libc.so.6为例,看看.dynamic的结构,与执行文件对比一下。
1 | pwndbg> x/20gx 0x7ffff7f9cbc0 |
可以看到两个链接文件的ELF64_Dyn的类型基本一致,说明两个文件的有关动态链接的结构相似的,后面所指向的诸如.dynstr、.dynsym、.rel.plt地址是不一样的,是各自的真实地址。
现在简单解释(感性的理解)_dl_runtime_reslove(link_map, rel_arg)是如何借助这些结构重定位某一个函数。
第一步,借助link_map找到.dynamic的加载地址,进而找到.rel.plt的位置。
第二步,借助rel_arg(作为偏移或者下标),找到的.rel.plt中指定函数的动态链接重定位表。
第三步,取出动态链接重定位表中的r_offset,用于找到.got.plt的位置(既got表)
第四步,取出动态链接重定位表中的r_info,找到函数的动态链接重定位表,取出其中的st_name,既.dynstr中的函数名字符。
一点补充(有关-fcf-protection)
这是ubuntu的gcc默认开启的一项保护措施,在第一次函数调用时,不会按照上面的流程,而是直接到glibc中,详情参考https://www.soinside.com/question/AENBEApAgMMbfzPviVeoBc
攻击手段
现在来具体分析一下这道boss题怎么做。由于给出了source code所以我们自己编译一个方便调试的执行文件,并且把随机数那一部分去掉,指令和上面那个demo一样
1 | [*] '/home/pwn/worktable/cnss2024/boss/src/attachment' |
首先来到init()函数,passwd指向一个mmap()出来的空间,passwd本身在.bss的最高位置。然后在这个空间中写入随机数,最后把前八位换成固定的deadbeef字符串,这样总共就有0x10个已写入字符。
1 | void init(){ |
动态调试一下,发现多划分了0x2000的长度。
1 | pwndbg> x/10gx 0x4040A0 |
再查看一下linkmap的地址,
再看看main(),read_num()就是atoll()。
1 | int main(){ |
大致内容比较明确,从passwd开始的位置可以8字节一组任意写,前提是知道原本那个地址的内容是什么。
这道题比较难回显,所以考虑ret2dlresolve。想法是,由于puts()在最后才会第一次调用,也就是那时会调用一次__dl_runtime_resolve来重定位puts.
另外的,由于无法控制压栈的内容,所以解释puts时的rel_arg和linkmap不能变,所以放弃伪造linkmap。
由于mmap的空间在ld内存的低位,而且偏移不变,所以可以尝试修改到ld的内容,改变linkmap内的内容,实现误导__dl_runtime_resolve。
首先查看一下linkmap的地址,发现都在ld内,重点修改的是执行文件的linkmap
1 | pwndbg> linkmap |
思路是,重定向时__dl_runtime_resolve会借助.dynstr中的字符串,在libc的linkmap中查找目标字符串的偏移,这个偏移+libc基址 被写到.got.plt中。所以这里实际上有两种方法,第一种方法,伪造一个.dynstr,使重定位查找到的不是puts,而是system;第二种方法,修改linkmap中libc的基地址,使.got.plt中被写入我们指定的函数。
博主的方法是第一种方法,并且使用docker容器作为环境,但是这种方法在docker容器中直接运行可以getshell,docker容器把attachment挂到端口上打远程时就不行,推测是直接运行的文件的内存布局和挂在端口上的不一样,尝试爆破出两者的偏移结果也没用。
exp.py仅供参考,更具体的思路是将linkmap中的l->info[DT_STRTAB]修改最后一位(LSB),变为l->info[DT_DEBUG]的地址,DT_DEBUG结构体的地址成员指向的是ld.so中的一段可读写内存,所以在这个位置的0x3e(puts字符串在.dynstr中的偏移)偏移处伪造一个system\x00字样,0x3e偏移处正好全是\x00,方便了工作。
1 | from pwn import * |