Android 逆向筆記 —— 說說 Dalvik 及其指令集

在進入正題之前,推薦閱讀一下之前的兩篇文章。第一篇是我的一篇譯文 —— 譯文找不到了,就放一下原文吧。

Closer Look At Android Runtime: DVM vs ART

上面這篇文章簡單比較了 Dalvik 和 Art 。其中的一些細節在我的另一篇文章 說說方舟編譯器 中也有所提及,大家可以大致瀏覽一下。

然後再推薦一篇 Android逆向筆記 —— DEX 文件格式解析,在最後解析 DexCode 部分時,詳細的逐字節的解析了一段 Dalvik 字節碼。大家可以挑這一段閱讀一下,對 Dalvik 字節碼有一個大概的認識。

下面就正式來進入 Dalvik 的世界。

Dalvik 虛擬機

Dalvik 是早期 Android 版本中用於運行安卓應用的虛擬機,由 Dan Bornstein 編寫的,名字來源於他的祖先曾經居住過名叫 Dalvík 的小漁村,村子位於冰島。當年也有一部分業內人士認爲 Dalvik 是 Google 爲了避免與 Oracle 的訴訟而誕生的產物。Dalvik 是基於 Apache License 2.0 發佈的。Google 說 Dalvik 是一個清潔室(clean room)的實現,而不是一個在標準 Java 運行環境的改進,這意味着它不繼承標準版本的或開源的 Java 運行環境的版權許可限制。關於這一點,Oracle 和一些專家還在討論中。

Dalvik 是解釋執行加上 JIT,每次app運行的時候,它動態的將一部分 Dalvik 字節碼
解釋爲機器碼。隨着 App 的運行,更多的字節碼被編譯和緩存。因爲 JIT 只編譯了一部分代碼,它具有更小的內存佔用和更少的設備物理空間佔用。但是,邊解釋邊執行,效率低下,這也是後來 Dalvik 遭到拋棄的原因。

從 Android 4.4 開始,Google 開始引入了全新的虛擬機 ART(Android Runtime)。ART 是基於 AOT 編譯的,由於安裝應用耗時過程,後期高版本的 Android 系統加入了加強版的 JIT 編譯。Dalvik 在 Android 5.0 中正式被刪除,ART 完成上位。那麼現在來學習 Dalvik 還有必要嗎?其實 ART 是向下兼容的,ART 和 Dalvik 是運行 Dex 字節碼的兼容運行時,因此針對 Dalvik 開發的應用也能在 ART 環境中運作。不過,Dalvik 採用的一些技術並不適用於 ART。因此,Dalvik 虛擬機的部分特性以及 Dalvik 字節碼指令其實和 ART 都是相通的。

Dalvik 和 JVM

Dalvik 和 JVM 並不兼容,甚至可以說完全是兩套機制。下面來說幾點它們之間的區別。

  1. 運行的字節碼不同

我們都知道 JVM(Java 虛擬機)識別的是 Class 文件,我之前寫過一篇 Class 文件格式詳解,詳細介紹了 Class 文件的二進制結構。JVM 運行的是 Java 字節碼,而 Dalvik 運行的是 Dalvik 字節碼。Dalvik 不識別單個的 Class 文件,而是將所有 Class 文件打包成 DEX 文件格式,通過解釋 DEX 文件來執行字節碼。

這樣帶來的直接好處就是 Dalvik 的可執行文件的體積更小。如果你瞭解 Class 文件格式的話,你會知道每個 Class 文件都有單獨的字符串常量池。如果不同的 Class 文件中有相同的字符串,那麼就存在重複存儲的情況。同樣的,如果一個類引用了其他類中的方法,相應的方法簽名也會被複制到該類文件中。這樣就會有很多不必要的冗餘信息,既浪費內存也影響執行效率。

那麼 DEX 文件是如何解決這個問題的呢?對 DEX 文件結構不瞭解的話,可以閱讀我的另一篇文章 Android逆向筆記 —— DEX 文件格式解析。DEX 文件提供了一個統一的共享的常量池,供所有類文件使用,這樣就避免了冗餘信心,減小了文件體積,提高了解析效率。

  1. 虛擬機架構不同

