专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
洋县吧  ·  太恶劣,确认系摆拍! ·  昨天  
洋县吧  ·  太恶劣,确认系摆拍! ·  昨天  
终码一生  ·  用好缓存的10条军规 ·  2 天前  
终码一生  ·  用好缓存的10条军规 ·  2 天前  
计算机与网络安全  ·  200页PPT AI ... ·  2 天前  
计算机与网络安全  ·  信息通信行业技术标准体系建设指南(2025- ... ·  2 天前  
51好读  ›  专栏  ›  看雪学苑

安卓逆向基础知识之ARM汇编和so层动态调试

看雪学苑  · 公众号  · 互联网安全  · 2025-05-31 17:59

正文

请到「今天看啥」查看全文



以下是栈帧管理:


SUB SP, SP, #16      @ 分配16字节栈空间
STR X29, [SP, #8]    @ 保存帧指针
ADD X29, SP, #8      @ 设置新帧指针
...
LDR X29, [SP, #8]    @ 恢复帧指针
ADD SP, SP, #16      @ 释放栈空间
RET                  @ 返回(使用X30中的地址)


这里再提一提条件执行,在ARM架构条件执行中,NZCV标志由CMP、ADDS等指令更新,用于控制条件分支,如B.EQ(相等时跳转)、B.NE(不相等时跳转)等指令的执行依赖这些标志的状态判断。


再讲讲ARM64 与 ARM32 的区别:


特性 ARM64(AArch64) ARM32(AArch32)
寄存器位数
64位(X0-X30)
32位(R0-R15)
链接寄存器
X30(LR)
R14(LR)
程序计数器
PC 不可直接访问
PC 为 R15,可直接修改
状态寄存器
分解为 NZCV、DAIF 等专用寄存器
CPSR(单一程序状态寄存器)


这里补充一个关于arm64寄存器的知识点,其实arm64寄存器除了X0到X30这种寄存器之外,还有W0到W30寄存器,而这两种寄存器的关系也十分简单,W0-W30 是 X0-X30 的低 32 位,两者共享同一物理寄存器,写入 W 寄存器时,X 寄存器的高 32 位会被清零,读取 W 寄存器时,仅访问低 32 位,高 32 位不参与运算。

在 ARM64 汇编中还有一种特殊的零寄存器WZR(32 位)和 XZR(64 位),当这两个寄存器作为源寄存器时值始终为零,举个例子:


ADD W0, W1, WZR   ; W0 = W1 +0 → 等价于 MOV W0, W1
SUB X2, X3, XZR   ; X2 = X3 -0 → 等价于 MOV X2, X3


而当 WZR 或 XZR 作为目标寄存器时,写入操作会被硬件忽略,这也举个例子:


MOV WZR, W1   ; 无效操作,WZR 的值仍为 0
STR X0, [XZR] ; 尝试写入内存地址 0,通常触发异常(取决于系统配置)


除了W寄存器和X寄存器之外还有其他的寄存器,如果要用到浮点数运算那就需要V寄存器,ARM64架构提供 32 个浮点寄存器,命名为 V0 至 V31,每个寄存器宽度为 128 位。浮点运算所使用的指令支持 单精度(F32) 和 双精度(F64) 浮点运算,以下是浮点运算指令:


指令 功能 示例
FADD
浮点加法
FADD S0, S1, S2
FSUB
浮点减法
FSUB D0, D1, D2
FMUL
浮点乘法
FMUL S3, S4, S5
FDIV
浮点除法
FDIV D3, D4, D5
FABS
取绝对值
FABS S6, S7
FNEG
取反
FNEG D6, D7
FSQRT
平方根
FSQRT S8, S9
FCMP
浮点比较
FCMP S10, S11
FMOV
浮点数移动
FMOV D8, D9


操作数可以是标量或向量。什么是标量和向量呢?标量就是单精度(32 位,用S系列寄存器表示)和双精度(64 位,用D系列寄存器表示),向量就是通过 SIMD(NEON 技术) 支持并行操作,比如同时处理 4 个单精度浮点数(4S)或 2 个双精度浮点数(2D),举个例子:


FADD V0.4S, V1.4S, V2.4S   ; 并行计算 4 个单精度浮点数的加法
FMUL D0, D1, D2            ; 双精度浮点数乘法


这些浮点寄存器可以通过不同的数据宽度后缀(如 B、H、S、D、Q)访问不同精度的数据。ARM64 的浮点/SIMD 寄存器支持多种数据宽度的访问方式,并非独立寄存器组,而是同一寄存器的不同视图:


后缀 数据宽度 描述 示例指令
Q
128 位
访问整个 128 位寄存器
ADD V0.Q, V1.Q, V2.Q
D
64 位
访问寄存器的低 64 位
FMUL D0, D1, D2
S
32 位
访问寄存器的低 32 位
FADD S0, S1, S2
H
16 位
访问寄存器的低 16 位
FCVT H0, S1
B
8 位
访问寄存器的低 8 位
LD1 {B0}, [X0]


Q0-Q31 :128 位完整视图,对应 V0.Q V31.Q

D0-D31 :64 位视图,对应 V0.D V31.D

S0-S31 :32 位视图,对应 V0.S V31.S

H0-H31 :16 位视图,对应 V0.H V31.H

B0-B31 :8 位视图,对应 V0.B V31.B


除此之外浮点运算和整数运算还有一个区别,那就是需要进行类型转换,可以使用 FCVT 指令在不同精度之间转换。


FCVT H0, S1     ; 将 S1 的单精度浮点数转换为半精度(H0)
FCVT D0, S1     ; 将 S1 的单精度浮点数转换为双精度(D0)


ARM64架构和ARM32架构浮点寄存器使用的区别还是有不小的,在ARM32中Q0 是一个独立的 128 位寄存器,D0 是其低 64 位,S0 是 D0 的低 32 位,在ARM64中所有视图统一为 V0-V31,通过后缀指定数据宽度,没有独立的 Q0-Q31 寄存器组。


现在我们对寄存器有了一定的了解,接下来将讲解ARM汇编的寻址方式,第一种寻址方式寄存器寻址,这种方式直接使用寄存器中的值作为操作数,无需访问内存。


mov r1, r2  ; 将 r2 的值复制到 r1


这种方式操作速度最快,仅涉及寄存器间的数据传输。一般适用于频繁的数据交换或临时值保存。


第二种寻址方式立即寻址,该方式操作数是直接编码在指令中的常量(立即数),就像下面的代码一样:


mov r0, #0xFF00  ; 将立即数 0xFF00 加载到 r0


ARM 立即数必须符合“8 位常数 + 4 位循环右移”格式(例如 0xFF00 是合法的,因为可以表示为 0xFF << 8)。而非法立即数需通过多次指令或内存加载实现。


第三种寻址方式寄存器移位寻址,这种方式对源寄存器的值进行移位操作后作为操作数。支持四种移位类型,分别是以下四种:


(1) 逻辑左移(LSL, Logical Shift Left)


操作 :将二进制位向左移动,低位补 0,高位溢出丢弃。

示例


(2) 逻辑右移(LSR, Logical Shift Right)


操作 :将二进制位向右移动,高位补 0,低位溢出丢弃。

示例


(3) 算术右移(ASR, Arithmetic Shift Right)


操作 :保留符号位(最高位),其余位右移,高位补符号位。

示例


(4) 循环右移(ROR, Rotate Right)


操作 :将二进制位循环右移,最低位移出的位补到最高位。

示例


这种方式能快速实现乘除运算(如 LSL #n 等效于乘 2n2 n ),也适用于位操作(如掩码提取、数据对齐)。


第四种寻址方式寄存器间接寻址,这种方式使用寄存器中的值作为内存地址,访问该地址处的数据。


ldr r1, [r2]  ; 将 r2 指向的内存地址的值加载到 r1


其实这个可以理解为C 语言中的 int x = *p;


这种方式必须通过 ldr 或 str 指令访问内存,适用于动态内存操作(如指针遍历)。

第五种寻址方式基址变址寻址,这种方式是通过基址寄存器(Base Register)加偏移量(Offset)计算有效地址。


ldr r1, [r2, #4]  ; 访问 r2 + 4 地址处的值


该种寻址方式还有两种变体:


前变址:先更新基址寄存器,再访问内存。


ldr r1, [r2, #4]!  ; 等效于 r2 = r2 + 4,然后 r1 = [r2]


后变址:先访问内存,再更新基址寄存器。


ldr r1, [r2], #4  ; 先 r1 = [r2],然后 r2 = r2 + 4


这种方式常用于数组遍历、结构体成员访问。


第六种寻址方式为多寄存器寻址,该方式是单条指令批量操作多个寄存器。


ldmia r11, {r2-r7, r12}  ; 从 r11 指向的地址连续加载数据到多个寄存器


模式:

IA(Increment After):操作后地址递增(默认模式)。

IB(Increment Before):操作前地址递增。

DA(Decrement After):操作后地址递减。

DB(Decrement Before):操作前地址递减。


这种方式常用于函数调用时批量保存/恢复寄存器(如 stmdb sp!, {r0-r12, lr})。

第七种方式为堆栈寻址,这种方式是基于堆栈指针( sp )的多寄存器操作,支持不同堆栈类型。


stmfd sp!, {r2-r7, lr}  ; 将寄存器压入满递减堆栈(ARM 默认)


堆栈类型:

FD(Full Descending):堆栈向低地址增长(压栈时 sp 先减后存)。

ED(Empty Descending):堆栈向低地址增长(压栈时 sp 先存后减)。

FA/EA:类似逻辑,但方向不同。 这种方式常用于函数调用时保存上下文(如保存 lr 和局部变量)。


这里也多提一嘴,我们有时可以看到像这样的ARM汇编指令:


stmfd sp!, {r1-r4}


该指令中的**!**符号的作用是 自动更新基址寄存器(SP)的值,具体表现为:


1. 基址寄存器回写 ! 表示指令执行后,基址寄存器( sp )的值会根据操作的内存偏移量自动更新。


满递减栈(Full Descending Stack)

  • stmfd
    在存储数据前,堆栈指针 sp 先递减(预递减)。
  • 存储完数据后, sp 的值会被更新为递减后的新地址。



5. 具体操作流程


! :存储数据到内存,但 sp 的值不变。

!

  1. sp
    先递减 4 * 4 = 16 字节 (每个寄存器占 4 字节,共 4 个寄存器)。
  2. r1-r4 的值依次存储到 sp 指向的新地址。
  3. sp
    最终指向存储后的新栈顶地址。



示例分析


stmfd sp!, {r1-r4}  ; 存储前 sp -= 16,存储 r1-r4,sp 更新为新地址


等效伪代码


应用场景


函数调用 :保存寄存器到栈中,并自动更新栈指针。

中断处理 :快速保存上下文,避免手动调整栈指针。


寻址方式总结


! 符号在 ARM 存储多寄存器指令中,表示 基址寄存器在操作后自动更新 。对于 stmfd sp!, {r1-r4} ,它确保栈指针 sp 在存储数据后指向新的栈顶位置,简化了堆栈管理的复杂性。


第八种寻址方式相对寻址,这种方式基于当前程序计数器( PC )的偏移量计算目标地址。


beq flag  ; 若条件满足,跳转到标签 flag 处
flag:     ; 目标地址 = PC + 偏移量(由汇编器自动计算)


这种方式有以下特点:


偏移量为有符号数,范围受指令格式限制(如 Thumb 模式为 ±2048)。


支持位置无关代码(PIC)。


这种方式常用于条件分支、循环控制、函数调用(如 bl func )。


了解完了寻址方式,接下来聊聊一些常见的套路:


1、在ARM32函数调用中,被调用函数需保存并恢复 R4-R11 寄存器的值,以确保调用者的状态不被破坏。此外,若函数内部使用到 LR(链接寄存器) ,也需保存其值(例如通过压栈)。这一机制保证了函数返回后,调用者的寄存器和程序流程能正确恢复。而在arm64中被调用函数则是需要保存并恢复X19-X29寄存器的值,若被调用函数需要调用其他函数,需保存 LR(X30),通常通过 STP 指令压栈。


2、在ARM32中, SP(R13) 是专用的栈指针寄存器。通过递减SP的值(如 SUB SP, SP, #N ),函数为局部变量分配栈空间;函数退出时需恢复SP(如 ADD SP, SP, #N )。这种机制实现了栈内存的高效管理,确保局部变量和函数调用的隔离性。而在arm64中SP(X31)寄存器专门用于栈指针寄存器,必须 16 字节对齐。通过 SUB SP, SP, #N 分配栈空间,N 需为 16 的倍数,函数退出前通过 ADD SP, SP, #N 恢复栈指针。


讲到这里我们来看一段arm64汇编代码:


.text:0000000000005318 ; jint JNI_OnLoad(JavaVM *vm, void *reserved)
.text:0000000000005318                 EXPORT JNI_OnLoad
.text:0000000000005318 JNI_OnLoad                              ; DATA XREF: LOAD:0000000000000918↑o
.text:0000000000005318
.text:0000000000005318 var_30          = -0x30
.text:0000000000005318 var_28          = -0x28
.text:0000000000005318 var_20          = -0x20
.text:0000000000005318 var_10          = -0x10
.text:0000000000005318 var_8           = -8
.text:0000000000005318 var_s0          =  0
.text:0000000000005318 var_s8          =  8
.text:0000000000005318
.text:0000000000005318 ; __unwind {
.text:0000000000005318                 SUB             SP, SP, #0x40
.text:000000000000531C                 STR             X21, [SP,#0x30+var_20]
.text:0000000000005320                 STP             X20, X19, [SP,#0x30+var_10]
.text:0000000000005324                 STP             X29, X30, [SP,#0x30+var_s0]
.text:0000000000005328                 ADD             X29, SP, #0x30
…………
.text:00000000000053C0                 LDP             X29, X30, [SP,#0x30+var_s0]
.text:00000000000053C4                 LDP             X20, X19, [SP,#0x30+var_10]
.text:00000000000053C8                 LDR             X21, [SP,#0x30+var_20]
.text:00000000000053CC                 ADD             SP, SP, #0x40 ; '@'
.text:00000000000053D0                 RET


我们可以看到首先通过SUB方法去把 SP 的值减去 0x40,通过这种方式对栈指针 SP 进行调整从而提升堆栈,也就是让栈顶指针向上提升 0x40 个字节。提升堆栈后通过STR命令将X21寄存器的值存到内存里,存放的位置为SP + 0x10 这个内存地址处,后续两次STR命令皆如此,第二行STR汇编代码把寄存器 X20 的值存到 SP + 0x20 处,把寄存器 X19 的值存到 SP + 0x28 处,第三行STR汇编代码把 X29 和 X30 的值分别存到 SP + 0x30 和 SP + 0x38 处,为什么0x28和0x38都是加8字节,因为 64 位寄存器占 8 个字节。


如此便把X21、X20、X19、X29、X39寄存器中的值压入堆栈中,保存的寄存器包括 被调用者保存寄存器(X19-X21) 和 栈帧指针(X29)、返回地址(X30)。接下来就是通过ADD指令把 SP 的值加上 0x30 后赋给 X29。这样一来,X29 就指向了 SP + 0x30 这个地址,也就是把 X29 当作栈底指针。


我们继续看函数调用的最后,可以看到先是通过LDP命令将之前压入堆栈的值重新读取出来赋值给原本的寄存器,这样便把这些寄存器原本的值还给了它,恢复顺序与保存顺序相反,接下来通过 ADD SP 释放之前分配的0x40个字节栈空间,恢复 SP 到函数入口时的位置,最后通过RET汇编代码跳转到链接寄存器X30保存的返回地址,结束函数调用。


接下来我们讲解资源重定位,当程序在编译时无法确定字符串的实际加载位置,就需要依赖资源重定位。也可以说资源重定位是程序加载到内存时,根据实际基地址调整代码和数据中引用地址的过程。其核心目的是解决程序在不同内存位置运行时地址不固定的问题。


编译后的程序通常假设从固定基地址运行,但实际加载地址可能不同。若代码中直接使用绝对地址,实际运行时地址会失效。所以要记录需要修正的地址,然后通过 PC 相对寻址 或 重定位条目修正,在运行时动态计算实际地址。


我们来看一段ARM 32汇编代码,展示资源重定位的实现过程。代码通过 PC 相对寻址 动态计算字符串地址,并调用 printf 函数输出结果:


; 代码段 (.text)
.text:0000072C          LDR R2, =(sResult - 0x738)   ; 加载字符串偏移量到 R2
.text:00000730          ADD R2, PC, R2               ; 计算字符串实际地址:R2 = PC + 偏移量
.text:00000734          MOV R0, R2                   ; R0 = 字符串地址("Result: %d"
.text:00000738          MOV R1, #42                  ; R1 = 要输出的数值(示例值 42
.text:0000073C          BL printf                    ; 调用 printf 函数
.text:00000740          ...                          ; 后续代码

; 只读数据段 (.rodata)
.rodata:00001






请到「今天看啥」查看全文