器→工具, 工具软件

Android反编译之.so文件

钱魏Way · · 14 次浏览

什么是.so文件

.so文件是Linux下共享链接库文件。由于Android操作系统的底层基于Linux系统,所以SO文件可以运行在Android平台上。Android系统也同样开放了C/C++接口供开发者开发Native程序。由于基于虚拟机的编程语言JAVA更容易被人反编译,因此越来越多的应用将其中的核心代码以C/C++为编程语言,并且以SO文件的形式供上层JAVA代码调用,以保证安全性。

静态链接与动态链接

对于初学C语言的朋友,可能对链接这个概念有点陌生,这里简单介绍一下。我们的C代码编译生成可执行程序会经过如下过程:

链接就是把目标文件与一些库文件生成可执行文件的一个过程。

  • 静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。链接器是一个独立程序,将一个或多个库或目标文件链接到一块生成可执行程序。这里的库指的是静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
  • 动态链接(Dynamic Linking),把链接这个过程推迟到了运行时再进行,在可执行文件装载时或运行时,由操作系统的装载程序加载库。这里的库指的是动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。值得一提的是,在Windows下的动态链接也可以用到.lib为后缀的文件,但这里的.lib文件叫做导入库,是由.dll文件生成的。

静态链接的优缺点

优点:

  • 代码装载速度快,执行速度略比动态链接库快;
  • 只需保证在开发者的计算机中有正确的.lib/.a文件,在以二进制形式发布程序时不需考虑在用户的计算机上.lib/.a文件是否存在及版本问题。

缺点:

  • 使用静态链接生成的可执行文件体积较大,包含相同的公共代码,造成浪费。

动态链接的优缺点

优点:

  • 多个可执行文件可以共享使用系统中的共享库。每个可执行文件都更小,占用的磁盘空间也相对比较小。而静态链接把所依赖的库打包进可执行文件,假如printf()被其他程序使用了上千次,就要被打包到上千个可执行文件中,这样会占用了大量磁盘空间。
  • 共享库的之间隔离决定了共享库可以进行小版本的代码升级,重新编译并部署到操作系统上,并不影响它被可执行文件调用。静态链接库的任何函数有了改动,除了静态链接库本身需要重新编译构建,依赖这个函数的所有可执行文件都需要重新编译构建一遍。

缺点:

  • 如果将一份目标文件移植到一个新的操作系统上,而新的操作系统缺少相应的共享库,程序将无法运行,必须在操作系统上安装好相应的库才行。
  • 共享库必须按照一定的开发和升级规则升级,不能突然重构所有的接口,且新库文件直接覆盖老库文件,否则程序将无法运行。

动态链接库详解

ldd命令查看动态链接库依赖

在Linux上,动态链接库有默认的部署位置,很多重要的库放在了系统的/lib和/usr/lib两个路径下。一些常用的Linux命令非常依赖/lib和/usr/lib64下面的各个库,比如:scp、rm、cp、mv等Linux下常用的命令非常依赖/lib和/usr/lib64下的各个库。不小心删除了这些路径,可能导致系统的很多命令和工具都无法继续使用。

我们可以用ldd命令查看某个可执行文件依赖了哪些动态链接库。

# on Ubuntu 16.04 x86_64
$ ldd /bin/ls
  linux-vdso.so.1 =>  (0x00007ffcd3dd9000)
 libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f4547151000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4546d87000)
 libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007f4546b17000)
 libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f4546913000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f4547373000)
 libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f45466f6000)

可以看到,我们经常使用的ls命令依赖了不少库,包括了C语言标准库libc.so。

如果某个Linux的程序报错提示缺少某个库,可以用ldd命令可以用来检查这个程序依赖了哪些库,是否能在磁盘某个路径下找到.so文件。如果找不到,需要使用环境变量LD_LIBRARY_PATH来调整,下文将介绍环境变量LD_LIBRARY_PATH。

SONAME文件命名规则

so文件后面往往跟着很多数字,这表示了不同的版本。so文件命名规则被称为SONAME:

libname.so.x.y.z

