往期目錄:
Class 檔案格式詳解
Smali 文法解析——Hello World
Smali —— 數學運算,條件判斷,循環
Smali 文法解析 —— 類
Android逆向筆記 —— AndroidManifest.xml 檔案格式解析
系列第一篇文章就分析過 Class 檔案格式,我們都知道
.java
源檔案經過編譯器編譯會生成 JVM 可識别的
.class
檔案。在 Android 中,不管是 Dalvik 還是 Art,和 JVM 的差別還是很大的。Android 系統并不直接使用 Class 檔案,而是将所有的 Class 檔案聚合打包成
DEX檔案,DEX 檔案相比單個單個的 Class 檔案更加緊湊,可以直接在 Android Runtime 下執行。
對于學習熱修複架構,加強和逆向相關知識,了解 DEX 檔案結構是很有必要的。再之前解析過 Class 檔案和 AndroidManifest.xml 檔案結構之後,發現看二進制檔案看上瘾了。。後面會繼續對 Apk 檔案中的其他檔案結構進行分析,例如 so 檔案,resources.arsc 檔案等。
DEX 檔案的生成
在解析 DEX 檔案結構之前,先來看看如何生成 DEX 檔案。為了友善解析,本篇文章中就不從市場上的 App 裡拿 DEX 檔案過來解析了,而是手動生成一個最簡單的 DEX 檔案。還是以 Class 檔案解析時候用的例子:
public class Hello {
private static String HELLO_WORLD = "Hello World!";
public static void main(String[] args) {
System.out.println(HELLO_WORLD);
}
}
首先
javac
編譯成
Hello.class
檔案,然後利用 Sdk 自帶的
dx
工具生成 DEX 檔案:
dx --dex --output=Hello.dex Hello.class
dx 工具位于 Sdk 的 build-tools 目錄下,可添加至環境變量友善調用。dx 也支援多 Class 檔案生成 dex。
DEX 檔案結構
概覽
關于 DEX 檔案結構的學習,給大家推薦兩個資料。
第一個是看雪神圖,出自非蟲,
第二個是 Android 源碼中對 DEX 檔案格式的定義,dalvik/libdex/DexFile.h,其中詳細定義了 DEX 檔案中的各個部分。
第三個是
010 Editor
,在之前解析 AndroidManifest.xml 檔案格式解析 也介紹過,它提供了豐富的檔案模闆,支援常見檔案格式的解析,可以很友善的檢視檔案結構中的各個部分及其對應的十六進制。一般我在代碼解析檔案結構的時候都是對照着 010 Editor 來進行分析。下面貼一張 010 Editor 打開之前生成的 Hello.dex 檔案的截圖:
我們可以一目了然的看到 DEX 的檔案結構,着實是一個利器。在詳細解析之前,我們先來大概給 DEX 檔案分個層,如下圖所示:
文末我放了一張詳細的思維導圖,也可以對着思維導圖來閱讀文章。
依次解釋一下:
-
DEX 檔案頭,記錄了一些目前檔案的資訊以及其他資料結構在檔案中的偏移量header :
-
字元串的偏移量string_ids :
-
類型資訊的偏移量type_ids :
-
方法聲明的偏移量proto_ids :
-
字段資訊的偏移量field_ids :
-
方法資訊(所在類,方法聲明以及方法名)的偏移量method_ids :
-
類資訊的偏移量class_def :
-
: 資料區data :
-
靜态連結資料區link_data :
從
header
到
data
之間都是偏移量數組,并不存儲真實資料,所有資料都存在
data
資料區,根據其偏移量區查找。對 DEX 檔案有了一個大概的認識之後,我們就來詳細分析一下各個部分。
header
DEX 檔案頭部分的具體格式可以參考 DexFile.h 中的定義:
struct DexHeader {
u1 magic[8]; // 魔數
u4 checksum; // adler 校驗值
u1 signature[kSHA1DigestLen]; // sha1 校驗值
u4 fileSize; // DEX 檔案大小
u4 headerSize; // DEX 檔案頭大小
u4 endianTag; // 位元組序
u4 linkSize; // 連結段大小
u4 linkOff; // 連結段的偏移量
u4 mapOff; // DexMapList 偏移量
u4 stringIdsSize; // DexStringId 個數
u4 stringIdsOff; // DexStringId 偏移量
u4 typeIdsSize; // DexTypeId 個數
u4 typeIdsOff; // DexTypeId 偏移量
u4 protoIdsSize; // DexProtoId 個數
u4 protoIdsOff; // DexProtoId 偏移量
u4 fieldIdsSize; // DexFieldId 個數
u4 fieldIdsOff; // DexFieldId 偏移量
u4 methodIdsSize; // DexMethodId 個數
u4 methodIdsOff; // DexMethodId 偏移量
u4 classDefsSize; // DexCLassDef 個數
u4 classDefsOff; // DexClassDef 偏移量
u4 dataSize; // 資料段大小
u4 dataOff; // 資料段偏移量
};
其中的
u
表示無符号數,
u1
就是 8 位無符号數,
u4
就是 32 位無符号數。
magic
一般是常量,用來标記 DEX 檔案,它可以分解為:
檔案辨別 dex + 換行符 + DEX 版本 + 0
字元串格式為
dexn0350
,十六進制為
0x6465780A30333500
。
checksum
是對去除
magic
、
checksum
以外的檔案部分作 alder32 算法得到的校驗值,用于判斷 DEX 檔案是否被篡改。
signature
是對除去
magic
、
checksum
、
signature
以外的檔案部分作 sha1 得到的檔案哈希值。
endianTag
用于标記 DEX 檔案是大端表示還是小端表示。由于 DEX 檔案是運作在 Android 系統中的,是以一般都是小端表示,這個值也是恒定值
0x12345678
。
其餘部分分别标記了 DEX 檔案中其他各個資料結構的個數和其在資料區的偏移量。根據偏移量我們就可以輕松的獲得各個資料結構的内容。下面順着上面的 DEX 檔案結構來認識第一個資料結構
string_ids
。
string_ids
struct DexStringId {
u4 stringDataOff;
};
string_ids
是一個偏移量數組,
stringDataOff
表示每個字元串在 data 區的偏移量。根據偏移量在 data 區拿到的資料中,第一個位元組表示的是字元串長度,後面跟着的才是字元串資料。這塊邏輯比較簡單,直接看一下代碼:
private void parseDexString() {
log("nparse DexString");
try {
int stringIdsSize = dex.getDexHeader().string_ids__size;
for (int i = 0; i < stringIdsSize; i++) {
int string_data_off = reader.readInt();
byte size = dexData[string_data_off]; // 第一個位元組表示該字元串的長度,之後是字元串内容
String string_data = new String(Utils.copy(dexData, string_data_off + 1, size));
DexString string = new DexString(string_data_off, string_data);
dexStrings.add(string);
log("string[%d] data: %s", i, string.string_data);
}
} catch (IOException e) {
e.printStackTrace();
}
}
列印結果如下:
parse DexString
string[0] data: <clinit>
string[1] data: <init>
string[2] data: HELLO_WORLD
string[3] data: Hello World!
string[4] data: Hello.java
string[5] data: LHello;
string[6] data: Ljava/io/PrintStream;
string[7] data: Ljava/lang/Object;
string[8] data: Ljava/lang/String;
string[9] data: Ljava/lang/System;
string[10] data: V
string[11] data: VL
string[12] data: [Ljava/lang/String;
string[13] data: main
string[14] data: out
string[15] data: println
其中包含了變量名,方法名,檔案名等等,這個字元串池在後面其他結構的解析中也會經常遇到。
type_ids
struct DexTypeId {
u4 descriptorIdx;
};
type_ids
表示的是類型資訊,
descriptorIdx
指向
string_ids
中元素。根據索引直接在上一步讀取到的字元串池即可解析對應的類型資訊,代碼如下:
private void parseDexType() {
log("nparse DexTypeId");
try {
int typeIdsSize = dex.getDexHeader().type_ids__size;
for (int i = 0; i < typeIdsSize; i++) {
int descriptor_idx = reader.readInt();
DexTypeId dexTypeId = new DexTypeId(descriptor_idx, dexStringIds.get(descriptor_idx).string_data);
dexTypeIds.add(dexTypeId);
log("type[%d] data: %s", i, dexTypeId.string_data);
}
} catch (IOException e) {
e.printStackTrace();
}
}
解析結果:
parse DexType
type[0] data: LHello;
type[1] data: Ljava/io/PrintStream;
type[2] data: Ljava/lang/Object;
type[3] data: Ljava/lang/String;
type[4] data: Ljava/lang/System;
type[5] data: V
type[6] data: [Ljava/lang/String;
proto_ids
struct DexProtoId {
u4 shortyIdx; /* index into stringIds for shorty descriptor */
u4 returnTypeIdx; /* index into typeIds list for return type */
u4 parametersOff; /* file offset to type_list for parameter types */
};
proto_ids
表示方法聲明資訊,它包含以下三個變量:
- shortyIdx : 指向 string_ids ,表示方法聲明的字元串
- returnTypeIdx : 指向 type_ids ,表示方法的傳回類型
- parametersOff : 方法參數清單的偏移量
方法參數清單的資料結構在 DexFile.h 中用
DexTypeList
來表示:
struct DexTypeList {
u4 size; /* #of entries in list */
DexTypeItem list[1]; /* entries */
};
struct DexTypeItem {
u2 typeIdx; /* index into typeIds */
};
size
表示方法參數的個數,參數用
DexTypeItem
表示,它隻有一個屬性
typeIdx
,指向
type_ids
中對應項。具體的解析代碼如下:
private void parseDexProto() {
log("nparse DexProto");
try {
int protoIdsSize = dex.getDexHeader().proto_ids__size;
for (int i = 0; i < protoIdsSize; i++) {
int shorty_idx = reader.readInt();
int return_type_idx = reader.readInt();
int parameters_off = reader.readInt();
DexProtoId dexProtoId = new DexProtoId(shorty_idx, return_type_idx, parameters_off);
log("proto[%d]: %s %s %d", i, dexStringIds.get(shorty_idx).string_data,
dexTypeIds.get(return_type_idx).string_data, parameters_off);
if (parameters_off > 0) {
parseDexProtoParameters(parameters_off);
}
dexProtos.add(dexProtoId);
}
} catch (IOException e) {
e.printStackTrace();
}
}
解析結果:
parse DexProto
proto[0]: V V 0
proto[1]: VL V 412
parameters[0]: Ljava/lang/String;
proto[2]: VL V 420
parameters[0]: [Ljava/lang/String;
field_ids
struct DexFieldId {
u2 classIdx; /* index into typeIds list for defining class */
u2 typeIdx; /* index into typeIds for field type */
u4 nameIdx; /* index into stringIds for field name */
};
field_ids
表示的是字段資訊,指明了字段所在的類,字段的類型以及字段名稱,在
DexFile.h
中定義為
DexFieldId
, 其各個字段含義如下:
- classIdx : 指向 type_ids ,表示字段所在類的資訊
- typeIdx : 指向 ype_ids ,表示字段的類型資訊
- nameIdx : 指向 string_ids ,表示字段名稱
代碼解析很簡單,就不貼出來了,直接看一下解析結果:
parse DexField
field[0]: LHello;->HELLO_WORLD;Ljava/lang/String;
field[1]: Ljava/lang/System;->out;Ljava/io/PrintStream;
method_ids
struct DexMethodId {
u2 classIdx; /* index into typeIds list for defining class */
u2 protoIdx; /* index into protoIds for method prototype */
u4 nameIdx; /* index into stringIds for method name */
};
method_ids
指明了方法所在的類、方法聲明以及方法名。在 DexFile.h 中用
DexMethodId
表示該項,其屬性含義如下:
- classIdx : 指向 type_ids ,表示類的類型
- protoIdx : 指向 type_ids ,表示方法聲明
- nameIdx : 指向 string_ids ,表示方法名
解析結果:
parse DexMethod
method[0]: LHello; proto[0] <clinit>
method[1]: LHello; proto[0] <init>
method[2]: LHello; proto[2] main
method[3]: Ljava/io/PrintStream; proto[1] println
method[4]: Ljava/lang/Object; proto[0] <init>
class_def
struct DexClassDef {
u4 classIdx; /* index into typeIds for this class */
u4 accessFlags;
u4 superclassIdx; /* index into typeIds for superclass */
u4 interfacesOff; /* file offset to DexTypeList */
u4 sourceFileIdx; /* index into stringIds for source file name */
u4 annotationsOff; /* file offset to annotations_directory_item */
u4 classDataOff; /* file offset to class_data_item */
u4 staticValuesOff; /* file offset to DexEncodedArray */
};
class_def
是 DEX 檔案結構中最複雜也是最核心的部分,它表示了類的所有資訊,對應
DexFile.h
中的
DexClassDef
:
- classIdx : 指向 type_ids ,表示類資訊
- accessFlags : 通路辨別符
- superclassIdx : 指向 type_ids ,表示父類資訊
- interfacesOff : 指向 DexTypeList 的偏移量,表示接口資訊
- sourceFileIdx : 指向 string_ids ,表示源檔案名稱
- annotationOff : 注解資訊
- classDataOff : 指向 DexClassData 的偏移量,表示類的資料部分
- staticValueOff :指向 DexEncodedArray 的偏移量,表示類的靜态資料
DefCLassData
重點是
classDataOff
這個字段,它包含了一個類的核心資料,在 Android 源碼中定義為
DexClassData
,它不在 DexFile.h 中了,而是在 DexClass.h 中:
struct DexClassData {
DexClassDataHeader header;
DexField* staticFields;
DexField* instanceFields;
DexMethod* directMethods;
DexMethod* virtualMethods;
};
DexClassDataHeader
定義了類中字段和方法的數目,它也定義在 DexClass.h 中:
struct DexClassDataHeader {
u4 staticFieldsSize;
u4 instanceFieldsSize;
u4 directMethodsSize;
u4 virtualMethodsSize;
};
- staticFieldsSize : 靜态字段個數
- instanceFieldsSize : 執行個體字段個數
- directMethodsSize : 直接方法個數
- virtualMethodsSize : 虛方法個數
在讀取的時候要注意這裡的資料是 LEB128 類型。它是一種可變長度類型,每個 LEB128 由 1~5 個位元組組成,每個位元組隻有 7 個有效位。如果第一個位元組的最高位為 1,表示需要繼續使用第 2 個位元組,如果第二個位元組最高位為 1,表示需要繼續使用第三個位元組,依此類推,直到最後一個位元組的最高位為 0,至多 5 個位元組。除了 LEB128 以外,還有無符号類型 ULEB128。
那麼為什麼要使用這種資料結構呢?我們都知道 Java 中 int 類型都是 4 位元組,32 位的,但是很多時候根本用不到 4 個位元組,用這種可變長度的結構,可以節省空間。對于運作在 Android 系統上來說,能多省一點空間肯定是好的。下面給出了 Java 讀取 ULEB128 的代碼:
public static int readUnsignedLeb128(byte[] src, int offset) {
int result = 0;
int count = 0;
int cur;
do {
cur = copy(src, offset, 1)[0];
cur &= 0xff;
result |= (cur & 0x7f) << count * 7;
count++;
offset++;
DexParser.POSITION++;
} while ((cur & 0x80) == 128 && count < 5);
return result;
}
繼續回到 DexClassData 中來。
header
部分定義了各種字段和方法的個數,後面跟着的分别就是
靜态字段、
執行個體字段、
直接方法、
虛方法的具體資料了。字段用
DexField
表示,方法用
DexMethod
表示。
DexField
struct DexField {
u4 fieldIdx; /* index to a field_id_item */
u4 accessFlags;
};
- fieldIdx : 指向 field_ids ,表示字段資訊
- accessFlags :通路辨別符
DexMethod
struct DexMethod {
u4 methodIdx; /* index to a method_id_item */
u4 accessFlags;
u4 codeOff; /* file offset to a code_item */
46};
method_idx
是指向 method_ids 的索引,表示方法資訊。
accessFlags
是該方法的通路辨別符。
codeOff
是結構體
DexCode
的偏移量。如果你堅持看到了這裡,是不是發現說到現在還沒說到最重要的東西,DEX 包含的代碼,或者說指令,對應的就是 Hello.java 中的 main 方法。沒錯,
DexCode
就是用來存儲方法的詳細資訊以及其中的指令的。
struct DexCode {
u2 registersSize; // 寄存器個數
u2 insSize; // 參數的個數
u2 outsSize; // 調用其他方法時使用的寄存器個數
u2 triesSize; // try/catch 語句個數
u4 debugInfoOff; // debug 資訊的偏移量
u4 insnsSize; // 指令集的個數
u2 insns[1]; // 指令集
/* followed by optional u2 padding */ // 2 位元組,用于對齊
/* followed by try_item[triesSize] */
/* followed by uleb128 handlersSize */
/* followed by catch_handler_item[handlersSize] */
};
我們打開 010 Editor,定位到 main() 方法對應的
DexCode
,對照進行分析:
public class Hello {
private static String HELLO_WORLD = "Hello World!";
public static void main(String[] args) {
System.out.println(HELLO_WORLD);
}
}
main()
方法對應的 DexCode 十六進制表示為 :
03 00 01 00 02 00 00 00 00 00 79 02 00 00 08 00
62 00 01 00 62 01 00 00 6E 20 03 00 01 00 0E 00
使用的寄存器個數是 3 個。參數個數是 1 個,就是
main()
方法中的
String[] args
。調用外部方法時使用的寄存器個數為 2 個。指令個數是 8 。
終于說到指令了,main() 函數中有 8 條指令,就是上面十六進制中的第二行。嘗試來解析一下這段指令。Android 官網就有 Dalvik 指令的相關介紹,連結。
第一個指令
62 00 01 00
,查詢文檔
62
對應指令為
sget-object vAA, [email protected]
,
AA
對應
00
, 表示
v0
寄存器。
BBBB
對應
01 00
,表示
field_ids
中索引為 1 的字段,根據前面的解析結果該字段為
Ljava/lang/System;->out;Ljava/io/PrintStream
,整理一下,
62 00 01 00
表示的就是:
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
接着是
62 01 00 00
。還是
sget-object vAA, [email protected]
,
AA
對應
01
,
BBBB
對應
0000
, 使用的是
v1
寄存器,field 位 field_ids 中索引為 0 的字段,即
LHello;->HELLO_WORLD;Ljava/lang/String
,該句完整指令為:
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
接着是
6E 20 03 00
, 檢視文檔
6E
指令為
invoke-virtual {vC, vD, vE, vF, vG}, [email protected]
。
6E
後面一個十六位
2
表示調用方法是兩個參數,那麼
BBBB
就是
03 00
,指向 method_ids 中索引為 3 方法。根據前面的解析結果,該方法就是
Ljava/io/PrintStream;->println(Ljava/lang/String;)V
。完整指令為:
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
最後的
0E
,檢視文檔該指令為
return-void
,到這 main() 方法就結束了。
将上面幾句指令放在一起:
62 00 01 00 : sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
62 01 00 00 : sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
6E 20 03 00 : invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
OE OO : return-void
這就是 main() 方法的完整指令了。還記得我之前的一篇文章 Smali 文法解析——Hello World,其實這個解析結果和 Hello.java 對應的 smali 代碼是一緻的:
.method public static main([Ljava/lang/String;)V
.registers 3
.prologue
.line 6
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
sget-object v1, LHello;->HELLO_WORLD:Ljava/lang/String;
invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
.line 7
return-void
.end method
總結
這種文章真的是又臭又長,但是耐下心去看,還是會有很大收貨的。最後來一張思維導圖總結一下:
Java 版本 DEX 檔案格式解析源碼,點我 DexParser
文章首發微信公衆号:
秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解。
更多 JDK 源碼解析,掃碼關注我吧!