Android Native内存调试工具建设

2,300 阅读22分钟

背景

随着移动App的发展,开发者也会面临着越来越多的底层需求,这些需求为了高性能或者涉及到底层的调度,通常会采取C/C++等可以直接与内存地址打交道的语言编写。虽然C/C++提供了出色性能与底层交互的能力,但是随着项目的复杂度不断提高以及业务逻辑的复杂,多线程等等,SIGSEGV 问题就会频繁遇见,它是最常见也是占比最多的Crash,不仅仅在APP中,甚至在Android系统中也有不少这些问题,这类问题通常表现在地址访问异常(地址不存在/地址访问权限异常/地址不属于当前进程可访问范围等等)。即使App业务不依赖so,但是也不可避免的依赖第三方的so,因此解决此类问题的手段就很关键。

本文将介绍导致SIGSEGV 几个问题的本质和排查此类问题的行业优秀实践案例,以及我们如何实现一个更加高效的内存调试工具。

造成SIGSEGV问题很大一部分是由于Memory Corruption 导致的,我们下面来了解一下Memory Corruption是什么,才能更好应对。

经典的Memory Corruption 问题

值得注意的是,下面例子均是以最简单的形式展示给读者,实际上Crash的场景可能跨域度很广,也难以复现,同时也有可能涉及多线程问题,但是问题本质是一样的。

UseAfterFree

下面是一个UseAfterFree的例子,即指针已经被释放了但是被再次使用,这种问题也通常被称为“野指针”

Java_com_example_scalpel_MemoryCorruptionTestActivity_testUseAfterFree(JNIEnv *env, jobject thiz) {
    int *pint = malloc(sizeof(int));
    *pint = 1;
    free(pint);
    *pint = 2;
}

调用free之后,其实指针是处于“无归属”状态,它有可能还指向原本的数据,也有可能指向了其他数据,具体的流程如下:

也就是说,操作free后的指针pint,它的行为其实是不确定的

  1. 修改了free前指针所指向的内容,逻辑算是正确

  2. 修改了当前进程中的内容,并且是可读可写的内存块时,不会发生崩溃但是会导致数据块被篡改发生不可预期的行为

  3. 修改到了当前进程无法访问的内容,抛出SIGSEGV 异常结束

我们可以看到,发生野指针调用,最好的结果其实是“应用崩溃”,因为可以发现有问题,如果应用没有崩溃反而把正常的业务数据修改了,那么之后会引发更大的问题。这种问题就是薛定谔的“指针”,你无法判断它究竟会怎么选择。也许恰巧某一个没有崩溃,某一次却引发了其他问题。

DoubleFree

同一块堆内存地址多次释放问题,当然说是Double但是不局限于两次,也可以是多次,比如常见的多次析构函数调用问题。

JNIEXPORT void JNICALL
Java_com_example_scalpel_MemoryCorruptionTestActivity_testDoubleFree(JNIEnv *env, jobject thiz) {
    int *pint = malloc(sizeof(int));
    *pint = 1;
    free(pint);
    free(pint);
}

第一次free之后,会把指针同样处于“无归属”状态,此时指针可以为NULL也可以为其他内容(没有重新把指针设置为NULL情况下),如果手动设置为NULL的话,那么再次free后会得到SIGSEGV 异常后“完美结束”, 如果没有设置的话,那么同样你也会得到一个跟UseAfterFree 类似的“薛定谔的指针”,它同样满足下面几种情况

再次free后,同样也取决于当前指针的状态,如果指针指向的地址没有被操作系统轮换,它同样会导致异常问题,比如多线程下,A线程申请了一块C内存,释放后,被B线程申请,恰巧还是同一块内存(合法)。此时A类再次发起释放,也就会导致B线程的这块内存被释放,很有可能B线程正在使用这块内存。

HeapBufferOverflow

堆内存溢出,包括上下溢出,比如:

JNIEXPORT void JNICALL
Java_com_example_scalpel_MemoryCorruptionTestActivity_testBoundary(JNIEnv *env, jobject thiz) {
    int *pint = malloc(sizeof(int));
    pint = pint - 10;
    //  或者 pint = pint + 10;
    *pint = 1;

}

这类问题都归结于,指针访问了不属于分配范围的内存,如图:

