Android应用逆向实战:从抓包到so层算法还原全解析
1. 项目概述与核心价值
最近在分析一个主流房产信息平台“某居客”的移动端应用,目标很明确:完成从APP端网络请求抓包到关键接口逆向,最终深入其核心的so层(共享库)进行算法解析的全过程。这听起来像是一个标准的移动安全分析流程,但实际操作中,你会发现从抓包开始就布满了“雷区”,而深入到so层后,更是对逆向工程基本功和耐心的一次全面考验。这个项目对于从事移动应用安全研究、风控策略对抗、数据合规审计,甚至是希望理解大型APP如何构建其客户端安全体系的开发者而言,都具有很高的参考价值。它不仅仅是一次技术演练,更是一次对现代APP混合防护策略(HTTPS证书绑定、代码混淆、Native层加密)的实战拆解。
简单来说,我们要做三件事:第一,成功捕获到APP发出的所有网络请求,特别是那些包含核心业务数据(如房源列表、详情、用户信息)的接口;第二,逆向分析这些接口的调用逻辑、参数构造和签名算法,理解其通信协议;第三,也是最硬核的部分,定位并逆向实现那些被转移到Android so库(C/C++层)中的关键加密或校验函数。整个过程,你会遇到证书锁定(SSL Pinning)、各种代码混淆、反调试,以及so层的控制流平坦化等保护手段。接下来,我就以一个实战者的视角,带你一步步拆解这个“某居客”,分享其中踩过的坑和总结出的有效技巧。
2. 环境准备与工具链选型
工欲善其事,必先利其器。一个稳定、高效的工具链是成功的一半。对于移动端抓包与逆向,工具的选择需要兼顾易用性、功能强大和对抗检测的能力。
2.1 抓包环境搭建
抓包是整个分析的入口。对于HTTPS流量,传统的方法(如设置系统代理)在遇到证书绑定时会立刻失效。“某居客”这类大型APP几乎必然使用了证书绑定。
我的方案是:使用一部已Root的Android真机 + 模块化注入工具。
- 设备选择:我选择了一台闲置的Android手机,刷入了Magisk获取Root权限。模拟器(如Genymotion、夜神)在对抗高级反调试和检测虚拟环境时容易出问题,真机更稳定。
- 核心工具:Frida和JustTrustMe模块(或类似功能的Xposed模块,如TrustMeAlready)。Frida是一个动态插桩框架,我们可以编写脚本,在运行时Hook住APP的证书验证逻辑,使其接受我们抓包工具(如Burp Suite或Charles)签发的证书。
- 抓包代理:Burp Suite Professional。它的Repeater、Intruder、Decoder等功能在后续参数分析和爆破时非常有用。社区版功能受限,但基础抓包足够。
具体搭建步骤:
- 在电脑上安装Burp Suite,配置好代理(如
127.0.0.1:8080),并导出Burp的CA证书。 - 在Root后的手机上,安装Magisk模块管理器,然后安装
MagiskFrida模块(或手动部署Frida-server到手机并运行)。 - 安装Xposed框架(如LSPosed),并安装
JustTrustMe模块。这个模块的作用是禁用或绕过大量APP的证书绑定逻辑。 - 将Burp的CA证书安装到手机的系统证书目录(
/system/etc/security/cacerts/),这需要Root权限。这一步确保了手机系统信任Burp。 - 配置手机Wi-Fi代理,指向运行Burp的电脑IP和端口。
注意:仅仅安装用户证书是不够的,Android 7.0以上,APP默认不信任用户安装的证书,必须将证书放入系统证书目录。这是第一个常见的坑。
2.2 逆向分析环境搭建
成功抓到包后,我们需要静态和动态分析APP本身。
反编译与静态分析:
- Jadx-GUI:这是我首选的Java反编译工具。它可以直接打开APK文件,将Dex代码反编译成可读性相当高的Java代码。用于快速浏览APP结构、查找关键类和方法。
- Apktool:用于反编译APK资源文件(如图片、布局XML)和
AndroidManifest.xml,对于理解APP权限和组件结构很重要。 - JEB或Ghidra:更强大的反编译器。当Jadx遇到复杂混淆处理不佳时,JEB的反编译效果往往更好。Ghidra则是免费的强大逆向工程框架,对Native层(so)的分析支持极佳。
动态调试与Hook:
- Frida:不仅是绕过SSL的利器,更是动态分析的瑞士军刀。我们可以编写Python脚本,主动调用APP的方法、修改函数返回值、打印调用栈和参数,这对于快速定位加密函数入口至关重要。
- Objection:基于Frida的命令行工具,可以快速实现一些常见操作,如禁用SSL绑定、内存搜索、列出类和方法等,适合快速侦查。
So层分析:
- Ghidra/IDA Pro:静态分析so文件的不二之选。Ghidra免费且功能强大,支持反编译C/C++代码。IDA Pro是行业标准,交互和插件生态更成熟。
- Frida:同样可以用于Hook so层导出的Native函数,动态获取函数的输入输出,辅助静态分析。
- radare2:命令行下的强大逆向工具,适合喜欢终端操作的研究者。
这套组合拳覆盖了从网络流量捕获、Java层逻辑分析到Native层算法还原的全链路需求。
3. 抓包实战与证书绑定绕过
环境准备好后,启动“某居客”APP,开始抓包。不出所料,最初的尝试可能一无所获,APP提示网络错误或直接闪退。这说明它检测到了代理或证书异常。
3.1 初步对抗与问题定位
首先,检查Burp Suite是否能看到任何HTTP/HTTPS请求。如果完全看不到,可能是APP使用了透明代理检测或证书绑定(SSL Pinning)。我们之前安装的JustTrustMe模块就是为了通用性地绕过证书绑定。如果安装了该模块后仍然抓不到包,可能有以下原因:
- APP使用了自定义的HTTP库或Socket通信:有些APP会使用
OkHttp的自定义TrustManager,或者直接使用Socket进行非HTTP协议通信。JustTrustMe主要针对系统证书验证逻辑,对高度自定义的实现可能失效。 - APP具备代理检测功能:APP会读取系统代理设置,如果发现设置了代理,则拒绝发送请求或走其他通道。
- So层实现的绑定:最棘手的情况,证书验证逻辑被编译到了so文件中,Java层只是一个壳子。
应对策略:
- 使用Frida脚本进行精准Hook:写一个Frida脚本,Hook常见的证书验证类,如
OkHttpClient.Builder的sslSocketFactory和hostnameVerifier,或者TrustManager的checkClientTrusted/checkServerTrusted方法。通过打印堆栈,可以找到APP具体在哪里进行了证书验证。// 示例Frida脚本:Hook OkHttp的证书验证 Java.perform(function() { var OkHttpClient_Builder = Java.use('okhttp3.OkHttpClient$Builder'); OkHttpClient_Builder.sslSocketFactory.implementation = function(sslSocketFactory, trustManager) { console.log(\"[*] OkHttpClient.Builder.sslSocketFactory() called!\"); console.log(Java.use(\"android.util.Log\").getStackTraceString(Java.use(\"java.lang.Exception\").$new())); // 返回一个接受所有证书的Factory // ... 这里可以替换成自定义的信任所有证书的Factory return this.sslSocketFactory(sslSocketFactory, trustManager); }; }); - 使用ProxyDroid等工具进行全局代理:这类工具可以在VPN层面实现代理,绕过APP层的代理检测。但可能会与抓包工具的证书安装产生冲突,需要仔细配置。
- 针对So层绑定:这需要进入下一步的逆向分析。可以先通过抓包工具看到TCP连接建立但SSL握手失败,或者APP直接崩溃,来间接判断。
3.2 成功捕获流量
经过Frida脚本的辅助Hook和JustTrustMe模块的作用,我最终成功在Burp Suite中看到了“某居客”的HTTPS流量。关键接口通常以.json或纯路径形式出现,例如/house/list、/detail/info等。此时,需要重点关注:
- 请求头(Headers):特别是
Authorization、X-Token、User-Agent(可能被自定义)、X-Sign、X-Timestamp等字段,这些往往是身份验证和请求签名的关键。 - 请求参数(Params/Body):查看GET参数或POST的JSON/form-data数据。注意是否有看似随机的字符串参数,如
sign、nonce、encryptedData等。 - 响应数据(Response):观察数据是明文的JSON,还是经过编码或加密的。如果是加密的,那么解密逻辑很可能在so层。
实操心得:不要一上来就分析所有接口。先进行常规的APP操作(如浏览首页、搜索房源、查看详情),在Burp中过滤出与这些操作相关的、数据量较大的接口。通常,列表接口和详情接口是分析签名和加密算法的突破口,因为它们参数相对固定,易于对比分析。
4. Java层接口逻辑逆向
抓到关键接口(例如/api/v1/house/search)后,下一步就是找到APP中构造这个请求的代码位置。
4.1 定位关键代码
常用的定位方法有几种:
- 关键词搜索法:在Jadx中,直接搜索接口路径的一部分,如
/house/search。但现代APP通常会将接口域名和路径拼接,可能搜索不到。可以搜索域名,或者搜索参数名,如cityId、keyword。 - 调用栈分析法:这是更高效的方法。在Frida脚本中,Hook住网络请求库的发送函数,例如
okhttp3.Call.execute()或HttpURLConnection.connect(),然后在调用时打印堆栈信息(Log.getStackTraceString(new Exception()))。堆栈会清晰地告诉你这个请求是从哪个类、哪个方法发起的。Java.perform(function() { var OkHttpClient = Java.use('okhttp3.OkHttpClient'); OkHttpClient.newCall.implementation = function(request) { var url = request.url().toString(); if (url.indexOf(\"ke.com\") != -1) { // 过滤特定域名 console.log(\"[*] Intercepting request to: \" + url); console.log(\"Stack Trace:\\n\" + Java.use(\"android.util.Log\").getStackTraceString(Java.use(\"java.lang.Exception\").$new())); } return this.newCall(request); }; }); - 参数回溯法:如果某个请求有一个特征明显的参数值(比如一个特定的时间戳或ID),可以在Jadx中搜索这个值的字符串或数字,可能直接定位到生成它的代码附近。
通过上述方法,我定位到了“某居客”请求构造的核心类,通常命名为XXXApiService、XXXHttpClient或XXXNetworkManager。
4.2 分析参数构造与签名逻辑
找到发起请求的类和方法后,在Jadx中查看其反编译的代码。你需要关注:
- BaseUrl和路径拼接:接口的完整URL是如何生成的。
- 公共参数添加:哪些参数(如设备ID
deviceId、版本号appVersion、渠道channel)是在拦截器(Interceptor)或方法开头统一添加的。 - 签名(Sign)生成:这是重中之重。寻找名为
getSign、generateSignature、calculateSign的方法。签名算法通常是对所有请求参数(有时包括请求体、时间戳、一个固定密钥secret)按照一定规则(如字典序排序)拼接后,再进行某种哈希(如MD5、SHA256)或HMAC运算。- 关键线索:在代码中搜索
MD5、SHA-256、Hmac、MessageDigest、Mac等关键词。 - 动态验证:用Frida Hook你怀疑的签名方法,打印其输入(参数Map)和输出(sign字符串)。然后在Burp Repeater中,用同样的参数手动计算一次签名,看是否匹配。这是验证你分析是否正确的最直接方法。
- 关键线索:在代码中搜索
踩坑记录:我最初发现一个generateSign方法,它接收一个Map<String, String>参数,返回一个字符串。Hook后发现它内部只是做了一次简单的MD5。但用这个算法计算出的签名和服务端验证不通过。后来通过更细致的堆栈分析发现,在调用generateSign之前,参数Map已经被另一个方法处理过,添加了一个从so层获取的dynamicKey。这就是典型的Java层与Native层协作:Java层负责收集和组装参数,核心的密钥或部分计算逻辑放在so层以增加逆向难度。
5. So层逆向分析与算法还原
当发现关键参数(如签名所需的密钥secret、加密数据的IV或key)是通过System.loadLibrary加载的so库中的Native方法获取时,战斗才真正开始。
5.1 定位与提取So文件
- 定位Native方法:在Jadx中,搜索
native关键字,或者查找调用System.loadLibrary(\"xxx\")的代码。通常会在一个static块中加载。找到声明Native方法的类,如NativeSignHelper。 - 提取So文件:APK本质是ZIP包,解压后在其
lib/目录下(可能有armeabi-v7a、arm64-v8a等子目录)找到对应的libxxx.so文件。将目标架构(通常选arm64-v8a)的so文件复制出来。
5.2 静态分析入口点
使用Ghidra或IDA Pro加载so文件。首先寻找导出函数(Exported Functions)。JNI(Java Native Interface)函数的命名有固定格式:Java_包名_类名_方法名。例如,Java_com_ke_app_util_NativeSignHelper_getEncryptKey。
- 在Ghidra中,可以在
Symbol Tree窗口的Functions里过滤Java_来快速定位。 - 找到目标函数后,开始分析其汇编或反编译后的C代码。
初始挑战:so文件很可能经过了混淆。函数名可能被抹去(只剩下地址),控制流可能被平坦化(Control Flow Flattening),并穿插了大量无用指令(垃圾代码)。这会让反编译出来的代码逻辑支离破碎,难以阅读。
5.3 动态辅助与算法追踪
静态分析受阻时,动态调试是破局关键。
Frida Hook Native函数:我们可以直接Hook so层导出的JNI函数。
// Hook Native方法,打印输入输出 Interceptor.attach(Module.findExportByName(\"libnative-sign.so\", \"Java_com_ke_app_util_NativeSignHelper_getSign\"), { onEnter: function(args) { // args[0]是JNIEnv*, args[1]是jclass/jobject, args[2]...是Java传入的参数 this.param1 = args[2]; // 假设第一个参数是jstring var param1Str = Java.vm.getEnv().getStringUtfChars(this.param1, null).readCString(); console.log(\"[Native] getSign called with param: \" + param1Str); }, onLeave: function(retval) { // retval是返回值,可能是jstring var retStr = Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log(\"[Native] getSign returned: \" + retStr); } });通过Hook,我们可以确认这个Native函数的输入输出是什么,验证它是否是我们寻找的签名或密钥生成函数。
使用Frida Stalker追踪指令:对于内部函数,可以使用Frida的
Stalker功能追踪CPU指令执行流,虽然数据量大,但有时能发现关键调用或循环。结合静态分析:用动态获取到的具体输入输出值(例如,输入字符串
“test”,输出“a1b2c3d4”),在静态反编译的代码中设置“断点”或进行数据流跟踪。在Ghidra中,你可以搜索这些常量字符串或数值,可能定位到关键的比较或计算代码块。
5.4 还原算法逻辑
在“某居客”的案例中,我追踪到一个关键的Native函数,它接收时间戳和设备ID的一部分,经过一系列位运算、查表(S-Box)和循环后,生成一个16字节的dynamicKey。这个dynamicKey会与Java层拼接好的参数字符串组合,再进行一次HMAC-SHA256运算,最终得到提交的sign。
还原步骤:
- 理解函数原型:明确输入参数的数量和类型(int, string, byte array),以及返回值类型。
- 梳理主流程:忽略混淆的垃圾代码和虚假分支,聚焦在那些对输入参数进行实际操作的指令上。寻找常见的加密算法特征,如:
- AES:
AES_encrypt/AES_decrypt函数调用,或者明显的SubBytes、ShiftRows、MixColumns、AddRoundKey操作序列(可通过查找固定的轮常数Rcon来辅助识别)。 - MD5/SHA:初始化魔数(如MD5的
0x67452301等),以及固定的循环位移操作。 - Base64:编码表(
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/)和=填充。 - TEA/XXTEA:简单的位运算、加法和异或,配合一个魔数(如
0x9E3779B9)。
- AES:
- 模拟实现:用Python或C语言,按照分析出的逻辑重写算法。这是一个反复验证的过程:用相同的输入,你的代码输出必须与Hook抓取的输出完全一致。
- 验证:将还原的算法用于构造新的请求参数,用Burp Repeater发送,看服务端是否正常响应。这是最终的验收标准。
注意事项:So层分析极其耗时,需要耐心。有时算法是标准算法的轻微变种(修改了S盒、初始向量IV,或者增加了额外的置换步骤)。可以尝试将so文件中的常量数组提取出来,与标准算法(如AES的S盒)进行对比,能快速识别算法类型。
6. 完整请求构造与复现
在成功还原了Java层的参数组装逻辑和so层的核心加密/签名算法后,就可以脱离APP,用任何编程语言(如Python)来模拟发送请求了。
6.1 构建请求流程
一个完整的自动化请求脚本通常包含以下步骤:
- 设备信息模拟:生成或固定
deviceId、imei(可随机生成符合格式的)、androidId等设备标识。 - 获取动态密钥:调用还原的Native算法函数(已用Python实现),传入必要的种子(如时间戳),计算出本次请求使用的
dynamicKey或secret。 - 组装业务参数:根据要查询的内容(如城市、页码、筛选条件)构造参数字典。
- 生成签名:
- 将业务参数、公共参数、动态密钥按照APP的规则(如按Key字典序排序后拼接成
k1=v1&k2=v2...的格式)拼接成待签名字符串。 - 使用还原的签名算法(如HMAC-SHA256)计算签名,并将签名值加入请求参数。
- 将业务参数、公共参数、动态密钥按照APP的规则(如按Key字典序排序后拼接成
- 发送请求:设置好必要的请求头(如
User-Agent、Content-Type、Authorization(如果有Token)),使用requests库发送HTTP请求。 - 处理响应:解析返回的JSON数据。如果数据被加密,还需调用还原的解密算法进行解密。
6.2 Python实现示例片段
import hashlib import hmac import time import requests # 1. 还原的So层算法:生成dynamic_key def native_get_dynamic_key(timestamp): # 这里是逆向还原的算法,可能包含位运算、查表等 seed = str(timestamp)[-6:] # ... 复杂的计算过程 ... dynamic_key = \"a1b2c3d4e5f67890\" # 示例输出 return dynamic_key # 2. 还原的签名算法 def generate_sign(params_dict, dynamic_key): # 步骤1: 参数排序并拼接 sorted_params = \'&\'.join([f\"{k}={params_dict[k]}\" for k in sorted(params_dict.keys())]) # 步骤2: 拼接动态密钥 string_to_sign = sorted_params + \"&key=\" + dynamic_key # 步骤3: 计算HMAC-SHA256 (假设这是还原出的算法) sign = hmac.new(dynamic_key.encode(\'utf-8\'), string_to_sign.encode(\'utf-8\'), hashlib.sha256).hexdigest() return sign # 3. 构造请求 def fetch_house_list(city_id, page): timestamp = int(time.time() * 1000) dynamic_key = native_get_dynamic_key(timestamp) base_params = { \"cityId\": city_id, \"page\": str(page), \"pageSize\": \"20\", \"timestamp\": str(timestamp), \"deviceId\": \"模拟的设备ID\", \"appVersion\": \"9.0.0\" } sign = generate_sign(base_params, dynamic_key) base_params[\"sign\"] = sign headers = { \"User-Agent\": \"Dalvik/2.1.0 (Linux; U; Android 10; MI 8 Build/QQ3A.200805.001)\", \"Content-Type\": \"application/x-www-form-urlencoded\" } response = requests.post(\"https://app.api.ke.com/api/v1/house/list\", data=base_params, headers=headers) return response.json() # 使用 if __name__ == \"__main__\": data = fetch_house_list(\"110000\", 1) print(data)7. 常见问题排查与对抗升级
在整个过程中,你会遇到各种问题。以下是一些常见问题及解决思路的速查表:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 抓不到任何包 | 1. 代理未正确设置或未生效。 2. APP检测并禁用了代理。 3. 使用了纯Socket或自定义协议。 | 1. 检查手机代理设置和Burp监听端口。 2. 使用Frida Hook System.getProperty(\"http.proxyHost\")等检测方法并返回空。3. 使用 tcpdump或Wireshark在手机上抓取原始网络包分析。 |
| HTTPS请求显示为Tunnel to | 证书绑定(SSL Pinning)生效。 | 1. 确认Burp CA证书已安装到系统证书目录。 2. 使用 JustTrustMe等Xposed模块。3. 编写Frida脚本Hook证书验证逻辑。 |
| APP启动后闪退 | 1. 检测到Root环境。 2. 检测到调试器或注入工具(如Frida)。 3. So层有反调试。 | 1. 使用Magisk Hide隐藏Root。 2. 使用Frida的 -f参数以spawn方式启动APP,而非attach。3. 使用 objection的android root disable或android anti-root disable命令。4. 分析so中的 ptrace、fork等反调试函数并用Frida绕过。 |
| 签名一直无效 | 1. 签名算法分析错误。 2. 遗漏了某个参数(如请求体、Cookie)。 3. 密钥或盐值是动态的,未正确获取。 | 1. 用Frida Hook多个可能的签名函数,对比输入输出。 2. 对比多个成功请求的参数差异,找出规律。 3. 检查网络请求拦截器,看是否有全局添加的参数。 4. 确认从so层获取的动态值是否正确。 |
| So层函数无法Hook | 1. 函数符号被剥离(stripped)。 2. 函数是静态的,未导出。 3. So文件有完整性校验。 | 1. 使用函数地址而非符号名进行Hook:Module.findBaseAddress(\"libxxx.so\").add(0x1234)。2. 在JNI调用的Java层方法上进行Hook,间接追踪。 3. 使用内存扫描找到函数特征码。 |
| 反编译代码逻辑混乱 | 代码被混淆(ProGuard等)。 | 1. 结合动态调试,关注实际执行的代码流。 2. 根据字符串常量、资源ID等线索推测类和方法名。 3. 尝试使用更强大的反编译器(如JEB)。 |
对抗升级的思考:作为分析方,我们也要意识到,我们的技术手段也在推动防护技术的升级。例如,越来越多的APP开始使用白盒加密(将密钥与算法深度融合)、虚拟机保护(VMP,将关键代码转换为自定义指令集在虚拟机中执行)、服务器协同(关键参数由服务器下发,每次不同)等更高级的方案。面对这些,逆向的难度和成本会指数级上升,往往需要结合静态分析、动态调试、硬件调试乃至侧信道分析等多种手段。这也意味着,在合规的前提下进行安全研究,需要持续学习和更新知识库。