JVM 是基於棧架構的。當程序運行時,Java 虛擬機會頻繁的對棧進行讀寫數據的操作。在這個過程中,不僅會多次進行指令分派和內存訪問,而且會耗費大量的 CPU 時間,因此,對於資源有限的手機設備來說,是一筆很大的開銷。每調用一個方法,就會分配一個新的棧幀並壓入棧。每從一個方法返回,就彈出相應的棧幀。

Dalvik 是基於寄存器架構的,數據的訪問直接在寄存器之間傳遞。

基於堆棧的機器與基於寄存器的機器誰更有優勢一直是個爭論不休的話題。
一般來說,基於堆棧的機器必須使用指令才能從堆棧上的加載和操作數據,因此,相對基於寄存器的機器,它們需要更多的指令才能實現相同的性能。但是基於寄存器機器上的指令必須經過編碼,因此,它們的指令往往更大。

上面這段來自百度。的確,Java 虛擬機的操作碼都是單字節的,其指令字總操作碼個數不超過 256 條。而 Dalvik 指令則長的多的多,數量也多的多。要執行相同的操作,JVM 需要短但是更多的指令,Dalvik 需要長但是更少的指令。Dalvik 的思路是用更長但是更少的指令來減少指令分派和內存訪問,以此提高運行效率。

Dalvik 指令

指令格式

關於 Dalvik 指令格式,官網 中也有相關介紹。只是官網的介紹實在過於晦澀,看了很多遍才理解。我這裏還是從實際的 Dalvik 指令來進行分析。把之前分析過的 main() 方法直接拿過來:

public static void main(String[] args) {
    System.out.println(HELLO_WORLD);
}

其 DexCode 如下:

62 00 01 00 62 01 00 00 6E 20 03 00 10 00 0E 00

這裏要注意的是 DEX 文件是小端表示法,低位在前,高位在後。通常低 8 位就是 op 碼,也就是我們說的操作碼。在上面的例子中,第一個操作碼就是 62,我們在 Dalvik 指令集中可以找到其代表的指令。關於 Dalvik 指令集,Android 開發者網站也做了總結,點我查看。另外,在 Android 4.4 之前的 AOSP 源碼中的 dalvik/libdex/DexOpcodes.h 中也有定義。我這裏直接在官網資源中查找 62,結果如下圖所示:

操作碼 62 表示的指令是 sget-object,表示獲取一個靜態對象。僅僅知道了操作碼的含義還是不夠的,我們還不知道該條指令的完整格式。注意列表最左側的 21c,它表示的就是指令的格式。關於指令的格式,Android 官網也做了相關總結,點我查看。同樣的,AOSP 中也有相關定義,位於 Android 4.0 版本中的 dalvik/docs/instruction-formats.html 文件。查一下 21c 的指令格式,如下圖所示:

可得 21c 對應的指令格式爲 AA|op BBBB。對應的上面的十六進制,做一下對比:

AA|op BBBB -> op vAA kind@BBBB
00|62 0001 -> 62 v00 kind@0001

這樣一看,就很清晰了。該指令一共是兩個 16 位的字,第一個 16 位的低 8 位是操作碼 62,表示 sget-object,高 8 位表示使用的是 v0 寄存器。第二個 16 位是索引值 1,指向 Dex 中 field_id 部分的第一項,根據之前的解析結果,第一項表示的字段是 Ljava/lang/System;->out;Ljava/io/PrintStream,整合一下,這個指令的完整格式如下:

sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;

表示獲取靜態字段 PrintStream.out,並保存在寄存器 v0 中。回頭再看一下 21c,它的每一個字符其實都是有含義的。

  • 2 表示該指令有多少個 16 位的字組成

  • 1 表示該指令最多使用多少個寄存器

  • c 爲類型碼,c 代表常量池索引

關於類型碼,還有很多種,如下表所示:

助記符位數含義
b8有符號立即數(字節)
c16、32常量池索引
f16接口常量(僅對靜態鏈接格式有效)
h16有符號立即數(32 位或 64 位值的高階位,低階位全爲 0)
i32有符號立即數(整型)或 32 位浮點數
l64有符號立即數(長整型)或 64 位雙精度浮點數
m16方法常量(僅對靜態鏈接格式有效)
n4有符號立即數(半字節)
s16有符號立即數(短整型)
t8、16、32分支目標
x0無額外數據

