iOS彙編入門教程(三)彙編中的 Section 與資料存取

Soulghost發表於2019-03-29

簡介

在前兩篇文章中,我們介紹了反彙編的方法,呼叫棧的基本概念,以及如何通過 Xcode 去除錯彙編程式碼,在這篇文章中,我們將介紹如何在彙編中通過 Section 來實現資料存取。

Segment 與 Section

在彙編程式碼中各個部分的頭部,我們常常能看到 .section 這樣的宣告,例如下面這段程式碼。

    ; Program
   .section __TEXT,__text,regular,pure_instructions
   .global _someFunc
   .p2align 2

_someFunc:
   mov  x0, #0
   ret
複製程式碼

用 MachOView 開啟一個 Mach-O 格式的可執行檔案,可以看到其中包含了大量 Segment 與 Section,例如下圖。

iOS彙編入門教程(三)彙編中的 Section 與資料存取

在 Stack Overflow 上,有一個關與 Section 與 Segment 的討論,回答中提到:

The segments contain information needed at runtime, while the sections contain information needed during linking.

A segment can contain 0 or more sections.

簡單地說,Segment 是 Section 的指標,Segment 會指引著系統在指定的位置載入 Section,如下圖所示。

iOS彙編入門教程(三)彙編中的 Section 與資料存取

其中 Segment 為下劃線開頭的大寫字母組合,Section 為下劃線開頭的小寫字母組合,例如 __TEXT,__text 代表 __TEXT Segment 指向的 __text Section。

在編寫彙編程式碼的過程中,我們只需要關心 Section 的定義,Segment 會由編譯系統自動建立,可以理解為我們定義了一系列離散的程式碼和資料,系統在構建 Mach-O 檔案時會將這些 Section 組合起來,將他們的地址通過 Section 統一管理。系統在執行 Mach-O 檔案時,只需要從頭部讀取 Mach-O Header 即可獲取到整個檔案的 Section 資訊,隨後再進行後續的執行時載入。

為什麼需要 Section

看下面一個例子,我們定義一個全域性變數 counter,以及一個 getCount 方法。

int counter = 1;
int getCount() {
    return counter;
}
複製程式碼

為了實現以上程式碼,編譯器必須為全域性變數 counter 預先分配好虛擬地址,以便程式 load 時建立起全域性變數的儲存區,Section 中的 DATA 段即可完成這樣的工作,它的宣告如下:

	.section	__DATA,__data
	.globl	_counter                ; @counter
	.p2align	2
_counter:
	.long	1                       ; 0x1
複製程式碼
  • 第一行用 .section 宣告瞭該資料位於 __DATA,__data 段,這個區段的特點是載入後可讀可寫,因此將變數儲存在這個區域;
  • 第二行的 .global 宣告說明變數符號 counter 是一個全域性變數,即可在其他檔案中通過 extern 的方式引入;
  • 第三行的 .p2align 是用於指定程式的對齊方式,這類似於結構體的位元組對齊,為的是加速程式的執行速度,p2align 的單位是指數,即按照 2 的 exp 次方對齊,上文中的 .p2align 2 即為按照 2^2 = 4 位元組對齊,也就是說,如果單行指令或資料的長度不足4位元組,將用 0 補全,超過 4 但不是 4 的倍數,則按照最小倍數補全;
  • 第四行是一個 label,用來表示 .long 1 所在的地址,以便後續的讀寫。

此外,程式碼也是一種資料,被存放在 __TEXT,__text 段,這個段的特點是記憶體空間只讀,因此適合存放程式碼等固定值。

如何讀寫 Section

讓我們看一下上面程式碼的完整彙編結果,使用如下命令即可將上文的 C 程式碼轉成彙編。

clang -S -arch arm64 -isysroot `xcrun --sdk iphoneos --show-sdk-path` -fno-asynchronous-unwind-tables <your_c_file_path>
複製程式碼

彙編的完整結果如下。

	.section	__TEXT,__text,regular,pure_instructions
	.globl	_getCount               ; -- Begin function getCount
	.p2align	2
_getCount:                              ; @getCount
	adrp	x8, _counter@PAGE
	add	x8, x8, _counter@PAGEOFF
	ldr	w0, [x8]
	ret
                                        ; -- End function
	.section	__DATA,__data
	.globl	_counter                ; @counter
	.p2align	2
_counter:
	.long	1                       ; 0x1
複製程式碼

