安卓 APK 逆向经验分享:会员、广告与启动流程分析
本文基于授权的本地测试 APK 整理,不包含目标软件名称、包名、接口地址、广告位 ID 或账号信息。仅用于学习 Android 逆向、调试和兼容性分析。
一、问题背景
在实际分析中,经常会遇到以下现象:
- 应用启动时显示开屏广告,并可能跳转到广告页面;
- 首页或内容页出现服务端下发的推广横幅;
- 部分功能根据会员状态限制次数或直接隐藏;
- APK 重打包后,登录、短信验证码或第三方 SDK 出现异常。
这类问题不能只依赖字符串搜索,通常需要结合静态分析、运行时日志和模拟器测试,先区分:
- 客户端界面限制;
- 客户端本地会员判定;
- 服务端真实权限校验;
- 第三方 SDK 的签名或包校验。
二、推荐工具链
- JADX:查看 Java/Kotlin 反编译结果;
- Apktool:解码资源、Manifest 和 Smali;
- Smali/Baksmali:重新汇编或拆解 DEX;
- ADB:安装 APK、抓取日志、截图和导出 UI 层级;
- Android 模拟器:进行干净安装、流程回归和崩溃定位;
- Zip 对齐与签名工具:生成可安装的测试 APK。
建议将项目分为以下目录:
project/
├─ input/ 原始 APK
├─ backup/ 原始备份
├─ work/ 解码文件、反编译代码、临时 DEX
├─ build/ 中间包和最终包
└─ test/ 日志、截图、测试记录
三、第一步:建立基线
在修改前记录:
- 文件大小和 SHA-256;
- 包名、版本号、最低及目标 SDK;
- Manifest 中的 Application、Launcher Activity 和 Provider;
- DEX 数量、ABI、是否存在加固或动态加载;
- 是否包含广告 SDK、支付 SDK、登录 SDK 和完整性校验。
同时对原始 APK 做一次干净安装,记录启动页面、登录页面、广告出现时机和异常日志。后续每次改包都应该与这份基线比较。
四、定位开屏广告和跳转
常见结构是:
Launcher/Main Activity
↓
启动管理器
↓
广告配置判断
↓
开屏广告加载
↓
广告关闭或失败回调
↓
进入主页面
分析时重点关注:
startLoadAd、loadSplash、showAd 等方法;
onAdDismiss、onAdFail、onComplete 等回调;
- 是否通过 Handler 延迟进入主页面;
- 广告点击回调是否调用 WebView、浏览器或外部 Intent。
在授权的本地测试版本中,较稳妥的处理方式是让启动管理器直接执行“完成”回调,而不是删除整个广告 SDK。这样可以保留类和资源依赖,降低启动崩溃概率。
五、定位首页和内容页广告
广告不一定来自广告 SDK,也可能是服务端返回的推广 Banner。常见特征包括:
- 数据模型中存在
banner、logo、url、item_type 等字段;
- Adapter 根据列表数量决定是否创建 Banner View;
- 点击 Banner 后调用统一跳转方法;
- 页面刷新时重新请求动态配置。
因此只屏蔽广告 SDK 还不够,还要检查服务端 Banner 的 Adapter。常见的客户端处理点有:
- 让广告 Adapter 的条目数量返回 0;
- 在设置 Banner 数据时清空列表;
- 将广告容器设置为隐藏;
- 对广告点击跳转增加空操作保护。
六、定位会员功能判定
会员限制通常集中在少数公共方法中,而不是每个页面单独实现。重点搜索:
isVip、isSVip、isMember;
getVip、getVipType、vipTime;
canUse、canAndDo、usageLimit;
- “次数限制”“开通会员”“会员中心”等资源文本。
常见逻辑如下:
读取本地 UserInfo
↓
读取 vip 数值或会员类型
↓
判断是否大于 0 / 是否等于高级会员类型
↓
决定功能次数、界面样式和是否跳转购买页
如果多个功能都调用同一个会员判断方法,优先分析公共方法,而不是逐个修改页面。对于显示层,还要同步检查会员名称和有效期字段,否则可能出现“功能已开放但页面仍显示普通用户”的不一致。
七、重签名导致的登录异常
这是重打包中最容易遇到的问题之一。APK 修改后必须重新签名,签名证书变化可能导致:
- 短信 SDK 报应用签名未配置;
- 一键登录或风控 SDK 拒绝请求;
- 服务端返回通用的“验证码错误”;
- 第三方支付、分享或推送功能失效。
定位方法:
- 清空 Logcat;
- 点击一次获取验证码;
- 搜索
SMS、verify、md5、signature、status 等关键词;
- 区分是 SDK 本地失败,还是应用自有接口返回失败。
如果应用本身已经存在备用的验证码接口或备用登录流程,应优先使用应用已有的正常流程进行本地兼容测试。不要把“客户端提示验证码错误”直接当成真实验证码错误。
八、构建和打包经验
1. 不要直接用 --no-res 的目录回编
只解码 DEX 而不解码资源时,回编后的 APK 可能缺少布局文件,启动时出现 Resources$NotFoundException。需要完整解码资源,或者采用“保留原 APK、只替换目标 DEX”的方式。
2. 资源异常要单独记录
部分 APK 的资源属性值对新版 AAPT2 不兼容,例如非法的重力或颜色值。修复时只改动报错资源,并记录原始内容和修改原因,避免大范围重排资源 ID。
3. 每次都要重新验证
签名后至少检查:
- ZIP 对齐是否成功;
- v1/v2/v3 签名是否有效;
- DEX 数量是否变化异常;
- Manifest 的启动 Activity 是否仍正确;
- APK 是否能在干净模拟器安装。
九、测试清单
- [ ] 冷启动不再出现开屏广告;
- [ ] 广告点击不会跳转外部页面;
- [ ] 首页和主要内容页没有服务端推广 Banner;
- [ ] 未登录状态仍能正常进入登录流程;
- [ ] 获取验证码和输入验证码流程正常;
- [ ] 登录后会员标识、会员有效期和受限功能状态一致;
- [ ] 会员功能不会因后台刷新立即恢复为普通状态;
- [ ] 页面旋转、后台恢复和进程重启不崩溃;
- [ ] Logcat 中没有新的致命异常。
十、经验总结
- 先找公共判定点,再改具体页面;
- 广告既可能来自 SDK,也可能来自服务端动态数据;
- 重签名问题要通过日志确认,不能只看界面提示;
- 尽量保留原有类、资源和 native 库,减少无关改动;
- 任何本地会员修改都不等于服务端真实开通,服务端权限仍可能独立校验;
- 所有测试应限定在自己拥有或明确授权的 APK、账号和设备上。
十一、一个可复现的完整流程
下面以“某健康类 APK”为例,演示从拿到文件到完成回归测试的完整过程。代码中的类名已经泛化,不能直接代表任何特定软件。
11.1 建立工程和记录基线
$root = "D:\lab\android_sample"
New-Item -ItemType Directory -Force -Path `
"$root\input", "$root\backup", "$root\work", "$root\build", "$root\test"
Copy-Item .\sample.apk "$root\input\sample.apk"
Copy-Item .\sample.apk "$root\backup\sample.original.apk"
Get-FileHash "$root\input\sample.apk" -Algorithm SHA256
记录以下信息:
package: com.example.sample
versionName: 3.7.0
versionCode: 370
minSdk: 24
targetSdk: 31
abi: arm64-v8a, armeabi-v7a
dex: classes.dex ... classes12.dex
这里的包名和版本只是示例,实际项目中应从 Manifest 或 APK 分析工具获取,不能凭文件名猜测。
11.2 解码和反编译
$apk = "D:\lab\android_sample\input\sample.apk"
$decoded = "D:\lab\android_sample\work\decoded"
$apktool = "D:\tools\apktool.jar"
$jadxOut = "D:\lab\android_sample\work\jadx"
java -jar $apktool d --force $apk -o $decoded
jadx.bat --show-bad-code -d $jadxOut $apk
常用搜索:
rg -n -i "isVip|isSVip|member|premium|vip|ads|banner|splash|startActivity|showAd" `
"$decoded\smali*" "$jadxOut\sources"
搜索结果需要回到调用链确认,单独命中字符串不能证明它就是实际控制点。
11.3 运行时确认调用链
$adb = "D:\Android\platform-tools\adb.exe"
& $adb devices -l
& $adb -s emulator-5554 install -r $apk
& $adb -s emulator-5554 logcat -c
& $adb -s emulator-5554 shell monkey -p com.example.sample 1
Start-Sleep 8
& $adb -s emulator-5554 logcat -d -v time > "$root\test\baseline.log"
重点观察:
- 首屏出现前后的 Activity 变化;
- 点击会员入口后是否只是本地提示,还是发起网络请求;
- 点击获取验证码时,失败来自第三方 SDK 还是应用自有接口;
- 页面广告是否来自 SDK View,还是服务端返回的 Banner 数据。
十二、会员判定的具体代码示例
12.1 Java 层原始逻辑
一种常见实现如下:
public boolean isVipValid() {
UserInfo info = AppPref.get().getUserInfo();
return info != null && info.getVip() > 0;
}
public boolean isSuperVip() {
UserInfo info = AppPref.get().getUserInfo();
return info != null && info.getVip() == 314;
}
调用方通常是:
if (Vip.isVip()) {
openFeature();
} else {
showVipPage();
}
12.2 Smali 层识别方法
对应的关键字节码可能类似:
invoke-virtual {v0}, Lcom/example/UserInfo;->getVip()I
move-result v0
if-lez v0, :not_vip
const/4 v0, 0x1
return v0
:not_vip
const/4 v0, 0x0
return v0
如果多个页面都调用同一个 isVipValid(),优先修改这个公共方法,而不是逐个改页面。一个保持登录状态、只改变会员分支的本地测试写法是:
.method public isVipValid()Z
.locals 1
invoke-static {}, Lcom/example/AppPref;->getUserInfo()Lcom/example/UserInfo;
move-result-object v0
# 未登录仍然返回 false
if-eqz v0, :not_vip
const/4 v0, 0x1
return v0
:not_vip
const/4 v0, 0x0
return v0
.end method
如果页面还会根据会员等级显示不同样式,可同步修改等级读取方法:
.method public getVip()I
.locals 1
const/16 v0, 0x13a # 314,示例中的高级会员类型
return v0
.end method
这样可以覆盖以下类型的客户端逻辑:
- 每日次数限制;
- 高级解析或高级处理入口;
- 无广告标识;
- 会员页面的头像、标签和有效期展示;
- 高级会员专属页面入口。
但这只改变客户端判断,服务端仍然可能再次校验用户权限。
十三、广告和开屏的具体修改示例
13.1 统一广告结果判断
原始逻辑常见为:
public ShowType getShowType(Context context) {
if (VipManager.getInstance(context).isVipValid()) {
return ShowType.HIDE;
}
return showType;
}
本地测试版可以将结果统一设为隐藏:
.method public getShowType(Landroid/content/Context;)Lcom/example/ShowType;
.locals 0
sget-object p1, Lcom/example/ShowType;->HIDE:Lcom/example/ShowType;
return-object p1
.end method
13.2 开屏广告直接结束
开屏管理器通常在广告关闭、失败或超时后调用完成回调:
private void startLoadAd() {
loadSplashAd();
}
private void notifyComplete() {
listener.onComplete();
}
本地测试版可以让入口直接走完成回调:
.method public startLoadAd()V
.locals 0
invoke-direct {p0}, Lcom/example/SplashAdManager;->notifyComplete()V
return-void
.end method
这种方式比删除整个广告 SDK 更稳定,因为其他类仍然可以找到 SDK 类型和资源。
13.3 服务端 Banner 不是广告 SDK
有些首页广告来自接口返回的列表:
public void setDataList(List<BannerBean> list) {
bannerList.clear();
bannerList.addAll(list);
notifyDataSetChanged();
}
public int getItemCount() {
return hide ? 0 : 1;
}
只屏蔽广告 SDK 时,这类 Banner 仍然会显示。可以在 Adapter 层让广告条目不创建:
.method public getItemCount()I
.locals 1
const/4 v0, 0x0
return v0
.end method
或者让 setDataList() 清空后不再触发显示。修改后应检查页面布局是否会留下空白区域。
十四、验证码异常的定位实例
一次测试中,点击“获取验证码”后界面显示“验证码错误”,但实际上还没有输入验证码。日志显示:
SMS failed: {"status":489,"detail":"application signature is not configured"}
根因不是用户输入错误,而是 APK 重签名后证书指纹发生变化,第三方短信 SDK 拒绝请求。
排查步骤:
1. 清空 Logcat;
2. 点击一次获取验证码;
3. 搜索 SMS、md5、signature、status、verify;
4. 记录失败回调携带的原始字符串;
5. 对比原始 APK 的调用链。
如果应用本身已经存在备用的自有验证码接口,代码可能同时存在两条路径:
// 第三方 SDK 路径
toSendCode(false);
// 应用自有接口路径
toSendOwnCode();
在授权测试版本中,可以将首次获取验证码改为应用已有的自有接口,并在成功回调中切换到对应的登录提交流程:
private void requestCode() {
showLoading();
api.getVerificationCode(phone, new Callback() {
@Override public void onSuccess() {
useSdkVerification = false;
showCodeInputPage();
}
@Override public void onFailure(String message) {
hideLoading();
showError(message);
}
});
}
提交验证码时也必须匹配同一条路径:
if (useSdkVerification) {
sdk.submitVerificationCode(phone, code);
} else {
api.loginByCode(phone, code);
}
这里的关键经验是:先确认失败位置,再决定修复点;不要把所有“验证码错误”都当成需要修改验证码校验。
十五、资源回编失败的处理
如果使用只解码 DEX 的目录回编,可能出现:
Resources$NotFoundException: File res/xxxxx.xml
原因是资源表仍在,但布局文件没有被解码回写。处理方式有两种:
方案 A:完整资源回编
java -jar apktool.jar d --force sample.apk -o work\decoded_full
java -jar apktool.jar b work\decoded_full -o build\unsigned.apk
如果 AAPT2 报某个资源属性非法,只修复报错行,并保留修改记录。
方案 B:保留原 APK,只替换 DEX
适合只改动少量 Smali 的情况。示例脚本:
import copy
import zipfile
src = "build/original_signed.apk"
dst = "build/patched_unsigned.apk"
replace = {
"classes5.dex": "work/classes5_fixed.dex",
"classes7.dex": "work/classes7_fixed.dex",
}
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w") as zout:
for info in zin.infolist():
if info.filename in replace:
data = open(replace[info.filename], "rb").read()
else:
data = zin.read(info)
zout.writestr(copy.copy(info), data)
之后必须重新 ZIP 对齐和签名:
java -jar uber-apk-signer.jar -a build\patched_unsigned.apk --allowResign
十六、Smali 修改后的验证方法
16.1 重新汇编单个 DEX
java -cp "smali.jar;libs\*" org.jf.smali.Main assemble `
--api 31 -j 16 `
-o work\classes7_fixed.dex `
work\decoded\smali_classes7
16.2 安装和启动验证
adb -s emulator-5554 uninstall com.example.sample
adb -s emulator-5554 install build\patched-aligned-debugSigned.apk
adb -s emulator-5554 logcat -c
adb -s emulator-5554 shell monkey -p com.example.sample 1
Start-Sleep 10
adb -s emulator-5554 logcat -d -v time > test\patched.log
16.3 关键日志筛选
rg -n -i "FATAL EXCEPTION|AndroidRuntime|VerifyError|NoSuchMethodError|Resources\$NotFound|Splash|Vip|SMS|signature" test\patched.log
十七、回归测试记录模板
测试包:patched-aligned-debugSigned.apk
设备:Android 模拟器 / 实机
安装:成功
启动:成功
冷启动开屏:未显示广告
首页 Banner:未显示
未登录流程:仍进入登录页
获取验证码:接口返回成功 / 失败原因:________
输入验证码:正常 / 异常:________
登录后会员标识:________
会员功能入口:________
后台恢复:正常 / 异常:________
致命日志:无 / 有:________
十八、常见问题和判断表
| 现象 |
优先检查 |
常见原因 |
| 启动白屏后退出 |
Logcat、Manifest、资源文件 |
资源缺失或 Application 初始化失败 |
| 仍然出现开屏广告 |
Splash 管理器和热启动入口 |
只改了首次启动,未改热启动 |
| 首页仍有商品横幅 |
Banner Adapter、动态数据模型 |
这是服务端 Banner,不是 SDK 广告 |
| 点击获取验证码即报错 |
SDK 失败回调原文 |
重签名证书未在第三方平台配置 |
| 会员页面变了但功能仍受限 |
具体功能调用链和请求参数 |
服务端再次校验会员状态 |
| 安装提示应用未安装 |
签名、ABI、Manifest |
与原版签名不同或架构不兼容 |
| 修改后闪退 |
新增日志和 VerifyError |
寄存器数量、方法签名或 DEX 回编错误 |
十九、最终经验
一个完整的 Android 逆向流程不是“搜索一个字符串后改一行代码”,而是:
基线记录
→ 静态定位
→ 运行时确认
→ 找公共判定点
→ 最小化修改
→ 回编/签名
→ 干净安装
→ 功能回归
→ 日志和截图归档
最重要的三个原则:
- 先判断问题属于客户端、服务端还是第三方 SDK;
- 优先修改公共入口,避免在几十个页面重复打补丁;
- 每次修改都保留原包、日志、截图和可复现命令。