利用IDA Pro静态分析和动态调试so文件
我们已经初步了解了一些逆向相关的知识,通过 jadx-gui 和 JEB 等工具,我们可以成功把 apk 文件中的 Java 代码反编译出来,在此基础上就可以查看实现逻辑了。但这个反编译过程仅仅停留在 Java 层面,这是什么意思呢?本节我们来详细解释一下。
在 Java 中有一个叫作 JNI 的东西,它的全称是 Java Native Interface,即 Java本地接口,这是 Java 调用 Native 语言的一种特性(这里说的Native 语言通常指 C/C++)。有了 JNI,Java 就可以调用由 C/C++ 编写的代码了。
JNI 是 Java 语言里本身就存在的,由于 Android 代码是基于 Java 编写的,因此 Android 自然也能使用 JNI 调用 C/C++ 编写的代码。
使用 JNI 有什么好处呢?对一些 Android App 来说,其中一个很大的好处便是可以提升防护等级,因为使用 C/C++ 编写好某个代码逻辑后,这部分代码会被编译到一个以 so 为名字后缀的文件(例如 libnative.so 文件)中,然后 Java 层需要直接加载该 so 文件并调用 so 文件暴露出来的方法来得到某个结果。重要的是,如果仅通过 jadx-gui 反编译,是无法把这个 so 文件还原成原来的 C/C++ 代码的,因为 jadx-gui 只能处理到 Java 层,对 Native 层则无能为力。换言之,如果某个加密算法是在 Native 层实现的,那么仅依靠反编译是无法知晓其中的真正逻辑的,这就进一步提高了逆向的难度。
那要想还原 so 文件中原本的 C/C++ 代码,有办法吗?有,但不能是反编译了,需要用反汇编。
其实,还原完整的 C/C++ 代码几乎是不可能实现的,但我们可以通过一些反汇编工具得到底层的汇编代码,我们可以通过这些代码的执行逻辑大致还原出对应的 C/C++ 代码。那有什么工具可以做到这一点呢?目前比较流行的就是 IDAPro 工具。
本节我们会以一个实现了 Native 层参数加密的 App 为例,初步分析其基本情况,然后试着用 IDA Pro 逆向它并还原 so 文件中的逻辑。在这个过程中,我们需要用 IDA Pro 工具对 so 文件进行静态分析和动态调试,以便更好地理解 so 文件中隐含的逻辑。
IDA Pro的简介
IDA Pro 的英文全称是 Interactive Disassembler Professional,即交互式反汇编器专业版,大家也称之为 IDA。它由一家总部位于比利时的 Hex-Rayd 公司开发,功能十分强大,是目前流行的反汇编软件之一,也是安全分析人士必备的一款软件。
IDA Pro 最重要的功能便是可以将二进制文件中的机器代码(如 010101)转化成汇编代码,甚至可以进一步根据汇编代码的执行逻辑还原出高级语言(如 C/C++)编写的代码,从而大大提高代码的可读性。IDA Pro 不仅仅局限于分析 Android 中的 so 文件,它可以处理和分析几乎所有的二进制文件, Windows、DOS、Unix、Linux、Mac、Java、.NET 等平台的二进制文件都不在话下。另外,IDA Pro 提供了图形界面和强大的调试功能,利用它我们可以直观地实时调试和分析二进制文件。除了这些,IDA Pro 还提供开放式的插件架构,我们可以编写自定义的插件轻松扩展其功能。
总之,IDA Pro 是一款极其强大的反汇编软件,已经成为业界安全分析必不可少的一个工具,更多介绍可以查看 IDA Pro 的官网。
准备工作
由于本节需要用 IDA Pro 工具对 so 文件进行逆向分析,因此首先要安装 IDA Pro 软件,具体的安装方式可以参考 https://setup.scrape.center/ida 。
其次需要准备一台 Android 真机并 ROOT,注意这次不能使用模拟器,因为动态调试的过程需要用支持 ARM 指令的设备运行,这里我使用的 Android 真机是 Nexus5,Android 版本是 11,CPU 是 32 位。准备好真机后,需要确保能使用 adb 命令在电脑上成功连接到该真机。
最后就是示例 App 了,可以打开 https://app8.scrape.center 下载安装包。本节我们需要的运行结果和之前的结果是类似的,不过这次不是在 Java 层实现请求 URL 中加密参数 token 的加密逻辑,而是改为了在 Native 层,真正的加密逻辑在 so 文件中。
抓包和反编译
首先在 Android 手机上安装示例 App,然后使用 Charles 抓包,抓包的具体过程可以参考 12.1 节的内容,抓包结果如图 13-100 所示。
图 13-100 Charles 的抓包结果
可以看到,请求数据的 URL 中带有一个 token 加密参数,而且每次请求时的这个参数值都不一样。返回结果和运行 App 后展示的电影列表是一一对应的,包含电影标题、类型、评分等数据,我们本节就是想爬取这些数据,因此需要把整个URL的参数构造逻辑还原出来。
为了找到加密参数 token 的加密逻辑,我们先用 jadx-gui 对 apk 文件进行反编译,并做初步分析,反编译结果如图 13-101 所示。
图13-101 反编译结果
从图 13-101 中框出来的内容可以看出,token 的值是调用 encrypt 方法得到的,需要传入 strings 和 offset 两个参数,strings 是一个列表['/api/movie'],offset 是数据的偏移量,这和前面案例的分析结果非常相似。
进一步追踪一下 encrypt 方法,其实现如下:
public class Encrypt {
public static String encrypt(List<String> strings, int offset) {
return Nativeutils.encrypt(TextUtils.join("", strings),offset);
}
}
可以看到它里面又调用了 NativeUtils 类的 encrypt 方法,该方法的第一个参数是 strings 中的内容拼合之后的结果,也就是 /api/movie 这个字符串,第二个参数还是 offset。
接着我们看一下 NativeUtils 类的实现代码:
public class NativeUtils {
public static native String encrypt(String str,int i);
static {
System.loadLibrary("native");
}
}
可以看到,这里并没有 encrypt 方法的具体实现,并且方法声明中多了一个 native 关键字,这证明实现过程在 Native 层,即 encrypt 方法是用 C/C++ 实现的。另外,在 encrypt 方法下面,可以看到对 loadLibrary 方法的调用,传入的参数是 native 字符串,这里其实就是指定了 so 文件的名称,在 apk 文件里会有一个叫作 libnative.so 的 so 文件隐含了 encrypt 方法的实现。
我们继续观察反编译结果,如图 13-102 所示。
图13-102 反编译结果中的资源文件
可以看到资源文件里的 lib 文件夹下正好有 libnative.s 所说的 so 文件。lib 文件夹下一共有 4 个文件夹,分别是 arm64-v8a、armeabi-v7a、x86 和 x86_64,libnative.so 文件可以运行在使用对应指令架构的设备上,这些设备分别如下。
-
arm64-v8a:适配第 8 代、64 位 ARM 处理器,主要是 Android 真机。
-
armeabi-v7a:适配第 7 代、32 位 ARM 处理器,主要是 Android 真机。
-
x86:适配 x86 架构、32位的处理器,主要是模拟器或一些平板设备。
-
x8664:适配x86架构、64位的处理器,主要是模拟器或一些平板设备。
要想知道自已的手机是用的哪种处理器,可以运行该命令来获取:
adb shell getprop ro.product.cpu.abi
由于我使用的是 Android 真机,而且 CPU 是 32 位的,所以运行结果是:
armeabi-v7a
这样当 App 运行时,就会加载执行 armeabi-v7a 文件夹下的 libnative.so 文件。
静态分析
现在我们使用 jadx-gui 工具把 so 文件导出,然后根据实际情况用 IDA Pro 打开 so 文件。这里我使用的是 armeabi-v7a 文件夹下的 libnative.so 文件,如果你的手机的 CPU 是 64 位的,可以使用 arm64-v8a 文件下的 so 文件。
打开 IDA Pro 后,直接把 so 文件拖入窗口中,就会出现配置选项,如图 13-103 所示。
图13-103 IDA Pro工具的配置选项
可以在 “Processor type” 中填写处理 so 文件的方式,这里已经默认选好了 ARM 相关的处理器,我们直接点击 “OK” 按钮,保持默认配置即可。稍等片刻后,就可以看到 IDA Pro 把 so 文件的内容解析出来了,如图 13-104 所示。
可以看到,页面左侧是 so 文件中的一个个方法及声明,右侧是 so 文件的反汇编结果,都是一些汇编指令。和我们平常见到的用高级语言(如 Java、Python)编写的代码相比,汇编指令的可读性要差很多,几乎都是底层的一些操作寄存器的命令,难道我们要一行行分析汇编指令把逻辑找出来吗?这就太烦琐了。
图13-104 解析出的 so 文件内容
IDA Pro 有一个非常强大的功能,就是可以帮我们把汇编指令转换成可读性更高的 C/C++ 代码,怎么操作呢?我们来看一下。
通过刚才的分析,我们知道 encrypt 方法在 so 文件中,于是按这个方法搜索一下,结果找到了一个 Java_com_goldze_mvvmhabit_utils_NativeUtils_encrypt 方法,点击该方法之后就可以看到对应的汇编代码实现,如图 13-105 所示。
图13-105 encrypt 方法的汇编代码实现
接着在右侧最上方的区块中,选中 Java_com_goldze_mvvmhabit_utils_Nativeutils_encrypt 这个方法名称,使其高亮显示,如图 13-106 所示。
图13-106 高亮显示选中的方法名称
此时直接点击 F5 或者从菜单中选择 View→Opensubviews→Generatepseudocode 选项,代表生成伪代码,如图 13-107 所示。
图13-107 选择 Generatepseudocode 选项
之后原来的汇编代码就被还原成了 C 语言代码,如图 13-108 所示。
图13-108 还原成的 C 语言代码
整体代码其实并不多,我们可以分析一下,首先是一个很长的方法调用,而且这个调用连续出现了很多次,类似下面这样:
std::_ndk1:basic_string<char,std::_ndk1::char_traits<char>,std::_ndk1::allocator<char>>::~basic_string(&v20);
这里其实就是调用了 ndk 中的 basic_string 方法,功能是把 v20 变量转换成一个字符串。另外,代码中还有几个看不出具体逻辑的方法,例如 sub_F804、Sub_F850 等,我们可以逐个点进去看看,这里点开 sub_F804 方法:
因为生成的是伪代码,所以有些语句并不完全符合代码的编写规范,经分析,Sub_10AA0 是一个空实现,if 分支和 else 分支分别调用 __push_back_slow_path 方法和 __construct_one_at_end 方法得到了返回结果 result。查阅相关文档(如 LLVM 的文档)后,发现 sub_F804 方法就是列表的 push_back 方法,功能是把 a2 指向的变量添加到 a1 指向的列表变量里。
对其他方法,可以按照类似的逻辑分析,例如这个调用:
可以看到,这里先通过 basic_string 把一个常量字符串 9fdLnciVh4FxQbri 赋值给 v19 变量,接着调用 sub_F804 方法把 v19 代表的字符串插人 v21 指向的列表尾部。
再往后,还可以观察到对 time、join、sha1、b64encode 方法的调用,虽然我们不能完全确定这些方法的实现细节,但大致可以推测出一些相关的逻辑是怎样实现的。
现在我们大概总结一下 encrypt 方法的实现流程。
-
初始化一个空列表 v21。
-
把 a3 赋值给 v5,然后转化为字符串赋值给 v20,再将其插入 v21 列表的尾部。
-
把字符串 9fdLnciVh4FxQbri 赋值给 v19,然后插入 v21 列表的尾部。
-
把 a4 赋值给 v6,然后转化为字符串赋值给 v18,再将其插入 v21 列表的尾部。
-
获取当前时间戳 v7,然后转化为字符串赋值给 v17,再将其插人 v21 列表的尾部。
-
调用 join 方法将 v21 列表中的元素拼接在一起,拼接字符对应的 ASCII 码是 44,即拼接字符是一个逗号,把拼接结果赋值给 v15。
-
调用 v15 的 sha1 方法,把结果赋值为 v16。
-
接着按同样的逻辑,初始化一个空列表 v14,然后把 v16 和时间戳 v17 插人这个列表的尾部。
-
再次调用 join 方法将 v14 中的元素拼接在一起,拼接字符依然是一个逗号,把拼接结果赋值给 v13。
-
把 v13 转换为字符串,然后赋值给 v11。
-
对 v11 进行 Base64 编码。
-
再进行一些字符串的赋值转换后,返回。
以上是我们观察还原后的 C/C++ 代码,并加以一些推敲后总结出的大致流程,但内部的具体细节我们还是不知道,例如进行的 Base64 编码是否标准,以及一些细节是否真的和我们推测的一样,这些都是待验证的。
所以,我们接下来借助 IDA Pro 的动态调试功能真正运行一下 encrypt 方法,看看整个过程是不是和我们想的一样。
动态调试
要进行动态调试,需要额外做一些准备工作。
首先找到 IDA Pro 安装目录下的 dbgsrv 文件夹,里面第一个就是 android_server 文件,如图 13-109 所示,我们需要把它放到手机里,然后运行,类似 frida-server 那样。有了它,电脑上的 IDA Pro 才能和手机连接起来,从而实现动态调试。
图13-109 dbgsrv 文件夹的内容
接着使用 adb 命令把它放到 /data/local/tmp 文件夹下,命令如下:
adb push android_server /data/local/tmp
如果你的手机 CPU 是 64 位的,就把 android_server64 文件放到对应的文件夹下,命令如下:
adb push android server64 /data/local/tmp
接下来,运行 adb she11 命令,进入 /data/local/tmp 目录,并切换到 Root 模式,命令如下:
adb shell $ cd /data/local/tmp $ su
再给 android_server 授予执行权限:
# chmod 777 android_server
如果你的手机 CPU 是 64 位的,就执行:
# chmod 777 android_server64
之后运行 android_server 或 android_server64 即可:
./android_server
整个操作流程如图 13-110 所示。
图13-110 所有准备工作
在默认情况下,android_server 会运行在手机的 23946 端口上,为了能够在电脑上访问到该端口,需要配置一下 adb 的端口转发:
adb forward tcp:23946 tcp:23946
这样访问电脑的 23946 端口,就相当于访问手机的 23946 端口了。
现在打开手机上的示例 App,让它运行起来。再新开一个 IDA Pro 窗口,在菜单中选择 Attach一→ Remote ARMLinux/Android debugger 选项,如图 13-111 所示。
这个选项用于连接一个远程的 Android 调试器,其实就是连接刚才我们启动的 android_server,下面我们填写地址和端口,地址是 localhost,端口是 23946,如图 13-1112 所示。
图13-111 RemoteARMLinux/Android debugger选项 图13-112 填写地址和端口
点击 OK 按钮,IDA Pro 会提示我们选择要挂载的进程,如图 13-113 所示。
图13-113 选择要挂载的进程
我们找到对应 App 的安装包 com.goldze.mvvmhabit 后点击 OK,稍等片刻,就会发现 IDA Pro 停下来了,如图 13-114 所示。
图13-114 IDAPro 反编译的结果
我们可以在右侧的 Modules 面板中找到已经加载好的 libnative.so 文件,如图 13-115 所示。
图13-115 Modules 面板
双击进入 libnative.so 文件,查看其中定义的方法,如图 13-116 所示。
图13-116 libnative.so 文件中的方法
我们找一下刚才在静态分析中找到的 Java_com_goldze_mvvmhabit_utils_NativeUtils_encrypt 方法,如图 13-117 所示。
图13-117 找到 Java_com_goldze_mvvmhabit_utils_NativeUtils_encrypt 方法
双击这个方法,即可在 IDA Pro 的左侧看到对应的汇编代码,这和刚才静态分析时看到的汇编代码几乎是一样的,如图 13-118 所示。
图13-118 方法对应的汇编代码
在这里,我们就可以添加断点进行动态调试了,点击代码左侧的蓝点,之后这个点会变成红色,整行代码会有红色背景,这证明断点成功打上了,如图 13-119 所示。
图13-119 为代码添加断点
接着我们点击 IDA Pro 页面左上角的运行按钮,使 App 的运行恢复正常,如图13-120所示。
图13-120 点击运行按钮
App 之前已经打开过了,因此已经执行了第一次数据请求,那怎么再次触发断点呢?很简单,发送第二次请求即可,我们可以在 App 中上拉列表,触发新的数据加载,然后就能看到 IDA Pro 的反编译停在了断点处,如图 13-121 所示。
图13-121 再次发送请求触发断点
在页面右侧,有一个 General registers 面板,其中显示了寄存器 R0 到 R10 的值,如图 13-122 所示。
图13-122 General registers 面板
我们可以点击 IDA Pro 页面上方的 “逐行执行” 按钮进行单步调试,如图 13-123 所示。
图13-123 点击 “逐行执行” 按钮
还可以点击 General registers 面板中间的 “jump” 按钮,查看对应寄存器中内容的详情,如图 13-124 所示。
图13-124 查看寄存器中内容的详情
点击 “jump” 按钮后,IDA Pro 页面下方的 HexView 面板会同步显示寄存器中的内容,其中左边是十六进制的数据,右边是数据对应的明文。在 HexView 面板中,还可以设置 “同步查看的寄存器的值”,例如图 13-125 中就设置了要同步查看 R0 寄存器的值
图13-125 设置同步查看 R0 寄存器的值
就这样,我们可以在调试过程中观察到代码的实际执行过程和对应的明文。
举个例子,在静态分析时,我们曾观察到一个常量字符串 9fdLnciVh4FxObri 的声明和赋值操作在这里下拉找一下,这个赋值操作对应的就是 LDR 指令,我们在这个位置下一个断点,如图 13-126 所示。
图13-126 在 LDR 指令处下一个断点
然后继续单步执行,可以看到 R1 寄存器被赋值了,如图 13-127 所示。
图13-127 把常量字符串赋给了 R1 寄存器
切换到页面下方的 Hex View 面板,就可以看到对应的明文了,如图 13-128 所示。
图13-128 Hex View 面板中的内容
这时大家可能会有疑问这些汇编代码对应的 C/C++ 代码是什么呢?在某些情况下,动态调试的过程中也可以将汇编代码还原成 C/C++ 代码,这样整个调试过程会变得更加直观。但在其他情况下,从汇编代码到 C/C++ 代码的转换并不可用,这时我们可以借助静态分析的结果。图 13-129 中展示的是动态调试过程中得到的汇编代码。
图13-129 动态调试过程中得到的汇编代码 图13-130 中展示的是静态分析过程中的汇编代码区块。
图13-130 静态分析过程中的汇编代码区块
可以看到,两个汇编代码是一一对应的,由于我们能在静态分析过程中找到对应的 C/C++ 代码的位置,因此动态调试过程中的位置也就可以找到了。经过一些调试分析,我们就能知道变量在整个 C/C++ 代码执行过程中的大致变化情况了,它的值肯定存在于一个或者多个寄存器中,我们通过 Hex View 面板就可以查看和验证。
算法还原
现在我们已经可以还原出基本的算法流程。
-
声明一个空列表,然后将传入的 /api/movie 字符串(对应 a3 参数)、9fdLnciVh4FxQbri 字符串、offset 变量(对应 a4 参数)和时间戳信息放入列表,再使用逗号把列表中的这些内容拼接起来。
-
对拼接得到的字符串使用 sha1 算法加密。
-
再声明一个空列表,然后将上述加密结果和时间戳信息放人列表,同样使用逗号把列表中的这些内容拼接起来。
-
对拼接得到的字符串进行 Base64 编码,最后返回即可。
以上便是 token 参数的加密逻辑,我们可以试着用 Python 代码实现一下:
这里我们用一个 get_token 方法实现了上述的加密逻辑。最后添加对该方法的调用即可:
INDEX_URL = 'https://app8.scrape.center/api/movie?limit={limit}&offset={offset}&token={token} MAX_PAGE = 10 LIMIT = 10
for i in range(MAX_PAGE): offset = i * LIMIT token = get_token('/api/movie', offset) index_url = INDEX_URL.format(limit=LIMIT, offset=offset, token=token) response = requests.get(index_url) print('response', response.json())
运行结果如下:
我们成功爬取到了数据!