Linker加载so失败问题分析

作者:段聪,腾讯社交平台部高级工程师

商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。

原文链接:https://wetest.qq.com/lab/view/421.html

WeTest 导读

近期测试反馈一个问题,在旧版本微视基础上覆盖安装新版本的微视APP,首次打开拍摄页录制视频合成时高概率出现crash。


那么我们直奔主题,看看日志:

图片1.png

另外复现的日志中还出现如下信息:

'/data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so: strtab out of bounds error

图片2.png

后经过测试,发现覆盖安装后首次使用美体功能也会出现crash,日志如下:

图片3.png

由于出现问题的场景都是覆盖安装首次使用,并且涉及到人体检测相关的so,似乎存在某种共同的原因。

因此Abort异常比起fault addr类问题更容易分析,先从前面Linker出现Abort异常的位置开始着手。

Linker是so链接和加载的关键,属于系统可执行文件,因此分析起来比较棘手。好在手上正好有一台刚刷完自己编译的Android AOSP的Pixel,做一些实验变得更轻松了。

出现异常的Linker代码linker_soinfo.cpp如下:

const char* soinfo::get_string(ElfW(Word) index) const {
 if (has_min_version(1) && (index >= strtab_size_)) {
   async_safe_fatal("%s: strtab out of bounds error; STRSZ=%zd, name=%d",
       get_realpath(), strtab_size_, index);
 }
 return strtab_ + index;
}
bool soinfo::elf_lookup(SymbolName& symbol_name,
                       const version_info* vi,
                       uint32_t* symbol_index) const {
 uint32_t hash = symbol_name.elf_hash();
 TRACE_TYPE(LOOKUP, "SEARCH %s in %s@%p h=%x(elf) %zd",
            symbol_name.get_name(), get_realpath(),
            reinterpret_cast<void*>(base), hash, hash % nbucket_);
 ElfW(Versym) verneed = 0;
 if (!find_verdef_version_index(this, vi, &verneed)) {
   return false;
 }
 for (uint32_t n = bucket_[hash % nbucket_]; n != 0; n = chain_[n]) {
   ElfW(Sym)* s = symtab_ + n;
   const ElfW(Versym)* verdef = get_versym(n);
   // skip hidden versions when verneed == 0
   if (verneed == kVersymNotNeeded && is_versym_hidden(verdef)) {
       continue;
   }
   if (check_symbol_version(verneed, verdef) &&
       strcmp(get_string(s->st_name), symbol_name.get_name()) == 0 &&
       is_symbol_global_and_defined(this, s)) {
     TRACE_TYPE(LOOKUP, "FOUND %s in %s (%p) %zd",
                symbol_name.get_name(), get_realpath(),
                reinterpret_cast<void*>(s->st_value),
                static_cast<size_t>(s->st_size));
     *symbol_index = n;
     return true;
   }
 }
 TRACE_TYPE(LOOKUP, "NOT FOUND %s in %s@%p %x %zd",
            symbol_name.get_name(), get_realpath(),
            reinterpret_cast<void*>(base), hash, hash % nbucket_);
 *symbol_index = 0;
 return true;
}

从代码上看,是在so的symtab中查找某个符号时ElfW(Sym)* s的地址出现异常,导致s->st_name获取到错误的数据。

通过复现问题,可以抓到更完整的 /data/tombstone日志,得到如下完整的信息:

图片4.png

尽管从tombstone中我们可以看到一些寄存器数据及寄存处地址附近内存数据,同时也可以看到crash时的虚拟内存映射表,仍然无法获取有价值的信息。另外通过几次复现,发现并不是每次Crash都是SIGABRT,也出现不少SIGSEGV信号,而调用栈和之前都是一样的,比如这个:

图片5.png

这基本上可以说明,并不是so本身的代码存在异常,只可能是加载的so出现了文件异常。

另外通过在linker中增加日志,并重新编译linker替换到/system/lib/linker中:

图片6.png
图片7.png

可以获取到如下的地址信息:

图片8.png

通过根据tombstone中的/proc/<poc>/maps的虚拟内存地址与日志打印的地址进行对比,可以发现最为符号表地址的s并没有指向so文件在虚拟内存中的地址段,因此可以怀疑,so加载确实出现了异常。

