iOS汇编入门教程(三)汇编中的 Section 与数据存取

4,789 阅读8分钟

简介

在前两篇文章中,我们介绍了反汇编的方法,调用栈的基本概念,以及如何通过 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,例如下图。

在 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,如下图所示。

其中 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 的实际内容。

可以看到,变量位于 __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 查看。

可以看到,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