lib是前缀,这是一个约定俗成的规则。x为主版本号(Major Version),y为次版本号(Minor Version),z为发布版本号(Release Version)。

  • Major Version表示重大升级,不同Major Version之间的库是不兼容的。Major Version升级后,或者依赖旧Major Version的程序需要更新代码,重新编译,才可以在新的Major Version上运行;或者操作系统保留旧Major Version,使得老程序依然能运行。
  • Minor Version表示增量更新,一般是增加了一些新接口,原来的接口不变。所以,在Major Version相同的情况下,Minor Version从高到低是兼容的。
  • Release Version表示库的一些bug修复,性能改进等,不添加任何新的接口,不改变原来的接口。

但是我们刚刚看到的.so只有一个Major Version,因为这是一个软连接,libname.so.x软连接到了libname.so.x.y.z文件上。

$ ls -l /lib/x86_64-linux-gnu/libpcre.so.3
/lib/x86_64-linux-gnu/libpcre.so.3 -> libpcre.so.3.13.2

因为不同的Major Version之间不兼容,而Minor Version和Release Version都是向下兼容的,软连接会指向Major Version相同,Minor Version和Release Version最高的.so文件上。

动态链接库查找过程

刚才提到,Linux的动态链接库绝大多数都在/lib和/usr/lib下,操作系统也会默认去这两个路径下搜索动态链接库。另外,/etc/ld.so.conf文件里可以配置路径,/etc/ld.so.conf文件会告诉操作系统去哪些路径下搜索动态链接库。这些位置的动态链接库很多,如果链接器每次都去这些路径遍历一遍,非常耗时,Linux提供了ldconfig工具,这个工具会对这些路径的动态链接库按照SONAME规则创建软连接,同时也会生成一个缓存Cache到/etc/ld.so.cache文件里,链接器根据缓存可以更快地查找到各个.so文件。每次在/lib和/usr/lib这些路径下安装了新的库,或者更改了/etc/ld.so.conf文件,都需要调用ldconfig命令来做一次更新,重新生成软连接和Cache。但是/etc/ld.so.conf文件和ldconfig命令最好使用root账户操作。非root用户可以在某个路径下安装库文件,并将这个路径添加到/etc/ld.so.conf文件下,再由root用户调用一下ldconfig。

对于非root用户,另一种方法是使用LD_LIBRARY_PATH环境变量。LD_LIBRARY_PATH存放着若干路径。链接器会去这些路径下查找库。非root可以将某个库安装在了一个非root权限的路径下,再将其添加到环境变量中。

动态链接库的查找先后顺序为:

  • LD_LIBRARY_PATH环境变量中的路径
  • /etc/ld.so.cache缓存文件
  • /usr/lib和/lib

比如,我们把CUDA安装到/opt下面,我们可以使用下面的命令将CUDA添加到环境变量里。

export LD_LIBRARY_PATH=/opt/cuda/cuda-toolkit/lib64:$LD_LIBRARY_PATH

如果在执行某个具体程序前先执行上面的命令,那么这个程序将使用这个路径下的CUDA;如果将这行添加到了.bashrc文件,那么该用户一登录就会执行这行命令,因此该用户的所有程序也都将使用这个路径下的CUDA。当同一个动态链接库有多个不同版本的.so文件时,可以将他们安装到不同的路径下面,然后使用LD_LIBRARY_PATH环境变量来控制使用哪个库。这种比较适合在多人共享的服务器上使用不同版本的库,比如CUDA这种版本变化较快,且深度学习程序又高度依赖的库。

除了LD_LIBRARY_PATH环境变量外,还有一个LD_PRELOAD环境变量。LD_PRELOAD的查找顺序比LD_LIBRARY_PATH还要优先。LD_PRELOAD里是具体的目标文件列表(A list of shared objects);LD_LIBRARY_PATH是目录列表(A list of directories)。

动态链接库的创建