因为手机root,可以直接获取到crash时的so文件(adb pull /data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so),导出来对比md5,然而发现与正常情况下的so是一模一样的:

图片9.png

既然前面的这些实验都没有得出什么有意义的结论,那么我回过头来分析一下,与问题关联的so加载到底有什么特殊性。

实际上,微视为了减包,将一部分so文件进行下发,由于so也处于不断迭代的过程中,新版本的微视可能会在后台更新so文件,那么客户端一旦发现新的版本有新的so,就会去下载so并进行本地替换。

那么这个过程有什么问题呢?唯一可能的问题,就是先加载了旧的so,之后下载新的so进行了热更新。

我们先看下微视中是否有这种现象。要观察这种现象,我们可以打开linker自身的调试开关,开启so加载的日志。通过设置系统属性,我们可以很容易地进行开启LD_LOG日志:

adb shell setprop debug.ld.all dlerror,dlopen

图片10.png

当然我们也可以只针对某个应用开启这个日志(设置系统属性debug.ld.app.)。另外,为了开启linker中更多的日志,比如DEBUG打印的信息等,我们只需要在adb shell中设置环境变量:

export LD_DEBUG=10

图片11.png
图片12.png

那么,我们重新复现问题,可以看到如下so加载过程:

6e193664b0c3bd7_mh1542192899167.jpg

这个过程表明:旧的so先被加载了,然后下载了新版本的so,并进行了替换。

这个过程有什么问题呢?根据《理解inode》一文我们可以得知,linux的文件系统使用的inode机制支持了so文件的热更新(动态更新),即每个文件都有一个唯一的inode号,打开文件后使用inode号区分文件而不是文件名:

八、inode的特殊作用由于inode号码与文件名分离,这种机制导致了一些Unix/Linux系统特有的现象。 1. 有时,文件名包含特殊字符,无法正常删除。这时,直接删除inode节点,就能起到删除文件的作用。 2. 移动文件或重命名文件,只是改变文件名,不影响inode号码。 3. 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。因此,通常来说,系统无法从inode号码得知文件名。 第3点使得软件更新变得简单,可以在不关闭软件的情况下进行更新,不需要重启。因为系统通过inode号码,识别运行中的文件,不通过文件名。更新的时候,新版文件以同样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新版文件,旧版文件的inode则被回收。

但是问题就出在这里,如果替换文件使用的是cp这样的操作,会导致原来的so文件截断,然后重新写入数据,但是inode并没有更新号,磁盘与内存中的信息出现不一致,这种情况在linux中很常见,比如这篇文章就进行了分析:

cp new.so old.so,文件的inode号没有改变,dentry找到是新的so,但是cp过程中会把老的so截断为0,这时程序再次进行加载的时候,如果需要的文件偏移大于新的so的地址范围会生成buserror导致程序core掉,或者由于全局符号表没有更新,动态库依赖的外部函数无法解析,会产生sigsegv从而导致程序core掉,当然也有一定的可能性程序继续执行,但是十分危险。2. mv new.so old.so,文件的inode号会发生改变,但老的so的inode号依旧存在,这时程序必须停止重启服务才能继续使用新的so,否则程序继续执行,使用的还是老的so,所以程序不会core掉,就像我们在第二部分删除掉log文件,而依然能用lsof命令看到一样。

还有更深入的解释:

Linux由于Demand Paging机制的关系,必须确保正在运行中的程序镜像(注意,并非文件本身)不被意外修改,因此内核在启动程序后会绑定 内存页 到这个so的inode,而一旦此inode文件被open函数O_TRUNC掉,则kernel会把so文件对应在虚存的页清空,这样当运行到so里面的代码时,因为物理内存中不再有实际的数据(仅存在于虚存空间内),会产生一次缺页中断。Kernel从so文件中copy一份到内存中去,a)但是这时的全局符号表并没有经过解析,当调用到时就产生segment fault , b)如果需要的文件偏移大于新的so的地址范围,就会产生bus error。

那么问题基本清晰了。我们在回去看看微视的代码,这里下载了so之后直接unzip到原来的路径,并没有先进行rm操作。