可以看到,底部即為上文講到的用於全域性變數儲存的 __DATA,__data 段的宣告,最上方則是對程式碼段 __TEXT,__text 的宣告,隨後即為 getCount 函式的程式碼。

從上面的結果可以看出,在彙編中,資料和程式碼是儲存在一起的,資料本質上也是一種程式碼,因此讀取 counter 變數本質上是從特定的地址讀取內容,一般而言,基於程式計數器 PC 進行定址即可,在 ARM64 中提供了可在 +/-4GB (33 bits) 範圍內定址的 adrp 命令,該命令的基本用法如下。

例如我們要找到 counter 變數,本質上是計算當前指令距離 counter 變數的距離,即計算基於 PC 的偏移量,能表示的偏移量的最大長度決定了能夠定址的空間大小,可以想象,如果程式碼和資料段之間的距離過大,將難以通過一次運算進行定址。計算 counter 變數地址的過程如下。

  1. 使用 adrp 命令計算出 _counter label 基於 PC 的偏移量的高 21 位,並儲存在 x8 暫存器中,@PAGE 代表頁偏移的高 21 位;

    adrp	x8, _counter@PAGE
    複製程式碼
  2. 使用 add 命令將餘下的 12 位補齊,通過 @PAGEOFF 代表頁偏移的低 12 位;

    add	x8, x8, _counter@PAGEOFF
    複製程式碼
  3. 此時,x8 中即為 counter 變數的實際地址了,通過 ldr 命令將暫存器的值讀取到 w0 中,作為函式返回值。

    ldr	w0, [x8]
    ret
    複製程式碼

看到這裡,相信你會有個很大的疑問,為什麼不能一次性的將地址載入到 x8,而要拆分成高 21 位和低 12 位呢,這是因為 ARM64 雖然支援 64 位地址,但指令的長度僅有 32 位,因此難以通過一條指令去編碼 64 位地址,所以才拆解成了 adrp + add 的組合,從而支援了正負 32 位地址偏移量範圍的定址。

如果你想深入瞭解基於 PC 的定址,可以閱讀 What are @PAGE and @PAGEOFF symbols in IDA? 中的高票回答。

學會了通過 adrp 讀取變數地址,那麼寫變數其實就是通過 str 將暫存器的值寫入變數地址,假如我們將計算結果儲存在了 w1 暫存器,那麼將 w1 寫入 counter 變數的程式碼如下。

_addCount:
    ; omit function start
    adrp    x8, _counter@PAGE
    add     x8, x8, _counter@PAGEOFF
    ; omit code for save new value to w1
    str     w1, [x8]
    ; omit function end
複製程式碼

字串的 Section 儲存

我們看如下這段程式碼。

#include <stdio.h>

char *secName = "MySec";

int main() {
    printf("the secName is %s", secName);
    return 0;
}
複製程式碼

這其中涉及到兩個字串,"MySec" 和 "the secName is %s",它們被儲存在 __TEXT,__cstring 段,宣告如下。

	.section	__TEXT,__cstring,cstring_literals
l_.str:                                 ; @.str
	.asciz	"MySec"

	.section	__TEXT,__cstring,cstring_literals
l_.str.1:                               ; @.str.1
	.asciz	"the secName is %s"
複製程式碼

所不同的是,"My_Sec" 被作為全域性變數 _secName 的初值,secName 的定義如下。

	.section	__DATA,__data
	.globl	_secName                ; @secName
	.p2align	3
_secName:
	.quad	l_.str
複製程式碼

需要注意的是,這裡的 _secName 符號是一個指標,它的值是字串 "MySec" 的地址。

通過 Xcode 和 Mach-O 驗證 Section 儲存

首先新建一個 iOS Empty Project,命名為 ASM,之所以使用 iOS Project,是為了獲得 ARM64 的執行環境,然後在工程中新建一個 example.s 檔案,整個工程的配置如下。

; example.s
    ; Program
    .section __TEXT,__text,regular,pure_instructions
    .global _getSectionName, _getSectionNameAddress
    .p2align 2

_getSectionName:
    adrp x8, _sectionName@PAGE
    add  x8, x8, _sectionName@PAGEOFF
    ldr  x0, [x8]
    ret

_getSectionVersion:
    adrp x8, _sectionVersion@PAGE
    add  x8, x8, _sectionVersion@PAGEOFF
    ldr  w0, [x8]
    ret