還有一種特殊情況指令的末尾會多出一個字母。如果是 s,表示指令採用靜態鏈接。如果是字母 i,表示指令應該被內聯處理。

寄存器命名

我們都知道 Dalvik 虛擬機是基於寄存器架構的,其使用的寄存器都是 32 位的。對於 64 位類型,使用相鄰兩個寄存器來表示。Dalvik 基本都是基於 ARM 架構的,ARM 架構的 CPU 本身就含有一定數量的寄存器,那麼 Dalvik 虛擬機支持多少個寄存器呢?我們來看一個 move 指令:

move/16 vAAAA, vBBBB

vAAAA vBBBB,每個大寫字母表示 4 位,一共就是 2^16 -1,也就是 65535 個。當然,不可能會有 65535 個真實寄存器。Dalvik 使用的是虛擬寄存器,它會將部分寄存器映射到 ARM 的寄存器上,另外一部分通過調用棧進行模擬。

Dalvik 虛擬機爲每一個進程維護一個調用棧,這個棧的作用之一就是虛擬寄存器。虛擬機通過處理字節碼對寄存器進行讀寫操作,實際上就是對棧空間進行讀寫。但是在實踐中,一個方法需要 16 個以上的寄存器不太常見,而需要 8 個以上的寄存器卻相當普遍,因此很多指令僅限於尋址前 16 個寄存器。

那麼寄存器是如何命名的呢?上面的分析中提到過 v0 寄存器,是不是 65535 個寄存器就是 v0 - v65535 呢?實際上,寄存器有兩種命名方式,v 命名法p 命名法。在介紹它們之前,先來說一些基本概念。以下面這個 add 函數爲例:

public int add(int a,int b){
    int c = a+b;
    return c;
}

它使用了幾個寄存器?如果不是很確定,可以查看其 smali 代碼中的 .registers 字段。答案是 4 個。根據 Dalvik 虛擬機規定,方法參數使用最後面的寄存器。這 4 個寄存器中的最後兩個就是存儲參數 ab。由於 add() 是非靜態函數,所以該方法總是會傳入當前對象的引用 this,所以實際上是 3 個參數,佔用最後 3 個寄存器。而剩餘的開頭的寄存器就是局部變量寄存器,在 add() 方法中只有一個局部變量寄存器,用於存儲 a+b 的值,就是第一個寄存器。下面就來看看 v 命名法p 命名法 分別是如何給這 4 個寄存器命名的。

v 命名法

v 命名法其實很簡單,就是上面說的 v0 - v65535。不管是參數寄存器,還是局部變量寄存器,一律以 v 開頭。在 add() 函數中,4 個寄存器命名如下:

  • v0 : 局部變量寄存器,存儲 a+b 的值

  • v1 : 當前引用 this

  • v2 : 參數寄存器,存儲 a 的值

  • v3 : 參數寄存器,存儲 b 的值

p 命名法

p 命名法針對參數寄存器進行了優化,參數寄存器的命名從 p0 開始,使得局部變量寄存器和參數寄存器得以很容易的進行區分。smali 語法中就是用了 p 命名法。我們來看下 add() 方法的 smali 代碼:

.method public add(II)I
    .registers 4
    .param p1, "a"    # I
    .param p2, "b"    # I

    .prologue
    .line 6
    add-int v0, p1, p2

    .line 7
    .local v0, "c":I
    return v0
.end method

這樣就很清晰了,4 個寄存器命名如下所示:

  • v0 : 局部變量寄存器,存儲 a+b 的值

  • p0 : 當前引用 this

  • p1 : 參數寄存器,存儲 a 的值

  • p2 : 參數寄存器,存儲 b 的值

p 命名法 更加已讀,一般都是使用 p 命名法。

Dalvik 描述符

在更深入的瞭解 Dalvik 字節碼前,先來看一下 Dalvik 是如何描述字段和方法的,這也有助於我們閱讀 smali 代碼。

類型描述符

