关于ld在pwn中利用的学习

本文最后更新于:2024年4月22日 上午

看好了,ld之力是这样用的!

关于ld段

谈到ld,资深pwn手一定不会陌生。作为glibc的动态链接器件,它担任着帮助程序完成动态链接的任务。

ld的利用常常被忽略(毕竟libc里面有更多资源,也更容易被利用)。但是有些时候尝试利用ld会有奇效。

之所以突然决定写这篇文章,是因为最近刚做一个偏门的ld库利用的题,就计划着做一个总结。

动态链接原理详解——ret2dl_resolve不只是抄板子

这个技巧提出很久了,已经有很多人写过相关的博客,我这里重新整理一下。

首先我们需要再论一下ret2libc,我们知道程序使用库函数要进行重定位,因此程序中有got和plt两个表。call一个库函数时,程序会先跳转到plt表,plt表会取出对应got表中存的函数指针并跳转执行。ret2libc的两个核心用法:跳转至plt表执行库函数(或者使用csu中的call直接取got表指针)和利用got表进行libc地址泄露,就是基于这个机制。

但是我们在学习ret2libc的时候,有一个被忽略的问题,那就是绑定的过程,即库函数怎么在libc中被找到的。这个过程也就是ret2dl_resolve这一过程的关键。

再论elf结构

首先,这个技巧只适用于延迟绑定,即RELRO没有开满的情况。如果程序的防护机制为Full RELRO,那么相关函数会提前绑定号,got表会变成只读,就不能进行延迟绑定,ret2dl_resolve也就无从谈起了。

这篇文章着重记录64位Partial RELRO的打法

所以我们需要简要分析ret2dl_resolve的过程,但是在此之前我需要介绍几个结构:

Elf64_Dyn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
Elf32_Off d_off;
} d_un;
} Elf32_Dyn;//32位程序

