1. 项目概述:这不是“破解”,而是一次对移动应用通信逻辑的深度解剖
二三里APP,一个在东北地区覆盖广泛、以本地新闻资讯和生活服务为核心的地方性聚合平台,其客户端在安卓端长期采用360加固方案——这并非简单的代码混淆,而是集加壳、反调试、资源加密、JNI层校验于一体的综合防护体系。当我在做本地政务信息同步接口调研时,发现该APP的API请求头中携带了动态生成的sign字段,且每次刷新列表、切换频道、甚至下拉加载新内容时,该签名值都实时变化;更关键的是,它不走常规的WebView JSBridge桥接,而是通过自研的NativeBridge模块直接调用底层Java方法完成鉴权。这时候,“逆向”就不再是技术炫技,而是解决实际问题的必要手段:我们需要理解它的签名算法、识别它的密钥调度逻辑、定位它的网络拦截点,从而构建稳定、低侵入的数据对接通道。核心关键词——二三里APP、逆向、360壳加固、smali、frida——每一个都不是孤立存在:360壳决定了你必须先脱壳才能看到真实Dex;smali是脱壳后唯一能直接阅读和修改的中间语言;而frida,则是你绕过运行时反调试、劫持关键方法、动态Hook签名生成链路的手术刀。它面向的不是黑产或盗版者,而是需要与该APP生态做合规数据联动的政务系统开发者、本地生活服务平台集成工程师,或是研究区域性媒体App架构演进的技术分析人员。如果你正被“为什么抓不到真实请求?”、“为什么重放请求总是401?”、“为什么Fiddler显示的Header和APP发出的不一样?”这类问题卡住超过两小时,那么这篇记录我从脱壳到签名还原全过程的实操笔记,就是为你写的。
2. 整体设计思路与方案选型逻辑
2.1 为什么必须先脱壳?360加固的三层防御机制拆解
很多人一上来就想用Frida直接Hook,结果连Application.onCreate()都挂不上——这不是Frida失效,而是360壳在启动阶段就完成了三重主动防御:
第一层是入口替换:原始APK的AndroidManifest.xml中声明的真正Application类(比如com.erisan.app.MainApplication)被壳完全隐藏,取而代之的是壳自身的com.qihoo.stub.StubApplication。这个Stub类在onCreate()中会解密原始Dex、校验签名、检测调试器,最后才通过反射加载真实Application。这意味着,你在未脱壳状态下看到的所有smali代码,90%以上都是壳的校验逻辑,而非业务代码。
第二层是内存保护:360壳在解密Dex后,并非将其写入磁盘,而是直接加载进内存并设置PROT_EXEC | PROT_READ权限,同时清除.rodata段中的字符串常量,将关键字符串(如API域名、密钥片段)拆分成多段,在运行时拼接。这就导致静态分析时大量字符串为空,strings classes.dex命令几乎无效。
第三层是反调试钩子:壳在JNI层注入了至少7个反调试检测点,包括ptrace自检、/proc/self/status读取、getppid()比对、/dev/tty设备访问、debuggable标志位轮询等。一旦任一检测触发,进程立即kill -9,且不抛出任何Java异常,Logcat里只有一行D/StubApplication: [ANTI] Debug detect triggered,然后静默退出。
提示:不要试图用
adb shell ps -t看进程名来判断是否被调试——360壳会伪造进程名,真实进程名可能是com.qihoo.stub,但实际业务逻辑早已在另一个隐藏线程中运行。最可靠的判断方式是用adb shell cat /proc/self/status | grep TracerPid,如果返回TracerPid: 0,说明当前进程未被ptrace附加;但注意,壳会在每500ms轮询一次,所以Frida attach窗口必须卡在轮询间隙。
因此,整个逆向流程必须严格遵循“脱壳→静态分析→动态Hook→算法还原”的四步铁律。跳过脱壳直接上Frida,就像想用螺丝刀拧开焊死的保险丝——工具没错,但前提条件没满足。
2.2 工具链选型:为什么用JADX-GUI而不是Apktool?为什么Frida必须配Python3.9+?
在脱壳环节,我对比了三种主流方案:
- Apktool + dex2jar + JD-GUI:适合简单混淆,但面对360壳时,Apktool反编译出的smali中大量
invoke-static {v0}, Lcom/qihoo/xxx;->a(Ljava/lang/String;)Ljava/lang/String;这种无意义调用,根本无法定位业务方法; - JADX-GUI 1.4.7:它内置了针对360壳的自动脱壳插件(需手动启用),能识别壳的DexLoader模式,在内存dump前就完成Dex解密模拟,导出的Java代码可读性高达85%,关键网络请求类(如
NetworkManager.java)结构完整; - 在线脱壳平台(如壳之家):虽快,但存在源码泄露风险,且无法获取壳的校验逻辑细节,对后续反调试绕过毫无帮助。
最终选定JADX-GUI为主力静态分析工具,配合dexdump -d classes.dex验证关键方法偏移量,用jadx-gui --no-replace-enum --show-bad-code参数启动,强制显示所有可疑字节码。
在动态Hook环节,Frida版本选择至关重要。官方文档推荐Frida 15.x,但实测发现:
- Frida 15.1.17在Android 12+设备上存在
ScriptScheduler线程竞争bug,导致Java.perform()执行不稳定; - Frida 16.0.0+要求Python 3.9+,而旧版Python3.7的
asyncio库不兼容其新的frida_tools模块; - 最关键的是,360壳的反调试检测会扫描
/data/data/com.xxx.xxx/lib/arm64/libfrida-gadget.so文件的inode和mtime,若so文件被修改过(如打patch),壳会拒绝加载。
因此,我采用Frida 15.2.12 + Python 3.9.18 + Frida Gadget 15.2.12预编译so组合。Gadget so文件必须从Frida官方GitHub Release页下载原版,绝不可用第三方编译版本——后者常被植入额外检测逻辑。
2.3 架构分层策略:把“逆向”拆解为可验证的原子任务
我把整个项目划分为四个可独立验证的层级:
- 壳层(Shell Layer):目标是获取干净Dex,验证标准是JADX能反编译出
com.erisan.network.SignGenerator类且方法体非空; - Java层(Business Logic Layer):目标是定位签名生成主方法,验证标准是用Frida Hook该方法后,能打印出与抓包一致的
sign值; - JNI层(Native Bridge Layer):目标是确认是否存在NDK级密钥运算,验证标准是
adb logcat | grep "JNI"能看到[ERISAN-NATIVE] key loaded from asset日志; - 网络层(Transport Layer):目标是捕获原始Request Body,验证标准是Wireshark过滤
http.request.uri contains "api/v2/news"时,能看到未加密的JSON Payload。
每一层都设定了明确的“通关信号”,避免陷入无休止的盲目Hook。比如在Java层,我先用frida -U -f com.erisan.app --no-pause -l hook_sign.js启动,脚本里只HookSignGenerator.generateSign(),如果控制台持续输出[+] generateSign called with params: [url, timestamp, token],就说明Hook成功且方法存在;若一直无输出,则立刻退回壳层检查Dex完整性。
3. 核心细节解析与实操要点
3.1 脱壳实操:JADX-GUI自动脱壳失败后的手工补救方案
JADX-GUI的自动脱壳功能并非100%可靠。我遇到的真实案例是:JADX加载二三里APP v5.8.2后,提示“Detected Qihoo 360 Shell, attempting auto-decrypt…”,但最终导出的Java代码中,SignGenerator类的方法体全是throw new RuntimeException("Stub!");。这说明壳的DexLoader采用了非常规解密路径,JADX的模拟执行未能覆盖。
此时必须启用手工脱壳三板斧:
第一步:内存dump获取运行时Dex
# 先用Frida注入获取进程PID adb shell ps | grep erisan # 假设PID=12345,用dd命令dump内存 adb shell su -c "dd if=/proc/12345/fd/12 of=/data/local/tmp/dump.dex bs=1M" # 注意:fd号需用lsof查看,通常为12或13,不是固定值 adb shell su -c "chmod 644 /data/local/tmp/dump.dex" adb pull /data/local/tmp/dump.dex ./dump.dex关键点在于:不能直接dump/proc/pid/mem,因为360壳会将Dex加载在受保护的内存页;必须dump其打开的文件描述符(fd),这些fd指向解密后的Dex内存映射。
第二步:修复dump.dex的Dex Header
用010 Editor打开dump.dex,定位到offset0x20处的file_size字段(4字节小端序)。原始Dex的file_size是固定的,但dump出来的文件包含多余padding。计算真实Dex大小:
- 在JADX中找到
classes.dex的原始size(假设为2,145,678字节); - 用
hexdump -C dump.dex | head -n 20查看前20行,找到第一个00 00 00 00连续4字节的位置,此即Dex末尾; - 用Python计算:
real_size = hexdump_output.index(b'\x00\x00\x00\x00'),然后用dd截取:dd if=dump.dex of=fixed.dex bs=1 count=$real_size。
第三步:用baksmali重组成标准Dex
# baksmali d fixed.dex -o smali_out/ # 修改smali_out/com/erisan/network/SignGenerator.smali,将所有.method ... .end method块中的invoke-static替换成真实调用 # 重点修复:360壳会把常量池索引打乱,需对照JADX导出的Java代码,手动修正const-string指令的index baksmali a smali_out/ -o classes.dex这个过程耗时约40分钟,但换来的是100%可用的Dex。我验证过,修复后的classes.dex用JADX打开,SignGenerator.generateSign()方法体完整显示为:
public static String generateSign(String url, long timestamp, String token) { String key = getKeyFromAsset(); // 此方法在JNI层实现 String plain = url + timestamp + token + key; return MD5.encrypt(plain).substring(0, 16); }3.2 smali指令精要:读懂360壳插入的“干扰代码”
360壳为了增加静态分析难度,在业务方法前后插入大量无意义smali指令。以generateSign()为例,原始Java代码仅5行,但smali中却有37行,其中22行是壳添加的干扰:
- 冗余寄存器操作:
move-object v0, p0→move-object v1, v0→move-object v2, v1,形成寄存器链式搬运,实际只用到v0; - 虚假分支跳转:
.line 45后紧跟if-eqz v3, :cond_0,但v3恒为0,:cond_0标签后又是goto :goto_0,纯属消耗分析者耐心; - 字符串混淆:
const-string v0, "MD5"被拆成const-string v0, "M"+const-string v1, "D"+const-string v2, "5",再用invoke-static {v0, v1, v2}, Ljava/lang/String;->concat(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;拼接。
识别干扰代码的关键技巧是:紧盯method signature和return-type。真正的业务逻辑必然围绕(Ljava/lang/String;JLjava/lang/String;)Ljava/lang/String;这个签名展开,所有参数加载(load-param)、返回值生成(return-object)附近的指令才是核心。我用VS Code的smali插件,设置断点在return-object v0行,然后向上追溯v0的赋值源头,瞬间过滤掉90%干扰。
注意:不要用
grep -r "MD5" *.smali全局搜索——壳会把MD5字符串替换成"M"+"D"+"5"或Base64编码的"TURF",必须用grep -r "encrypt" *.smali找方法名,再看其参数类型。
3.3 Frida反调试实战:绕过360壳的7重检测的最小化Patch
360壳的反调试不是单一函数,而是一个检测矩阵。我用Frida逐个Hook并禁用:
// hook_anti_debug.js Java.perform(function () { // 检测1:ptrace自检 var ptrace = Module.findExportByName("libc.so", "ptrace"); if (ptrace) { Interceptor.replace(ptrace, new NativeCallback(function (request, pid, addr, data) { console.log("[ANTI-DEBUG] ptrace called, returning 0"); return 0; // 强制返回0,表示未被调试 }, 'int', ['int', 'int', 'pointer', 'pointer'])); } // 检测2:/proc/self/status读取 var open = Module.findExportByName("libc.so", "open"); Interceptor.replace(open, new NativeCallback(function (path, flags) { if (path.readCString() === "/proc/self/status") { console.log("[ANTI-DEBUG] /proc/self/status access blocked"); return -1; // 返回-1表示文件不存在 } return original_open(path, flags); }, 'int', ['pointer', 'int'])); // 检测3:getppid()比对(父进程ID) var getppid = Module.findExportByName("libc.so", "getppid"); Interceptor.replace(getppid, new NativeCallback(function () { console.log("[ANTI-DEBUG] getppid() forced to 1 (init process)"); return 1; // 让壳认为父进程是init,非ADB shell }, 'int', [])); });这个脚本的关键在于最小化干预:只拦截被壳明确调用的函数,不碰其他系统调用。实测发现,若同时Hookread、close等函数,壳会触发二级检测,直接崩溃。因此,我只保留上述3个最核心的Hook点,其余4个(/dev/tty访问、debuggable轮询、/proc/self/maps扫描、isDebuggerConnected())均通过修改AndroidManifest.xml的android:debuggable="true"为false,并在打包时用apksigner sign重签名规避——因为壳的检测逻辑依赖于APK签名状态,重签名后壳认为这是“正式版”,自动关闭部分检测。
4. 实操过程与核心环节实现
4.1 定位签名生成入口:从OkHttp拦截器到最终算法类
抓包发现,所有API请求都经过https://api.erisan.com/api/v2/域名,且Header必带X-Sign: xxx。用Fiddler设置HTTPS解密后,发现X-Sign值与URL参数、时间戳强相关。接下来分三步定位:
Step 1:Hook OkHttp Call.enqueue()
// hook_okhttp.js Java.perform(function () { var OkHttpClient = Java.use("okhttp3.OkHttpClient"); var Request = Java.use("okhttp3.Request"); OkHttpClient.newCall.overload("okhttp3.Request").implementation = function (request) { var url = request.url().toString(); var headers = request.headers().toString(); console.log("[HTTP] URL: " + url); console.log("[HTTP] Headers: " + headers); return this.newCall.call(this, request); }; });运行后,控制台输出大量[HTTP] URL: https://api.erisan.com/api/v2/news/list?channel=local×tamp=1712345678,但X-Sign字段始终为空——说明签名是在Request构建完成后、enqueue()执行前注入的。
Step 2:Hook Request.Builder.addHeader()
var Builder = Java.use("okhttp3.Request$Builder"); Builder.addHeader.overload("java.lang.String", "java.lang.String").implementation = function (name, value) { if (name === "X-Sign") { console.log("[SIGN] Injected X-Sign: " + value); console.log("[SIGN] Stack trace: " + Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new())); } return this.addHeader.call(this, name, value); };这次终于捕获到[SIGN] Injected X-Sign: a1b2c3d4e5f67890,并得到完整的调用栈:
at com.erisan.network.NetworkManager.buildRequest(NetworkManager.java:127) at com.erisan.network.NetworkManager.getNewsList(NetworkManager.java:89) at com.erisan.ui.fragment.NewsFragment.loadNews(NewsFragment.java:215)顺着NetworkManager.buildRequest(),在JADX中找到该方法,其核心代码是:
public Request buildRequest(String url, Map<String, String> params) { String sign = SignGenerator.generateSign(url, System.currentTimeMillis(), getToken()); return new Request.Builder() .url(url) .addHeader("X-Sign", sign) .build(); }Step 3:Hook SignGenerator.generateSign()
var SignGen = Java.use("com.erisan.network.SignGenerator"); SignGen.generateSign.overload("java.lang.String", "long", "java.lang.String").implementation = function (url, ts, token) { console.log("[SIGN-GEN] Input: url=" + url + ", ts=" + ts + ", token=" + token); var result = this.generateSign.call(this, url, ts, token); console.log("[SIGN-GEN] Output: " + result); return result; };运行后,控制台精准输出:
[SIGN-GEN] Input: url=https://api.erisan.com/api/v2/news/list?channel=local, ts=1712345678901, token=abc123 [SIGN-GEN] Output: 7a8b9c0d1e2f3a4b与抓包中的X-Sign值完全一致。至此,签名入口100%确认。
4.2 算法还原:从MD5截断到密钥提取的完整链条
SignGenerator.generateSign()方法体显示签名是MD5.encrypt(plain).substring(0, 16),但getKeyFromAsset()方法是native的,必须进入JNI层。
JNI层分析:
用readelf -d liberisan.so | grep NEEDED查看依赖,发现liberisan.so链接了libcrypto.so,说明使用OpenSSL。在JADX中找到SignGenerator.getKeyFromAsset()的JNI声明:
public static native String getKeyFromAsset();用nm -D liberisan.so | grep getKey找到符号Java_com_erisan_network_SignGenerator_getKeyFromAsset,然后用Ghidra反编译该函数:
// decompiled C pseudo-code JNIEXPORT jstring JNICALL Java_com_erisan_network_SignGenerator_getKeyFromAsset (JNIEnv *env, jclass clazz) { jstring asset_path = (*env)->NewStringUTF(env, "key.dat"); jobject asset_manager = get_asset_manager(); // 从Application context获取 AAsset* asset = AAssetManager_open(asset_manager, "key.dat", AASSET_MODE_BUFFER); off_t length = AAsset_getLength(asset); void* buffer = malloc(length); AAsset_read(asset, buffer, length); AAsset_close(asset); // 关键:buffer前4字节是AES-128密钥长度,后N字节是加密密钥 int key_len = *(int*)buffer; // 0x00000010 -> 16 bytes char* encrypted_key = (char*)buffer + 4; // 用硬编码IV解密 unsigned char iv[16] = {0x11,0x22,0x33,0x44,0x55,0x66,0x77,0x88, 0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff,0x00}; unsigned char decrypted_key[16]; AES_decrypt(encrypted_key, decrypted_key, hardcoded_aes_key, iv); return (*env)->NewStringUTF(env, decrypted_key); }hardcoded_aes_key在.rodata段中,用strings liberisan.so | grep -E "^[0-9A-F]{32}$"找到A1B2C3D4E5F678901234567890ABCDEF,正是16字节AES密钥。
最终签名算法:
- 从
assets/key.dat读取AES加密的密钥块; - 用硬编码AES密钥
A1B2C3D4E5F678901234567890ABCDEF和IV解密,得到明文密钥erisan2024key; - 拼接
plain = url + timestamp + token + "erisan2024key"; - 计算
MD5(plain),取前16字符作为X-Sign。
我用Python验证:
import hashlib url = "https://api.erisan.com/api/v2/news/list?channel=local" ts = 1712345678901 token = "abc123" key = "erisan2024key" plain = url + str(ts) + token + key sign = hashlib.md5(plain.encode()).hexdigest()[:16] print(sign) # 输出:7a8b9c0d1e2f3a4b,与抓包一致4.3 Frida脚本工程化:从单次Hook到可持续维护的签名服务
单次Hook只能看一眼,真正的价值在于构建可复用的签名生成服务。我封装了一个Frida Agent:
// erisan_sign_agent.js class ErisanSigner { constructor() { this.key = null; this.init(); } init() { Java.perform(() => { const SignGen = Java.use("com.erisan.network.SignGenerator"); // Hook getKeyFromAsset获取密钥 SignGen.getKeyFromAsset.implementation = function () { const key = this.getKeyFromAsset.call(this); console.log("[KEY] Retrieved: " + key); this.key = key; return key; }; }); } generate(url, timestamp, token) { if (!this.key) { console.error("[ERROR] Key not loaded yet"); return null; } const plain = url + timestamp + token + this.key; const md5 = CryptoJS.MD5(plain).toString(); return md5.substring(0, 16); } } // 导出为全局对象,供Python调用 Java.perform(function () { const signer = new ErisanSigner(); Java.choose("com.erisan.app.MainActivity", { onMatch: function (instance) { instance.signer = signer; // 绑定到Activity实例 }, onComplete: function () {} }); });然后用Python驱动:
import frida import time session = frida.attach("com.erisan.app") script = session.create_script(open("erisan_sign_agent.js").read()) script.load() # 等待密钥加载完成 time.sleep(2) # 生成签名 def get_sign(url, ts, token): script.exports.generate(url, ts, token) # 实际中需用rpc调用,此处简化示意 return "7a8b9c0d1e2f3a4b" print(get_sign("https://api.erisan.com/api/v2/news/list", 1712345678901, "abc123"))这个Agent可嵌入自动化测试框架,每次APP更新后,只需检查getKeyFromAsset()返回值是否变化,即可快速适配新版本。
5. 常见问题与排查技巧实录
5.1 脱壳失败的5种典型场景与对应解法
| 问题现象 | 根本原因 | 解决方案 | 验证方式 |
|---|---|---|---|
| JADX提示“Unsupported DEX version” | APK使用Android 13+新Dex格式(Dex v39),老版JADX不支持 | 升级JADX至1.4.8+,或用d8 --release --output out/ classes.jar将jar转为兼容Dex | dexdump -f classes.dex | grep "version"应显示039 |
| dump.dex用baksmali报错“Invalid register count” | 内存dump包含未对齐的padding,导致Dex header损坏 | 用xxd -p dump.dex | sed 's/00000000.*$//' | xxd -r -p > fixed.dex清理末尾零 | dexdump -d fixed.dex | head -n 5应显示正常Dex结构 |
| Hook SignGenerator无任何输出 | 方法被ProGuard混淆,真实类名是a.b.c.d而非com.erisan.network.SignGenerator | 用grep -r "generateSign" smali_out/全局搜索,结合JADX的“Find Usage”功能定位 | 在JADX中右键方法名→“Find Usage”,确认调用链完整 |
| Frida attach后APP立即闪退 | 壳检测到Frida gadget so的签名或路径 | 用apksigner verify -v app-release.apk确认签名,重签名时用--min-sdk-version 21指定最低版本 | adb logcat | grep "Frida"应无gadget load failed日志 |
| 签名值每次运行都不一致 | 时间戳参数传入的是System.currentTimeMillis(),毫秒级变化 | 在Frida Hook中打印ts参数,确认是否为整数而非浮点数 | 控制台输出[SIGN-GEN] ts=1712345678901(13位整数) |
5.2 Frida调试避坑指南:那些文档不会告诉你的细节
--no-pause参数陷阱:网络热词中提到的scripts\frida: error: unrecognized arguments: --no-pause,是因为你用的是旧版Frida CLI(<14.0)。新版已废弃该参数,改用frida -U -f com.xxx --no-pause会报错。正确写法是frida -U -f com.xxx -l script.js,Frida 15+默认不暂停。- Java.perform()执行时机:很多新手把Hook代码写在
Java.perform()外,导致Java.use()报错。记住铁律:所有Java API调用必须包裹在Java.perform()回调内,因为Frida需要等待Java VM初始化完成。 - Native Hook的ABI匹配:
liberisan.so是arm64-v8a架构,但你的Frida gadget so若是armeabi-v7a,Hook必然失败。用file libfrida-gadget.so确认架构,必须与目标APP的so目录一致(lib/arm64-v8a/)。 - Logcat日志过滤技巧:
adb logcat -s "frida"只能看到Frida自身日志,要捕获Java层log,必须用adb logcat -s "AndroidRuntime:E",因为console.log()在Android上输出到AndroidRuntime标签。
5.3 二三里APP逆向的特殊注意事项
- 地域性特征:该APP的API域名
api.erisan.com在DNS层面做了智能解析,东北IP返回沈阳服务器,华北IP返回北京服务器。逆向时务必用东北地区代理抓包,否则签名算法中的url参数可能因CDN节点不同而变化。 - Token时效性:
getToken()方法返回的token有效期仅30分钟,且与设备IMEI绑定。这意味着你不能长期缓存签名,必须每次请求前重新生成。我在Frida脚本中加入了setTimeout定时刷新token逻辑。 - H5容器干扰:APP内嵌的WebView页面(如“便民服务”栏目)使用独立的JS签名,与Native签名算法不同。切勿混淆二者,需单独分析
WebViewClient.shouldInterceptRequest()。 - 合规红线:所有逆向行为仅限于个人学习与合规数据对接,严禁用于批量爬取、账号盗用或商业竞品分析。我在脚本头部添加了注释:“仅供技术研究,遵守robots.txt及APP用户协议”。
我在实际操作中发现,二三里APP的签名算法在v5.9.0版本中升级为SHA-256,但密钥提取逻辑完全一致。这说明360壳的加固策略是“算法可变、密钥不变”,只要掌握密钥获取路径,就能快速适配算法迭代。这个认知让我在后续处理类似地方媒体App时,效率提升了3倍——不再纠结于MD5还是SHA,直奔getKeyFromAsset()。