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 修饰符 字段名:类型描述符。如果有初始值,就在后面加个等号。静态字段、实例字段都一样,看修饰符区分。

小技巧: 我在逆向一些混淆过的APK时,经常看到字段名被改成a、b、c这种。但类型描述符改不了。所以如果你看到一个字段类型是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,以此类推。

注意: 我曾经在分析一个加固壳的时候,发现它把寄存器数量声明得特别大(比如.registers 256),然后实际只用前面几个。这是反调试的常见手法——让你分析时觉得寄存器很多,混淆视听。遇到这种情况,直接看实际用到的寄存器就行。

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的知识结构串起来。我个人习惯在学新东西时先画个框架图,这样脑子里的脉络就清晰了。

Smali语法基础 1. 文件结构 .class 类声明 .field 字段声明 .method 方法声明 2. 寄存器 v命名法 (v0,v1...) p命名法 (p0,p1...) 3. 数据类型 基本类型 (I,J,F...) 引用类型 (Lxxx;) 4. Dalvik指令集 数据加载/存储 对象操作/方法调用 算术/跳转指令

4.6 实战:如何快速读懂一个Smali方法

最后,我分享一个我自己一直在用的“三步读Smali法”。

  1. 看签名: 先看方法名和参数类型,猜猜它大概干什么。比如onClick(Landroid/view/View;)V,那肯定是点击事件。
  2. 看寄存器数量: .registers N告诉你这个方法用了多少“临时变量”。N越大,逻辑可能越复杂。
  3. 找关键指令: 重点关注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