APP 攻防2:安卓逆向-APK结构-Smali语法与反编译重打包实战
学习自B站小迪安全
抓包抓不到、校验绕不过时,最后一条路是直接把 App 拆开改。本文从零基础出发:先讲清 APK 到底是什么、Android 应用是怎么组织起来的、Smali 语法怎么读怎么写,再给出完整的实操流水线——查壳、脱壳、反编译、汉化、改代码、回编译、签名、安装测试,并配套三个可直接照做的案例(本地会员状态修改、界面广告位移除、接口响应篡改)。
一、开始之前:前置知识与工具清单
1.1 前置知识(仅列要点)
- Java 基础语法:类、方法、字段、
public/private/static、if/else、返回值——Smali 本质是 Java 字节码的文本化表示,读懂 Java 才能读懂 Smali。 - APK 与 ZIP 的关系:APK 就是一个 ZIP 压缩包,改后缀为
.zip即可解压。 - DEX 字节码:Java 源码 →
.class→ DEX 格式,Android 运行的是 DEX。 - API 与 HTTP 接口:能看懂 JSON 响应,理解"客户端→服务端"的请求响应模型。
- Android 四大组件概念:Activity / Service / BroadcastReceiver / ContentProvider。
- Activity 生命周期:
onCreate→onResume→onPause→onDestroy的调用顺序。 adb调试桥基础:安装、卸载、拉取、推送、开 shell。- 命令行操作:能在 Windows / macOS / Linux 终端中执行命令、看懂报错。
- 抓包基础:知道如何抓取并重放一个 HTTP 请求(第三个案例会用到)。
1.2 工具与环境清单(仅列要点)
| 序号 | 工具 | 类别 | 用途 |
|---|---|---|---|
| 1 | 安卓模拟器(最好可 root 的真机) | 运行环境 | 调试与安装测试 |
| 2 | Magisk & XP & LSP 框架 | HOOK 环境 | 系统级 Hook 基础环境 |
| 3 | Jadx-Gui | 反编译 | 图形化查看 Java 反编译代码 |
| 4 | ApkScan-PKID | 查壳 | 判断 APK 是否加固、何种壳 |
| 5 | MT 管理器 & NP 管理器 | 打包签名综合 | 手机上直接改包、重签名 |
| 6 | 开发助手 & 开发者助手 | 信息获取综合 | 查看界面布局、控件 ID、Activity 栈 |
| 7 | 算法助手 & XP & LSP 框架模块 | HOOK | 常见算法与逻辑的 Hook |
| 8 | Smali 语法查询手册 | 语法参考 | Smali 逻辑修改辅助 |
| 9 | apktool | 反编译/回编译 | 命令行核心工具,反编译出可编辑的 smali |
| 10 | apksigner / jarsigner / keytool / zipalign | 签名 | 重打包后的签名与对齐 |
| 11 | 脱壳工具 | 脱壳 | 处理加固 App |
| — | adb | 调试桥 | 安装、拉取、shell |
HOOK 环境安装参考:https://blog.csdn.net/danran550/article/details/132256027
脱壳相关资料:https://mp.weixin.qq.com/s/poQPuvaQPadQxRu_WGvy1A
二、先建立整体认知:一条完整的逆向流水线
逆向不是"随便翻翻代码",而是一条有固定顺序的流水线。顺序不能乱,否则后面每一步都会失败。
┌──────────┐
│ 逆向流程 │
└────┬─────┘
▼
┌──────────┐
│ 查壳 │
└────┬─────┘
▼
◇ 有没有壳? ◇
┌─────────┴─────────┐
有壳 无壳
│ │
▼ │
┌──────────┐ │
│ 脱壳 │ │
└────┬─────┘ │
└─────────┬──────────┘
▼
┌──────────┐
│ 反编译 │◄──────┐
└────┬─────┘ │
▼ │
┌──────────┐ │
│ 汉化 │ 1. Arsc 汉化
└────┬─────┘ 2. Xml 汉化
▼ 3. Dex 汉化
┌──────────┐ │
│ 修改代码 │───────┘(改完如需再验证,回到反编译查看)
└────┬─────┘
▼
┌──────────┐
│ 回编译签名 │
└────┬─────┘
▼
┌──────────┐
│ 安装测试 │
└──────────┘
每一步的意义:
| 阶段 | 目的 | 失败后果 |
|---|---|---|
| 查壳 | 判断是否加固。加固后 classes.dex 是空的,直接反编译看不到真实逻辑 |
后续全部白做 |
| 脱壳 | 在运行时把内存中解密后的 DEX 导出 | 无壳则跳过 |
| 反编译 | 把 DEX 还原成可读、可编辑的 Smali 代码 | 无法定位修改点 |
| 汉化 | 让界面文案变成可读语言,方便定位资源 | 只能靠猜 |
| 修改代码 | 改判断、改跳转、改返回值 | — |
| 回编译签名 | 重新打包,并用新签名签名(改包后原签名必然失效) | 安装失败 |
| 安装测试 | 验证修改是否生效 | — |
2.1 逆向的两个关键动作
任何一次逆向修改,逻辑上都能拆成"定位"和"修改"两个动作:
逆向关键点
├── 快速定位
│ ├── 搜索关键字 ← 从界面文案/提示语反查代码
│ └── 抓取按钮 id ← 从控件 ID 反查事件处理逻辑
└── 修改方法
├── 修改判断 ← if-eqz ⇄ if-nez,或删掉判断
├── 强制跳转 ← goto 到目标分支
└── 修改寄存器的值 ← const/4 v0, 0x1 把 false 变 true
记住这三条修改手法,90% 的本地逻辑修改都能覆盖。
三、APK 是什么:结构剖析
APK 全称 Android Package,本质上就是一个压缩包——在电脑上把 .apk 后缀改成 .zip 就能解压。
解压后你会看到这些目录与文件:
| 文件/目录 | 注释 |
|---|---|
assets 目录 |
存放 APK 的静态资源文件,如视频、音频、图片等 |
lib 目录 |
armeabi-v7a 基本通用所有 Android 设备,arm64-v8a 只适用于 64 位 Android 设备,x86 常用于 Android 模拟器。其目录下的 .so 文件是 C 或 C++ 编译的动态链接库文件 |
META-INF 目录 |
保存应用的签名信息。签名信息可以验证 APK 文件的完整性,相当于 APK 的身份证(用于验证文件是否被修改) |
res 目录 |
存放资源文件,包括图片、字符串等等;APK 的"脸面"由其 layout 文件设计 |
AndroidManifest.xml 文件 |
APK 的应用清单信息,描述了应用的名字、版本、权限、引用的库文件等等信息 |
classes.dex 文件 |
Java 源码编译后生成的 Java 字节码文件,是 APK 运行的主要逻辑 |
resources.arsc 文件 |
编译后的二进制资源文件,是一个映射表,映射着资源和 id,通过 R 文件中的 id 就可以找到对应的资源 |
3.1 我自己的理解:为什么这些目录对逆向很重要
把上面这张表按"逆向时最常翻哪几个"重新排一次,会更好用:
-
AndroidManifest.xml—— 一切从这里开始。
它告诉你 App 有哪些 Activity、哪个是入口(android.intent.action.MAIN+android.intent.category.LAUNCHER)、声明了哪些权限、是否开了debuggable、是否配了networkSecurityConfig。定位逻辑时,先从 manifest 找到目标界面,再去反编译代码里搜这个 Activity 名。 -
classes.dex—— 逻辑全在这里,也是你的"手术台"。
所有本地判断(是否 VIP、是否已购买、是否允许访问)最终都落在 DEX 里的某几个方法上。反编译 → 改 Smali → 回编译,改的就是它。 -
resources.arsc+res/—— 字符串资源的"翻译字典"。
代码里不会明文出现"升级会员",而是引用一个资源 ID(如0x7f0f0123)。字符串资源和代码之间靠 ID 关联,这条关联关系正是"快速定位"的桥梁:界面上的文案 →strings.xml→ 资源 ID → 代码中的引用。 -
assets—— 配置文件与证书的常见藏身处。
很多 App 把config.json、客户端证书(.p12/.bks)、离线规则表放在这里。它不参与编译,所以内容经常是明文的,是找硬编码密钥的第一个地方。 -
lib—— Native 层逻辑。
加解密算法、核心校验、反调试往往在.so里。Java 层改不动的东西,通常最后落到这里(这属于进阶内容,本文不展开)。 -
META-INF—— 改包的"副作用"就在这。
它保存签名。你只要动过任何一个字节,原签名就失效了,必须重新签名,否则安装时会报INSTALL_PARSE_FAILED_NO_CERTIFICATES或Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]。这是新手最容易踩的坑。
四、Android 四大组件
理解组件是"读懂 manifest、定位界面"的前提。
| 组件 | 描述 |
|---|---|
| Activity(活动) | 在应用中的一个 Activity 可以用来表示一个界面,可以理解为"活动":一个活动开始,代表 Activity 组件启动;活动结束,代表一个 Activity 的生命周期结束。一个 Android 应用必须通过 Activity 来运行和启动,Activity 的生命周期交给系统统一管理 |
| Service(服务) | 可以在后台执行长时间运行操作而没有用户界面的应用组件,不依赖任何用户界面,例如后台播放音乐、后台下载文件等 |
| Broadcast Receiver(广播接收器) | 用于接收广播信息并做出对应处理的组件。比如常见的系统广播:通知时区改变、电量低、用户改变了语言选项等 |
| Content Provider(内容提供者) | 应用程序之间唯一的共享数据途径。主要功能是存储并检索数据,以及向其他应用程序提供访问数据的接口。Android 内置的许多数据都是使用 Content Provider 形式供开发者调用的(如视频、音频、图片、通讯录等) |
对逆向的意义:逆向 App 时,Activity 对应"你能看到的界面",Service 对应"你看不到的后台行为",Broadcast Receiver 是"事件入口",Content Provider 是"数据出口"。定位一个功能,本质上就是回答"这个界面是哪个 Activity、它调了哪个方法"。
五、Activity 生命周期:改代码前必须懂的一件事
┌──────────┐
│ Activity 开始 │
└──────┬───────┘
▼
┌──────────┐
┌──────────────►│ onCreate() │
│ └──────┬───────┘
│ ▼
│ ┌──────────┐ ┌────────────┐
│ │ onStart() │◄─────│ onRestart()│
│ └──────┬───────┘ └─────▲──────┘
│ ▼ │
│ ┌───────────┐ │
│ │ onResume() │───────────────┘
│ └──────┬────┘ 用户按了回退键,
│ ▼ 前一个 Activity
│ ┌──────────┐ 重新获得焦点
│ │ Activity 运行中 │
│ └──────┬────┘
│ ▼
│ ┌──────────┐
│ │ onPause() │
│ └──────┬────┘
│ 由用户操作返回该 ▼
│ Activity,通常是 ┌──────────┐
│ 重启 App ┌────│ onStop() │
│ │ └─────┬────┘
│ │ ▼
│ │ ┌────────────┐
│ │ │ onDestroy() │
│ │ └─────┬──────┘
│ │ ▼
│ │ ┌──────────┐
│ │ │ Activity 终止 │
│ │ └──────────┘
│ │
│ 内存低,更高优先级的 ┌──────────┐
├──────────────────────────│ 进程被杀死 │
│ App 需要内存 └──────────┘
└──────────────────────────────────────
各回调触发时机(来自图解标注):
| 回调 | 触发时机 |
|---|---|
onCreate() |
Activity 开始,初始化 |
onStart() |
即将可见 |
onResume() |
获得焦点,进入"运行中" |
onPause() |
另一个 Activity 转入该 Activity 之前,获得焦点,前一个 Activity 还可见 |
onStop() |
前一个 Activity 已经不可见了 |
onDestroy() |
Activity 完成了要完成的工作,或由系统销毁了 |
onRestart() |
用户按了回退键,前一个 Activity 重新获得焦点 |
| 进程被杀死 | 内存低,更高优先级的 App 需要内存 |
必须记住的一条结论(这是新手最容易误解的地方):
以上全部都是回调方法——我们不能去调用,我们做的只是重写方法里面的内容。什么时候调用,由 Activity(系统)决定。我们能够手动调用的只有
finish()方法,该方法用于关闭某个 Activity。
为什么这对逆向很重要:
- 你想让某个界面"改完就退出",就是在
onCreate或事件回调里插入finish(); - 你想让某个初始化逻辑"提前执行",就得把它挪到
onCreate(最早的入口); - 你想让某个校验"不再执行",就找到它所在的回调方法,把方法体清空或直接返回。
六、Smali 语法基础:逆向的"手写代码"层
Smali 是 DEX 字节码的文本反汇编形式。反编译工具给你的是 Smali,回编译工具吃的也是 Smali——所以改代码 = 改 Smali。
6.1 寄存器:v 与 p
Smali 中所有数据都存在寄存器里,分两类:
| 寄存器 | 含义 | 说明 |
|---|---|---|
v0 ~ vN |
局部变量寄存器 | 方法内定义的临时变量 |
p0 ~ pN |
参数寄存器 | 方法的入参 |
关键规则:
- 在非静态方法中,
p0永远是this(当前对象本身),真正的形参从p1开始; - 在静态方法中,没有
p0 = this,形参从p0开始。
.method public isVip()Z
.registers 2 # 本方法总共用 2 个寄存器
.locals 1 # 其中 1 个是局部变量(另一个是 p0)
const/4 v0, 0x1 # v0 = 1(true)
return v0 # 返回 v0
.end method
这是最经典的一处修改:把方法体换成上面这三行,任何返回布尔值的方法都会被强制改成
true。
6.2 数据类型描述符
| Smali 写法 | Java 类型 |
|---|---|
V |
void |
Z |
boolean |
B |
byte |
S |
short |
C |
char |
I |
int |
J |
long |
F |
float |
D |
double |
Ljava/lang/String; |
String(对象用 L...; 包裹,包名用 / 分隔) |
[I |
int[](数组前面加 [) |
6.3 常用指令速查
常量加载
const/4 v0, 0x1 # 加载 4 位常量(-8 ~ 7),最常用
const/16 v0, 0x100 # 加载 16 位常量
const v0, 0x10000 # 加载 32 位常量
const/high16 v0, 0x40000000 # 加载 32 位常量的高 16 位
const-string v0, "hello" # 加载字符串常量
const-wide v0, 0x0L # 加载 64 位(long)常量
方法调用
# 调用实例方法:invoke-virtual {对象, 参数...}, 类;->方法名(参数类型)返回类型
invoke-virtual {p0}, Lcom/example/app/User;->isVip()Z
# 调用静态方法
invoke-static {v0}, Ljava/lang/Integer;->parseInt(Ljava/lang/String;)I
# 调用私有方法 / 构造方法
invoke-direct {p0}, Lcom/example/app/User;-><init>()V
# 调用父类方法
invoke-super {p0}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
# 取出上一条 invoke 的返回值,必须紧跟其后
move-result v0
move-result-object v0
返回
return-void # 无返回值
return v0 # 返回寄存器 v0(int/boolean 等)
return-object v0 # 返回对象
return-wide v0 # 返回 long/double
条件跳转(修改判断的核心)
if-eqz v0, :cond_0 # if (v0 == 0) 跳转
if-nez v0, :cond_0 # if (v0 != 0) 跳转
if-ltz v0, :cond_0 # if (v0 < 0) 跳转
if-gtz v0, :cond_0 # if (v0 > 0) 跳转
if-eq v0, v1, :cond_0 # if (v0 == v1) 跳转
if-ne v0, v1, :cond_0 # if (v0 != v1) 跳转
if-lt v0, v1, :cond_0 # if (v0 < v1) 跳转
if-ge v0, v1, :cond_0 # if (v0 >= v1) 跳转
if-gt v0, v1, :cond_0 # if (v0 > v1) 跳转
if-le v0, v1, :cond_0 # if (v0 <= v1) 跳转
goto :goto_0 # 无条件跳转
命名规律:
eq=等于、ne=不等于、lt=小于、ge=大于等于、gt=大于、le=小于等于;结尾的z表示与 0 比较(zero)。写判断时把指令名读出来,逻辑就清楚了。
其他常用
new-instance v0, Ljava/lang/StringBuilder; # 创建对象
invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V
sget-object v0, Lcom/example/app/Config;->TAG:Ljava/lang/String; # 读静态字段
sput-boolean v1, Lcom/example/app/Flag;->DEBUG:Z # 写静态字段
iget v0, p0, Lcom/example/app/User;->vipType:I # 读实例字段
iput v1, p0, Lcom/example/app/User;->vipType:I # 写实例字段
6.4 三种修改手法(对应"修改方法")
手法一:修改判断(取反)
# 修改前:v0 == 0 时跳转到"非会员"分支
if-eqz v0, :cond_not_vip
# 修改后:v0 != 0 时跳转 → 判断被反转
if-nez v0, :cond_not_vip
手法二:强制跳转
# 修改前:条件跳过去
if-eqz v0, :cond_vip
# 修改后:无条件跳过去(直接删除判断逻辑)
goto :cond_vip
手法三:修改寄存器值
# 修改前:0 表示"不是会员"
const/4 v0, 0x0
# 修改后:1 表示"是会员"
const/4 v0, 0x1
手法四:直接清空方法体(最暴力、最有效)
.method public isVip()Z
.registers 2
const/4 v0, 0x1
return v0
.end method
注意:修改 Smali 时寄存器数量不能随意缩小。
const/4 v0, 0x1+return v0只用 1 个寄存器,但.locals保持原值是最安全的做法——多余的寄存器声明不会报错,缺少的会直接编译失败。
七、视图修改:Activity 与 Xml
本地修改除"逻辑"外,还有一类是"界面"。它涉及三件事:
7.1 时间机制
App 中的时间相关判断(试用期、限时免费、倒计时、每日签到)通常依赖系统时间或服务端返回的时间戳。理解它的意义在于:定位到 System.currentTimeMillis()、new Date() 这类调用点,就找到了判断的源头。
服务端返回的时间戳一般是 13 位毫秒级(如 1693635001297);如果看到 10 位或更短的"远未来"数值(如 253392455349,换算后是公元 1 万年以后),那基本就是"永久有效"的标记值——这类字段是修改时的重点目标。
7.2 引用更改(资源 ID 的映射关系)
界面上的每个控件、每段文案都有一个资源 ID。找到 ID,就找到了代码的入口。
<!-- res/layout/activity_main.xml -->
<TextView
android:id="@+id/tv_vip_status"
android:text="@string/vip_status_text" />
<ImageView
android:id="@+id/bannerViewPager"
android:layout_width="match_parent"
android:layout_height="100dp" />
定位流程:
- 用开发助手类工具打开运行中的 App,切换"布局查看"模式;
- 点击目标控件,读出它的
id(如bannerViewPager)或所属 Activity 类名; - 在反编译目录中反查这个 id 的引用:
# 在 res/values/public.xml 中找到 id 的数值
grep -n "bannerViewPager" target_src/res/values/public.xml
# 在 smali 中搜索所有引用该 id 的位置
grep -rn "0x7f0f0xxx" target_src/smali*/
- 跳到命中的 smali 文件,那里就是这个控件的处理逻辑。
7.3 汉化:让界面可读
界面文案如果是外文或编码混乱,定位会变得非常困难。汉化分三层:
| 层次 | 对象 | 说明 |
|---|---|---|
| Arsc 汉化 | resources.arsc |
资源映射表,改的是资源表本身 |
| Xml 汉化 | res/layout/*.xml 等 |
布局与资源 xml 中的硬编码文案 |
| Dex 汉化 | classes*.dex |
代码中硬编码的字符串常量 |
操作要点:用 MT 管理器 或 NP 管理器 可以直接在手机上打开 APK,选择"Arsc 编辑"或"Xml 编辑",逐条替换文案后保存重签名。命令行场景下,则是改 res/values/strings.xml 后重新回编译。
八、实操:环境准备与核心命令
8.1 常用 adb 命令
adb devices # 列出已连接设备
adb connect <设备IP>:5555 # 连接网络设备/模拟器
adb install -r app.apk # 安装(-r 覆盖安装)
adb install -t app.apk # 允许安装 test 包
adb uninstall <目标包名> # 卸载
adb shell # 进入设备 shell
adb shell pm list packages # 列出所有包名
adb shell pm list packages | grep xxx # 按关键字过滤包名
adb shell dumpsys activity activities # 查看当前 Activity 栈(定位界面类名)
adb pull /data/local/tmp/x.dex . # 从设备拉文件到本地
adb push local.apk /data/local/tmp/ # 从本地推文件到设备
adb logcat | grep -i "xxx" # 查看日志
8.2 反编译与回编译(apktool)
# 反编译(得到可编辑的 smali + res + AndroidManifest.xml)
apktool d target.apk -o target_src
# 如果反编译报错,尝试不反编译资源(-r)
apktool d -r target.apk -o target_src
# 修改 smali / xml 之后,回编译
apktool b target_src -o target_patched.apk
目录结构:
target_src/
├── AndroidManifest.xml ← 清单文件
├── apktool.yml ← 回编译所需的元信息(勿删)
├── res/ ← 资源(layout / values / drawable)
├── smali/ ← 主 DEX 的 smali
├── smali_classes2/ ← 第 2 个 DEX 的 smali(多 dex 情况)
└── assets/ ← 静态资源
多 DEX 提示:大型 App 会有
smali_classes2、smali_classes3……搜索时一定要用smali*/通配,只搜smali/会漏掉大半逻辑。
8.3 重新签名(回编译后必做)
改动过任何字节后,原签名失效,必须重新签名。
# ① 生成签名密钥(首次执行,之后复用)
keytool -genkeypair -v \
-keystore my.keystore \
-alias mykey \
-keyalg RSA -keysize 2048 -validity 36500 \
-storepass 123456 -keypass 123456 \
-dname "CN=test, OU=test, O=test, L=test, ST=test, C=CN"
# ② 对齐(必须在签名之前!)
zipalign -v -p 4 target_patched.apk target_aligned.apk
# ③ 签名(推荐 apksigner,支持 v1/v2/v3 方案)
apksigner sign \
--ks my.keystore \
--ks-pass pass:123456 \
--key-pass pass:123456 \
--out target_signed.apk \
target_aligned.apk
# ④ 校验签名
apksigner verify --verbose target_signed.apk
# ⑤ 安装
adb install -r target_signed.apk
顺序不能错:
apktool b → zipalign → apksigner → adb install
zipalign 必须在 apksigner 之前;如果先签名再对齐,会破坏 v2 签名,导致安装失败。
若使用 jarsigner(老方案):
jarsigner -verbose -keystore my.keystore \
-storepass 123456 -keypass 123456 \
target_patched.apk mykey
jarsigner 只做 v1 签名,Android 11+ 上对部分目标可能安装失败,优先用 apksigner。
8.4 常见报错对照表
| 报错 | 原因 | 解决 |
|---|---|---|
INSTALL_PARSE_FAILED_NO_CERTIFICATES |
改包后没签名 | 执行 8.3 的签名流程 |
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
新旧签名不一致 | 先 adb uninstall <包名> 再装,或用同一 keystore 签名 |
INSTALL_FAILED_INVALID_APK |
未 zipalign,或 zipalign 在签名之后 | 按 zipalign → apksigner 顺序重做 |
apktool b 报 brut.androlib.AndrolibException |
反编译时资源解析异常 | 用 apktool d -r 跳过资源反编译 |
| 反编译出的 smali 是空的 | App 已加固(classes.dex 被壳替换) |
先脱壳 |
九、实操案例一:本地会员状态修改
目标:把"非会员"状态在客户端显示为 VIP / 解除本地功能限制。
整体思路:界面文案 → 字符串资源 → 资源 ID → 代码引用 → 判断点 → 改返回值。
步骤 1:定位关键字
先用开发助手类工具在运行中的 App 上确认目标界面的文案(例如"开通会员""升级 VIP"),然后在反编译目录中搜索:
# 搜索字符串资源中与会员相关的条目
grep -rn "vip" target_src/res/values/strings.xml | head -30
# 在 smali 中搜索会员相关的字段名与方法名
grep -rn "vipType\|isVip\|vipPastDueTime\|vipLabel" target_src/smali*/ | head -40
如果代码里有明显的语义化命名(isVip、vipType),直接命中;如果被混淆成 a.a.b(),则先搜索字符串常量(如提示语"会员"),再反向追踪到调用它的方法。
更省力的入口:用 Jadx-Gui。
jadx-gui target.apk打开后,右上角全局搜索isVip,直接跳转到方法体,右侧面板会同步显示对应的 Smali。定位到方法后,再回到target_src/smali*/中修改。
步骤 2:找到资源 ID 及其引用(若从界面文案入手)
# 1. 在 public.xml 中找到文案的 id 数值
grep -n "vip_status_text" target_src/res/values/public.xml
# 假设输出:<public type="string" name="vip_status_text" id="0x7f0f0123" />
# 2. 在 smali 中反查该 id 的引用
grep -rn "0x7f0f0123" target_src/smali*/
步骤 3:进入判断逻辑
命中位置通常长这样:
.method public isVip()Z
.registers 3
iget v0, p0, Lcom/example/app/UserInfo;->vipType:I
const/16 v1, 0x100 # 0x100 = 256,判断 vipType 是否为 256
if-ne v0, v1, :cond_not_vip
const/4 v0, 0x1 # 是会员
return v0
:cond_not_vip
const/4 v0, 0x0 # 不是会员
return v0
.end method
步骤 4:修改返回值
方案 A(推荐,最简洁)——直接清空方法体:
.method public isVip()Z
.registers 3
const/4 v0, 0x1
return v0
.end method
方案 B ——只改判断指令:
# if-ne v0, v1, :cond_not_vip ← 改为下面这行
if-eq v0, v1, :cond_not_vip
方案 C ——强制跳转到"是会员"分支:
goto :cond_is_vip
步骤 5:回编译、签名、安装
apktool b target_src -o target_patched.apk
zipalign -v -p 4 target_patched.apk target_aligned.apk
apksigner sign --ks my.keystore --ks-pass pass:123456 --key-pass pass:123456 \
--out target_signed.apk target_aligned.apk
adb uninstall <目标包名>
adb install -r target_signed.apk
验证:打开 App 查看会员状态;若未生效,说明判断点不止一处(常见于"显示层"与"权限校验层"分别判断),需要在 smali*/ 中搜出所有返回该状态的位置逐个修改。
重要说明:这类修改只改变客户端本地显示与本地判断。服务端仍然知道你的真实身份,涉及服务端下发的权益(如云端内容、真实交易)不会因此获得。这也正是它属于"客户端逻辑安全"问题的原因——服务端绝不能信任客户端传来的状态。
十、实操案例二:移除界面广告位(视图修改)
目标:把界面上的广告横幅从视图中移除。
整体思路:布局查看 → 控件 id → 反查引用 → 改长宽/可见性。
步骤 1:用开发助手查看布局
- 打开目标 App,停在有广告的界面;
- 切换到布局查看模式(悬浮窗中的"布局"或"层级"功能);
- 点击广告区域,读取控件的
id与所属类,例如:
控件 id : bannerViewPager
类 : android.support.v4.view.ViewPager
父布局 : com.example.app.widget.BannerLayout
步骤 2:反查引用
# 一、先看布局文件里有没有直接定义
grep -rn "bannerViewPager" target_src/res/layout/
# 二、在 public.xml 中拿到 id 数值
grep -n "bannerViewPager" target_src/res/values/public.xml
# 三、在 smali 中反查(广告常由代码动态添加,布局文件里可能没有)
grep -rn "0x7f0f0xxx" target_src/smali*/
步骤 3:修改方案(三选一,由简到繁)
方案 A:直接改布局文件(最省事)
<!-- 修改前 -->
<com.example.app.widget.BannerViewPager
android:id="@+id/bannerViewPager"
android:layout_width="match_parent"
android:layout_height="100dp" />
<!-- 修改后:高度归零 + 隐藏 -->
<com.example.app.widget.BannerViewPager
android:id="@+id/bannerViewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone" />
方案 B:在 smali 中改可见性
在 setContentView 之后插入隐藏控件的代码:
# 找到控件
const v0, 0x7f0f0xxx
invoke-virtual {p0, v0}, Lcom/example/app/MainActivity;->findViewById(I)Landroid/view/View;
move-result-object v0
# 设置为 GONE(8 = View.GONE)
const/16 v1, 0x8
invoke-virtual {v0, v1}, Landroid/view/View;->setVisibility(I)V
方案 C:从代码中移除广告加载调用
搜索广告 SDK 的加载方法(如 loadAd、showAd、initBanner),找到后把调用整行注释或删除:
# 删除这一行即可(注意连同 move-result 一起删,否则会编译报错)
invoke-virtual {v0}, Lcom/example/ads/AdManager;->loadBanner()V
提醒:方案 C 最彻底,但广告 SDK 常与业务逻辑耦合;删掉后若出现
move-result悬挂、或后续代码依赖其返回值,会直接崩溃。建议先用方案 A/B 验证效果,再考虑方案 C。
步骤 4:回编译安装
同案例一步骤 5。安装后重进界面,广告位应已消失或占位为零。
十一、实操案例三:接口响应篡改(前端逻辑安全问题)
目标:不依赖任何本地代码,只改服务器返回的响应体,观察客户端行为变化。
这一类问题的本质:客户端把服务端返回的状态直接当作自己的状态,且不校验、不二次确认。它是典型的前端逻辑安全漏洞。
步骤 1:抓取接口响应
用移动端抓包工具(如小黄鸟一类的 Android 端代理抓包工具,或按前一篇文章的方法用 Burp + Frida)抓取 App 的用户信息接口,得到类似响应:
{
"code": 200,
"data": {
"id": 2220156,
"userId": "xxxxxxxx",
"phoneNum": "138****0000",
"username": null,
"avatarUrl": null,
"coverUrl": null,
"gender": 0,
"vipType": 256,
"vipPastDueTime": 0,
"vipLabelLevel": 3,
"vipLabel": "元老用户",
"createTime": 1693635001297,
"updateTime": 1693635001297,
"vitalityVipPastDueTime": 253392455349,
"accountSign": null,
"webBackground": null,
"unionId": null,
"age": 0,
"category": null,
"isVirtual": 0,
"qqUnionId": null,
"pcVipType": 256,
"pcVipPastDueTime": 253392455349,
"vitalityPcVipPastDueTime": 253392455349,
"token": "xxxxxxxxxxxxxxxx"
},
"error_msg": null
}
上述响应中的用户 ID、手机号、令牌均为脱敏占位符,实际抓取到的真实数据属于个人敏感信息,测试中应严格保护。
步骤 2:读懂字段含义
| 字段 | 含义 | 关键点 |
|---|---|---|
code |
业务状态码 | 200 表示成功 |
vipType |
会员类型 | 256 是某个具体的会员档位 |
vipPastDueTime |
会员过期时间戳 | 0 表示已过期 / 无会员 |
vipLabelLevel / vipLabel |
会员等级与标签 | 展示层用 |
vitalityVipPastDueTime |
活力值会员过期时间 | 253392455349 → 远未来,即"永久"标记 |
pcVipType / pcVipPastDueTime |
PC 端会员字段 | 同一套身份,多端字段 |
token |
身份令牌 | 绝对敏感,禁止外泄 |
读数据的关键洞察:
vipPastDueTime: 0(已过期)与vitalityVipPastDueTime: 253392455349(永久)同时存在,说明 VIP 权益被拆成了多个独立字段分别判断。改动一个字段可能只影响一部分功能——这解释了为什么"只改一个地方没效果"。- 时间戳单位要看清:
1693635001297是 13 位毫秒;253392455349是 12 位,按秒算是公元 1 万年以后,属于人为构造的"永不失效"值。
步骤 3:修改响应并重放
在抓包工具中:
- 右键该请求 → Send to Repeater(重放器);
- 把响应体中的
vipPastDueTime由0改为一个未来时间戳,或直接把vipType改成更高的档位; - 点击 Send,观察响应;
- 若需要在 App 中看到效果,则用工具的"修改响应"(Match and Replace / Break 响应)功能,让 App 实际收到被篡改的响应。
计算一个未来时间戳:
# Linux / macOS
date -d "+1 year" +%s000
# Windows PowerShell
[DateTimeOffset]::UtcNow.AddYears(1).ToUnixTimeMilliseconds()
步骤 4:结果解读与安全结论
如果修改后客户端立即表现为"会员生效" → 这是一个前端逻辑安全漏洞,说明:
- 客户端直接信任了服务端返回的会员状态;
- 更关键的是,服务端在设计上可能也缺少对状态的二次校验。
正确的修复方向:
- 服务端做权威判定:每一项需要会员权益的操作,服务端都必须基于数据库中的真实状态重新校验,不接受客户端的任何"状态声明"。
- 客户端不做安全决策:客户端只做展示,不做权限控制。
- 关键字段签名或加密:对响应体做完整性保护,防止中间人篡改。
- 多字段一致性校验:发现
vipType与vipPastDueTime逻辑矛盾(有类型却已过期)时应告警。
十二、工具速查表
| 工具 | 类别 | 典型用途 |
|---|---|---|
| Jadx-Gui | 反编译 | 图形化查看 Java 代码,全局搜索方法名/字符串 |
| ApkScan-PKID | 查壳 | 拖入 APK 判断是否加固、什么壳 |
| apktool | 反编译/回编译 | apktool d / apktool b,命令行核心 |
| MT 管理器 / NP 管理器 | 综合 | 手机上直接编辑 Arsc/Xml/Dex 并重签名 |
| 开发助手 / 开发者助手 | 信息获取 | 查看布局层级、控件 id、当前 Activity |
| 算法助手 | Hook | 针对常见加密/签名算法的 Hook 辅助 |
| XP / LSP 框架模块 | Hook | 系统级 Hook,配合各类模块使用 |
| Smali 语法查询手册 | 参考 | 指令含义速查 |
| keytool / zipalign / apksigner | 签名 | 生成密钥、对齐、签名、校验 |
| adb | 调试桥 | 安装、拉取、shell、查看 Activity 栈 |
十三、常见问题(FAQ)
Q1:反编译出来的 smali 目录是空的,或者代码完全看不懂?
说明 App 已加固。先脱壳(见 1.2 中的脱壳资料链接),拿到解密后的 DEX 再反编译。另一种可能是多 DEX——检查是否存在 smali_classes2 等目录。
Q2:改了代码,回编译报错 error: .locals 数量不足?
方法内使用的寄存器数量超过了声明的 .locals/.registers。把 .registers 调大 1~2 即可(宁多勿少)。
Q3:安装时报签名冲突?
先 adb uninstall <目标包名> 卸载原版,再安装改包。
Q4:安装后 App 闪退?
可能原因:① 删代码时连带删掉了必需的 move-result;② App 有签名校验(检测到签名变化即退出);③ 删掉了某段初始化逻辑。排查方式:adb logcat | grep "AndroidRuntime" 看崩溃栈。
Q5:改了返回 true,但功能还是不可用?
说明存在多处判断,或真正的限制在服务端 / Native 层。做法:全局搜索所有返回该状态的位置逐个改;若 smali 中找不到,说明逻辑在 .so 里,需要进入 Native 层分析。
Q6:怎么快速知道当前是哪个 Activity?
adb shell dumpsys activity activities | grep -i "mResumedActivity\|topResumedActivity"
命中的类名就是当前界面,再去 smali*/ 中搜索这个类名即可。
十四、安全与合规边界
本文所有内容仅适用于获得合法授权的测试场景——你自己的设备、你所在组织书面授权的目标、以及公开的测试靶场。
- 反编译、修改、重打包他人应用,即使不用于牟利,也可能违反著作权法与目标平台的服务条款;
- 修改本地会员状态、去除广告不会获得任何真实权益,修改服务端响应同样会受到服务端校验拦截——真正的安全价值在于发现"客户端不做安全决策"这一设计缺陷并向厂商报告;
- 抓取到的接口响应中可能包含他人手机号、令牌等个人信息,必须按最小必要原则处理,测试后按流程销毁;
- 相关的漏洞应当通过厂商的漏洞报送渠道或国家信息安全漏洞共享平台提交,而不是公开传播或用于牟利。
十五、参考链接
工具与项目
- HOOK 环境安装参考(Magisk & XP & LSP 框架):https://blog.csdn.net/danran550/article/details/132256027
- 脱壳相关资料:https://mp.weixin.qq.com/s/poQPuvaQPadQxRu_WGvy1A
资料索引
- 配套资料(录像、素材、工具包等)下载地址:https://docs.qq.com/doc/DQ3Z6RkNpaUtMcEFr
附:命令速查卡
# —— 查壳 / 打开 ——
# ApkScan-PKID 拖入 APK 判断壳类型
jadx-gui target.apk
# —— 反编译 ——
apktool d target.apk -o target_src
apktool d -r target.apk -o target_src # 跳过资源反编译
# —— 定位 ——
grep -rn "isVip" target_src/smali*/
grep -rn "vip" target_src/res/values/strings.xml
grep -n "bannerViewPager" target_src/res/values/public.xml
grep -rn "0x7f0f0xxx" target_src/smali*/
adb shell dumpsys activity activities | grep -i mResumedActivity
# —— 回编译 ——
apktool b target_src -o target_patched.apk
# —— 签名 ——
keytool -genkeypair -v -keystore my.keystore -alias mykey \
-keyalg RSA -keysize 2048 -validity 36500 \
-storepass 123456 -keypass 123456 \
-dname "CN=test, OU=test, O=test, L=test, ST=test, C=CN"
zipalign -v -p 4 target_patched.apk target_aligned.apk
apksigner sign --ks my.keystore --ks-pass pass:123456 --key-pass pass:123456 \
--out target_signed.apk target_aligned.apk
apksigner verify --verbose target_signed.apk
# —— 安装测试 ——
adb uninstall <目标包名>
adb install -r target_signed.apk
adb logcat | grep "AndroidRuntime"

浙公网安备 33010602011771号