4、Smali语法基础:Dalvik指令集、寄存器与数据类型、Smali文件结构
好,咱们今天聊聊Smali。说实话,很多刚接触逆向的朋友一看到.smali文件就头大,觉得这玩意儿跟天书似的。我当年第一次反编译一个APK,打开一看满屏的".locals"、"v0"、"iput-object",说实话也懵了一阵。但后来我发现,这东西其实比Java字节码好懂多了——它就是把Dalvik虚拟机干的事儿,用人话(好吧,半人话)写出来了。
咱们这节课,就把它彻底掰开揉碎。你只要跟着我走一遍,保证以后看Smali就像看自家门牌号一样熟。
4.1 从Java到Smali:一个简单的例子
先看个最直观的例子。假设我们有这么一段Java代码:
public class Hello {
private String name;
public Hello(String name) {
this.name = name;
}
public String sayHello() {
return "Hello, " + this.name;
}
}
编译成Dex之后,用baksmali反编译出来,对应的Smali长这样:
.class public LHello;
.super Ljava/lang/Object;
.field private name:Ljava/lang/String;
.method public constructor <init>(Ljava/lang/String;)V
.registers 3
.param p1, "name" # Ljava/lang/String;
.prologue
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
iput-object p1, p0, LHello;->name:Ljava/lang/String;
return-void
.end method
.method public sayHello()Ljava/lang/String;
.registers 5
.prologue
new-instance v0, Ljava/lang/StringBuilder;
invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V
const-string v1, "Hello, "
invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
iget-object v1, p0, LHello;->name:Ljava/lang/String;
invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
move-result-object v0
return-object v0
.end method
嗯,看着是不是有点眼熟了?其实结构非常清晰:.class声明类,.field声明字段,.method声明方法。说白了,它就是Java类的另一种“长相”。
4.2 Smali文件结构:.class、.field、.method
咱们拆开来看。一个完整的Smali文件,通常由三大部分组成。
4.2.1 .class 声明
文件的第一行,就是类的声明:
.class public LHello;
.super Ljava/lang/Object;
这里LHello;是类的完整描述符。注意那个L和分号——这是Dalvik的“类型描述符”写法。我刚开始看的时候老觉得这分号多余,后来才明白,这是为了明确标识一个对象类型。你想想看,如果没有分号,解析器怎么知道"Ljava/lang/String"到哪儿结束?
访问修饰符也直接写在.class后面:public、private、final、abstract等等。接口的话会写成.class public interface LIFoo;。
4.2.2 .field 字段声明
字段声明也很直白:
.field private name:Ljava/lang/String;
.field public static final TAG:Ljava/lang/String; = "HelloApp"
格式就是:.field 修饰符 字段名:类型描述符。如果有初始值,就在后面加个等号。静态字段、实例字段都一样,看修饰符区分。
Landroid/content/Context;,那八成是持有了Context引用——这对分析Activity劫持很有帮助。
4.2.3 .method 方法声明
方法是最复杂的部分。一个方法块以.method开始,以.end method结束。里面包含指令、寄存器声明、异常处理等。
.method public sayHello()Ljava/lang/String;
.registers 5
.prologue
... 指令序列 ...
return-object v0
.end method
这里.registers 5表示这个方法用了5个寄存器。.prologue是方法起始标记,相当于Java字节码里的.prologue。方法签名写在方法名后面,括号里是参数类型,括号外是返回类型。
4.3 寄存器与数据类型
好,接下来是重头戏——寄存器。Dalvik虚拟机是基于寄存器的架构,这一点和基于栈的JVM完全不同。说白了,JVM的指令是“压栈、弹栈、操作栈顶”,而Dalvik是“把值放到v0,把v0和v1相加,结果存到v2”。
4.3.1 寄存器的两种命名方式
Smali里寄存器有两种写法:
- v命名法:
v0, v1, v2, ...局部寄存器,从0开始编号。 - p命名法:
p0, p1, p2, ...参数寄存器。注意!p0在非静态方法里就是this引用。
举个例子,一个非静态方法有3个参数(包括this),那么:
- p0 = this
- p1 = 第一个参数
- p2 = 第二个参数
而局部变量从v0开始。如果方法声明了.registers 5,那v0-v4都是可用的,其中p0-p2占用了v0-v2的位置。嗯,这里有点绕,我建议你记一个原则:p寄存器是v寄存器的别名。p0就是v0,p1就是v1,以此类推。
4.3.2 数据类型描述符
Dalvik的类型描述符和JVM基本一致,但有一些细节不同。我整理了一张表:
| Smali写法 | Java类型 | 说明 |
|---|---|---|
| V | void | 无返回值 |
| Z | boolean | 布尔型 |
| B | byte | 字节型 |
| S | short | 短整型 |
| C | char | 字符型 |
| I | int | 整型 |
| J | long | 长整型(64位,占2个寄存器) |
| F | float | 单精度浮点 |
| D | double | 双精度浮点(64位,占2个寄存器) |
| Lxxx; | 对象类型 | 如Ljava/lang/String; |
| [xxx | 数组类型 | 如[I表示int[] |
这里有个坑:long和double占两个寄存器。比如你用v0存一个long,那v1就被它占了,你不能再用v1存别的。我见过有人逆向时没注意这个,结果分析出来的逻辑全是错的。
4.4 Dalvik指令集速览
Dalvik指令集大概有200多条,但常用的也就二三十条。咱们挑最核心的说。
4.4.1 数据加载与存储
const/4 v0, 0x1 # 把常量1存入v0
const-string v1, "hello" # 把字符串"hello"存入v1
const-wide v2, 1000L # 把长整型1000存入v2(实际占v2和v3)
move v0, v1 # 把v1的值复制到v0
move-object v0, v1 # 复制对象引用
move-result v0 # 把上一个invoke指令的返回值存入v0
move-result-object v0 # 同上,但用于对象
4.4.2 对象操作
new-instance v0, Ljava/lang/StringBuilder; # 创建对象
iput-object v1, p0, LHello;->name:Ljava/lang/String; # 设置实例字段
iget-object v0, p0, LHello;->name:Ljava/lang/String; # 获取实例字段
sput-object v0, LHello;->TAG:Ljava/lang/String; # 设置静态字段
sget-object v0, LHello;->TAG:Ljava/lang/String; # 获取静态字段
4.4.3 方法调用
invoke-direct {p0}, Ljava/lang/Object;-><init>()V # 调用私有方法/构造方法
invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder; # 调用虚方法
invoke-static {v0}, Ljava/lang/System;->exit(I)V # 调用静态方法
invoke-interface {v0}, Ljava/util/List;->size()I # 调用接口方法
注意花括号里的寄存器列表——第一个是调用目标对象(静态方法除外),后面依次是参数。
4.4.4 算术与跳转
add-int v0, v1, v2 # v0 = v1 + v2
sub-int v0, v1, v2 # v0 = v1 - v2
mul-int v0, v1, v2 # v0 = v1 * v2
div-int v0, v1, v2 # v0 = v1 / v2
if-eq v0, v1, :cond_label # 如果v0 == v1,跳转
if-ne v0, v1, :cond_label # 如果v0 != v1,跳转
if-gt v0, v1, :cond_label # 如果v0 > v1,跳转
goto :label # 无条件跳转
4.5 知识体系总览
说了这么多,咱们用一张图把整个Smali的知识结构串起来。我个人习惯在学新东西时先画个框架图,这样脑子里的脉络就清晰了。
4.6 实战:如何快速读懂一个Smali方法
最后,我分享一个我自己一直在用的“三步读Smali法”。
- 看签名: 先看方法名和参数类型,猜猜它大概干什么。比如
onClick(Landroid/view/View;)V,那肯定是点击事件。 - 看寄存器数量:
.registers N告诉你这个方法用了多少“临时变量”。N越大,逻辑可能越复杂。 - 找关键指令: 重点关注
invoke-xxx(调了哪些方法)、if-xxx(条件分支)、return-xxx(返回值)。这三类指令基本勾勒出了方法的骨架。
举个例子: 如果你看到一个方法里调用了invoke-static {...}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;)I,那八成是在打印错误日志。这在分析崩溃堆栈时特别有用——我曾经靠这个在混淆代码里定位到了一个隐藏的try-catch块。
好了,Smali的基础语法就讲到这里。说实话,这些东西光看是记不住的,你得动手。找个APK反编译出来,随便打开几个smali文件对照着看,不出三天你就能熟练了。记住,逆向工程这行,手熟而已。
公众号:蓝海资料掘金营,微信deep3321