Smali 是 Android 系统中 Dalvik 虚拟机与 ART 运行时所执行字节码的文本化表示。一个 APK 安装包中的 classes.dex 文件经过反编译后,可以得到大量以 .smali 为后缀的文件,它们用接近汇编的语法记录了每个类的字段、方法以及方法内的寄存器操作。对于没有 Java 源码的 APK,修改 Smali 是调整应用行为最直接的途径。掌握 Smali 语法之后,就可以通过 apktool 这类工具完成从反编译到重打包、再到签名的完整闭环。

初看 Smali 代码会有种回到汇编时代的错觉:每个方法都显式声明寄存器数量,每次数据搬运和运算都要指定寄存器编号。但与 x86 或 ARM 汇编相比,Smali 抽象层级更高,面向对象特征依然保留,比如类继承、接口实现、虚方法调用等都用关键字表达清楚。理解这些基础概念,后续修改字节码时才能快速定位目标方法并做出正确改动。
一、Smali 核心语法与寄存器模型
每个 Smali 文件通常对应一个 Java 类,文件开头会声明类名、父类以及实现的接口。例如 .class public Lcom/example/Test; 表示一个名为 com.example.Test 的公开类,.super Ljava/lang/Object; 表示它继承自 Object。类中的方法和字段与 Java 侧一一对应,构造器写作 <init>,静态初始化块写作 <clinit>。这些尖括号在 Smali 源码中属于方法名的一部分,阅读时不要和普通标签混淆。
方法内部依赖寄存器保存数据和中间结果。寄存器分为参数寄存器和局部寄存器,参数寄存器以 p 开头,局部寄存器以 v 开头。如果方法同时使用 .registers 和 .locals,参数寄存器数量会从寄存器总量中划分出来。例如一个非静态方法有 3 个参数且使用 this,则 p0 代表 this 对象,p1 到 p3 依次对应传入参数。局部变量则从 v0 开始编号。
Smali 的类型描述符是另一项基础功。基本类型用单个字母表示:V 表示 void,Z 表示 boolean,I 表示 int,J 表示 long,F 表示 float,D 表示 double。引用类型用 L 开头并以分号结尾,例如 Ljava/lang/String; 表示字符串类型,数组则用左方括号表示维度,例如 [I 表示 int 数组。方法返回值和参数列表也有专门描述格式,例如 (II)I 表示接收两个 int 并返回 int。
.class public Lcom/example/Test;
.super Ljava/lang/Object;
# direct methods
.method public constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public add(II)I
.registers 4
.param p1, "a" # I
.param p2, "b" # I
add-int v0, p1, p2
return v0
.end method
上面的示例展示了构造器和普通方法的典型结构。其中 invoke-direct 用于调用构造器或私有方法,invoke-virtual 用于调用可被覆盖的虚方法,invoke-static 用于调用静态方法,invoke-super 用于调用父类实现。调用时需要用大括号列出参数寄存器,冒号后写明目标方法描述符。修改逻辑时最常见的操作就是替换调用目标,或直接改函数返回值。
二、从 APK 反编译到定位关键 Smali
拿到一个 APK 后,修改 Smali 的第一步通常是用 apktool 反编译。apktool 会把 classes.dex 转成 smali 目录,同时将 AndroidManifest.xml 和 resources.arsc 解码为可读文本,这样既能改代码,也能改资源和清单配置。基本命令如下:
apktool d demo.apk -o demo_src apktool b demo_src -o rebuilt.apk
反编译完成后,smali 目录的层级会和 Java 包名一一对应,例如 com/example/demo/MainActivity.smali。定位关键代码时,优先从字符串、Toast 提示、日志输出或者资源 ID 入手。可以在 smali 目录中执行 grep 搜索可疑文案,再回溯到方法调用点。某些经过混淆的 APK 中类名会变成 a、b、c 这种短名称,方法名也可能无法直接读懂,但只要抓住关键判断逻辑和返回值,仍然可以做到精确修改。
如果目标方法逻辑简单,比如返回一个布尔值来标记是否拥有 VIP 权限,只需要把 const/4 v0, 0x0 改成 const/4 v0, 0x1,整个应用的行为就会发生变化。复杂一点的场景涉及条件跳转,例如将 if-eqz v0, :cond_fail 改为 if-nez v0, :cond_fail,或者直接删除跳转指令,让流程无视判断结果继续向下执行。修改前建议先对照 smali 语法和寄存器含义,确认 v0 在跳转前保存的值就是判断依据。
三、重打包与签名流程
代码改完后,需要将修改后的目录重新打包为 APK。apktool 的 b 命令可以完成这一步骤,它会重新生成 classes.dex,并按照目录中的资源配置还原 APK 包结构。刚生成的 rebuilt.apk 通常没有签名,无法直接安装到安卓设备上。传统签名方式使用 jarsigner,但当前更推荐使用 apksigner,因为后者对 v2 和 v3 签名的支持更完整,也符合新版 Android 的校验机制。
zipalign -f 4 rebuilt.apk aligned.apk apksigner sign --ks demo.keystore --out final.apk aligned.apk
重打包过程中还有一个容易被忽略的步骤:zipalign。它是 Android SDK 提供的对齐工具,能够让 resources.arsc 等文件按 4 字节边界对齐,减少运行时内存映射开销。如果直接用 apksigner 签名未对齐的包,虽然安装可能成功,但在部分设备上会出现性能或兼容性问题。建议顺序是先 zipalign,再进行 apksigner 签名。签名完成后,可以通过 adb install -r final.apk 覆盖安装测试,或者先卸载原包再安装以避免签名冲突。
有些应用在启动时会校验自身签名,发现重打包后直接崩溃或者提示盗版。这类保护通常会读取签名摘要并与预设值比对。绕过思路包括定位校验方法并把结果强制设为合法值,或者搜索 getPackageInfo、PackageManager 等调用点进行拦截。但要注意,签名校验可能不止一处,混淆后的逻辑也会增加定位成本。对普通学习或渗透测试场景,优先找那些直接控制高风险动作的方法即可。
四、实战修改与常见坑
以一个常见的 VIP 功能判定为例。某个 APK 中有一个静态方法 isPro()Z,多处按钮刷新和页面跳转都会查询它。反编译后发现原本代码返回常量 0。我们只需要把 const/4 v0, 0x0 修改为 const/4 v0, 0x1,再重打包签名,就能让应用认为当前用户已开通高级功能。完整 Smali 修改如下:
.method public static isPro()Z
.registers 2
const/4 v0, 0x1
return v0
.end method
修改 Smali 不能只关注单条指令,还要注意寄存器数量是否足够。如果新增了局部变量却没有增加 .registers 或 .locals 的数值,运行时会因为寄存器越界而抛异常。另外,long 和 double 类型占用两个连续的寄存器,例如 v0 和 v1,后面的寄存器编号需要顺延。方法调用后的返回值也会覆盖对应寄存器,因此在插入新的调用时必须检查周围寄存器是否存活。
另一个高频问题是 DEX 方法引用数或字段引用数超限。老版本 DEX 格式中方法引用数量有 64K 限制,虽然现在很多应用已经启用 MultiDex,但重打包工具如果处理不当,仍可能生成引用表错乱的 dex。遇到这种情况可以先检查 APK 是否包含多个 classes.dex,并使用 Apktool 较新版本重新构建。只要保持原 APK 的资源结构和 multidex 配置不被破坏,大部分普通应用都能正常完成回编译。
最后强调边界:Smali 修改和 APK 重打包是 Android 安全测试、逆向学习和部分软件兼容性调试中的常用手段,但未经授权修改他人应用并分发属于侵权行为。学习相关技术时,建议使用自己编写的测试 APK、开源应用或已获得授权的商业包,避免触碰法律风险。