typedef struct {
Elf64_Xword d_tag;
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;

这个段是由上述数据类型组成的一个表,记录了和重定位信息相关的数据项

d_tag表示类型,后面的联合体表示其数值(或者是地址,指向其他的段)

DT_STRTAB

有一个特点是表中第一个字节为\x00。字符串表,存放函数名字符串。

这个表里存的都是需要重定位的函数名字,重定位操作一个关键就是根据这个字符串找对应函数的。

DT_SYMTAB

这个是动态符号表

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;

这个结构有好多的版本,不过基本都是这种六个变量的情况,名字换了罢了。

同样地,我们发现第一个表项也是空,这和STRTAB有点类似。

从第二个表项开始,每个的第一个元素值得注意,这是一个差值的形式,点进去可以发现这个其实就是字符串在STRTAB的偏移地址。

其他的成员暂时不用在意。

DT_JMPREL

1
2
3
4
5
6
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;

这个结构比较关键,在IDA找到对应的表我们可以发现这个表分为了两个部分,上面的都是已经重定位过的函数,下面几项的是未重定位过的函数。

每一个表项对应一个函数,r_offset表项记录了其got表项地址。

r_info关系函数在got表项中的下标。可以看到在64位程序里,下标放在了高四个字节,低四个字节有一些类似于标志的东西。所有普通的库函数低位都是7。

第三个成员不知道有啥用,一般都是0,关系不大。

延迟绑定概述

我们回顾一下学ret2libc时看过的延迟绑定的过程

这题的附件取自DASCTF 2023 11月的A sad story,我等会放在最后

plt表一般如下

1
2
.plt.sec:00000000000010E0 F3 0F 1E FA                   endbr64
.plt.sec:00000000000010E4 F2 FF 25 35 2F 00 00 bnd jmp cs:off_4020

可以看到jmp到了got的表项地址,但是我们知道got表只有经过了延迟绑定之后才能有函数地址。在此之前,got表里一般存的是这一个函数的地址:

1
2
3
4
.plt:0000000000001030                               sub_1030 proc near
.plt:0000000000001030 F3 0F 1E FA endbr64
.plt:0000000000001034 68 00 00 00 00 push 0
.plt:0000000000001039 F2 E9 E1 FF FF FF bnd jmp sub_1020

然后又跳到

1
2
3
.plt:0000000000001020                               ; __unwind {
.plt:0000000000001020 FF 35 E2 2F 00 00 push cs:qword_4008
.plt:0000000000001026 F2 FF 25 E3 2F 00 00 bnd jmp cs:qword_4010

可以看到这两个地方也是在plt,但是和别的plt表项不大一样。第一个代码段push了一个0,push和jmp的地址点击去发现是got表的0和1,其实这两项是在正序执行之后才装载进got表的。这两项分别是link_map地址和dl_runtime_resolve。

link_map是一个重要的结构,保存了以上几个重要重定位节的地址,这个结构应该在libc里面。

dl_runtime_resolve执行需要参数,这个函数的传参不是基于寄存器的,而是基于栈的,程序执行时会先把参数压栈,然后进入函数之后保存寄存器并将栈上的参数取出。它需要的参数就是push 0和push link_map_addr,也就是说参数分别为link_map地址和函数的一个“下标”

因此我们进行ret2dl_resolve的思路,就是伪造这两个参数并执行dl_runtime_resolve函数。

函数实现细节

在讨论ret2dl_resolve攻击的细节之前,我们需要详细了解dl_runtime_resolve函数的实现。

dl_runtime_resolve

glibc源码中并未找到这个函数,其实这个函数本身是一段汇编指令,实现的操作主要是保存相关寄存器,并调用_dl_fixup

_dl_fixup

刚进入函数的时候代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
DL_FIXUP_VALUE_TYPE
attribute_hidden __attribute((noinline)) DL_ARCH_FIXUP_ATTRIBUTE
_dl_fixup(
#ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
ELF_MACHINE_RUNTIME_FIXUP_ARGS,
#endif
struct link_map *l, ElfW(Word) reloc_arg)
{
const ElfW(Sym) *const symtab = (const void *)D_PTR(l, l_info[DT_SYMTAB]);
const char *strtab = (const void *)D_PTR(l, l_info[DT_STRTAB]);

const uintptr_t pltgot = (uintptr_t)D_PTR(l, l_info[DT_PLTGOT]);

const PLTREL *const reloc = (const void *)(D_PTR(l, l_info[DT_JMPREL]) + reloc_offset(pltgot, reloc_arg));
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM)(reloc->r_info)];
const ElfW(Sym) *refsym = sym;
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
lookup_t result;
DL_FIXUP_VALUE_TYPE value;

D_PTR(a,b)这个宏等价于a->b,可以看到这里从link_map中的l_info成员内部取了好多的结构的地址。这几个结构,是elf文件的节头部表的表项的地址。

link_map是一个很大的结构,这里不放全了,需要的读者可以自己去/include/link.h中查阅

下面的分支,可以看到,函数通过STRTAB的偏移取到了要绑定的函数的名称字符串,并作为参数传进了_dl_lookup_symbol_x这个函数当中。这个函数的作用就是在libc中根据函数名搜索到对应函数,我们先不分析,只要先记住这个分支进入前提是sym->st_other为0,(sym正常情况下是elf中对应函数的symtab表项)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
if (__builtin_expect(ELFW(ST_VISIBILITY)(sym->st_other), 0) == 0)
{
const struct r_found_version *version = NULL;

if (l->l_info[VERSYMIDX(DT_VERSYM)] != NULL)
{
const ElfW(Half) *vernum =
(const void *)D_PTR(l, l_info[VERSYMIDX(DT_VERSYM)]);
ElfW(Half) ndx = vernum[ELFW(R_SYM)(reloc->r_info)] & 0x7fff;
version = &l->l_versions[ndx];
if (version->hash == 0)
version = NULL;
}

/* We need to keep the scope around so do some locking. This is
not necessary for objects which cannot be unloaded or when
we are not using any threads (yet). */
int flags = DL_LOOKUP_ADD_DEPENDENCY;
if (!RTLD_SINGLE_THREAD_P)
{
THREAD_GSCOPE_SET_FLAG();
flags |= DL_LOOKUP_GSCOPE_LOCK;
}

#ifdef RTLD_ENABLE_FOREIGN_CALL
RTLD_ENABLE_FOREIGN_CALL;
#endif

result = _dl_lookup_symbol_x(strtab + sym->st_name, l, &sym, l->l_scope,
version, ELF_RTYPE_CLASS_PLT, flags, NULL);

/* We are done with the global scope. */
if (!RTLD_SINGLE_THREAD_P)
THREAD_GSCOPE_RESET_FLAG();

#ifdef RTLD_FINALIZE_FOREIGN_CALL
RTLD_FINALIZE_FOREIGN_CALL;
#endif

/* Currently result contains the base load address (or link map)
of the object that defines sym. Now add in the symbol
offset. */
value = DL_FIXUP_MAKE_VALUE(result,
SYMBOL_ADDRESS(result, sym, false));
}

另一个分支是ret2dl_resolve关注的重点,SYMBOL_ADDRESS这个宏会进行一个l->addr+sym->st_value的操作,并且将这个结果返回。

如果我们是劫持了程序执行流,在push了一个idx(一个已经绑定过的函数的下标)之后调用了push link_map,然后进行动态链接的话。假如我们控制程序走到了这个分支,在l->addr这里填入了两个函数的差值offset,那么程序就会找到got表项中存放的函数地址,然后相加,得到想劫持到的函数地址。这样就可以实现ret2dl_resolve

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
else
{
/* We already found the symbol. The module (and therefore its load
address) is also known. */
value = DL_FIXUP_MAKE_VALUE(l, SYMBOL_ADDRESS(l, sym, true));
result = l;
}

/* And now perhaps the relocation addend. */
value = elf_machine_plt_value(l, reloc, value);

if (sym != NULL && __builtin_expect(ELFW(ST_TYPE)(sym->st_info) == STT_GNU_IFUNC, 0))
value = elf_ifunc_invoke(DL_FIXUP_VALUE_ADDR(value));

#ifdef SHARED
/* Auditing checkpoint: we have a new binding. Provide the auditing
libraries the possibility to change the value and tell us whether further
auditing is wanted.
The l_reloc_result is only allocated if there is an audit module which
provides a la_symbind. */
if (l->l_reloc_result != NULL)
{
/* This is the address in the array where we store the result of previous
relocations. */
struct reloc_result *reloc_result = &l->l_reloc_result[reloc_index(pltgot, reloc_arg, sizeof(PLTREL))];
unsigned int init = atomic_load_acquire(&reloc_result->init);
if (init == 0)
{
_dl_audit_symbind(l, reloc_result, sym, &value, result);

/* Store the result for later runs. */
if (__glibc_likely(!GLRO(dl_bind_not)))
{
reloc_result->addr = value;
/* Guarantee all previous writes complete before init is
updated. See CONCURRENCY NOTES below. */
atomic_store_release(&reloc_result->init, 1);
}
}
else
value = reloc_result->addr;
}
#endif

/* Finally, fix up the plt itself. */
if (__glibc_unlikely(GLRO(dl_bind_not)))
return value;

return elf_machine_fixup_plt(l, result, refsym, sym, reloc, rel_addr, value);
}