Dalvik 字節碼中只有兩種類型,基本類型和引用類型。除了對象和數組以外,其他的所有 Java 類型都是基本類型。這和 JVM 的類型描述符是基本一致的。基本類型都是使用單個字母來表示。數組類型使用 [ 表示。除數組以外的引用類型使用 L 加上全限定名錶示。如下表所示:

類型描述符類型
vvoid,只用於返回值類型
Zboolean
Bbyte
Sshort
Cchar
Iint
Jlong
Ffloat
Ddouble
L對象類型
[數組

基本類型都很簡單,就不多說了,下面舉一個引用類型的例子。例如 String 對象,其全限定名是 java/lang/String;,在 Dalvik 中就表示爲 Ljava/lang/String;。對於數組,又可以分爲基本類型數組和引用類型數組,其格式都是 [ 加上類型描述符。int[] 就是 [IString[] 就是 [java/lang/String;。多維數組就是多個 [,例如 int[][] 就是 [[I

字段

字段的表示統一用如下格式:

類型;->字段名稱:類型描述符

比如一個 com.test.Test 類中的一個 String 類型的 name 字段,在 Dalvik 中就可表示爲:

Lcom/test/Test;->name:Ljava/lang/String

方法

方法的描述和字段的描述有一些類似,區別在於方法多了一個返回值的描述,其基本格式如下:

類型;->方法名(參數類型描述符)返回值類型描述符

com.test.Test 類中的 add() 方法爲例,就是上面用到的兩數相加的函數,其在 Dalvik 中描述爲:

Lcom/test/Test;->add(II)I

add(II) 中的兩個 I 表示兩個 int 類型參數,後面跟的一個 I 表示返回值類型是 int。

Dalvik 指令集

有了上面的知識儲備之後,就可以具體的學習 Dalvik 指令集了。除了之前介紹過的官方文檔和 AOSP 中關於 Dalvik 指令集的整理,我個人經常閱讀的,還有一份國外開發者整理的 Dalvik Opcodes,都是很好的學習資料。我也會基於此版本整理一份完整的中文版 Dalvik 操作碼,可能還需要一段時間才能整理出來,到時候會開源出來。

下面簡單整理一下 Dalvik 指令集。

空指令

語法說明
nop空指令,通常用於對齊

數據操作指令

語法說明
move vA, vB將 vB(4 位) 寄存器的值賦給 vA(4 位) 寄存器
move/from vAA, vBBBB將 vBBBB(16 位) 寄存器的值賦給 vAA(8 位) 寄存器
move-object vA, vB將 vB(4 位) 寄存器存儲的對象賦給 vA(4 位) 寄存器
move-object/from16 vAA, vBBBB將 vBBBB(16 位) 寄存器存儲的對象賦給 vAA(8 位) 寄存器
move-result vAA將最新的 invoke-kind 的單字非對象結果移到指定的寄存器 vAA 中
move-result-wide vAA將最新的 invoke-kind 的雙字非對象結果移到指定的寄存器 vAA 中
move-result-object vAA將最新的 invoke-kind 的對象結果移到指定的寄存器 vAA 中
move-exception將剛剛捕獲的異常保存到給定寄存器中

返回指令

語法說明
return-void返回 void
return-vAA返回一個 32 位非對象類型
return-wide vAA返回一個 64 位非對象類型
return-object vAA返回一個對象類型

數據定義指令

語法說明
const/4 vA, #+b將給定的字面值(符號擴展爲 32 位)移到指定的寄存器 vA 中
const vAA, #+BBBBBBBB將給定的字面值移到指定的寄存器 vAA 中
const/high16 vAA, #+BBBB0000將給定的字面值(右零擴展爲 32 位)移到指定的寄存器 vAA 中
const-wide/16 vAA, #+BBBB將給定的字面值(符號擴展爲 64 位)移到指定的寄存器對 vAA 中
const-wide vAA, #+BBBBBBBBBBBBBBBB將給定的字面值移到指定的寄存器對 vAA 中
const-string vAA, string@BBBB將通過給定的索引獲取的字符串引用移到指定的寄存器 vAA 中
const-class vAA, type@BBBB將通過給定的索引獲取的類引用移到指定的寄存器 vAA 中

鎖指令

語法說明
monitor-enter vAA獲取指定對象的互斥鎖
monitor-exit vAA釋放指定對象的互斥鎖

類型判斷指令

語法說明
check-cast vAA, type@BBBB如果給定寄存器 vAA 中的引用不能轉型爲指定的類型,則拋出 ClassCastException
instance-of vA, vB, type@CCCC如果指定的引用是給定類型的實例,則爲給定目標寄存器賦值 1,否則賦值
new-instance vAA, type@BBBB根據指定的類型構造新實例,並將對該新實例的引用存儲到目標寄存器 vAA 中

數組操作指令

語法說明
array-length vA, vB獲取寄存器 vB 中數組的長度,並存入寄存器 vA
new-array vA, vB, type@CCCC構造指令類型(type@CCCC) 和指定大小(vB) 的數組,並賦給 寄存器 vA
filled-new-array {vC, vD, vE, vF, vG}, type@BBBB構造指令類型(type@BBBB) 和指定大小的數組,並填充內容

異常指令

語法說明
throw vAA拋出 vAA 寄存器指定的異常

跳轉指令

語法說明
goto +AA無條件跳轉至指定偏移處,偏移量爲 AA
if-test vA, vB, +CCCC如果兩個給定寄存器的值比較結果符合預期,則跳轉到偏移量 CCCC 處

if-test一樣,還有 if-eq if-ne if-lt if-ge if-gt if-le if-testz if-eqz if-nez if-ltz if-gez if-ltz if-gez if-gtz if-lez,這些指令格式都是一致的。

字段操作指令

字段操作指令分爲兩類,分別是對於普通字段和靜態字段的操作。

普通字段
語法說明
iinstanceop vA, vB, field@CCCC對已標識的字段執行已確定的對象實例字段運算,並將結果加載或存儲到值寄存器中

針對不同類型的普通字段,有如下命令:

iget、iget-wide、iget-object、iget-boolean、iget-byte、iget-char、iget-short
iput、iput-wide、iput-object、iput-boolean、iput-byte、iput-char、iput-short
靜態字段
語法說明
sstaticop vAA, field@BBBB對已標識的靜態字段執行已確定的對象靜態字段運算,並將結果加載或存儲到值寄存器中

針對不同類型的靜態字段,有如下命令:

sget、sget-wide、sget-object、sget-boolean、sget-byte、sget-char、sget-short
sput、sput-wide、sput-object、sput-boolean、sput-byte、sput-char、sput-short

方法調用指令

方法調用指令的格式爲 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB, 具體的有如下指令:

語法說明
invoke-virtual調用正常的虛方法(該方法不是 private、static 或 final,也不是構造函數)
invoke-super調用父類方法
invoke-direct調用非 static 直接方法(也就是說,本質上不可覆蓋的實例方法,即 private 實例方法或構造函數)
invoke-static調用 static 方法
invoke-interface調用實例的接口方法

數據運算和轉換指令

數據運算指令和數據轉換指令都比較簡單,且數量很多,這裏就不浪費篇幅來寫出來了,感興趣的同學可以查閱資料看一下。後續我也會開源一個完整版的 Dalvik 指令集的表格。

總結

本文介紹了 Dalvik 虛擬機的相關知識,比較了 Dalvik 虛擬機和 JVM,後續着重介紹了 Dalvik 指令集。看懂看會 Dalvik 指令對我們做逆向是很有幫助的,畢竟想要修改程序邏輯,大部分時間就是在和 smali 代碼打交道。而 smali 代碼就是基於 Dalvik 指令集的。如果你閱讀過 smali 代碼,應該會對上面提到的 Dalvik 指令很熟悉。

最後再放一下 Android 逆向筆記系列的其他文章,按順序閱讀效果更佳!

Class 文件格式詳解

Smali 語法解析——Hello World

Smali —— 數學運算,條件判斷,循環

Smali 語法解析 —— 類

Android逆向筆記 —— AndroidManifest.xml 文件格式解析

Android逆向筆記 —— DEX 文件格式解析

Android 逆向筆記 —— ARSC 文件格式解析

Android 逆向筆記 —— 一個簡單 CrackMe 的逆向總結


文章首發微信公衆號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解。

更多逆向相關知識,掃碼關注我吧!

發佈了78 篇原創文章 · 獲贊 6 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章