匿名 APK 逆向经验分享:从问题定位到可回滚验证
本文将一次 Android 棋类应用的逆向、功能分析和本地测试过程整理成通用流程。软件名称、包名、路径、哈希和服务器地址均已匿名化。示例只针对自己拥有或明确获准测试的 APK。
1. 目标和范围
这次工作关注的是本地 APK 的结构和行为:高级功能开关、本地引擎、棋盘识别、悬浮窗、WDL 评估和 JSON 记录。网络服务器上的账户权限、支付状态和服务端校验,不能简单等同于本地布尔值。
2. 工作区和备份
不要直接覆盖原 APK;每个阶段都保留可回滚文件:
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$root = 'D:\lab\boardapp'
New-Item -ItemType Directory -Force "$root\backup_$stamp" | Out-Null
Copy-Item '.\sample.apk' "$root\backup_$stamp\sample_original.apk"
Get-FileHash '.\sample.apk' -Algorithm SHA256
建议目录:
boardapp/
original/ 原 APK、版本信息、哈希
decoded/ apktool 解码结果
jadx/ Java 反编译结果
smali_work/ 实际编辑的 smali
build/ dex、对齐包、最终 APK
logs/ logcat、测试记录
backup_*/ 每个阶段的回滚点
每次只改一个主题,并给产物加后缀,例如 v1_gate、v2_wdl、v3_ui,方便定位回归问题。
3. 第一轮基线检查
先记录文件、签名和设备状态:
Get-Item '.\sample.apk' | Select-Object FullName,Length,LastWriteTime
Get-FileHash '.\sample.apk' -Algorithm SHA256
adb devices -l
adb -s <serial> shell getprop ro.product.cpu.abilist
adb -s <serial> shell wm size
adb -s <serial> shell wm density
需要记录 APK 大小、版本号、最低/目标 SDK、原生库 ABI、模拟器分辨率、内存和线程数。多模拟器环境中,所有命令都显式写 -s <serial>,不要使用默认设备。
4. 解码和静态定位
常用工具:apktool、jadx、baksmali/smali、ripgrep、zipalign、apksigner、adb 和 Python。
apktool d -f '.\sample.apk' -o '.\decoded'
jadx -d '.\jadx' '.\sample.apk'
rg -n -i 'vip|member|premium|expire|license|feature|engine|overlay|accessibility' .\decoded .\jadx
按三层分析:
- Manifest:入口 Activity、Service、AccessibilityService、悬浮窗权限和 FileProvider;
- DEX:会员判断、按钮回调、引擎命令、棋盘识别和 JSON 写入;
- Native/资源:
.so、可执行引擎、NNUE/模型文件、配置和 ABI。
不要只搜索“会员”。实际判断经常是普通布尔值、时间戳比较、枚举状态或远程配置字段。
5. 运行时观察
静态结果必须用运行时行为确认:
adb -s <serial> logcat -c
adb -s <serial> shell monkey -p com.example.boardapp 1
adb -s <serial> logcat -v time -d > .\logs\startup.log
Select-String -Path .\logs\startup.log -Pattern 'FATAL|AndroidRuntime|VerifyError|Exception|engine|overlay'
辅助命令:
adb -s <serial> shell dumpsys activity activities
adb -s <serial> shell dumpsys activity services com.example.boardapp
adb -s <serial> shell dumpsys window windows
对正在进行的对局,不要为了取日志而强制停止应用。读取记录文件可以使用只读的 adb pull:
adb -s <serial> pull '/sdcard/BoardApp/records' '.\export'
6. 定位本地高级功能开关
先建立“现象—调用链—真正判定点”表:
| 现象 |
可能位置 |
要确认的内容 |
| 按钮灰色 |
布局或点击回调 |
是 UI 限制还是权限限制 |
| 点击后提示需要高级权限 |
Activity/Presenter |
判断函数的返回值和调用者 |
| 功能能勾选但不执行 |
Service/Worker |
是否还有第二层运行时校验 |
| 重启后状态恢复 |
Preferences/数据库 |
状态是否只是缓存 |
| 登录后才有效 |
网络接口 |
是否存在服务端签名或令牌校验 |
稳定的定位顺序:
- 找到按钮点击回调;
- 沿调用链找到
isFeatureEnabled()、hasPrivilege() 一类的判定;
- 确认返回值是否又被其他位置覆盖;
- 在授权的本地样本中只做最小改动;
- 同时验证 UI、后台任务、引擎连接和实际效果。
抽象的 smali 结构:
.method public isFeatureEnabled()Z
.registers 2
iget-boolean v0, p0, Lcom/example/FeatureState;->enabled:Z
return v0
.end method
只改按钮颜色通常只是“看起来解锁”,并不能保证后台功能真的执行。
7. smali 中最容易踩的坑
7.1 参数寄存器和临时寄存器别名
在 .registers 2 的静态单参数方法中,p0 会映射到最后一个物理寄存器。不要把同一个寄存器同时当临时变量:
# 容易出错:v1 可能与 p0 是同一个物理寄存器
.method private static toPercent(I)I
.registers 2
const/16 v1, 0x64
div-int/2addr p0, v1
return p0
.end method
更安全的写法:
.method private static toPercent(I)I
.registers 2
const/16 v0, 0xa
div-int/2addr p0, v0
return p0
.end method
这类寄存器错误曾导致 WDL 三项全部显示为 100%。排查时要分别查看引擎原始输出、字段复制和显示转换。
7.2 方法描述符和调用点
如果把:
onInfo(Lq2/h;IZ)V
改成:
onInfo(Lq2/h;IZZ)V
必须同步检查所有调用点,否则会出现 NoSuchMethodError 或 VerifyError。方法签名、参数顺序和返回类型尽量保持不变。
8. 引擎、NNUE、WDL 和视角
本地 UCI 引擎通常通过标准输入输出通信:
uci
isready
setoption name Threads value 2
setoption name Hash value 256
setoption name UCI_ShowWDL value true
position fen <fen>
go movetime <ms>
引擎负责搜索,NNUE 负责局面评估。启动成功时一般能看到:
NNUE evaluation using <network>.nnue (...)
WDL 常见格式是千分比:
info ... score cp 120 wdl 420 500 80 nodes ...
胜 和 负
统一红方、黑方和“棋盘下方/我的棋子”视角时,必须确认:
- FEN 中的 side-to-move;
- 引擎分数相对于哪一方;
- 棋盘下方识别出的阵营;
- 交换胜/负时是否需要同步交换 cp 符号。
wdl 1000 0 0 表示引擎认为当前局面极端优势,不一定是显示错误。wdl 29 942 29 则约为 3%/94%/3%。
9. JSON 记录设计
每步保存原始走法、局面、引擎数据和胜率:
{
"fileVersion": 1,
"engine": "local-uci",
"scorePerspective": "playerSide",
"moves": [
{
"ply": 16,
"side": "black",
"move": "c7d5",
"fenAfter": "<fen>",
"scoreCp": 134,
"depth": 12,
"timeMs": 11,
"pv": "h6g8 e9d9 ...",
"playerWinRate": 83,
"playerDrawRate": 17,
"playerLossRate": 0
}
]
}
胜率只是“当时引擎的看法”,不能直接当作最终胜负。若要判断真实结果,还要记录结算页面、超时、认输或将死事件。
10. 重新打包、对齐和签名
java -cp '.\tools\smali_deps\*' org.jf.smali.Main assemble `
'.\smali_work' -o '.\build\classes.dex'
python '.\tools\replace_dex.py' `
'.\original\sample.apk' '.\build\classes.dex' `
'.\build\unsigned.apk'
& '.\tools\zipalign.exe' -f -p 4 `
'.\build\unsigned.apk' '.\build\aligned.apk'
java -jar '.\tools\apksigner.jar' sign `
--ks '.\tools\lab.jks' --ks-pass pass:<password> `
--ks-key-alias lab --out '.\build\sample_patched.apk' `
'.\build\aligned.apk'
java -jar '.\tools\apksigner.jar' verify --verbose `
'.\build\sample_patched.apk'
Get-FileHash '.\build\sample_patched.apk' -Algorithm SHA256
常见失败原因:旧 META-INF 未清除、签名方案不匹配、dex 未替换、原生库误删、模型路径错误、ABI 不匹配,以及安装后残留旧数据。
11. 安装和冒烟测试
adb -s <serial> install -r '.\build\sample_patched.apk'
adb -s <serial> shell monkey -p com.example.boardapp 1
adb -s <serial> logcat -d -v time > '.\logs\after_install.log'
检查顺序:启动、功能入口、引擎 bestmove、悬浮窗触摸、棋盘识别、红黑视角、WDL 变化、JSON 写入、关闭悬浮窗后的底层点击和重开一局后的状态清零。
12. 典型问题和经验
顶部文字或胜率被截断
常见原因是 TextView 单行、固定高度或悬浮层覆盖。应调整最大行数、布局高度和触摸区域,而不是简单缩小字体。
点击选择框后崩溃
检查 Popup/Dialog 是否成功创建、回调是否引用了销毁的 View,以及异常发生在 UI 线程还是后台线程。以完整 AndroidRuntime 堆栈为准。
游戏界面像暂停,关闭脚本后才突然超时
通常是悬浮窗或 Accessibility/屏幕捕获层拦截了触摸、改变焦点或占用了 UI 线程。应缩小覆盖区域,让非控件区域不消费触摸,并降低截图和写文件频率。
引擎启动后一直等待
依次检查 ABI、可执行权限、模型文件路径、工作目录、标准输入输出、线程数和内存。不要直接把所有问题归结为棋力。
13. 回滚和证据链
每个版本至少保留:原 APK、哈希、修改前后 smali、构建命令、签名结果、设备 serial/ABI、测试截图、logcat、JSON、已知问题和回滚方法。
版本:v3_wdl_fix
输入:sample_original.apk / SHA256=...
改动:Recorder、EngineParser、OverlayTextView
验证:assemble、zipalign、apksigner、启动冒烟测试
回滚:恢复 backup_*/sample_original.apk
14. 最终总结
- 先备份、再基线、后修改;
- 先确认调用链,再改判定点;
- 一次只改一个变量;
- 静态分析和 logcat 必须互相验证;
- 引擎、网络、FEN、视角和 UI 是不同层次的问题;
- “能点”不等于“功能真的解锁”;
- 胜率是评估,不是结果;
- 测试设备必须显式指定,避免误操作正在使用的实例。
15. 合规说明
逆向分析应限定在自己拥有、得到授权或明确用于安全研究的样本。不要把账号、密钥、个人数据、服务器令牌或未公开商业资源写进分享文档。发布修改后的 APK 前,还应检查原项目许可证、网络模型许可证和目标平台规则。