背景
随着移动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,它的行为其实是不确定的
-
修改了free前指针所指向的内容,逻辑算是正确
-
修改了当前进程中的内容,并且是可读可写的内存块时,不会发生崩溃但是会导致数据块被篡改发生不可预期的行为
-
修改到了当前进程无法访问的内容,抛出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的核心了
-
第一步:首先Gwp-ASan会初始化一块内存池子,每一块内存都是处于不可用状态,即当前不可读也不可写,只要发生写入动作,那么就会立刻发生Crash,这种内存池子按照页大小存在,所以也被称为GuardPage
-
第二步:当发生内存分配时,这里以malloc举例子,这时候就会从池子挑选一块内存,注意这块内存并不是随意挑选的,它要满足这块内存的前后都是GuardPage (我们下面会说明为什么需要满足这个条件),这个我们也可以称为ValidPage,此时malloc的地址会完全处于ValidPage之内,同时根据配置进行按左对齐或者按右对齐( 紫色块所示)
-
第三步:发生内存释放时,比如调用free函数时,此时会把整个ValidPage重新变成GuardPage,注意这块内存并没有释放,只是把整个page变色。
-
重复内存分配与内存释放之后,直到池子再也没有内存块可以分配,即ValidPage已经没有了,就会呈现GuardPage与ValidPage相互交替的场景。
Gwp-ASan 具体的代码可以从这里看(gwp-asan),因为本文不是代码解析的文章,因此不完全追源码,后面我们也将会实现一个加强版本的Gwp-ASan。
为什么Gwp-ASan能够检测出“原始错误”
下面我们来解释一下,为什么要这么设计,我们还是按照经典的Memory Corruption 问题来看,为什么上面的机制可以能够立马发现使用有误的内存
- UseAfterFree || Double Free:
当发生Free后,分配的内存会被重新设置为GuardPage,因此再次访问时,就会立刻触发问题报警
- 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!