libmemguard: 排查内存异常访问bug的终极利器

简介

先贴gayhub地址:libmemguard。它的思路简而言之就是说,如果程序的某个变量/某块内存被莫名其妙地修改了,那么我们可以对这块内存mprotect设置为只读,然后当它被写的时候就会触发一个异常signal,其中引发错误的内存读写地址会作为一个参数传递给signal handler,就可以帮助我们debug了。

Code

Talk is cheap, show the code first.

注意这段代码只是一个原理demo,实际上GNU已经有一个POSIX跨平台库libsigsegv来做这件事情了。

:::C++
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <assert.h>
#include <stdint.h>
#include <time.h>
#include <execinfo.h>
#include <limits.h>
#include <ucontext.h>
#include <sys/types.h>
#include <sys/mman.h>

#define arrsizeof(x) (sizeof(x)/sizeof(x[0]))
#define PAGE_ALIGN(addr)  (void *)((uint64_t)addr & ~(4096 - 1))

static char buffer[4096];
static void *buf_page_addr = NULL;

static void handler(int sig, siginfo_t *siginfo, void *ctx)
{
  ucontext_t *context = (ucontext_t *)ctx;
  int ret = mprotect(buf_page_addr, 4096, PROT_READ | PROT_WRITE);
  assert(ret == 0);
    printf ("Violate addr = %p, RIP = 0x%08x\n",
          siginfo->si_addr, context->uc_mcontext.gregs[REG_RIP]);
  void *bt[128];
  int bt_size;
  bt_size = backtrace(bt, arrsizeof (bt));
  backtrace_symbols_fd(bt, bt_size, fileno(stderr));
  exit(0);
}

void test() {
  buffer[0] = 1;
  buf_page_addr = PAGE_ALIGN(buffer);
  printf("now protect addr %p.\n", buffer);
  int ret = mprotect(buf_page_addr, 4096, PROT_READ);
  assert(ret == 0);
  assert(buffer[0] == 1);
  buffer[0] = 2;
  assert(buffer[0] == 2);
}

int main (int argc, char *argv[])
{
  printf("main addr = %p\n", main);
    struct sigaction act;
    memset (&act, '\0', sizeof(act));

    /* Use the sa_sigaction field because the handles has two additional parameters */
    act.sa_sigaction = &handler;

    /* The SA_SIGINFO flag tells sigaction() to use the sa_sigaction field, not sa_handler. */
    act.sa_flags = SA_SIGINFO;

    if (sigaction(SIGSEGV, &act, NULL) < 0 ||
      sigaction(SIGBUS, &act, NULL) < 0 ||
      sigaction(SIGTRAP, &act, NULL) < 0) {
        perror ("sigaction");
        return 1;
    }
  puts("Test ready.");

  test();

    return 0;
}

编译:

g++ -g -rdynamic test.cpp -o test

输出:

$ ./test
main addr = 0x400da8
Test ready.
now protect addr 0x601480.
Violate addr = 0x601480, RIP = 0x00400d7b
./test[0x400cb5]
/opt/compiler/gcc-4.8.2/lib/libc.so.6(+0x35470)[0x7f0a08a6b470]
./test(_Z4testv+0x94)[0x400d7b]
./test(main+0xde)[0x400e86]
/opt/compiler/gcc-4.8.2/lib/libc.so.6(__libc_start_main+0xf5)[0x7f0a08a57bd5]
./test[0x400af9]

代码说明

上面代码只是一个demo,并没有实现地址判断的机制。里面有一点小地方需要注意,当我们进入信号处理函数的时候,如果想要程序能够正确继续执行下去,就需要消除异常。比如这是个内存访问错误,那么我们就必须解除对这块内存区域的保护,否则的话EIP会又回到先前执行出错的那条指令前继续执行,就像是缺页异常一样,只不过我们的处理程序却没有把事情做好,所以这里就会陷入死循环

需要注意的是,根据POSIX其实我们并不能在信号处理上下文中调用mprotect这样非安全的函数,这会扰乱user space的状态;但是考虑到Linux(以及所有现代Unix)将它实现为系统调用,因此实际上我们在这里可以安全地陷入内核态而不会引发什么意外。当然,如果是为了可移植性,还是要严谨滴遵守posix spec的——似乎唯一portable的行为好像就是退出……

不足与改进

一个问题在于,mprotect显然不能只保护我们想要检查的那一小部分,因为x86的内存权限位是跟着页表走的,所以至少要修改一整页的权限。这样的话我们只要在handler里面检查这个地址是否是我们所关注的那块内存区域,如果是的话就打印调用栈或者是打log,不是的话可以考虑直接触发core dump。

但如何做到只关注我们想要保护的一小段内存(小于pagesize),如果写了这个页面内其他我们不关心的地址,就先直接放行让应用继续执行这个内存操作?一个可行的思路是利用第三个参数ucontext(Linux Only)修改返回地址,跳过触发signal的指令,但是这种方式显然一点也不platform independent。libsigsegv其实也并没有实现这样的功能,而是在这种情况下直接注销了信号处理函数,可能实现起来确实会比较tricky吧。gdb倒是实现了类似的功能,只不过它是通过debugging registers实现的硬件断点,需要以父子进程的模式来调用ptrace设置调试逻辑,是一种专门用来实现调试器的机制,多少有点复杂,并且可以设置的硬件断点数量取决于调试寄存器的个数。

trap大法好

其实我们需要实现这样一个功能,对于我们需要“放行”的内存读写指令,我们可以先取消相应地址mprotect的页面保护,执行这条指令,然后再打开mprotect的页面保护,最后再跳过这条指令执行下一条就行了。所以问题就在于如何实现运行时只执行这一条访存指令然后就把保护加回来?一种直观的想法是这里我们是不是可以JIT一个函数出来,将那条访存指令复制过来,然后在其前后插入我们需要的系统调用?但问题在于,我们不能够保证这条指令的上下文是与原先完全一致的,寄存器里的值很可能并不符合预期。

所以这里我们还是在信号处理回调中解除页面保护,然后直接将下一条指令第一个字节修改为int 3,利用相应的回调函数再把页面保护加回来,以及把下一条指令改回来。至于说如何在运行时找出一条指令的长度,从而定位下一条指令,简单的想法是使用一些反汇编工具库例如libudis86这类,但实际上我们有更好的选择,逆向工程里面有个概念叫length disassembler,也就是说仅仅用来反汇编出指令长度即可,这样的实现效率当然会高得多。

comments powered by Disqus
Published:
2017-02-09
分类:
Tag: