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

  1. 修改APK运行逻辑: 通过修改Smali代码,再重新编译打包成新的APK,可实现修改APP的运行逻辑,甚至逆向破解。
  2. 动态调试APK: 通常静态分析APK是不够的,如果需要彻底分析APK的执行逻辑,需要通过动态调试(常使用smalidea)来进行。
  3. 加强理解能力: 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
  1. 其中修饰符就是public、private、protected、static、final等,和Java中的差不多,另外对于类还有interfaceenum来表示这个类是一个接口或者枚举类
  2. Smali中的类名都是L包名路径/类名;,例如Android中的TextView类,它的包名是android.widget,如果你要在Smali中表示这个类,就要写成Landroid/widget/TextView;
  3. 源文件名就是编译这个类的java文件名,如Main.java,仅用于debug,因此删了也没影响。
  4. 接口可以有0个或者多个,表示这个类实现了哪些接口,例如:
.implements Landroid/view/View$OnClickListener;
  1. 注解可以有0个或者多个,注解的话就是Java代码中@XXX之类的代码,例如比较常见的@Override@Nullable@NonNull等,其实不了解注解也没关系,毕竟一般情况也用不上,只要在Smali中看到annotation时知道它是注解就行了;
  2. 字段可以有0个或者多个,其语法为.field 描述符 字段名:字段类型,例如Java代码中定义了text字段:
public String text;

其对应的Smali代码为:

.field public text:Ljava/lang/String;

当一个字段是staticfinal(即静态常量)且它的类型是基本类型时,可以直接为它赋值

.field public static final ID:I = 0x7f0a0001

如果定义的字段包含注解,那么语法是:

.field XXXXX
    {注解列表}
.end field
  1. 方法可以有0个或者多个,Smali中定义方法的语法是:
.method 描述符 方法名(参数类型)返回类型
    方法代码...
.end method

其中参数类型可以有0个或多个返回类型必须是一个,当要表达多个参数类型时,只需简单地将它们连接到一起,例如 (int, int, String) 表示为 (IILjava/lang/String;)

方法代码是最复杂的部分,将在后面的文章中慢慢介绍

Smali变量类型

在Java中类型分为基本类型引用类型
基本类型共有9个,它们在Smali中的对应关系是:

JavaSmali
voidV
booleanZ
byteB
shortS
charC
intI
longJ
floatF
doubleD

其中除了boolean对应Zlong对应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代码,你肯定会注意到有很多v0v1v2p0p1p2之类的标识符,这些都代表了寄存器。
那么寄存器是什么?你可以把它认为是变量,或者是暂时存放东西的地方。

例如:
有一个静态方法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

另外寄存器v0v1v2后面的数字也不是随便写的,需要在方法的开头用.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
v2p0
v3p1
v4p2

当调用abc(11, 22, 33)时,p0中的值初始化为11p1中的值初始化为22p2中的值初始化为33v0和v1不会被初始化

普通寄存器对应参数寄存器初始化
v0
v1
v2p011
v3p122
v4p233

当把寄存器数量改成6(.registers 6),寄存器就会变成下表所示:

普通寄存器对应参数寄存器初始化
v0
v1
v2
v3p011
v4p122
v5p233

思考一下,如果不使用参数寄存器,代码中全部用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()中则分别用的是p1p2

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/rangeinvoke-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/