ARM架构PWN入门

0x1 编译一个arm程序:

#32位小端

Shell
arm-linux-gnueabihf-gcc test.c -o test

#64位小端

Shell
aarch64-linux-gnu-gcc test.c -o test

0x2 运行和调试

这种异架构在qemu下的调试方法都差不多,这里就写简略一些了:

运行:

Shell
qemu-arm -L /usr/arm-linux-gnueabihf ./test
qemu-aarch64 -L /usr/aarch64-linux-gnu/ ./test

调试的时候就是再加上-g 1234

另一个终端里:

Shell
gdb-multiarch
set architecture arm (或者aarch64)
set endian little
target remote localhost:1234
(add-symbol-file ./test)

0x3 arm架构基础知识

0.AArch64固定 4 字节指令长度。

ARM32 支持两种主要的执行状态,可以在运行时动态切换:

  • ARM 状态:固定 4 字节长度。要求 4 字节对齐。

  • Thumb 状态:指令长度为 2 字节或 4 字节(Thumb-2)。

Thumb 指令集最初是为减小代码体积而设计的精简指令集,早期 Thumb 以 16 位编码为主,功能上是 ARM 的一个子集,后来 Thumb-2 扩展为 16/32 位混合编码。

在我们写shellcode的时候,在某些有溢出漏洞的函数中不能包含 \x00,例如strcpy、memcpy 等,但是ARM 模式的指令通常含有较多的零字节,而Thumb 指令更短,代码密度更高,切换到thumb状态就可以大幅降低出现 NULL 字节的机会。

在支持 ARM/Thumb interworking 的 AArch32 架构中,当通过 BX、BLX 进行跳转/返回时,CPU 会检查目标地址的最低位(bit[0]):

若 bit[0] = 1,则将 CPSR.T 置 1,切换至 Thumb(T32) 状态;

若 bit[0] = 0,则将 CPSR.T 清 0,切换至 ARM(A32) 状态(仅当处理器支持 ARM 状态时有效)。

该最低位不参与实际取指地址计算,硬件会自动执行 PC = 目标值 & ~1。

当前执行状态始终由 CPSR.T 位指示,而非仅依赖本次写入 PC 的值。

1. 缓存一致性

这个和MIPS是一样的,写入shellcode后使用sleep(1)刷新缓存,这里就不多赘述了。

2. 文字池机制

文字池(Literal Pool)是 ARM 汇编器在 .text 段中自动维护的一块常量数据区域,主要用来弥补指令编码对立即数和地址表达能力的限制。由于单条指令通常无法直接容纳任意 32 位或 64 位常量,在使用伪指令 ldr rn, =value 或 ldr xn, =label 时,汇编器往往会将这些值放入就近的文字池中,再生成一条基于 PC 相对寻址的加载指令,让 CPU 先定位到池中的常量,再把数据读入寄存器。

3. 寄存器

Arm32 下:

寄存器名 别名 用途
R0 - 第 1 个参数,返回值寄存器
R1 - 第 2 个参数,或存放 64 位返回值高位
R2-R3 - 第 3,4 个参数
R4–R10 - 被调用者保存寄存器,常用于保存局部状态。其中R7也用于存放系统调用号。
R11 FP 帧指针,便于栈帧定位与回溯
R12 IP 过程内临时寄存器,供链接器、PLT 寻址中转使用
R13 SP 栈指针
R14 LR 链接寄存器,保存返回地址
R15 PC 程序计数器,控制流寄存器
CPSR 当前程序状态寄存器 保存条件标志位和执行状态位(包括 ARM/Thumb 状态)

Aarch64:

寄存器名 别名 用途
X0–X7 - 前 8 个参数;X0 也用于返回值
X8 - 间接返回值地址,常用于返回大结构体。同时也用于存放系统调用号。
X9–X15 - 临时寄存器,调用者保存
X16-X17 - 过程内临时寄存器,常供链接器/PLT 使用
X18 - 平台保留寄存器,是否可用依系统而定
X19–X28 - 被调用者保存寄存器
X29 FP 帧指针
X30 LR 链接寄存器,保存返回地址
SP Stack Pointer 栈指针,独立寄存器
PC Program Counter 程序计数器,不能当作普通通用寄存器直接使用
W0–W30 X0–X30 的低 32 位视图 32 位访问形式;写入 W 寄存器会清零对应 X 寄存器高 32 位
XZR / WZR Zero Register 零寄存器;读出为 0,写入丢弃

4. 常见汇编指令

