Android 捕获native栈溢出真的需要 sigaltstack & SA_ONSTACK 吗

229 阅读6分钟

背景

这篇文章起因是周末在Linux上写的个小程序发生了栈溢出,然而我的简易crash handler没有捕获到,于是简单记录一下。

这个问题其实很简单:在crash sdk初始化的时候调用sigaltstack来准备一个备用栈,在栈溢出的时候用于运行signal handler(设置了 SA_ONSTACK flag),然而有个显然的问题我没注意:sigaltstack 是为当前线程设置备用栈的,如果只是在初始化crash sdk的线程中调用 sigaltstack,那么其他线程栈溢出的时候自然是抓不到的。

不过这个问题在Android上还有点不同:

  1. 上面的“策略”(只在初始化crash sdk的线程中调用 sigaltstack)在Android上是work的,其他线程的栈溢出也能捕获,这是为啥呢?
  2. 在Android上,我们的 sigaction 不设置 SA_ONSTACK,“大概率”也能捕获栈溢出的问题,这又是为啥呢?

sigaltstack 的实现 & SA_ONSTACK 的作用

sigaltstack

sigaltstack 在内核中的实现是:do_sigaltstack

static int
do_sigaltstack (const stack_t *ss, stack_t *oss, unsigned long sp)
{
  struct task_struct *t = current;
​
  if (oss) {
    memset(oss, 0, sizeof(stack_t));
    oss->ss_sp = (void __user *) t->sas_ss_sp;
    oss->ss_size = t->sas_ss_size;
    oss->ss_flags = sas_ss_flags(sp) |
      (current->sas_ss_flags & SS_FLAG_BITS);
  }
​
  if (ss) {
    void __user *ss_sp = ss->ss_sp;
    size_t ss_size = ss->ss_size;
    unsigned ss_flags = ss->ss_flags;
    int ss_mode;
​
    if (unlikely(on_sig_stack(sp)))
      return -EPERM;ss_mode = ss_flags & ~SS_FLAG_BITS;
    if (unlikely(ss_mode != SS_DISABLE && ss_mode != SS_ONSTACK &&
        ss_mode != 0))
      return -EINVAL;
​
    if (ss_mode == SS_DISABLE) {
      ss_size = 0;
      ss_sp = NULL;
    } else {
      if (unlikely(ss_size < MINSIGSTKSZ))
        return -ENOMEM;
    }
​
    t->sas_ss_sp = (unsigned long) ss_sp;
    t->sas_ss_size = ss_size;
    t->sas_ss_flags = ss_flags;
  }
  return 0;
}
  • 注意其中的current,他是用于获取指向当前线程结构的指针,这段逻辑比较简单:就是将我们提供的备用栈的 ss_sp & ss_size 分别设置到当前线程结构task_struct 的 sas_ss_sp & sas_ss_size 字段中
  • 从这段代码可以看出:sigaltstack 只会为当前线程设置备用栈,因此若想支持捕获所有线程的栈溢出问题应该在所有线程上调用 sigaltstack
SA_ONSTACK

SA_ONSTACK 这个 flag 主要是内核在handle signal 时,会根据这个 flag 来获取 signal handler 运行时的初始sp,具体可以看:sigsp

static inline unsigned long sigsp(unsigned long sp, struct ksignal *ksig)
{
  if (unlikely((ksig->ka.sa.sa_flags & SA_ONSTACK)) && ! sas_ss_flags(sp))
#ifdef CONFIG_STACK_GROWSUP
    return current->sas_ss_sp;
#else
    return current->sas_ss_sp + current->sas_ss_size;
#endif
  return sp;
}
​
static inline int sas_ss_flags(unsigned long sp)
{
  if (!current->sas_ss_size)
    return SS_DISABLE;
​
  return on_sig_stack(sp) ? SS_ONSTACK : 0;
}

从这段代码可以看出:只有设置了 SA_ONSTACK flag 并且当前线程已经设置备用栈时,signal handler 才会在我们设置的备用栈上运行

Android 的特殊之处

不设置备用栈、栈溢出时 signal handler 也能正常执行

我们可以在 signal handler 中加段代码来判断这个时候 handler 的 stack frame 其实不在线程栈中:

static void sighandler(int, struct siginfo*, void*) {
    pthread_attr_t attr;
    if (pthread_getattr_np(pthread_self(), &attr) != 0) {
        FATAL("failed to get pthread's attr");
    }
​
    void* stackBase;
    size_t stackSize;
    if (pthread_attr_getstack(&attr, &stackBase, &stackSize) != 0) {
        FATAL("failed to get stack");
    }
​
    stack_t sigStack;
    if (sigaltstack(nullptr, &sigStack) != 0) {
        FATAL("failed to get old sig stack");
    }
​
    LOGI("stack base: %p, stack size: %ld, sig stack base: %p, sig stack size: %ld, curr stack value addr: %p"
         , stackBase, stackSize, sigStack.ss_sp, sigStack.ss_size, &stackSize);
​
    // ...
}
  1. 通过 pthread_attr_getstack 可以拿到线程栈:stack base & stack size
  2. 通过 sigaltstack(nullptr, &sigStack) 可以在不修改备用栈的情况下去获取当前线程的备用栈信息
  3. &stackSize 当前函数局部变量是分配在当前 stack frame 中的,通过它的地址可以看出 stack frame 的位置

在 Linux 上,如果没有为线程设置备用栈,那么取出的 stack base & stack size 都是 0,从上面的测试代码中可以看出在 Android 上虽然我们没有主动为线程设置备用栈,但是是能取到的,而且通过局部变量的地址可以看出当前的 signal handler 也确实运行在这个备用栈上的。

Android 上线程的备用栈从何而来?