板子

放上一个fake_link_map生成器,我知道好多人是想来抄板子的。

64位Partial RELRO

懒得写了,先写最重要的作为记录

伪造link_map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def create_link_map(l_addr,know_got,link_map_addr):
link_map=p64(l_addr & (2 ** 64 - 1))
#dyn_relplt
link_map+=p64(0)
link_map+=p64(link_map_addr+0x18) #ptr2relplt
#relplt
link_map+=p64((know_got - l_addr)&(2**64-1))
link_map+=p64(0x7)
link_map+=p64(0)

#dyn_symtab
link_map+=p64(0)
link_map+=p64(know_got-0x8)

link_map+=b'/flag\x00\x00\x00'

link_map=link_map.ljust(0x68,b'B')

link_map+=p64(link_map_addr) #ptr2dyn_strtab_addr
link_map+=p64(link_map_addr+0x30) #ptr2dyn_symtab_addr

link_map=link_map.ljust(0xf8,b'C')

link_map+=p64(link_map_addr+0x8) #ptr2dyn_relplt_addr
return link_map

这个函数可以构造link_map结构,参数第一个填libc.sym[target]-libc.sym[know],第二个填一个已知函数的got表地址,第三个填伪造的link_map地址

其中0xf8偏移为伪造的elf_dyn节中对应的JMPREL表项的地址,实际指向开头的一个位置,那里存着JMPREL的地址。0x68偏移为伪造的STRTAB表地址,0x70偏移为伪造的SYMTAB表项地址。

进行dl_runtime_resolve函数调用时,下标参数填0,link_map填伪造的link_map地址。

注意,dl_runtime_resolve函数使用时,会造成抬栈行为。这种题目往往会伴随栈迁移。因此需要靠后布置rop链和bss段,避免污染got表或者触发段错误。

同时,link_map的布置也应该远离rop链子。

link_map攻击

我们可以直接攻击ld段内存放的link_map结构。当然这不意味着非要任一地址写。有时候mmap会在ld前面分配空间,如果有越界漏洞,就可以攻击ld结构。

ld上方的神秘的mmap段

(挖坑待填)
有时候mmap会把内存分配到ld上方固定偏移处。ld上方紧邻的地方也有一个不知何时通过mmap创建的段。,这个mmap的段内有时候可能有ld地址。

_dl_lookup_symbol_x细节剖析

(挖坑待填)
劫持strtab指针,篡改延迟绑定的函数名字

check_match

(挖坑待填)
从elf进行延迟绑定时候,elf文件内保存了当前函数的版本号(GLBC2.2.4这种)check_match会进行检查。经过测试,canary的报错函数和openat是一个版本。可以直接替换,以此绕过canary


关于ld在pwn中利用的学习
http://example.com/2024/04/14/Blog/Pwn/pwn note/ld/ld攻击/
作者
Jmp.Cliff
发布于
2024年4月14日
许可协议