这些问题多发于指针的强制转换或者使用偏移的情况,当指针操作到不属于自身管理的数据范围时,本例子是属于int类型的内存块之外进行数据的写入,那么就很有可能造成相临内存块的内容被污染,同时这种写入行为也不会得到操作系统的保护从而引发问题。

问题本质

通过学习到上面几种经典的Memory Corruption 问题,我们能够明白常见的crash 产生的本质,实际上,我们往往在生产环境遇到的crash,往往是上面的多种组合,同时上面的问题在多线程环境下也将会变得更加复杂。因此此类问题,通过“事后”的堆栈,往往很难出现有效的堆栈信息。因为上面的这几种情况,往往crash发生的地方,往往不是问题的根本,比如以下情况

行业中的实践-Gwp-ASan

当然,此类问题是行业中的通病,比如Android系统本身也需要内存调试工具去优化自身的问题,这里面比较有代表的方案就是Gwp-ASan了。

国内大厂,比如字节跳动在火山引擎中也有内存调试方案,具体的实现跟Gwp-ASan思路一致,同时鸿蒙上面的三方库中,也有对Gwp-ASan的移植,那么我们来聊一下,Gwp-ASan是什么,谷歌又是怎么做的。

我们从上面经典的Memory Corruption问题了解到,出现问题的本质其实就是使用了不应该使用的内存 , 那么我们有没有方法去判断哪些内存是不应该被使用的呢?其实这个问题,就是Gwp-ASan的核心了

  1. 第一步:首先Gwp-ASan会初始化一块内存池子,每一块内存都是处于不可用状态,即当前不可读也不可写,只要发生写入动作,那么就会立刻发生Crash,这种内存池子按照页大小存在,所以也被称为GuardPage

  2. 第二步:当发生内存分配时,这里以malloc举例子,这时候就会从池子挑选一块内存,注意这块内存并不是随意挑选的,它要满足这块内存的前后都是GuardPage (我们下面会说明为什么需要满足这个条件),这个我们也可以称为ValidPage,此时malloc的地址会完全处于ValidPage之内,同时根据配置进行按左对齐或者按右对齐( 紫色块所示)

  3. 第三步:发生内存释放时,比如调用free函数时,此时会把整个ValidPage重新变成GuardPage,注意这块内存并没有释放,只是把整个page变色。

  4. 重复内存分配与内存释放之后,直到池子再也没有内存块可以分配,即ValidPage已经没有了,就会呈现GuardPage与ValidPage相互交替的场景。

Gwp-ASan 具体的代码可以从这里看(gwp-asan),因为本文不是代码解析的文章,因此不完全追源码,后面我们也将会实现一个加强版本的Gwp-ASan。

为什么Gwp-ASan能够检测出“原始错误”

下面我们来解释一下,为什么要这么设计,我们还是按照经典的Memory Corruption 问题来看,为什么上面的机制可以能够立马发现使用有误的内存

  1. UseAfterFree || Double Free:

当发生Free后,分配的内存会被重新设置为GuardPage,因此再次访问时,就会立刻触发问题报警

  1. HeapBufferOverflow:

当发生上溢出或者是下溢出时,此时指针会范围非malloc区域,如果当前是下溢出检测,那么就会访问到ValidPage右侧相连的GuardPage,从而直接命中报警。同理,如果是上溢出检测,那么就会访问到ValidPage左侧的GuardPage,产生报警

Gwp-ASan 堆栈采集

当然,单纯进行检测原始错误是不够的,我们需要额外的堆栈信息去辅佐我们进行问题的排查,比如使用到ValidPage的堆栈信息,以及知道它是何时被分配又何时被回收的,这样才能去做问题的分析,因此Gwp-ASan 通过DispatchTable Hook的方式(后面我们也会介绍),把malloc,realloc,free等分配释放内存的调用进行调用拦截,在其中进行堆栈的记录和其他必要的信息记录。

void AllocationMetadata::RecordAllocation(uintptr_t AllocAddr,
                                          size_t AllocSize) {
  Addr = AllocAddr;
  RequestedSize = AllocSize;
  IsDeallocated = false;

  AllocationTrace.ThreadID = getThreadID();
  DeallocationTrace.TraceSize = 0;
  DeallocationTrace.ThreadID = kInvalidThreadID;
}

记录下ValidPage的关键信息后,就可以为后续的错误判断提供依据了

当产生MemoryCorruption问题时,使用者就会触碰GuardPage从而触发SIGSEGV 信号,在初始化的时候,Gwp-ASan会注册一个SIGSEGV 信号的处理器,当接收到传递的SIGSEGV 信号,会对出现问题的地址进行判断,如果出现问题的地址在检测池地址当中,那么就是属于Gwp-ASan的目标了,因此就会把我们上面提前存放的堆栈信息提供给了使用者,同时判断是哪种MemoryCorruption问题。

Gwp-ASan是采样型内存检测工具

因为需要记录堆栈信息和其他额外的信息,这个过程会存在一定的损耗,比如堆栈记录以及锁等待等问题,因此gwp-asan本身是以采样的方式提供的,因为所有的内存分配和释放都要进行检测的话,那么势必会造成内存量以及性能损耗,因此Android中默认的采样率为1/2500。

bool gwp_asan_initialize(const MallocDispatch* dispatch, bool*, const char*) {
  prev_dispatch = dispatch;

  Options Opts;
  Opts.Enabled = true;
  //采样参数设置
  Opts.MaxSimultaneousAllocations = 32;
  Opts.SampleRate = 2500;
  Opts.InstallSignalHandlers = false;
  Opts.InstallForkHandlers = true;
  Opts.Backtrace = android_unsafe_frame_pointer_chase;

  GuardedAlloc.init(Opts);
   ...

  __libc_shared_globals()->gwp_asan_state = GuardedAlloc.getAllocatorState();
  __libc_shared_globals()->gwp_asan_metadata = GuardedAlloc.getMetadataRegion();
  return true;
}

Gwp-ASan 的不足

当然,Gwp-ASan 本身并不是完美无缺的,这种方案本身只能检测固定大小的内存问题,比如分配内存要在一个ValidPage设定的大小之内,否则将无法检测。但是根据统计,大部分的MemoryCorruption问题其实都在较小内存中发生,这个也无妨,因为ValidPage通常取maxpage的大小。还有一个问题是Gwp-ASan的有效bug查找率会比较低,第一个原因是Gwp-ASan本身是采样的,采样率的控制很重要,同时因为Gwp-ASan采取的是Dispatchtable Hook,属于针对callee的hook,是面向所有so都生效的,但是实际上出现问题的so,很大概率只局限在某一个或者单一的so。

Gwp-ASan 本身属于系统级别的检测,采样率的部署和动态化难以控制,同时也缺乏更多信息注入的能力,并且无法实现针对某个特定的so开启,定向性差,因此我们可以根据Gwp-ASan 的思想,进行自研解决上面提到的采样率部署以及动态监测

货拉拉的内存调试工具

通过上面,我们已经了解到实现整个工具的思路,下面我们将会从Hook工具选取,GuardPage与VaildPage实现,堆栈获取,信号处理等展开

我们的目标是要实现一个可以进行定向监测(只针对某个so的内存分配进行检测),且能够自主控制采样率的内存工具。定向监测 能够让我们在线下环境就能够依靠大量的测试用例针对某个so进行内存检测,同时我们也能够放开内存限制,能够让更多的VaildPage得到检查而不用过于担心内存问题,从而把更多且更难以检测的bug扼杀在摇篮。而Gwp-ASan现有的工具是无法实现的,我们无法寄托于大海捞针式的进行检测。

Hook方案选取

Hook工具的选取是实行定向监测的关键,Gwp-ASan是针对Callee(被调用函数)进行的hook,因此所有调用方(即所有具备分配内存的so)都会参与GuardPool的消耗(获取ValidPage)。

DispatchTable Hook

我们知道,GwpASan其实是通过DispatchTable Hook实现的,那么DispatchTable是什么呢?顾名思义,就是分配表,比如我们在Android调用一个malloc函数,它的实现并不一定是libc 的malloc,这个概念很重要,也有可能是添加了特殊功能的malloc实现,比如用于检测分配内存的gwp-asan-malloc 或者malloc hook 。主要取决于当前DispatchTable的实现。更加详细的例子我们不展开,可以参考这篇文章。原理其实相当于内存分配相关的函数中,Android系统特地留了一个口子,用于后续函数hook,像malloc debug都是用了这个口子。

内存分配DispatchTable如下

struct MallocDispatch {
  MallocCalloc calloc;
  MallocFree free;
  MallocMallinfo mallinfo;
  MallocMalloc malloc;
  MallocMallocUsableSize malloc_usable_size;
  MallocMemalign memalign;
  MallocPosixMemalign posix_memalign;
#if defined(HAVE_DEPRECATED_MALLOC_FUNCS)
  MallocPvalloc pvalloc;
#endif
  MallocRealloc realloc;
#if defined(HAVE_DEPRECATED_MALLOC_FUNCS)
  MallocValloc valloc;
#endif
  MallocIterate malloc_iterate;
  MallocMallocDisable malloc_disable;
  MallocMallocEnable malloc_enable;
  MallocMallopt mallopt;
  MallocAlignedAlloc aligned_alloc;
  MallocMallocInfo malloc_info;
} __attribute__((aligned(32)));

我们可以通过替换DispatchTable的方式,能够把几乎常见的一些内存分配函数与释放函数进行hook处理。


//替换dispatch_table为我们自定义的dispatch table
c_global->malloc_dispatch_table = *dynamic;
atomic_store(&c_global->default_dispatch_table, dynamic);

if (c_global->current_dispatch_table == NULL) {
    atomic_store(&c_global->current_dispatch_table,
                 dynamic);
}

if (mprotect(c_global, PAGE_SIZE, PROT_READ) == -1) {
    return 0;
}
return 1;

Got Hook

Got 表hook 是常见的native hook,他属于针对Caller(调用者)进行的hook,这方面的介绍有很多,笔者就不多赘述,如图

同时,像malloc,free这些函数,它其实是属于外部调用函数,因此同样满足查got表的过程,got表是每个so单独有的,在加载时会对未解析的符号进行解析,因此我们是可以做到只针对单独so进行got表修改,从而达到我们实现内存分配与释放流程的定向监控,因此我们可以选择Got表hook

实现内存调试方案

我们根据上文已经了解到了GwpASan的核心思想,下面我们看如何实现这个核心思想内容,大概流程如下:

初始化GuardPool

为了支持动态配置采样率,我们可以选择把关键的配置通过JNI暴露给java方,使用者只需要根据配置就可以调整整个GuardPool的大小以及GuardPool的监测模式,比如为了监测HeapBufferOverFlow问题,我们可以完全选择左对齐或者右对齐,能够实现更全面的内存检测,同时我们可以支持在线下全量采样 ,让内存踩踏问题在线下被扼杀。GuardPool初始化很简单,我们通过mmap分为一块大内存即可,之后再把这块内存一块块切分成GuardPage,同时我们把GuardPage按照页大小的方式进行分配。同时别忘了分配一块记录堆栈信息与额外信息的内存,我们把它称为Metadata,这里可以添加你感兴趣的任何数据。

void init_asan(int mode, int available_size) {
    align_mode = mode;
    total_available_size = available_size;
    system_page_size = sysconf( _SC_PAGESIZE);
    length = (2 * total_available_size + 1) * system_page_size;
    global_ptr = mmap(NULL, length, PROT_NONE,
                      MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
    free_slots = calloc(total_available_size, sizeof(int));
    pthread_mutex_init(&pool_mutex, NULL);
    pthread_mutex_init(&record_mutex, NULL);
    meta = calloc(total_available_size, sizeof(struct Metadata));
}

比如我们这里定义的额外数据

struct Metadata {
    // malloc 返回地址
uintptr_t addr;
    // malloc 实际申请的大小
size_t size;
    // 该slot是否被释放
 int is_deallocated;
    // 分配时堆栈
 char *allocation_trace;
    // 释放时堆栈
 char *deallocation_trace;
    pid_t alloc_pid;
    pid_t dealloc_pid;
};

分配与释放内存

分配内存的时候,我们以malloc举例子,首先我们要从GuardPool中找到一块合适的内存,然后再根据对齐模式进行按左对齐还是按右对齐分配

void *call_malloc(size_t byte_count) {
    pthread_mutex_lock(&pool_mutex);
    int index = get_free_slots_index();
    pthread_mutex_unlock(&pool_mutex);
    // 无可用index 返回
 if (index == -1 || byte_count >= system_page_size) {
        return  NULL;
    }

    size_t page = sysconf( _SC_PAGESIZE);
    // 当前初始地址大小
size_t alignment = alignof(max_align_t);
    // 当前池子里面的page初始地址
uintptr_t slot_start = (uintptr_t) (global_ptr + (2 * index + 1) * page);
    uintptr_t slot_end = (uintptr_t) (global_ptr + (2 * index + 2) * page);
    free_slots[index] = 1;

    uintptr_t alloc_ptr;
    if (align_mode == 0) {
        alloc_ptr = align_up(slot_start, alignment);
    } else {
        alloc_ptr = align_down(slot_end - byte_count, alignment);
    }

    uintptr_t align_alloc_ptr = get_page_addr(alloc_ptr, system_page_size);

    if (0 != mprotect((void *) align_alloc_ptr, round_up_to(byte_count, system_page_size),
                      PROT_READ | PROT_WRITE)) {
        return  NULL;
    }
    // 分配时,获取到的index 改为1,然后 改写meta里面的数据
size_t frames_sz = unwind_fp_unwind(g_frames, sizeof(g_frames) / sizeof(g_frames[0]),
                                         NULL);

    pthread_mutex_lock(&record_mutex);
    meta[index].allocation_trace = unwind_frames_get(g_frames, frames_sz, NULL);
    meta[index].size = byte_count;
    meta[index].aaddr = alloc_ptr;
    meta[index].is_deallocated = 0;
    meta[index].alloc_pid = gettid();
    __android_log_print(ANDROID_LOG_ERROR, MEM_TAG, "malloc %lu " ,alloc_ptr);
    pthread_mutex_unlock(&record_mutex);
    return (void *) alloc_ptr;
}

释放时,我们要把原本属于ValidPage的内容变成GuardPage,这里面来到了第一个检测点,就是需要判断是否发生了DoubleFree,如果发现当前释放的地址处于GuardPage,证明处于未被分配状态,因此就要进行Crash抛出归因,发生了DoubleFree


int call_free(void *free_ptr) {
    // 不属于scan范围内的
 if (free_ptr < global_ptr || free_ptr > global_ptr + length) {
        return -1;
    }

    pthread_mutex_lock(&pool_mutex);
    size_t index = addr_to_slot((uintptr_t) free_ptr);
    pthread_mutex_unlock(&pool_mutex);

    // double free
 if (meta[index].is_deallocated == 1) {

        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "double free addr invalid %zu meta addr is %lu free addr is %lu" , index,
                            meta[index].aaddr, (uintptr_t) free_ptr);

        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "double free trace %s" , meta[index].deallocation_trace);
        raise(6);
        return 0;
    }
    if (meta[index].aaddr != (uintptr_t) free_ptr) {
        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "free ptr != alloc ptr addr invalid %zu meta addr is %lu free addr is %lu" ,
                            index,
                            meta[index].aaddr, (uintptr_t) free_ptr);

        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "free ptr != alloc ptr trace %s" , meta[index].allocation_trace);
        raise(6);
        return 0;
    }
    uintptr_t slot_start = (uintptr_t) (global_ptr + (2 * index + 1) * system_page_size);
    // remmap
 void *value = mmap((void *) slot_start, system_page_size, PROT_NONE,
                       MAP_FIXED | MAP_ANONYMOUS | MAP_PRIVATE, -1,
                       0);
    if (value == MAP_FAILED) {
        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG, "map fail" );
        return -1;
    }

    size_t frames_sz = unwind_fp_unwind(g_frames, sizeof(g_frames) / sizeof(g_frames[0]),
                                         NULL);
    pthread_mutex_lock(&record_mutex);
    free_slots[index] = 0;
    meta[index].is_deallocated = 1;

    // 分配时,获取到的index 改为1,然后 改写meta里面的数据
meta[index].deallocation_trace = unwind_frames_get(g_frames, frames_sz, NULL);
    pthread_mutex_unlock(&record_mutex);
    return 0;
}

信号处理

当GuardPage错误的使用时,就会产生SIGSEGV(11) Crash,因此我们需要进行信号监听,同时我们也要区分出这个是由于GuardPage的访问导致的还是其他错误导致的,同时为了避免影响crash率,我们不应该把guardpage引发的crash上报,因此我们可以通过signalchain机制,通过直接调用libc的sigaction64 (64位下) 的函数直接先于Android系统本身的信号注册。

void init_sig_handler() {
    void *libc = dlopen( "libc.so" , RTLD_LOCAL);
    if ( __predict_true(NULL != libc)) {
        // sigaction64()
libc_sigaction64 = (libc_sigaction64_t) dlsym(libc, "sigaction64" );
        dlclose(libc);
    }

    struct sigaction64 sigc;
    sigc.sa_sigaction = sig_func;
    // 信号处理时,先阻塞所有的其他信号,避免干扰正常的信号处理程序
sigfillset(&sigc.sa_mask);
    sigc.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART;

    struct sigaction64 *ac = calloc(1, sizeof(struct sigaction));
    prev_action = ac;
    // 只注册SIGSEGV 避免干扰其他信号,否则还要处理mask
libc_sigaction64(SIGSEGV, &sigc, prev_action);
}

发生信号时,需要判断当前属于UseAfterFree还是HeapBufferOverflow导致的,并给出归因信息与MetaData排查信息等

void sig_func(int sig_num, siginfo_t *info, void *ptr_attr) {
    __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                        "发生了crash %d" , sig_num);
    xunwind_cfi_log(-1, -1, ptr_attr, MEM_TAG, ANDROID_LOG_ERROR, NULL);

    void *fault_addr = info->si_addr;
    uintptr_t addr = (uintptr_t) fault_addr;
    __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                        "检测到发生异常的地址为 %lu %lu" ,
                        (uintptr_t) fault_addr, (uintptr_t) global_ptr);

    // 如果处于这个范围内
 if (addr >= (uintptr_t) global_ptr && addr <= ((uintptr_t) global_ptr + length)) {
        size_t index = addr_to_slot((uintptr_t) fault_addr);
        __android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "检测到发生异常的地址为 %lu %lu index 为 %zu" ,
                            (uintptr_t) fault_addr, (uintptr_t) global_ptr, index);

        print_corruption_reason(index, (uintptr_t) fault_addr);

        // 判断是否是上溢还是下溢
__android_log_print(ANDROID_LOG_ERROR, MEM_TAG,
                            "输出堆栈信息 : 分配信息\n %s \n 释放信息:%s \n" ,
                            meta[index].allocation_trace, meta[index].deallocation_trace);
    }

    // 返回上一个信号链
 if ((unsigned  int) (prev_action->sa_flags) & (unsigned  int) SA_SIGINFO) {
        prev_action->sa_sigaction(sig_num, info, ptr_attr);
    } else {
        if (SIG_DFL != prev_action->sa_handler && SIG_IGN != prev_action->sa_handler) {
            prev_action->sa_handler(sig_num);
        }
    }
}

堆栈获取选择

无论是线上线下,32位手机已经可以忽略不计了,堆栈获取选择上,我们需要全量采样的情况下,如果选取普通的CFI 方式进行堆栈获取(调用栈信息,Android系统提供libbacktrace )或者基于EH 栈回溯(比如_Unwind_Backtrace), 那么性能的损耗会很大,特别是在连续内存分配的场景,因此这几种都是我们的备用方案,剩下唯一选择就是FP堆栈回溯了,我们简单介绍一下FP堆栈回溯:

在aarch64中,函数调用时通常都会把X29(FP)寄存器进行保存,FP寄存器用于保存函数调用者的栈帧指针,比如以下函数反编译:

因此我们想,如果我们顺着FP一层层往下找,是不是就能把整个调用链给找到,这就是FP回溯的思想

Android 中实现fp回溯代码如下,这个是在Android 11 引入的,核心思想就是不断通过frame_record查找

__attribute__((no_sanitize("address", "hwaddress"))) size_t android_unsafe_frame_pointer_chase(
    uintptr_t* buf, size_t num_entries) {
  // Disable MTE checks for the duration of this function, since we can't be sure that following
  // next_frame pointers won't cause us to read from tagged memory. ASAN/HWASAN are disabled here
  // for the same reason.
  ScopedDisableMTE x;

  FP结构体
  struct frame_record {
    uintptr_t next_frame, return_addr;
  };
  确定fp回溯范围
  auto begin = reinterpret_cast<uintptr_t>(__builtin_frame_address(0));
  auto end = __get_thread_stack_top();

  stack_t ss;
  if (sigaltstack(nullptr, &ss) == 0 && (ss.ss_flags & SS_ONSTACK)) {
    end = reinterpret_cast<uintptr_t>(ss.ss_sp) + ss.ss_size;
  }

  size_t num_frames = 0;
  while (1) {
  ....
#else
    auto* frame = reinterpret_cast<frame_record*>(begin);
#endif
    if (num_frames < num_entries) {
      uintptr_t addr = __bionic_clear_pac_bits(frame->return_addr);
      if (addr == 0) {
        break;
      }
      buf[num_frames] = addr;
    }
    ++num_frames;
    if (frame->next_frame < begin + sizeof(frame_record) || frame->next_frame >= end ||
        frame->next_frame % sizeof(void*) != 0) {
      break;
    }
    begin = frame->next_frame;
  }

  return num_frames;
}