_getSectionNameAddress:
    adrp x8, _sectionName@PAGE
    add  x8, x8, _sectionName@PAGEOFF
    mov  x0, x8
    ret

    ; Global Data
    .section __DATA,__data
    .global _sectionVersion
    .p2align 2
_sectionVersion:
    .long 100

    .global _sectionName
    .p2align 3
_sectionName:
    .quad l_str

    ; String Literal
    .section __TEXT,__text,cstring_literals
l_str:
    .asciz "MySec"
複製程式碼
// main.m
#import "AppDelegate.h"
#include <mach-o/dyld.h>

extern int sectionVersion;
extern const char * sectionName;
extern uint64_t getSectionNameAddress(void);
extern const char * getSectionName(void);

uint64_t getProcessBaseAddress() {
    uint32_t numberImages = _dyld_image_count();
    for (uint32_t i = 0; i < numberImages; i++) {
        const struct mach_header *header = _dyld_get_image_header(i);
        const char *name = _dyld_get_image_name(i);
        const char *p = strrchr(name, '/');
        if (p && strcmp(p + 1, "ASM") == 0) {
            return (uint64_t)header;
        }
    }
    return -1;
}

int main(int argc, char * argv[]) {
    uint64_t baseAddress = getProcessBaseAddress();
    uint64_t sectionNameAddress = getSectionNameAddress();
    printf("process base address at 0x%llx\n", baseAddress);
    printf("the version is %d\n", sectionVersion);
    printf("get section address is 0x%llx\n", sectionNameAddress - baseAddress);
    printf("get section name %s\n", getSectionName());
    return 0;
}
複製程式碼

下面我們執行程式碼,觀察控制檯的輸出。

process base address at 0x100640000
the version is 100
get section address is 0x8de0
get section name MySec
複製程式碼

第一行列印出了程式執行的基址,隨後分別列印了變數 sectionVersion 的值以及變數 sectionName 的地址和值,上述彙編程式碼相信通過講解你已能夠讀懂,下面著重講一下用於驗證的 C 程式碼。

  1. 最上面的 extern 宣告用於將彙編程式碼定義的變數和函式引入檔案。

    extern int sectionVersion;
    extern const char * sectionName;
    extern uint64_t getSectionNameAddress(void);
    extern const char * getSectionName(void);
    複製程式碼
  2. dyld 函式用於獲取主二進位制 (ASM.app) 載入的基址,Mach-O 檔案載入時,將以基址為偏移量,將所有虛擬地址對映到記憶體空間,因此獲取到基址和變數在記憶體空間中的地址後,通過 實際地址 - 基址 即可得到變數的虛擬地址,即在 Section 中分配的地址;

  3. main 函式部分,為了得到 sectionName 的實際地址,第三個 printf 使用了 實際地址 - 基址 的公式來得到其虛擬地址。

上面程式碼的輸出告訴了我們 sectionName 的值位於地址 0x8de0,下面我們用 MachOView 開啟這個二進位制檔案,檢視一下 0x8de0 的實際內容。

iOS彙編入門教程(三)彙編中的 Section 與資料存取

可以看到,變數位於 __DATA,__data 段,其值為 0x6b0c,需要注意的是,iOS 採用了小端位元組序,即低位元組在低位,高位元組在高位,所以在讀記憶體的值的時候每 2 個位元組需要倒序讀取,其原理可以用下面一段程式碼解釋和判斷。

uint16_t u = 1;
// for value 0x0001
// address        | +0 | +1 |
// big-endian     | 00 | 01 |
// little-endian  | 01 | 00 |
// first byte     big = 0x00, little = 0x01
printf("%s endian\n", *(uint8_t*)&u ? "little" : "big");
複製程式碼

通過上文我們知道,sectionName 的值是 0x6b0c,是一個地址,這也驗證了 sectionName 本身是個地址,那麼 0x6b0c 儲存的是不是字串 "MySec" 呢,我們繼續通過 MachOView 檢視。

iOS彙編入門教程(三)彙編中的 Section 與資料存取

可以看到,0x6b0c 位於 __TEXT,__text段,其值為 "MySec\0",至此我們完成了驗證,讀者可以自己嘗試去驗證 sectionVersion 的儲存位置和值。

參考資料

  1. How can I get load address of an iOS app?
  2. What are @PAGE and @PAGEOFF symbols in IDA?
  3. What's the difference of section and segment in ELF file format
  4. BSS段、資料段、程式碼段、堆與棧
  5. ARM Document
  6. The A64 instruction set

相關文章