由于动态链接库函数的共享特性,它们不会被拷贝到可执行文件中。在编译的时候,编译器只会做一些函数名之类的检查。在程序运行的时候,被调用的动态链接库函数被安置在内存的某个地方,所有调用它的程序将指向这个代码段。因此,这些代码必须实用相对地址,而不是绝对地址。在编译的时候,我们需要告诉编译器,这些对象文件是用来做动态链接库的,所以要用地址不无关代码(Position Independent Code (PIC))。

对gcc编译器,只需添加上 -fPIC 标签,如:

gcc -fPIC -c file1.c
gcc -fPIC -c file2.c
gcc -shared libxxx.so file1.o file2.o

注意到最后一行,-shared 标签告诉编译器这是要建立动态链接库。这与静态链接库的建立很不一样,后者用的是 ar 命令。也注意到,动态链接库的名字形式为 “libxxx.so” 后缀名为 “.so”

也可以使用如下命令:

gcc test_a.c test_b.c test_c.c -fPIC -shared -o libtest.so

动态链接库的使用

动态链接库与普通的程序相比而言,没有main函数,是一系列函数的实现。通过shared和fPIC编译参数产生so动态链接库文件。程序在调用库函数时,只需要连接上这个库即可。下面展示一个整型和浮点型加法实现的库:

/*add.h*/

#ifndef ADD_H
#define ADD_H

/*整型加法*/
int addi(int a, int b);

/*浮点型加法*/
float addf(float a, float b);

#endif
/*add.c*/

#include "add.h"

int addi(int a, int b)
{
  int sum = a + b;
  return sum;
}

float addf(float a, float b)
{
  float sum = a + b;
  return sum;
}

编译生成add.so文件:gcc -shared -fPIC add.c -o libadd.so,然后在当前目录下生成libadd.so文件(注意动态链接库名称要用lib开头)。然后编写一个调用该动态链接库的测试程序:

/*test.c*/

#include <stdio.h>
#include "add.h"

int main()
{
  int ai = 10;
  int bi = 10;

  float af = 10.1;
  float bf = 10.1;

  printf("%d + %d = %d\n", ai, bi, addi(ai, bi));
  printf("%f + %f = %f\n", af, bf, addf(af, bf));

  return 0;
}

编译生成可执行文件:gcc test.c -o test -L./ -ladd -Wl,-rpath=./

编译参数说明:

  • -l后面是链接库的名称,省略lib,例如连接so,就是指定-ladd
  • -L./选项在链接时指定动态链接库路径(不一定是当前路径),编译通过,但执行时还是会报错:找不到文件需要再通过-Wl,-rpath=./指定运行时路径,运行时按照指定路径寻找动态库

然后执行程序,执行结果如下所示:

以看到浮点数运算和预期不太一致。

动态加载so文件

上面展示了在编译可执行文件时就需要在编译命令中指定so信息的方式。其实和Windows下动态加载dll一样,Linux提供了dlopen、dlsym、dlerror、dlclose函数获取动态链接库的函数。通知这四个函数可以实现一个插件程序,方便程序的扩展和维护动态链接库的函数定义如下:

#include <dlfcn.h>

void *dlopen(const char *filename, int flag);
char *dlerror(void);
void *dlsym(void *handle, const char *symbol);
int dlclose(void *handle);

使用这四个函数再编写一个调用上面的libadd.so链接库

/*test2.c*/

#include <stdio.h>
#include <dlfcn.h>

#define SO_NAME "./libadd.so"

int main()
{
  void *handle;               //so文件句柄
  int (*add1)(int, int);      //定义函数指针
  float (*add2)(float, float);
  char *error;
  int a1 = 30;
  int b1 = 12;
  float a2 = 30.30;
  float b2 = 20.20;

  //加载动态链接库到内存
  handle = dlopen(SO_NAME, RTLD_NOW);
  if (NULL == handle){
    printf("dlopen error!\n");
    return -1;
  }

  //根据函数名获取addi在动态链接库中的地址
  add1 = dlsym(handle, "addi");
  printf("%d + %d = %d\n", a1, b1, add1(a1, b1));

  add2 = dlsym(handle, "addf");
  printf("%f + %f = %f\n", a2, b2, add2(a2, b2));

  //卸载so
  dlclose(handle);

  return 0;
}