Android上的线程似乎默认有一个备用栈,那这个备用栈是何时设置的呢?可以通过 hook + 抓栈 的方式来定位:

  1. PLT hook sigaltstack
  2. aarch64 上基于fp做栈回溯
  3. 对pc符号化,拿到对应的库 & 符号名(这里通过dladdr不行,因为调用的符号不是导出的动态符号,可以用xDL)
static void unwind(uintptr_t fp, int max) {
    auto stackInfo = getCurrThreadStack();
    if (!stackInfo) {
        return;
    }
​
    int count = 0;
    static void* cache = nullptr;
    while (count < max && isFpValid(fp, stackInfo->stackBase, stackInfo->stackTop)) {
        auto fpp = reinterpret_cast<uintptr_t*>(fp);
        auto preFp = *fpp;
        auto preLr = *(fpp + 1);
        fp = preFp;
​
        xdl_info_t info;
        if (xdl_addr((void*)(preLr - 4), &info, &cache)) {
            LOGI("%s:%s", info.dli_fname, info.dli_sname);
        }
    }
}
​
static int sigaltstackProxy(const stack_t* newStack, const stack_t* oldStack) {
    BYTEHOOK_STACK_SCOPE();
​
    unwind((uintptr_t)__builtin_frame_address(0), 64);
​
    return BYTEHOOK_CALL_PREV(sigaltstackProxy, newStack, oldStack);
}

通过上面的demo代码可以定位到:在Android上创建出线程后会先执行__pthread_start在其中会为当前线程设置备用栈,之后才会去执行start_routine,可以参考 bionic 中的 pthread_create.cpp。(其实这个不用hook也容易猜到~)

Android 上不设置 SA_ONSTACK 为啥也能在备用栈上运行?

从 Linux 内核代码看,如果我们的 sigaction 没有添加SA_ONSTACK flag 的话,即使我们为线程设置了备用栈,也不会使用的:

static inline unsigned long sigsp(unsigned long sp, struct ksignal *ksig)
{
  if (unlikely((ksig->ka.sa.sa_flags & SA_ONSTACK)) && ! sas_ss_flags(sp))
#ifdef CONFIG_STACK_GROWSUP
    return current->sas_ss_sp;
#else
    return current->sas_ss_sp + current->sas_ss_size;
#endif
  return sp;
}

然而,在 Android 上在不添加 SA_ONSTACK flag 的情况下,依然能在备用栈上运行,并且能捕获栈溢出的case,这又是为啥呢?

通过抓栈也能发现我们的handler是由_ZN3art11SignalChain7HandlerEiP7siginfoPv(也就是:art::SignalChain::Handler(int, siginfo*, void*))调过来的。

因为Android中java代码可能会被jit生成机器码,生成的机器码中的 NPE 表现为 SIGSEGV,art 虚拟机需要识别出这种case,于是 art 搞了个 sigchain,在其中注册了一个signal handler 来处理这种情况,我们注册的 signal handler 会被放到 chain 中,由他代理调用(如果不是java的npe的话),所以以SIGSEGV为例,向系统注册signal handler的是 libsigchain,而他注册的时候 flags 中是有 SA_ONSTACK,我们加不加其实不影响:

void Register(int signo) {
#if defined(__BIONIC__)
    struct sigaction64 handler_action = {};
    sigfillset64(&handler_action.sa_mask);
#else
    struct sigaction handler_action = {};
    sigfillset(&handler_action.sa_mask);
#endif
​
    handler_action.sa_sigaction = SignalChain::Handler;
    handler_action.sa_flags = SA_RESTART | SA_SIGINFO | SA_ONSTACK;#if defined(__BIONIC__)
    linked_sigaction64(signo, &handler_action, &action_);
#else
    linked_sigaction(signo, &handler_action, &action_);
#endif
  }

不过有一点要注意的是:栈溢出也有可能触发的是 SIGBUS,而 art sigchain 没有为 SIGBUS 注册handler,所以这个case下如果我们的 sigaction flags 中没有 SA_ONSTACK,那么便不会使用备用栈了。

总结

Linux
  1. glibc 线程默认没有备用栈,如果要捕获栈溢出的问题,需要自己设置
  2. signal handler要使用备用栈,必须添加 SA_ONSTACK(如果没有设置备用栈但添加了这个flag,会使用当前线程栈,对于栈溢出的case,会触发 force_sigsegv)
void force_sigsegv(int sig, struct task_struct *p)
{
  if (sig == SIGSEGV) {
    unsigned long flags;
    spin_lock_irqsave(&p->sighand->siglock, flags);
    p->sighand->action[sig - 1].sa.sa_handler = SIG_DFL;
    spin_unlock_irqrestore(&p->sighand->siglock, flags);
  }
  force_sig(SIGSEGV, p);
}
Android
  1. bionic 线程在运行用户的 start_routine 之前会为线程创建备用栈(mmap),在线程退出时回收(munmap)

    1. 所以不用我们创建备用栈了,而且如果我们创建了的话,bionic创建的那个也得等线程退出时才会回收,会多占虚存
    2. 如果bionic没默认设置备用栈的话,我们捕获栈溢出的问题还比较麻烦:对于crash sdk初始化后创建的线程我们可以通过hook pthread_create、pthread_exit 来处理,但是app启动的时候系统已经创建了一些线程,他们的栈溢出怎么捕获呢
  2. 不设置 SA_ONSTACK 依然能在备用栈上运行:是因为对于 SIGSEGV,我们的 sigaction 设置被 libsigchain 给代理了,真正设置这个 sigaction 的是 libsigchain,而他是带了 SA_ONSTACK flag 的。所以实际上要想在备用栈上运行 SA_ONSTACK 还是必须的