1. 项目背景与目标拆解
1.1 这次逆向分析到底在分析什么
拿到一台装有咖啡品牌App的安卓设备,我的第一反应并不是直接拖进JADX里看代码,而是先想清楚一件事:这次逆向分析的目标是什么。因为目标不同,技术路径会差出十万八千里。
单说“咖啡App”,市面上这类应用的功能高度集中:会员注册与登录、积分商城、门店定位、点单支付、优惠券核销。这类业务型App在技术上通常走的是“标准安卓工程 + 服务端接口”的路线,客户端本身没有太复杂的逻辑,真正的核心全部藏在服务端的接口协议里。所以,对一个咖啡App做逆向分析,最值得投入精力的方向其实是接口层:搞明白它的签名参数是怎么生成的、请求头里有哪些字段参与了服务端校验、加密逻辑是在Java层还是SO层。
另外一种情况是,你想搞清楚它是否安全存储了用户隐私,或者它的支付流程是否有漏洞。这个方向就更偏向数据流与权限分析,关注的是App读写了哪些本地文件、是否明文保存用户手机号、SharedPreferences里是否存在token硬编码。
我在开始前把目标明确为三点:第一,确认App是否做了加固处理;第二,还原出核心接口的签名生成逻辑;第三,把整体通信链路上可能的隐患梳理出来。这个定位既符合安全研究的技术价值,也足够落地,不至于陷入无休止的加解密迷宫里。
1.2 为什么选择咖啡App作为分析对象
道理很简单,咖啡App属于中低频使用的工具类应用,不像社交或电商类App那样拥有超高强度的风控体系。它的逆向分析难度适中,非常适合梳理一套完整的分析流程。你可以在这个项目里练到APK静态分析、抓包、脱壳、动态调试、算法还原这些核心技能,又不会被顶级商业加固方案摁在地上摩擦。
更关键的是,咖啡App的接口签名逻辑五花八门。有些团队直接用MD5把参数拼接后加盐做签名,有些则引入AES-GCM做整体加密,还有的在HTTPS之上自定义一套协议层。不同实现方式的破解思路完全不同,练一遍基本上等于把这些主流套路都过了一遍。对我自己来说,这也是一个很好的素材库:以后遇到同类业务型App时,能第一时间判断出它的技术栈大概是什么水平。
另一个现实原因在于,咖啡App的业务链路足够长。从一个陌生设备登录到完成一笔下单,中间至少经过十几个接口,每个接口都可能附带不同的签名字段和加密策略。这条路完整走一遍之后,你对整个移动端通信协议设计方法的理解会提升一个档次,而不只是停留在“会用frida hook函数”这个层面。
1.3 法律边界与合规底线
逆向分析本身是中性技术,但用在别人生产的商业App上,必须严格限定在安全研究、学习交流的范畴内。我在整个分析过程中,只对接口协议的技术特性做验证,全程不涉及破解会员权益、绕过支付、盗取用户数据等任何违规行为。
有一点我想特别强调:就算你把签名算法完全还原出来了,也不要去尝试恶意调用服务端接口,更不要批量抓取用户数据。别让技术热情把自己带进灰色地带。如果你所在的公司需要对竞品做技术调研,也一定要走正式法务流程,确认授权范围后再动手。
这篇文章里展示的所有技术和思路,都应当只用于你拥有合法分析权限的App,或者用来加固你自己开发的App。把逆向当成理解攻防逻辑的手段,而不是牟利的工具,这既是对行业负责,也是对自己负责。
2. 环境准备与信息收集
2.1 APK体量与基础信息检查
我拿到目标App后做的第一件事,是先用aapt检查APK的基本信息,包括包名、版本号、权限声明和targetSdkVersion。这一步看起来不起眼,但信息量非常大。
aapt dump badging coffee.apk重点看三列:package、sdkVersion、targetSdkVersion。如果targetSdkVersion低于26,说明App没有完全适配分区存储,后续动态调试时路径选择的自由度会更高;如果权限列表里出现了READ_PRIVILEGED_PHONE_STATE或QUERY_ALL_PACKAGES这种敏感权限,就要单独标记出来,后面动态分析时优先追踪。
紧接着用APK查壳类的工具过一遍,确认它是否有加固痕迹。常用的是pkid或者直接用jadx打开后观察是否存在明确的入口壳类。如果App用了360加固、腾讯乐固、爱加密这类方案,在Manifest里的Application类通常会被替换成壳的载体类,比如com.stub.StubApp或者com.tencent.StubShell.TxAppEntry。看到这些类名的时候,基本可以断定这是一个加固过的App,后续不能用普通的静态分析直接看业务代码。
我当时查看的结果是,这个咖啡App使用了爱加密的免费版加固。免费版意味着脱壳难度相对较低,因为部分加固函数仍然保留在Java层,未全部下沉到SO中。这个信息直接决定了下一步的技术选择。
2.2 工具链选型与搭配逻辑
很多人一开始就纠结用JADX还是GDA,用Charles还是Burp Suite,其实工具选型没有绝对正确答案,关键取决于目标App的加固状态和通信协议强度。我在这个项目里采用的是“基础三件套加动态补充”的组合。
静态分析使用JADX结合GDA。JADX的优点是反编译速度快、代码结构清晰,对普通APK的还原度非常高;但当遇到混淆代码时,JADX处理嵌套泛型和重载方法的可读性会比较差,这时候GDA的手动修复能力和交叉引用功能反而更好用。两者的定位不是二选一,而是配合使用。
抓包工具我这里选择的是Charles配合Fiddler双开。Charles的HTTPS解析界面更直观,断点修改请求方便;Fiddler的Composer模式和脚本自动化更顺手。实测下来,这个咖啡App没有做代理检测,Charles可以稳定抓到全部请求,不需要额外处理绕过反代理的逻辑。
动态分析工具是frida和Xposed的组合。frida用来动态hook、实时查看函数调用栈和返回值,速度快、脚本灵活;Xposed(具体用LSPosed)更适合做持久化模块。比如某次需要持续篡改签名结果观察服务端行为,用Xposed模块写一个hook脚本挂在App上,比每次启动都重新加载frida脚本要稳定得多。
2.3 签名校验与重打包预判
在正式反编译之前,还需要判断一下这个App有没有做签名校验,这关系到后续脱壳和重打包是否可行。最直接的办法是拿原APK做一次签名,然后换一个签名再安装,看App是否闪退或出现异常提示。
# 删除原签名 keytool -printcert -jarfile coffee.apk # 使用新的keystore重签名 apksigner sign --ks my.keystore --out coffee_re.apk coffee.apk如果重打包后的App运行正常,就说明它没有做签名校验,反向于脱壳后的重打包分析要省很多力气。如果直接闪退,大概率是用PackageManager的getPackageInfo对比了签名信息,那就需要在脱壳后动态hook掉这个校验方法。
我实测的咖啡App没有这层防护,省掉了一个麻烦步骤。但值得注意的是,没有签名校验不代表没有其他完整性校验,后面脱壳后看到校验逻辑时再做处理。
3. 抓包与请求链路分析
3.1 代理配置与证书信任问题处理
给安卓App配代理这件事,看起来简单,实际上暗坑很多。第一步是让手机与电脑处在同一局域网,然后修改WiFi代理指向电脑IP与端口。这个方法对大多数App有效,但有个前提:App没有禁用系统代理。如果遇到那种不走系统代理的App,就要用iptables配合redsocks把流量强制转发到代理端口。
Charles配置好之后,屏幕上会出现大量https乱码,这是因为安卓App默认不信任用户安装的CA证书。解决方式有两种:一种是直接把Charles的证书安装到系统证书目录;另一种是把App的networkSecurityConfig拉出来看,检查它是否只信任系统证书。
如果networkSecurityConfig配置了trust-anchors只包含系统证书,你需要将证书文件命名为<hash>.0放入/system/etc/security/cacerts/目录。这要求设备已经有root权限。我当时用的是一台Pixel模拟器,直接adb root后push进去,重启生效。
还有个小细节:每次配置完证书后,要确认Charles的SSL Proxying设置里已勾选了目标域名。否则即使证书装好了,Charles也不会主动解密HTTPS流量,只会显示一堆隧道连接。
3.2 拦到请求后的第一轮分析
等到咖啡App的请求开始出现在Charles面板上时,第一轮分析的核心不是去读每个接口的语义,而是先给全部请求做一次“体检”。
我会关注三个层面:接口路径、请求头字段、POST请求的body体特征。这个咖啡App的登录接口是/api/v1/user/login,POST请求的body里除了账号密码,还有一个显眼的长字符串sign,目测由32位十六进制字符组成,疑似MD5。请求头里则有固定的nonce字段,每次请求都不同,且带有一个timestamp。
这几乎是业务型App的通用套路:时间戳防重放 + 随机数混淆 + 参数签名防篡改。服务端校验的核心就是这三个字段的组合。假如签名算法是简单地把所有参数按字典序拼接后做MD5,那破解难度会非常低;但如果引入AES或者RSA,就要另想办法。
我先把登录接口的请求整体复制下来,包括请求头、请求体、时间戳和nonce,然后用一个python脚本把不同时间点、不同nonce下的请求做对比,找出哪些字段会变、哪些字段固定不变。这一步能快速缩小签名算法的参与范围,后期动态hook时也能有的放矢。
3.3 未加固部分的福利:网络层仍有Java代码可读
这个咖啡App虽然用了爱加密的加固,但我发现一个有趣的细节:它的网络层代码并没有全部下沉到SO库中。像okhttp3.Interceptor、Retrofit的Service接口这些都还保留在Java层,只是被R8混淆过。
这意味着,即使不脱壳,直接拿JADX打开加固壳,也有可能看到业务代码的调用关系。因为加固方案有时只会加密真正的Application入口和部分核心类,剩余的资源文件和动态代理生成的代码仍然是可读的。这种情况在免费版加固方案里非常常见,爱加密的免费版并不会把所有类都加密,只保护特定的ContentProvider和ShellApplication。
顺着这个线索,我在JADX里直接搜索了sign相关的gradle混淆映射,虽然看不到完整的方法名,但能搜到大量a.b.c风格的混淆类。再配合一些交叉引用,很快定位到网络拦截器所在的类。节省了不少先脱壳再分析的精力。
3.4 代理抓包失败的两种典型场景
抓包过程中最容易遇到两类问题,很多人会误判为反爬策略,其实是自己配置不对。
第一种现象:App在开启代理后直接无法联网。检查之后发现,这个咖啡App虽然没有禁用系统代理,但在某些模块里调用了NetworkCapabilities来判断网络状态,如果代理改变了网络体验,就会被判定为“当前网络不可用”,直接拒绝发起请求。解决思路是对该系统调用的返回值进行hook,把它改成“移动网络在线”即可。
第二种现象:代理能连上,但请求全是CONNECT隧道,无法解密内容。查了一圈,发现是Android 7.0以上版本的networkSecurityConfig默认不信任用户证书。如果不想折腾系统证书,其实还可以用frida直接hookSSLContext相关的trustManager方法,把证书校验逻辑强制放行。
这两种问题在后续逆向中大概率还会遇到,提前把底层原理摸透,后面分析效率才能提上来。
4. 脱壳与代码还原实操
4.1 对爱加密免费版脱壳的整体思路
对于有着“爱加密”加固特征的APK,有一个常见的误区:拿一个脱壳工具就开始跑,等跑完发现dump出来的dex文件乱七八糟,代码里全是垃圾指令。这不是工具问题,而是对加固方案缺少基本了解。
爱加密免费版的执行流程大致是这样:壳的Application入口先被系统加载,这个入口会负责解密真正的业务dex文件,然后动态加载进当前进程。所以脱壳的核心思路就是:在壳解密完真实dex、但还没把它扔进内存运行前,把内存中的dex镜像完整保存下来。实现上一般有两种路径,一种是修改系统源码来dump,另一种是使用frida直接attach找到DexClassLoader的加载点。
我用的方案是frida的dex_dump脚本,配合DexClassLoader的loadClass断点来定位dex文件在内存中的地址。这个方案虽然简单,但对免费版的爱加密已经足够了。唯一需要注意的是,运行时会持续产生大量dex镜像,有些是垃圾数据,需要根据文件头dex\n035来过滤。
4.2 脱壳后的代码清洗与整体分析
脱壳完成后会得到一堆dex文件,不能直接拿过来就当成最终源码。要用frida-dexdump自带的合并工具先把多个dex合并,然后导入JADX进行一次整体反编译。如果反编译后的代码里仍然出现大量无法解析的类,说明加固的类还没有被完整dump下来,需要重新调整hook时机,挂到ClassLoader.loadClass触发前的一瞬间。
整个咖啡App在脱壳后的代码量大约在几百个类,不算大。静态分析时我主要做两件事:第一,找入口Activity和Application的onCreate逻辑,确认脱壳是否完整;第二,把网络层相关的package标记出来,逐个看它的签名生成代码是否存在。
我在JADX里搜索关键词MessageDigest、SecretKeySpec、Mac以及sign=,很快就锁定了三处疑似签名生成代码。一处是用MD5做参数签名,一处是用HmacSHA256做请求头签名,还有一处是AES加密用于敏感字段传输。一个咖啡App用三层加密,老实说有点防御过剩,但对逆向分析来说反而更有料了。
4.3 脱壳后重打包与动态测试
脱壳的一个直接收益是:你可以在不修改官方App的前提下,把脱壳后的代码重新打包、换签名、装上设备,进行后续动态调试。这样最大的好处是调试时可以看到所有的logcat输出,还能直接以root权限访问App的私有目录。
重打包的时候要注意,脱壳后的dex如果数量多,得用smali/baksmali重新组装。我先用apktool d把脱壳后的APK解包,替换其中的dex文件,再重新构建和签名。但apktool可能无法自动处理爱加密壳本身的校验,所以重新打包后要先确认壳入口是否被正确移除或者替换为空壳入口,否则App可能启动后直接crash。
我用一个简单的方法绕过这个问题:把脱壳后的dex放进去后,同时把原有壳的StubApp类替换成一个自定义的空壳类,入口只调用原始的attachBaseContext,不带任何解密逻辑。这样App启动时可以直接加载真实的业务dex。
重打包后测试了几轮,基本功能都能跑通,说明脱壳和重打包成功。此时已经把App从“加固模式”解锁为“可调试模式”,接下来动态分析会顺畅很多。
5. 签名算法还原与动态调试
5.1 从静态代码中定位sign生成逻辑
脱壳后的代码中搜索到的MessageDigest一般会有多处分发,原代码可能是经过混淆的,但逻辑链路仍然清晰。最终的签名生成大概率集中在某个工具类里,它先读取请求参数,将参数按照特定规则排序,再拼上固定的盐值,最后计算MD5。
我在JADX里定位到一个静态方法,其代码大致如下:
public static String getSign(String path, String body, String nonce, long timestamp) { String raw = path + body + nonce + timestamp + SALT; return md5(raw); }再看SALT的定义,发现它不是硬编码在代码里,而是通过BuildConfig读取的。这就有点意思了——说明签名算法的盐值来源于构建配置,不同渠道包的盐值可能不同。如果更新版本时盐值变化,旧签名就会全部失效,这种设计明显是为了防止签名算法被逆向后直接复用。
我需要进一步追踪BuildConfig里的SALT值。好消息是,R8混淆不会破坏BuildConfig字段,直接搜索这个字段即可拿到字符串明文。但也有可能字符串被拆分拼接,这种情况下只能通过动态hook来获取完整的盐值。
5.2 动态hook签名函数来验证猜想
静态分析只能得到“代码长什么样”,要想确认它真的在运行时被调用,动态hook是必经之路。我用frida写了一段脚本,hook住签名方法,打印入参和返回值:
Java.perform(function() { var clz = Java.use("com.coffee.app.util.SignUtil"); clz.getSign.implementation = function(a, b, c, d) { var ret = this.getSign(a, b, c, d); console.log("Path=" + a); console.log("Body=" + b); console.log("Nonce=" + c); console.log("Timestamp=" + d); console.log("Sign=" + ret); return ret; }; });启动App,随便触发一个登录请求,frida的输出面板立刻打印出了完整的参数组合和签名结果。和我静态分析拿到的那串代码完全对得上。这一步的验证非常关键,否则很可能你静态分析出来的代码在运行时根本不会被调用,那就是白忙一场。
这个咖啡App的签名逻辑验证结果:所有参与签名的参数按固定顺序拼接,加上固定盐值后整体MD5。虽然拼装顺序没有特别反人类,但盐值的存在使得外部无法直接伪造请求。
5.3 遇到反调试与完整性校验后调整策略
动态hook过程中,我还遇到过一次反调试。具体表现为:用frida attach模式启动时,App直接弹“检测到调试环境”并闪退。排查后确认,App在启动阶段的Application.attachBaseContext里检测了TracerPid和Debug.isDebuggerConnected。
处理办法是给App套一个frida-server的隐型启动模式,或者直接用early instrumentation模式在attachBaseContext执行前注入代码。我用的是frida的-f参数配合--runtime=v8模式,在App启动的极早期阶段就绕过检测。关键是要在壳的attachBaseContext方法执行前完成hook,时机非常重要。
另外一个典型坑是:脱壳重打包后,App运行时会对自身dex文件的哈希值做校验,一旦发现被修改就会触发自毁。解决思路是hook住校验方法,将返回结果强行置为合法值。动手之前,先确认这个校验是在Java层还是SO层,如果在SO层,处理难度要大得多。
5.4 完整还原sign算法的最后一公里
拿到完整的参数组合、盐值和加密方式之后,最后一步是把验证过的逻辑迁移到本地脚本中,实现离线签名。
import hashlib, time, random, string def generate_sign(path, body, nonce, timestamp): SALT = "coffee_secret_salt_2023" raw = path + body + nonce + str(timestamp) + SALT return hashlib.md5(raw.encode()).hexdigest() nonce = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) timestamp = int(time.time()) path = "/api/v1/user/login" body = '{"account":"test","password":"123456"}' sign = generate_sign(path, body, nonce, timestamp) print(sign)运行脚本生成一串签名,然后和Charles里抓到的真实签名对比,格式一致、结果可控。这已经足够证明签名算法被完全还原了。
不过这只是一个起点。服务端通常还会校验nonce的时效性和唯一性,意味着就算你能算出合法签名,也不能无限重放。真正的安全边界要看服务端那边的处理逻辑,而不只是客户端签名有没有被破解。
6. 常见问题与防坑指南
6.1 抓包时Charles显示乱码的排查流程
这个问题我在分析过程中至少遇到过三次。现象是Charles能截获HTTPS请求,但Response内容是一堆看不懂的字符。排查顺序是:第一,确认手机上是否已正确安装Charles证书;第二,确认Charles的SSL Proxying设置里勾选了目标域名;第三,检查App是否设置了networkSecurityConfig只信任系统证书;第四,确认系统时间和证书时间是否匹配。
如果以上都没问题,还有可能是不小心抓到了HTTP/2的流量,Charles在某些版本下对HTTP/2解密的兼容性不佳。可以在Proxy Settings里关闭HTTP/2支持再试。
排查抓包问题时,先记录当前App版本、安卓版本、代理方式和证书安装位置,这四个参数直接决定抓包方案的可行性。磨刀不误砍柴工,及时把这些信息记录下来,后面的动态调试效率会高很多。
6.2 脱壳产物是空文件或者不完整时怎么处理
脱壳反编译出的dex文件大小很小,用JADX打开全是垃圾类,这表明dump时机不对。以爱加密的机制来说,真实dex的加载是一个延时操作,有时候会等到某个Activity启动后才加载出来,而不是Application刚创建就完成。
解决方案是在frida脚本里主动轮询进程的ClassLoader,找到加载了目标包名类的ClassLoader后,再执行dex dump。把dump的触发点从“时间驱动”变成“事件驱动”,成功率会高非常多。
6.3 动态hook时如何避免日志刷屏与干扰
咖啡App里埋了很多日志点,hook所有方法时会发现控制台输出几千行日志,真正的签名信息反而被淹没。优化方法是细分入口,只hook特定类或特定方法,并在脚本里加入filter,只打印非空返回值和非空参数的那几条记录。
另外一个更优雅的方案是hook日志类,比如android.util.Log或App自定义的LogUtils,在输出层拦截并渲染重点信息。避免刷屏之后,Frida的稳定性和控制台可读性都会提升不少。
6.4 加固App的Application入口被篡改后如何定位真实入口
脱壳后重打包时,如果把壳的Stub入口换掉,需要确认业务代码真正以哪个Application作为入口。很多加固App会把业务Application通过ContentProvider机制加载,所以反编译后要留意Manifest里注册的ContentProvider,以及它的onCreate方法。
我在这个咖啡App里发现它的真实Application入口被转移到了原始的CoffeeApp类中,但在Manifest的android:name里注册的却是壳的StubApp。所以查找真实入口时,不能只看Manifest,还要在脱壳后的代码里搜索attachBaseContext和onCreate的完整实现,再确认类的依赖关系。
6.5 常见问题速查表一句话版本
| 现象 | 可能原因 | 排查手法 |
|---|---|---|
| 抓包全是CONNECT隧道 | 证书未安装或系统证书未信任 | 把证书装入系统证书目录 |
| 动态hook无输出 | 加固App尚未完整加载业务dex | 切换为early instrumentation |
| 脱壳dex为空或太小 | dump时机不对 | 改用ClassLoader事件驱动 |
| 重打包后闪退 | 壳校验或签名不一致 | hook完整性校验或保留原壳 |
| 接口签名始终不一致 | 参数拼接顺序或盐值不对 | 动态hook打印完整入参 |
这几种场景在逆向工程里几乎是必然遇到的,把排查顺序记在脑子里,至少能帮你省掉两个小时的试错时间。
7. 本次分析的总结与心得
整个咖啡App逆向分析走完,最大的感受是:移动端安全的攻防重点从来不在客户端本身,而在于服务端是否信任了不该信任的客户端。一个可以自由脱壳、重打包、篡改请求的App,如果服务端没有配套的风控策略,那么无论客户端做多少层加密都是形同虚设。
在分析过程中,我对爱加密免费版加固的理解也更深了一层。免费版保住的只是静态层面的代码可见性,一旦进入动态层,detached进程、内存dump、frida hook等手段有太多孔隙可以利用。商业App在安全设计上如果想真正提升门槛,至少要把签名校验下沉到SO层,并引入动态防护机制,否则只做静态加固其实挡不住有决心的人。
这次项目也验证了一个思路:对称加密和哈希算法本身没有绝对安全,安全取决于密钥管理和服务端验证策略。这个咖啡App的盐值放在BuildConfig里,虽然不算裸奔,但对一个资深逆向者来说,基本上属于半公开的信息。密钥如果在服务端下发或者每次会话动态生成,破解难度会高两个级别。
如果你也想拿一款App做逆向练手,我的建议是不要从社交类、金融类这种风控做到极致的App开始,找一款中低频使用的业务型App,按这篇文章的思路走一遍,你会对整个移动安全技术栈有一个非常扎实的理解。等这条路走顺了,再往SO层逆向、unidbg模拟执行这些更深的坑里跳也不迟。