
1. 项目概述这不是“破解”而是一次对移动应用通信逻辑的深度解剖二三里APP一个在东北地区覆盖广泛、以本地新闻资讯和生活服务为核心的地方性聚合平台其客户端在安卓端长期采用360加固方案——这并非简单的代码混淆而是集加壳、反调试、资源加密、JNI层校验于一体的综合防护体系。当我在做本地政务信息同步接口调研时发现该APP的API请求头中携带了动态生成的sign字段且每次刷新列表、切换频道、甚至下拉加载新内容时该签名值都实时变化更关键的是它不走常规的WebView JSBridge桥接而是通过自研的NativeBridge模块直接调用底层Java方法完成鉴权。这时候“逆向”就不再是技术炫技而是解决实际问题的必要手段我们需要理解它的签名算法、识别它的密钥调度逻辑、定位它的网络拦截点从而构建稳定、低侵入的数据对接通道。核心关键词——二三里APP、逆向、360壳加固、smali、frida——每一个都不是孤立存在360壳决定了你必须先脱壳才能看到真实Dexsmali是脱壳后唯一能直接阅读和修改的中间语言而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 # 假设PID12345用dd命令dump内存 adb shell su -c dd if/proc/12345/fd/12 of/data/local/tmp/dump.dex bs1M # 注意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 ifdump.dex offixed.dex bs1 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, Mconst-string v1, Dconst-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字符串替换成MD5或Base64编码的TURF必须用grep -r encrypt *.smali找方法名再看其参数类型。3.3 Frida反调试实战绕过360壳的7重检测的最小化Patch360壳的反调试不是单一函数而是一个检测矩阵。我用Frida逐个Hook并禁用// hook_anti_debug.js Java.perform(function () { // 检测1ptrace自检 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])); // 检测3getppid()比对父进程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:debuggabletrue为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 1Hook 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?channellocaltimestamp1712345678但X-Sign字段始终为空——说明签名是在Request构建完成后、enqueue()执行前注入的。Step 2Hook 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, MapString, String params) { String sign SignGenerator.generateSign(url, System.currentTimeMillis(), getToken()); return new Request.Builder() .url(url) .addHeader(X-Sign, sign) .build(); }Step 3Hook 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: urlhttps://api.erisan.com/api/v2/news/list?channellocal, ts1712345678901, tokenabc123 [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?channellocal 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转为兼容Dexdexdump -f classes.dex | grep version应显示039dump.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] ts171234567890113位整数5.2 Frida调试避坑指南那些文档不会告诉你的细节--no-pause参数陷阱网络热词中提到的scripts\frida: error: unrecognized arguments: --no-pause是因为你用的是旧版Frida CLI14.0。新版已废弃该参数改用frida -U -f com.xxx --no-pause会报错。正确写法是frida -U -f com.xxx -l script.jsFrida 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-v7aHook必然失败。用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()。