这里我们可以看到,FP回溯时,需要确定回溯的上下限制,比如已经超过了当前的线程栈顶时,此时就不应该继续查找了

这里我们可以看到Android中是直接调用stack_top获取的

__BIONIC_WEAK_FOR_NATIVE_BRIDGE
extern "C" __LIBC_HIDDEN__ uintptr_t __get_thread_stack_top() {
  return __get_thread()->stack_top;
}

但是在Android11以下,我们是不同直接通过pthread_self()->stack_top 获取的,因为bionic (android的libc实现)pthread在不同的Android版本中有不同的实现,但是我们可以通过迂回的方式计算,即:通过线程栈大小加上初始地址得到:

pthread_attr_t attr;
if (0 != pthread_getattr_np(thrd, &attr)) return;

uintptr_t stack_low, stack_high;
size_t stack_sz;
if (0 != pthread_attr_getstack(&attr, (void **)(&stack_low), &stack_sz)) return;
stack_high = (uintptr_t)stack_low + (uintptr_t)stack_sz;

uintptr_t guard_sz = 0;
if (0 != pthread_attr_getguardsize(&attr, &guard_sz)) guard_sz = 0x1000;

pthread_attr_destroy(&attr);

unsigned  long low = (stack_low + guard_sz);
unsigned  long high = stack_high;

至此,整个FP回溯就介绍完成了,可以看到FP回溯只涉及几个指针操作,因此速度是所有堆栈获取方案中最快的。当然,行内也有很多unwind相关的优秀库,比如xunwind都可以提供上面fp回溯的能力。

部分收益展示

这个工具使用后,我们就立马扫描出困扰货拉拉司机端多年的一个Top1的NativeCrash,这个是三方地图引擎层的问题,出现在多线程场景下的一个UseAfterFree,找到问题后,我们后续就制定了修复计划且成功修复

检测到发生异常的地址为 542639079424 542639075328
检测到发生异常的地址为 542639079424 542639075328 index 为 100
meta pid alloc: 19206 dealloc 0
UAF
输出堆栈信息 : 分配信息
 #00 pc 0000000000043f8c  /data/app/~~3zVb4alLhCAgbBpCVnaAKw==/com.example.scalpel-x8oY7Ga8Kr3mxDzzr1EDSg==/base.apk!/lib/arm64-v8a/xxxx
#01 pc 0000000000043d44  /data/app/~~3zVb4alLhCAgbBpCVnaAKw==/com.example.scalpel-x8oY7Ga8Kr3mxDzzr1EDSg==/base.apk!/lib/arm64-v8a/xxxx
#02 pc 00000000000007e8  /data/app/~~3zVb4alLhCAgbBpCVnaAKw==/com.example.scalpel-x8oY7Ga8Kr3mxDzzr1EDSg==/base.apk!/lib/arm64-v8a/xxx.so
#04 pc 0000000212e0e41c  <unknown>
 
 释放信息:#00 pc 00000000000446ac  /data/app/~~3zVb4alLhCAgbBpCVnaAKw==/com.example.scalpel-x8oY7Ga8Kr3mxDzzr1EDSg==/base.apk!/lib/arm64-v8a/xxx
#01 pc 0000000000044770  /data/app/~~3zVb4alLhCAgbBpCVnaAKw==/com.example.scalpel-x8oY7Ga8Kr3mxDzzr1EDSg==/base.apk!/lib/arm64-v8a/xxxx
-------------

然后,整个接入内存调试工具,只需要一行代码即可启动:

Scalpel.installScan(1000,0,检测的so)

总结

通过本文,我们了解到了常见的MemoryCorruption问题有哪些,同时我们也能够知道当前行业中的优秀实践,以Gwp-ASan为例子,我们了解到了当前方案的核心以及不足,通过我们后续研发的内存调试工具,很好的克服了定向性差的问题以及配置采样的问题,把内存调试工具变成更适合定位app的问题so!

内容引用

如何实现内存分配函数的DispatchTable Hook

JNIHook