执行命令gcc test2.c -o test2 -ldl编译程序(这里就不需要在编译的时候指明动态链接库的路径了)。然后执行程序:

备注:在ubuntu可能因为没有libdl库导致使用-ldl选项编译报错undefined reference to ‘dlopen’。可能的原因是你使用的命令是:gcc -ldl test2.c -o test2,-ldl选项放在中间会导致问题,修改为gcc test2.c -o test2 -ldl将编译选项放在最后即可!

查看静态库和动态库中有那些函数名

有时候可能须要查看一个库中到底有哪些函数,nm工具能够打印出库中的涉及到的全部符号,这里的库既能够是静态的也能够是动态的。

nm列出的符号有不少, 常见的有三种:

  • 一种是在库中被调用,但并无在库中定义(代表须要其余库支持),用U表示;
  • 一种是在库中定义的函数,用T表示,这是最多见的;
  • 另一种是所 谓的”弱态”符号,它们虽然在库中被定义,可是可能被其余库中的同名符号覆盖,用W表示。

假设开发者但愿知道的hello库中是否引用了 printf():

$nm libhello.so | grep printf

发现printf是U类符号,说明printf被引用,可是并无在库中定义。

由此能够推断,要正常使用hello库,必须有其它库支持,使用ldd工具查看hello依赖于哪些库:

$ldd hello libc.so.6=>/lib/libc.so.6(0x400la000) /lib/ld-linux.so.2=>/lib/ld-linux.so.2 (0x40000000)

从上面的结果能够继续查看printf最终在哪里被定义。

Android项目中的.so文件

开发Android应用时,有时候Java层的编码不能满足实现需求,就需要到C/C++实现后生成SO文件,再用System.loadLibrary()加载进行调用,这里成为JNI层的实现。常见的场景如:加解密算法,音视频编解码等。so是与平台相关的二进制机器码,Android应用支持的cpu架构取决于APK中位于lib或jniLib目录中的.so文件。应用程序二进制接口ABI(Application Binary Interface)定义了二进制文件(尤其是.so文件)如何运行在相应的系统平台上,从使用的指令集、内存对齐到可用的系统函数库。

Android系统目前支持以下七种不同的CPU架构:ARMv5,ARMv7 (从2010年起),x86 (从2011年起),MIPS (从2012年起),ARMv8,MIPS64和x86_64 (从2014年起),每一种都关联着一个相应的ABI。典型的 ABI 包含以下信息:

  • 机器代码应使用的 CPU 指令集。
  • 运行时内存存储和加载的字节顺序。
  • 可执行二进制文件(例如程序和共享库)的格式,以及它们支持的内容类型。
  • 用于解析内容与系统之间数据的各种约定。这些约定包括对齐限制,以及系统如何使用堆栈和在调用函数时注册。
  • 运行时可用于机器代码的函数符号列表 – 通常来自非常具体的库集。
  • 支持一个或多个指令集。

很多设备都支持多于一种的ABI。例如ARM64和x86设备也可以同时运行armeabi-v7a和armeabi的二进制包。但最好是针对特定平台提供相应平台的二进制包,这种情况下运行时就少了一个模拟层(例如x86设备上模拟arm的虚拟层),从而得到更好的性能(归功于最近的架构更新,例如硬件fpu,更多的寄存器,更好的向量化等)。

我们可以通过Build.SUPPORTED_ABIS得到根据偏好排序的设备支持的ABI列表。但你不应该从你的应用程序中读取它,因为Android包管理器安装APK时,会自动选择APK包中为对应系统ABI预编译好的.so文件,如果在对应的lib/ABI目录中存在.so文件的话。

每一个CPU架构对应一个ABI,一个cpu属于某一种架构,多核cpu需要属于相同架构才能一起工作,很多设备仅支持一种的CPU架构。如果你要完美兼容所有类型的手机,理论上是要在的libs目录下放置各个架构平台的SO文件。