更近一步,我们自己写个demo测试下刚才的问题(2个按钮,一个加载指定so,一个调用so中的native方法):

图片14.png

代码不能再简单了:

图片15.png

正常加载so然后执行native方法都是ok的,使用rm+mv替换或者adb push替换也都是ok的,最后再按照错误的方法操作,步骤为:

  1. 启动app,点击加载so;
  2. 通过cp命令替换so;
  3. 点击执行native方法;
图片16.png

结果确实是crash了:

图片17-截头图.png

日志如下,是不是很最开始的日志信息一样呢:

图片18.png

到此,我们有两种解决办法:

  1. 如果so有升级,先不加载旧的so,等新的so下载完成之后再加载;
  2. 可以先加载旧的so,但是下载了新的so之后,要删除旧的so,再进行替换。

引文参考:

https://www.cnblogs.com/cnland/archive/2013/03/19/2969337.html

https://www.cnblogs.com/cnland/archive/2013/03/20/2970537.html

http://www.nginx.cn/1329.html

http://www.ruanyifeng.com/blog/2011/12/inode.html

https://www.bo56.com/linux%E4%B8%8Bcp%EF%BC%8Cmv%E8%BF%9B%E8%A1%8C%E5%8A%A8%E6%80%81%E5%BA%93%E8%A6%86%E7%9B%96%E9%97%AE%E9%A2%98%E5%88%86%E6%9E%90/

如果使用当中有任何疑问,欢迎联系腾讯WeTest企业QQ:2852350015

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Elson's web

【译】开始在web使用JS Modules

原文说的JS modules,实际上指的是ES6的模块化特性,通过<script type="module">可以实现不经过打包直接在浏览器中import/ex...

36890
来自专栏java学习

针对java初学者以及自学者的一篇入门教程

Java基础 | 数据库 | Android | 学习视频 | 学习资料下载 最新通知 按照我去培训机构的学习经历,给初学还有自学Java 的同学一个基本的学习...

44390
来自专栏CSDN技术头条

Redis错误配置详解

Redis提供了许多提高和维护高效内存数据库使用的工具。在无需额外配置应用层的前提下,Redis独特的数据类型、指令和命令调优就可以满足应用的需求,但是错误的配...

230100
来自专栏情情说

RabbitMQ实战:消息通信模式和最佳实践

通过前2篇的介绍,了解了消息通信的主要元素和交互过程,以及如何运行和管理RabbitMQ,这篇将站在开发模式的角度理解「面向消息通信」带来的好处,以及在各种场景...

57250
来自专栏顶级程序员

硬盘数据恢复的十大神器

因为众所周知的原因:硬盘总是坏!但是嘛,其实硬盘数据恢复也是那么难,一起来看看! 在一切工作进行之前,请先判断硬盘是否有损坏,以及缺损类型,而往往硬盘出现问题...

49360
来自专栏QQ音乐前端团队专栏

【译】开始在web中使用JS Modules

15920
来自专栏北京马哥教育

RabbitMQ源码解析前奏--partitions

一、集群与网络分区 RabbitMQ集群不能很好滴容忍网络分区。如果你正在考虑跨越广域网部署集群,则你最好使用federation或者shovel。 然而事故发...

31140
来自专栏FreeBuf

硬盘数据恢复的神器有哪些?

在一切工作进行之前,请先判断硬盘是否有损坏,以及缺损类型,而往往硬盘出现问题主要集中下以下两个方面: 物理(驱动器故障或者组件故障); 逻辑(文件系统出错或是数...

20080
来自专栏云计算

重新审视分布式(微服务)体系结构中的全局数据一致性

早在2015年的时候,我写了几篇文章,介绍如何通过搭载标准Java EE事务管理器以获得跨分布式服务的数据一致性(查看原文请点击这里,基于Spri...

17020
来自专栏个人分享

分布式系统中的线程与进程

  虽然进程构成了分布式系统中的基本组成单元,但是操作系统提供的用于构建分布式系统的进程在粒度上还是太大了,而就粒度而言,将每个进程细分为若干控制线程的形式则更...

7210

扫码关注云+社区

领取腾讯云代金券