Android基础之Smali语法
Android基础之Smali语法
前言
众所周知,java是一种面向对象的编程语言,一条狗,一部手机,一片树叶都可以是对象,它就像是对现实世界的映射。
Android代码一般是用java编写的,执行java程序一般需要用到java虚拟机
,在Android平台上也不例外,但是出于性能上的考虑,并没有使用标准的JVM,而是使用专门的Android虚拟机
(使用 C 或 C++ 代码实现,5.0以下为Dalvik
,5.0以上为ART
)。
Android虚拟机的可执行文件并不是普通的class文件,而是重新整合打包后生成的dex文件。dex文件反编译
之后就是Smali代码,Smali实质上就是java字节码,所以说,Smali语言是Android虚拟机的反汇编语言
。
为什么要了解Smali
- 修改APK运行逻辑: 通过修改Smali代码,再重新编译打包成新的APK,可实现修改APP的运行逻辑,甚至逆向破解。
- 动态调试APK: 通常静态分析APK是不够的,如果需要彻底分析APK的执行逻辑,需要通过动态调试(常使用smalidea)来进行。
- 加强理解能力: Smali是介于Android虚拟机与java之间的语言,了解它有助于我们更好的理解代码的运行过程,从而写出性能更优的程序。
Smali简单入门
举个例子,最简单的java代码:
System.out.println("Hello World");
把它写成Smali大概就是:
# 获取System类中的out字段,存到v0中
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
# 把"Hello World"存到v1中
const-string v1, "Hello World"
# 调用虚方法println,传入参数v0, v1
# 相当于v0.println(v1)
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
可以看到一句简单的Java代码会被分解成几句较为复杂的Smali代码,你可能会问为什么要搞这么复杂?因为Java是给人看的,Smali是给虚拟机执行的。
就像你叫一个人走过来,正常人他可以直接走过来,但如果是机器人,你就得告诉它先跨左脚,角度多少,然后右脚抬起后脚跟,重心往前…最后走完多少步停下,如果遇到转弯或者障碍的话就更复杂了。
没错,教机器做事就是得这么麻烦,所以同理Smali也是在和机器对话,因此比较麻烦。
Smali基本结构
你可以在一个xxx.java中定义多个类
(包括匿名内部类
),但一个smali文件只能定义一个类
,简单来看,其一般格式是:
.class 修饰符 类名
.super 父类的类名
.source 源文件名
# 接口
.implements 接口类名
# 注解
.annotation xxxxxxx
xxxxxx
.end annotation
# 字段
.field 描述符 字段名:字段类型
# 方法
.method 描述符 方法名(参数类型)返回类型
方法代码...
.end method
- 其中
修饰符
就是public、private、protected、static、final等,和Java中的差不多,另外对于类还有interface
和enum
来表示这个类是一个接口
或者枚举类
。 - Smali中的
类名
都是L包名路径/类名;
,例如Android中的TextView类,它的包名是android.widget,如果你要在Smali中表示这个类,就要写成Landroid/widget/TextView;
。 源文件名
就是编译这个类的java文件名,如Main.java
,仅用于debug,因此删了也没影响。接口
可以有0个或者多个,表示这个类实现了哪些接口,例如:
.implements Landroid/view/View$OnClickListener;
注解
可以有0个或者多个,注解的话就是Java代码中@XXX之类的代码,例如比较常见的@Override
、@Nullable
、@NonNull
等,其实不了解注解也没关系,毕竟一般情况也用不上,只要在Smali中看到annotation
时知道它是注解就行了;字段
可以有0个或者多个,其语法为.field 描述符 字段名:字段类型
,例如Java代码中定义了text字段:
public String text;
其对应的Smali代码为:
.field public text:Ljava/lang/String;
当一个字段是static
和final
(即静态常量)且它的类型是基本类型
时,可以直接为它赋值
:
.field public static final ID:I = 0x7f0a0001
如果定义的字段包含注解
,那么语法是:
.field XXXXX
{注解列表}
.end field
方法
可以有0个或者多个,Smali中定义方法的语法是:
.method 描述符 方法名(参数类型)返回类型
方法代码...
.end method
其中参数类型
可以有0个或多个
,返回类型
必须是一个
,当要表达多个参数类型时,只需简单地将它们连接到一起,例如 (int, int, String)
表示为 (IILjava/lang/String;)
方法代码是最复杂的部分,将在后面的文章中慢慢介绍
Smali变量类型
在Java中类型分为基本类型
和引用类型
基本类型
共有9个,它们在Smali中的对应关系是:
Java | Smali |
---|---|
void | V |
boolean | Z |
byte | B |
short | S |
char | C |
int | I |
long | J |
float | F |
double | D |
其中除了
boolean对应Z
,long对应J
,其它都是对应首字母大写
,还是很好记的
而引用类型
,在Smali中都是用L包名路径/类名;
表示,例如Android中的TextView类,它的包名是android.widget,如果你要在Smali中表示这个类,就要写成Landroid/widget/TextView;
Smali中通过在类型前面加[
来表示该类型的数组,例如[I
表示int[]
,[Ljava/lang/String;
表示String[]
,如果要表示多维数组,只需要增加[的数量
,例如[[I
表示二维数组int[][]
调用方法
Smali中必须以非常详细的形式指定要调用的方法,包括类名、方法名、参数类型和返回类型,其具体形式是:类名->方法名(参数类型)返回类型
例如:
System.out.println("Hello world");
其中out是System的一个静态字段
,它的类型
是PrintStream,println是PrintStream中的一个方法
,其返回类型
为void,因此其调用方法为:
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
引用字段
和调用方法类似,在引用一个字段时,同样需要用非常详细的形式指定字段,其具体形式是:类名->字段名:字段类名
例如:
System.out.println("Hello world");
在调用println方法前需要现将System类的字段out放到寄存器v0中,也就是下面这句代码:
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
后面那部分正是字段out的完整表达形式
寄存器
普通寄存器
如果你有看过一些Smali代码,你肯定会注意到有很多v0
、v1
、v2
、p0
、p1
、p2
之类的标识符,这些都代表了寄存器。
那么寄存器是什么?你可以把它认为是变量
,或者是暂时存放东西
的地方。
例如:
有一个静态方法static void abc(String s)
,如果你要在Java方法中调用这个方法,直接输入abc("Hello");
就行了。
而在Smali中,你不能直接把字符串参数传递给方法,你需要一个寄存器(比如v0
),先把"Hello"
放到v0
中,然后再调用abc方法
,并告诉它你需要的参数在v0
里面:
# 定义一个字符串常量"Hello"放到v0中
const-string v0, "Hello"
# 调用abc方法,需要的参数放在v0中
invoke-static {v0}, LXX;->abc(Ljava/lang/String;)V
另外寄存器v0
、v1
、v2
后面的数字也不是随便写的,需要在方法的开头用.registers N
来指定寄存器的数量
,然后才可以使用寄存器v0到v(N-1)
。
参数寄存器
上面说的都是普通寄存器vN
,另外Smali还特意定义了一种参数寄存器pN
,用于存放这个方法传入的参数的值。
如果一个方法有n个寄存器,有m个参数,那么n必须大于等于m
,并且n个寄存器的后面m个是参数寄存器。
举个例子:
某个静态方法static void abc(int, int, int)
,它一共有3个参数,如果它一共有5个寄存器(通过.registers N
定义,N不小于3
):
普通寄存器 | 对应参数寄存器 |
---|---|
v0 | |
v1 | |
v2 | p0 |
v3 | p1 |
v4 | p2 |
当调用abc(11, 22, 33)
时,p0
中的值初始化为11
,p1
中的值初始化为22
,p2
中的值初始化为33
,v0和v1不会被初始化
:
普通寄存器 | 对应参数寄存器 | 初始化 |
---|---|---|
v0 | ||
v1 | ||
v2 | p0 | 11 |
v3 | p1 | 22 |
v4 | p2 | 33 |
当把寄存器数量改成6(.registers 6
),寄存器就会变成下表所示:
普通寄存器 | 对应参数寄存器 | 初始化 |
---|---|---|
v0 | ||
v1 | ||
v2 | ||
v3 | p0 | 11 |
v4 | p1 | 22 |
v5 | p2 | 33 |
思考一下,如果不使用参数寄存器,代码中全部用vN
,那么改变寄存器数量后,你还得改多少代码?
隐藏参数
对于非静态方法
,它的参数寄存器数量比实际参数多了一个
,p0
会固定用于表示当前类实例(Java中的this
),从p1开始才是真正的参数,我们可以通过Java2Smali
工具来验证一下,Java代码如下:
public class Main{
static void test1(String s, int i){
System.out.println(s);
System.out.println(i);
}
void test2(String s, int i){
System.out.println(s);
System.out.println(i);
}
}
test1()
和test2()
的唯一区别就是一个是静态
一个是非静态
test1()
的Smali代码如下:
.method static test1(Ljava/lang/String;I)V
.registers 3
.prologue
.line 4
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p0}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 5
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p1}, Ljava/io/PrintStream;->println(I)V
.line 6
return-void
.end method
test2()
的Smali代码如下:
.method test2(Ljava/lang/String;I)V
.registers 4
.prologue
.line 9
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 10
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p2}, Ljava/io/PrintStream;->println(I)V
.line 11
return-void
.end method
两个方法都是依次打印出两个参数,test1()
中打印第一个参数用的是p0
,打印第二个参数用的是p1
,对照之下test2()
中则分别用的是p1
和p2
那test2()
中的p0
真的代表this
吗?我们也可以修改代码验证下:
public class Main{
static void test1(String s, int i){
System.out.println(s);
System.out.println(i);
}
void test2(String s, int i){
System.out.println(this);
}
}
test2()
的Smali代码如下:
.method test2(Ljava/lang/String;I)V
.registers 4
.prologue
.line 9
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p0}, Ljava/io/PrintStream;->println(Ljava/lang/Obiect;)V
.line 10
return-void
.end method
由此便证实了,在Smali中对于非静态方法
,会使用p0
指代this
(即当前对象
),p1
为方法的第一个参数
而在静态方法
中,p0
为方法的第一个参数
调用方法的指令
Smali语法中调用方法的指令一共有5条,分别是:
指令名称 | 含义 |
---|---|
invoke-virtual | 调用虚方法 |
invoke-direct | 直接调用方法 |
invoke-static | 调用静态方法 |
invoke-super | 调用父类方法 |
invoke-interface | 调用接口方法 |
使用语法是:invoke-xxxxxx {参数列表}, 类名->方法名(参数类型)返回类型
所以当在Smali代码中看到invoke开头
的指令,就可以直接确定这句代码用于调用某个方法
,至于invoke-后面跟着的单词
,取决于它要调用的方法的类型
调用虚方法
虚方法其实是Java多态
中的一个概念,大家应该知道Java中子类可以重写父类中可被继承的非final方法
,调用这些方法时,都需要使用invoke-virtual
指令,才能实现多态的特性,例如下面代码:
Object obj = "123";
obj.equals("456");
这边调用equals方法的Smali代码为:
invoke-virtual {v0, v1}, Ljava/lang/Object;->equals(Ljava/lang/Object;)Z
表面上看是调用Object的equals
方法,但是由于obj实际上是字符串“123”,而字符串类String中重写了equals方法
,所以虚拟机最后调用的是String的equals
方法
直接调用方法
由于调用虚方法时,虚拟机需要先查找该方法是否被重写,而对于那些无法被重写的方法,查找显得是在浪费时间,所以使用invoke-direct
指令来提高效率
,其通常用于final方法
、private方法
、构造方法
调用静态方法
这个没什么好说的,调用static方法
时,就使用invoke-static
调用父类方法
在子类中,如果它已经重写
了父类的XX方法,而又想调用
父类的XX方法时,可通过super.XX()
来调用,其对于的指令就是invoke-super
调用接口方法
这个很好理解,invoke-xxxxxx {参数列表}, 类名->方法名(参数类型)返回类型
,如果类名对应的类是个接口
,那么xxxxxx就得写interface
补充
上面的5条指令都有对应的range扩展指令
,也就是:invoke-virtual/range
、invoke-direct/range
等
使用语法是:invoke-xxxxxx/range {vN...vM}, 类名->方法名(参数类型)返回类型
,其中N小于M
其等价于:invoke-xxxxxx{vN, vN+1,vN+2, ..., vM-2, vM-1, vM}, 类名->方法名(参数类型)返回类型
,其中N小于M
一般只会对拥有很多个参数
的方法使用range指令,来减少
生成的代码体积
以及提高运行效率
更多语法
以上只是初步的介绍了Smali基础语法,然而实际的Smali中还有许多其它的指令,例如空指令
、数据定义指令
、数据移动指令
、数据转换指令
、数学运算指令
、数组操作指令
、实例操作指令
、字段操作指令
、比较指令
、跳转指令
、锁指令
、异常指令
、返回指令
等等。
当然,我们可能平时用Smali的时候并不多,因此粗略的了解一下便可以了,更多语法可参考下面的文档:
https://ctf-wiki.org/android/basic_operating_mechanism/java_layer/smali/smali/