通用汇编指令:

  • LDR (Load Register):从内存中读取数据到寄存器。ldr r0, [r1, #4] (ARM32) 或 ldr x0, [x1, #8] (AArch64),表示从基址寄存器加上偏移量的内存地址处取出数据放入目标寄存器。

  • STR (Store Register):将寄存器中的数据存入内存。str r0, [r1, #4] (ARM32) 或 str x0, [x1, #8] (AArch64),表示将寄存器中的数据写入对应的内存地址处。

  • MOV (Move):将一个寄存器的值或立即数赋给目标寄存器。mov r0, #1 (ARM32) 或 mov x0, x1 (AArch64),表示进行寄存器间的数据复制或赋值;在 AArch64 中,mov 常作为其他指令的别名出现。

  • ADD (Add):加法运算。add r0, r1, r2 (ARM32) 或 add x0, x1, x2 (AArch64),表示计算两个操作数相加,并将结果存入目标寄存器。

  • SUB (Subtract):减法运算。sub r0, r1, #0x10 (ARM32) 或 sub x0, x1, #0x10 (AArch64),表示从第一个操作数中减去第二个操作数,并将结果存入目标寄存器。

  • B (Branch):无条件相对跳转。b label,表示直接跳转到 label 标签处执行,不保存返回地址,常用于函数内部的循环或 if-else 分支。

  • BL (Branch with Link):带链接的函数跳转。bl func,表示调用外部或内部函数 func,跳转前会将下一条指令的地址(返回地址)保存到 LR(Link Register)中。

  • ASR (Arithmetic Shift Right):算术右移。将寄存器中的值向右移动指定位数,左侧补符号位(正数补 0,负数补 1)。asr x0, x1, #2,表示将 x1 的值右移 2 位,结果存入 x0。

Arm32 中特有的汇编指令:

  • BX (Branch and eXchange):分支跳转并支持状态切换。bx r3,表示跳转到 r3 中保存的绝对地址,并根据目标地址最低位的状态信息在 ARM 和 Thumb 状态之间切换。

  • PUSH (Push registers to stack):将一个或多个寄存器的值压入栈中。push {r4, r5, lr},表示将 r4、r5 和 lr 寄存器压栈保存,并自动向低地址方向更新 SP 指针。

  • POP (Pop registers from stack):从栈中弹出一个或多个值到寄存器中。pop {r4, r5, pc},表示从栈顶恢复 r4、r5 的值,并将原本保存在栈上的返回地址直接弹入 pc,常用于函数返回和寄存器恢复。

  • LDM / STM (Load / Store Multiple):批量内存加载与存储。ldmia r0!, {r1-r3},表示从 r0 指向的内存处连续读取数据放入 r1、r2、r3 中,并自动更新 r0 的地址。PUSH 和 POP 在 ARM32 中通常可视为这类多寄存器操作指令的常用别名。

AArch64 特有的指令:

  • LDP (Load Pair of Registers):从内存中连续读取两个数据到两个寄存器中。ldp x29, x30, [sp], #0x10,表示从 sp 处取出 16 字节分别放入 x29 (FP) 和 x30 (LR),然后将 sp 增加 0x10,常用于函数尾声的栈平衡与弹栈操作。

  • STP (Store Pair of Registers):将两个寄存器的数据连续存入内存。stp x29, x30, [sp, #-0x10]!,表示先将 sp 减去 0x10,然后将 x29 和 x30 存入新的 sp 地址处,常用于函数开头的开辟栈帧与保存状态。

  • BLR (Branch with Link to Register):跳转到寄存器中保存的绝对地址。blr x3,表示调用 x3 中存放着目标地址的函数,并将返回地址存入 X30 (LR),这是 AArch64 中进行间接函数调用的标准方式。

  • RET (Return from subroutine):从子程序返回。ret,表示函数执行完毕,默认跳转到 X30 (LR) 寄存器中保存的地址;在 AArch64 中它是独立的返回指令。

  • CBZ / CBNZ (Compare and Branch on Zero / Non-Zero):比较并跳转。cbz x0, label,表示如果 x0 的值为 0,则跳转到 label 处。AArch64 将“与 0 比较并跳转”直接合并为单条指令,能够减少指令数并简化控制流。

  • ADRP (Address of Page):计算某个符号/标签所在 4KB 页的页基址。adrp x0, label,表示以当前 PC 为基准,计算 label 所在 4KB 页的起始地址并放入 x0。

  • NEGS (Negate and Set flags):取相反数并更新状态标志位。negs x0, x1,表示计算 0 - x1 的值并将结果存入 x0,同时根据结果更新 N, Z, C, V 标志。

可以发现arm32里面是没有ret指令的,所以实际上arm32返回一般是通过bx lr或pop {…, pc}实现的。

5. 函数约定调用及栈帧结构

在arm32下,使用寄存器R0-R3传递前四个参数,如果还有多的参数则放到栈上。同时寄存器R0也用于传递函数返回值。系统调用号则存在寄存器R7中。

在aarch64下,使用寄存器X0-X7传递前八个参数,如果还有多的参数则放到栈上。同时寄存器X0也用于传递函数返回值。系统调用号则存在寄存器X8中。

Arm32 系统调用号:

https://elixir.bootlin.com/linux/v5.13.19/source/arch/arm64/include/asm/unistd32.h#L35

Aarch64 系统调用号:

https://elixir.bootlin.com/linux/v5.13.19/source/include/uapi/asm-generic/unistd.h#L642

Aarch64 栈帧结构:

Aarch64栈帧结构和x86_64,mips64下的有比较大的区别,我自己调试出来大概是这样的一个结构:

aarch64_SF

可以看到,在没有canary的情况下,FP和LR两个寄存器的值是被放到当前函数栈帧的最低位置(不考虑Dynamic Allocation),而这一操作是由汇编指令stp x29,x30,[sp,#local_xx]!来实现的。此时如果我们可以进行栈溢出的话,是无法覆盖到本函数的LR的,但是可以溢出覆盖到父函数的LR。

在开启canary的情况下,可以发现,存在canary的函数栈帧发生了较大的变化,FP和LR的值被存到了当前函数栈帧的最高地址处(此时就比较像x86_64的结构了,但还是有所不同,canary和FP、LR并不是紧贴着的)。函数的序言变成了这样:

但是没有canary的栈帧没有变化,还是和之前相同,FP和LR在该栈帧的最低地址处。

Tips:

造成这一变化的原因在CVE-2023-4039中有详细解释,这里就简单介绍一下:

GCC原始生成的aarch64栈布局(就是上面栈帧图中最左边的)是有缺陷的,对于alloca这样的动态分配变量会被分配到栈的更低位置(上面图中的Dynamic Allocation),这就导致动态变量发生溢出时,会直接覆盖返回地址,而完全不会触及位于更高地址处的canary。(但是GCC 项目方认为,这应归类为一个被遗漏的强化缺陷,而并非一个漏洞本身)

6. others

Aarch64下一些可能好用的寻找gadget的命令(还是需要结合栈帧的具体情况去寻找合适的gadget):

Asm
ROPgadget --binary /usr/aarch64-linux-gnu/lib/libc.so.6 | grep -F "ldr x0, [sp, #0x18]"
ROPgadget --binary /usr/aarch64-linux-gnu/lib/libc.so.6 | grep --color=always -F "ldp x21, x22, [sp," | grep "ret$"
ROPgadget --binary /usr/aarch64-linux-gnu/lib/libc.so.6 | grep "mov x0, x2[1-2] ; blr x"
ROPgadget --binary /usr/aarch64-linux-gnu/lib/libc.so.6 --only "ldr|mov|add|blr"
ROPgadget --binary /usr/aarch64-linux-gnu/lib/libc.so.6 --only "ldp|ret"

简单的shellcode:

Asm
.global _start
_start:
    adr x0, binsh // x0 = "/bin/sh"
    mov x1, xzr // argv = NULL
    mov x2, xzr // envp = NULL
    mov x8, #221 // syscall: execve
    svc #0

binsh:
    .ascii "/bin/sh\0"

不使用文字池的话,直接在栈上构造即可:

Asm
.global _start
_start:
    mov x1, #0x622f
    movk x1, #0x6e69, lsl #16
    movk x1, #0x732f, lsl #32
    movk x1, #0x68, lsl #48
    sub sp, sp, #16
    str x1, [sp]
    mov x0, sp // filename = "/bin/sh"
    mov x1, xzr // argv = NULL
    mov x2, xzr // envp = NULL
    mov x8, #221 // execve
    svc #0

7. ARM 现代安全扩展

ARM 从 v8.3-A 开始,陆续引入了 PAC、BTI、MTE 三项硬件级安全扩展,这些机制直接改变了 AArch64 平台上的利用难度。

有关这些内容可能之后会再记录,这里就先咕了(

END


ARM架构PWN入门
http://whi4ed0g.xyz/2025/12/13/ARM-PWN-lessons/index/
作者
whi4ed0g
发布于
2025年12月13日
许可协议