这样一写,虽然可以兼容所有机型,但你的项目体积也会变得非常庞大。是否一定需要带入这么多SO文件去兼容呢?答案是否定的。对于CPU来说,不同的架构并不意味着一定互不兼容,根据目前Android共支持七种不同类型的CPU架构,其兼容特点可总结如下:

  • armeabi设备只兼容armeabi
  • armeabi-v7a设备兼容armeabi-v7a、armeabi
  • arm64-v8a设备兼容arm64-v8a、armeabi-v7a、armeabi
  • X86设备兼容X86、armeabi
  • X86_64设备兼容X86_64、X86、armeabi
  • mips64设备兼容mips64、mips
  • mips只兼容mips

根据以上的兼容总结,我们还可以得到一些规律:

  • armeabi的SO文件基本上可以说是万金油,它能运行在除了mips和mips64的设备上,但在非armeabi设备上运行性能还是有所损耗
  • 64位的CPU架构总能向下兼容其对应的32位指令集,如:x86_64兼容X86,arm64-v8a兼容armeabi-v7a,mips64兼容mips

当一个应用安装在设备上,只有该设备支持的CPU架构对应的.so文件会被安装。在x86设备上,libs/x86目录中如果存在.so文件的话,会被安装,如果不存在,则会选择armeabi-v7a中的.so文件,如果也不存在,则选择armeabi目录中的.so文件(因为x86设备也支持armeabi-v7a和armeabi)。

从目前移动端CPU市场的份额数据看,ARM架构几乎垄断,所以,除非你的用户很特殊,否则几乎可以不考虑单独编译带入X86、X86_64、mips、mips64架构SO文件。除去这四个架构之后,还要带入armeabi、armeabi-v7a、arm64-v8a这三个不同类型,这对于一个拥有大量SO文件的应用来说,安装包的体积将会增大不少。未来只需要适配一款cpu架构arm64-v8。

我们往往很容易对.so文件应该放在或者生成到哪里感到困惑,下面是一个总结:

  • Android Studio工程放在main/jniLibs/ABI目录中(当然也可以通过在gradle文件中的设置jniLibs.srcDir属性自己指定)
  • Eclipse工程放在libs/ABI目录中(这也是ndk-build命令默认生成.so文件的目录)
  • AAR压缩包中位于jni/ABI目录中(.so文件会自动包含到引用AAR压缩包的APK中)
  • 最终存放于APK文件中的lib/ABI目录中
  • 通过PackageManager安装后,在小于Android 5.0的系统中,.so文件位于app的nativeLibraryPath目录中;在大于等于Android 5.0的系统中,.so文件位于app的nativeLibraryRootDir/CPU_ARCH目录中。

.so文件的反编译

IDA pro全名为交互式反汇编器专业版(Interactive Disassembler Professional),简称为IDA。就本质而言,IDA是一种递归下降反汇编器。为了克服递归下降的缺点,IDA在区分数据与代码的同时,还设法确定这些数据的类型。通过IDA pro对dll、so等文件进行反编译后,用户通常看到的是经过数据类型区分的汇编语言形式的代码。IDA pro还有一个亮点,就是类似C语言的“中间语言”,汇编语言的阅读是需要一些学习成本的,类C的“中间语言”有助于用户更好地查看实现逻辑。

打开IDA pro后,弹出选择打开方式界面,如下图所示。so文件选择ELF for ARM选项,点击OK!

打开后就会进入到IDA pro的主界面,界面中间显示的是汇编文件的具体信息(用户license、so源文件信息等),左边部分展示的是so文件反汇编生成的汇编方法名,上边部分展示汇编文件按照数据类型的分类情况。IDA pro还提供了6种视图,来帮助用户更好地分析so文件,如下图所示。

由于JNI函数都是“Java + 包名”开始的,我们可以在方法名显示部分左击并输入“Java”就会找到so文件里的JNI函数,如下图所示:

双击该方法就可以看到“IDA View”,即JNI函数的汇编语言,如果对汇编语言不熟悉,可以按“F5”使用IDA pro提供的插件,将该汇编语言转换为Pseudocode,即类C中间语言的格式。如下图所示:

这样就完成了so文件的反汇编,可以看出,NDK对代码的保护是有效果的,反汇编后的攻击成本比Java高了不少。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注