樱落彼方
无论修改 AOT 生成代码的 Android Runtime Hook,亦或操作映射内存的 Native Inline Hook,许多需要复制机器码的框架都免不了要考虑这样一个问题:如何让拷贝的机器码执行与原机器码相同的逻辑?
之所以存在这个问题,是因为指令集中存在一些相对取址的指令,其目标地址会因为指令所在地址的变动而变动。
举个简单的例子,以下两条指令所加载内存的位置并不相同。
0xa0 ldr x16, target // x16 = 0x1234
target:
0xa4 .quad 0x1234
...
0xb0 ldr x16, target // x16 = 0x5678
target:
0xb4 .quad 0x5678
以上两条 ldr
指令的值均为 0x58000030
,加载数据的目标地址均为指令本身地址加上 0x4
的偏移,相同的指令读取了不同区域的内存。
由于相对取址的存在,如果需要拷贝的代码块在执行时,遇到可能存在的相对地址操作时仍能操作未拷贝的目标代码,就必须修复代码块逻辑。否则可能会操作预期外地址中的数据。
该问题的一种解决办法是,完整复制所有参与操作的内存区域(拷贝或重映射)。对于 Android Runtime 等可获取函数编译后指令总数的场景来说,这种方案或许还存在可用空间,但在许多无法确定代码块大小的场景下,要保证覆盖操作区域,只能保守地扩大复制范围,最终可能会消耗可观的内存资源。
甚至,完整复制的方案无法解决函数的相对地址调用问题,以下面的伪代码为例。
origin_fun_a:
0xa0 ...
... // execute origin_fun_a code
0xc0 call_relative origin_fun_b // call #0x4
origin_fun_b:
0xc4 ...
... // execute origin_fun_b code
/* copy */
copy_fun_a:
0xd0 ...
... // execute origin_fun_a code
0xe0 call_relative copy_fun_b // call #0x4
copy_fun_b:
0xe4 ...
... // execute copy_fun_b code
/* hook origin */
origin_fun_a:
0xa0 ...
... // execute "hook_fun_a" code
0xc0 call_absolute copy_fun_a // execute origin implementation
origin_fun_b:
0xc4 ...
... // execute "hook_fun_b" code
/* How to execute "hook_fun_b" implementation when invoke hooked origin_fun_a() ? */
圈里,圈外
代码块内的指令通常存在两种情况需要处理,即目标地址是否在代码块之外。
如果指令的目标地址在代码块之外 —— 这里称这一类指令为 out-block instruction,则一般需要将相对取址改为绝对取址,但受限于指令大小,通常无法用单条指令来实现相同地址的绝对取址,所以需要将原本的单条相对取址指令改为多条指令来实现。
// before fixing
0xa0 b #0x20 // go to 0xc0
// after fixing
0xa0 ldr <temp register> <label with absolute address>
0xa4 br <temp register>
由于 ARM 64 架构下无法直接操作 pc 寄存器,因此我们不得不占用一个寄存器来实现绝对地址跳转。
指令修复的大部分场景,跳转目标不是我们自己编写指令,通过栈来保存寄存器是不可能的。
并且,通常情况下,绝对取址需要在修复代码块中分配一块数据区,用于存放计算后的绝对地址。
而对于目标地址在代码块内的指令 —— 这里称这一类指令为 in-block instruction,为了减少指令数,可以保留原本的相对取址指令。不过,因为相对取址指令与目标指令之间存在 out-block instruction,则修复后两者之间的偏移可能会增大,因为它们之间的指令数增多了,此时需要重新设置相对取址指令的取址偏移。
// before fixing
0xa0 b #0x08 // go to 0xa8
0xa4 ... // instruction will be fixed
0xa8 add x1, x1, #0x1
// after fixing
0xa0 b #0x0c // go to 0xac, change 0x08 to 0x0c
0xa4 ... // fixed
0xa8 ... // fixed
0xac add x1, x1, #0x1
修复方案
对于 in-block instruction,仅需在修复完所有 out-block instruction 后重新计算目标地址偏移即可,因此这里仅探讨 out-block instruction 的修复。
out-block instruction 的通用修复思路是,根据当前指令所在地址与指令的偏移数据,计算出绝对地址,并创建一个 64 位的数据区保存,执行修复代码时通过 ldr
指令将绝对地址载入到一个临时寄存器中(通常是 x16、x17),再进行后续操作。
ldr x16, target
<continue with x16>
... // other instructions
target:
.quad <absolute address>
一种常规思路是将所有修复所需的数据区放置在整个修复代码块的末尾,这种做法需要计算完所有修复指令数后,才能得出数据块与
ldr
指令的偏移,因此在构造完修复代码块后还需要回头更改ldr
指令的加载偏移。ldr x16, inst_1_target // ⎤ fix instruction 1 <continue with x16> // ⎥ <continue with x16> // ⎦ ldr x16, inst_2_target // ⎤ fix instruction 2 <continue with x16> // ⎦ ldr x16, inst_3_target // ⎤ fix instruction 3 <continue with x16> // ⎥ <continue with x16> // ⎦ ... // fix instruction 4..n b after_data // skip data block inst_1_target: // ⎤ data block .quad <instruction 1 target absolute address> // ⎥ inst_2_target: // ⎥ .quad <instruction 2 target absolute address> // ⎥ inst_3_target: // ⎥ .quad <instruction 3 target absolute address> // ⎥ ... // ⎦ after_data: ...
另一种做法是将数据块直接放置在当前修复指令的末尾,因为修复方案是确定的,所以加载偏移也是绝对的,缺点是每一个指令修复都需要一条跳转指令来跳过数据区,而前一种方案在整个修复代码块中仅需一条跳转指令来跳过数据区。
ldr x16, #0xc // ldr x16, target <continue with x16> // if we already know that instruction need to fix will be converted into <continue with x16> // two instructions, then the offset of "ldr", #0xc, is absolute. b #0x8 // skip data block target: .quad <absolute address>
b、bl
指令 b
与 bl
都是相对取址跳转指令,指令代码的格式也相似,区别在于 bl
指令会将自己下一条指令的地址存放到 lr(即 x30)寄存器中,方便后续执行的代码跳转回当前位置。
b
指令格式如下,其中 0 ~ 25 是立即数,乘 4 得目标地址:0 0 0 1 : 0 1 ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ?
bl
指令与b
指令仅第 31 位存在差异:1 0 0 1 : 0 1 ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ?
修复方案较为简单:通过 ldr
指令读取计算后的绝对地址,然后通过与原指令类型相同的寄存器跳转指令跳转。
// before fix
b <label>
// after fix
ldr x16, target
br x16
...
target:
.quad <label absolute address>
// before fix
bl <label>
// after fix
ldr x16, target
blr x16
...
target:
.quad <label absolute address>
b.cond
由于没有条件寄存器跳转指令,因此需借助二次跳转来实现条件判断。
b.cond
指令格式如下,其中 0 ~ 3 为条件,5 ~ 23 是立即数,乘 4 得目标地址:0 1 0 1 : 0 1 0 0 : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? 0 : ? ? ? ?
// before fix
b.cond <label>
// after fix
b.cond match
b skip_match
match: // ⎤ match block, like fixing instruction "b"
ldr x16, target // ⎥
br x16 // ⎦
skip_match:
...
target:
.quad <label absolute address>
adr、ldr
因为 adr
指令与 ldr
指令本身便涉及寄存器操作,所以无需占用临时寄存器便可以实现修复,复用原本的寄存器即可。
adr
指令格式如下,其中 0 ~ 4 为目标寄存器,5 ~ 23 是立即数,乘 4 得目标地址:0 ? ? 1 : 0 0 0 0 : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ?
ldr
指令格式相似:0 ? 0 1 : 1 0 0 0 : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ?
// before fix
adr <register>, <label>
// after fix
ldr <register>, target
...
target:
.quad <label absolute address>
// before fix
ldr <register>, <label>
// after fix
ldr <register>, target
ldr <register>, [<register>]
...
target:
.quad <label absolute address>
adrp
adrp
指令以内存页为单位计算相对地址偏移,因此可以计算比 adr
指令更广的地址范围,但操作粒度也从指令膨胀到了内存页。
虽然一般情况下,adrp
指令本身就可以覆盖到正负 4G 的内存范围,但我们难以预料修复完的拷贝代码块会存放在内存中的哪个位置,在拷贝代码块中继续使用 adrp
指令不太现实,因此对于 in-block instruction,需要将其替换为等价的 adr
指令,out-block instruction 仍旧替换为绝对取址实现。
adrp
指令格式如下,其中 0 ~ 4 为条件,5 ~ 23 是立即数,乘 4096 得目标地址:1 ? ? 1 : 0 0 0 0 : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ? : ? ? ? ?
// in-block before fix
adrp <register>, <label>
// in-block after fix
adr <register>, <label>
// out-block before fix
adrp <register>, <label>
// out-block after fix
ldr <register>, target
...
target:
.quad <label absolute address>