Android Arm Inline Hook

相信有搞过Windows开发的都会跟我一样感慨吧,相比起Win32 Ring3的Inline hook Arm的Inline hook真的复杂太多了,为什么这么说呢,反汇编Win32的程序你会发现函数头部一般都是这样的

mov edi, edi
push ebp
mov ebp, esp
sub esp, 0e8h
push ebx
push esi
push edi
....

在开辟栈空间保存寄存器前面都会有几条相同的指令

mov edi, edi
push ebp
mov ebp, esp

这几条指令占用5个字节,刚好是可以覆盖一个jmp指令上去,从而实现hook跳转,mov edi, edi 看上去是一条多余的无用指令,但其实是微软别有用心的埋上去的,作用就是为了可以用来做hook,详情可以看: Why do Windows functions all begin with a pointless MOV EDI, EDI instruction 这是官方给的说明,可以看出微软已经为了hook预埋了官方支持了,但是回到Arm下就没有此等待遇了,如果像Win32这样去覆盖的话,就会出现函数头部部分功能指令被覆盖而导致了源函数功能缺失问题,因此在hook前还得做指令备份,另外Arm还分armv7a armv8a 还有thumb指令集等等,所以Arm下的inlinehook技术水平含量比Win32的高多了。

复杂归复杂,但本质上Arm跟Win32的inlinehook原理是一样的,离不开下面几个步骤

  • 查找目标函数地址
  • 写入jmp指令
  • hook函数执行完后jmp回源函数

对于Arm在写入jmp指令前还要备份源指令,hook函数执行完还需要执行备份指令等等操作。

在开始写Arm的inlinehook前需要先掌握一些Arm指令,由于armv7a基本已经绝迹了,这里我们只介绍armv8a,下面所提到的Arm都是特指Arm64

A64指令基础

  • MOV指令
    MOV指令是把操作数赋值到寄存器里,举个例子,下面这条指令的作用是把0x40值赋值给x2寄存器
mov x2, 0x40
  • 跳转指令
    前面提到的jmp指令是x86的,对于Arm跳转指令是 B/BL/BR/BLR 等等B系列指令,而跳转也分了绝对地址寻址跟相对地址寻址两种方式,B/BL 是相对地址寻址方式,目标地址是相对PC的偏移地址,两者都是无条件跳转,区别是BL会把下一条指令地址保存到LR(x30)寄存器里,这样当子程序执行完了也能借助LR(x30)寄存器跳转回到主程序继续执行。与之对应的BR/BLR就是绝对地址寻址跳转,BLRBL性质一样,在跳转前会把下一条指令地址保存到LR(x30)寄存器里。举个例子:
mov x0, 0x400000 ; 把0x400000地址赋值给x0寄存器
br x0  ; 跳转到0x400000去

b 8 ; 跳转到PC+8的地方去执行
str x0, [sp]
sub sp, sp, #-0x10
  • STR指令
    STR指令就是store register,作用是把寄存器的值保存到内存中,常用于寄存器压栈保存。举个例子,下面第一条指令作用是把x5寄存器的值保存到内存地址0x400000中,第二条指令作用是把x6寄存器的数据保存到0x400000+8的内存地址中
mov x2, 0x400000
str x5, [x2]
str x6, [x2, #8]
  • LDR指令
    LDR指令就是load register,作用是把数据从内存中加载到寄存器去,常用于从栈里恢复先前保存好的寄存器。举个例子,如下面第一条指令的作用是把x1寄存器所指向的内存数据加载到x0寄存器里,第二条指令的作用是把将当前PC寄存器的地址 + 0x8偏移,取出地址内容填充到x1寄存器中
ldr x0, [x1]
ldr x1, 0x8
  • LDP/STP指令
    ldr/stp的作用是跟ldr/str指令相似的,区别是支持多字节(16 byte)操作,举个例子:
ldp x1, x2, [x0]  ; 把x0寄存器所指向的内存数据加载到x1寄存器里,把0x+0x8所指向的内存数据加载到x2寄存器里
stp x3, x4, [x5] ; 把x3的数据存储到x5指向的内存中,把x4的数据存储到x5+0x8所指向的内存中
  • PC寄存器
    PC寄存器就是Program Counter,指向了当前正在执行的指令地址,网上有些文章会说PC指向了当前正在执行的下一条指令的地址,这里我从官方找到了一份权威说明来辟谣 Program Counter in AArch64 state

  • SP寄存器
    SP就是Stack Pointer 栈顶指针,相当于x86的esp

  • 通用寄存器
    Arm64的通用寄存器有31个 分别是x0-x30,其中x29是栈底寄存器(fp),x30是程序链接寄存器(lr),

  • 函数
    函数返回值一般情况下会保存到x0寄存器中,超过8字节会保存到栈顶去。
    函数参数会保存在x0-x7寄存器中,超过8个参数会保存到堆栈里。

Inline hook原理+实现

有了前面这些基础指令知识后,我们就可以自己写出一个简单的Inline hook程序了。在开始之前先说明下Inline hook的一下专业术语

  • 跳板程序:
    跳板程序就是负责从源函数跳出去的一组指令代码,通常这部分代码约简练约好

  • shellcode程序:
    通常情况下跳板程序会跳转到shellcode处,由shellcode来负责寄存器保存还原,堆栈平衡等等工作,shellcode并不是必须的,跳板程序可以直接跳转到hook函数去,要不要shellcode是根据自己的需求而定的。

  • hook函数
    这个就无须多解析了,源函数被替换后的hook函数。

最简单的跳板

假设我们有下面源函数跟hook函数:

void origin_print() {
    __android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 1");
    __android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 2");
    __android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 3");
    __android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 4");
    __android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 5");
}

void hook_print() {
    __android_log_print(ANDROID_LOG_INFO, "nls", "hook_print invoke");
}

在没hook之前调用origin_print的时候会打印出origin_print invoke日志,效果如下:

现在要hook origin_print,hook后调用origin_print的时候会跳转到hook_print来执行。

前面我们已经说过了hook本质就是在函数头部插入一条无条件跳转指令,当调用此函数时候便会跳转到hook函数去执行了,跳转可以用B指令,在A64指令基础里也介绍过了,要注意的是B指令跳转的地址是偏移地址,我们用目标地址-源地址就能算出偏移地址来,这里我们构造一条跳转指令

void simpleHook(void* originAddr, void* hookAddr) {
    __android_log_print(ANDROID_LOG_INFO, "nls", "simpleHook originAddr: %x, hookAddr: %x", originAddr, hookAddr);

    // .text:000000000000FAC8 ; void __cdecl origin_print()
    // .text:000000000000FB7C ; void __cdecl hook_print()
    // B 0xb4 机器码是 \x2d\x00\x00\x14 0xb4 是偏移地址
    unsigned  char jumpCode[4] = {0x2d, 0x00, 0x00, 0x14};
    ChangePageProperty(originAddr, 4);
    memcpy(originAddr, jumpCode, 4);
    clear_cache_addr(originAddr, 4);
    __android_log_print(ANDROID_LOG_INFO, "nls", "simpleHook 2");
}

执行下simpleHook,origin_print函数就被hook掉了,此时调用origin_print会跳转到了hook_print函数去并且打印出hook_print invoke日志效果如下:
跳板升级版

上面的跳板仅用了一条指令,可以说是最简单的跳板程序了,但B指令的跳转范围是有限的,它的跳转最大范围是128m,如果hook函数不在这个范围内就不能使用B指令了,这种情况下我们就得使用另外一种无条件跳转指令BR了,BR指令的用法如下

BR Xn

Xn可以是任意通用寄存器,BR会跳转到Xn寄存器所指向的内存地址去,B指令跳转的是偏移地址,而BR跳转的是绝对地址,把跳板指令修改成如下

ldr x0, 8
br x0
addr

因此要把上面的simpleHook修改成下面这样

void simpleHook(void* originAddr, void* hookAddr) {
    __android_log_print(ANDROID_LOG_INFO, "nls", "simpleHook originAddr: %x, hookAddr: %x", originAddr, hookAddr);
    // 跳板:
    // ldr x0, 8
    // br x0
    // addr
    // 对应的机器码是\x40\x00\x00\x58\x00\x00\x1f\xd6
    unsigned  char jumpCode[16] = {0x40, 0x00, 0x00, 0x58, 0x00, 0x00, 0x1f, 0xd6};
    memcpy(jumpCode + 8, &hookAddr, 8);
    ChangePageProperty(originAddr, 16);
    memcpy(originAddr, jumpCode, 16);
    clear_cache_addr(originAddr, 16);
}

跳板程序占用16字节长度会覆盖掉源函数3条指令,但这样通过BR就能跳转到更远的hook函数去了。

shellcode程序

很显然的跳板如果使用了BR指令做跳转的话就必然会污染到一个寄存器,demo里跳板重写了x0,这必然会导致函数传参有问题,如果把origin_print跟hook_print函数修改成带参数的如下:

void origin_print(int a, int b, std::string str) {
  // 省略。。。
}
void hook_print(int a, int b, std::string str) {
    __android_log_print(ANDROID_LOG_INFO, "nls", "hook_print invoke a: %d, b: %d, str: %s", a, b, str.c_str());
}
//调用
origin_print(10, 20, "123abc");
// hook origin_print后输出
// b跟str参数都能正常拿到,a参数却拿不到了,因为参数a在x0里,跳板把x0重写了导致a数据错了
22827-22827/com.nls.test I/nls: hook_print invoke a: -468124660, b: 20, str: 123abc

b跟str参数都能正确拿到,a参数却拿不到了,因为参数a正式保存在x0寄存器里的,跳板把x0覆盖了也导致参数a丢失了。

要解决寄存器被污染的问题,要么就选一个不会被占用的寄存器(如x16,x17),要么就在使用前先把寄存器保存下来用完再恢复,显然的第一种方法更加简单,但通用性却不高,这里我们使用第二种方式,先把寄存器保存到栈里,这样跳板程序就要改成

stp x1, x0, [sp, #-0x10]
ldr x0, 8
br x0
addr

在跳转hook函数之前还得加上一段shellcode代码,跳板程序会先跳转到shellcode代码处,由shellcode程序负责还原寄存器并且跳转到hook函数,最后还要还原寄存器数据,恢复堆栈平衡。

我们构造如下shellcode程序

shellcode_start:
    sub     sp, sp, #0x10    ;开辟栈控件
    ldr     x0, [sp, #8]     ;恢复x0
    str     x30,  [sp]       ;保存下x30
    ldr     lr, 8;
    b       12
hook_function_addr: 
.double 0xffffffffffffffff
    blr     lr; 
    ldr     x30, [sp]  ;恢复x30
    add     sp, sp, #0x10 ;恢复堆栈
    br      lr
shellcode_end:

shellcode写好了把它写进内存里,代码如下:

void* build_shellcode(void* hookAddr) {
    void* pShellCodeStart = &shellcode_start;
    void* pShellCodeEnd = &shellcode_end;
    void* pHookFunAddr = &hook_function_addr;
    size_t nShellCodeSize = pShellCodeEnd - pShellCodeStart;
    size_t nHookAddrOffset = pHookFunAddr - pShellCodeStart;
    //void* pShellCodeAddr = malloc(nShellCodeSize);
    long nPageSize = sysconf(_SC_PAGE_SIZE);
    void* pShellCodeAddr = NULL;
    int nResCode = posix_memalign(&pShellCodeAddr, nPageSize, nPageSize);
    if (pShellCodeAddr != NULL && !nResCode) {
        memcpy(pShellCodeAddr, pShellCodeStart, nShellCodeSize);
        void** hookFunAddr = pShellCodeAddr + nHookAddrOffset;
        *hookFunAddr = hookAddr;
        if (!changePageProperty(pShellCodeAddr, nShellCodeSize)) {
            return NULL;
        }
        return pShellCodeAddr;
    }
    return NULL;
}

代码比较简单,首先是申请一块内存页,把shellcode代码写进去,把hookAddr地址写到shellcode里面,修改内存页属性为可执行,最后返回shellcode地址。

同样的上面的simpleHook函数代码要修改成如下:

    // stp x1, x0, [sp, #-0x10]
    // ldr x0, 8
    // br x0
    // addr
    unsigned  char jumpCode[20] = {0xe1, 0x03, 0x3f, 0xa9, 0x40, 0x00, 0x00, 0x58, 0x00, 0x00, 0x1f, 0xd6};
    void* pShellCodeAddr = build_shellcode(hookAddr);
    if (pShellCodeAddr != nullptr) {
        memcpy(jumpCode + 12, &pShellCodeAddr, 8);
        changePageProperty(originAddr, 20);
        memcpy(originAddr, jumpCode, 20);
        clear_cache_addr(originAddr, 20);
        __android_log_print(ANDROID_LOG_INFO, "nls", "simpleHook 2");
    }

再执行下simpleHook进行hook操作,这时候我们的hook程序就能正确的拿到函数参数了,效果如下:
回调源函数

通常在hook某函数后,我们还得在hook函数里选择性的回调源函数,直接调用源函数肯定是不行的,这会导致死循环,正确的做法是要跳转回我们跳板程序的下一行代码处继续执行,那么被我们跳板覆盖掉的逻辑岂不是没了?是的,所以在写入跳板之前还得先备份好指令,在回调源函数前先执行备份指令,完了之后再调回跳板后的代码处继续执行。

继续把跳板程序修改成如下:

stp x1, x0, [sp, #-0x10]  
ldr x0, 8
br x0
addr
ldr x0, [sp, -0x8]

主要是多加了一条指令ldr x0, [sp, -0x8],因为从hook函数调回源函数也是一样需要一个跳板程序的,这样一来也是会覆盖掉一个寄存器作为代价,而这里加上的这行指令就是为了还原跳板程序覆盖掉的寄存器。

此时的跳板程序大小是6条指令长度,因此源函数也需要备份头部6条指令

//备份源指令
void* pNewOriginAddr = pShellCodeAddr + nShellCodeSize;
memcpy(pNewOriginAddr, pOriginAddr, 24);

因为前面申请的shellcode空间足够的大,我们直接在shellcode的尾部构造跳板负责跳回源函数,虽然hook函数的跳板是24字节长度,但源函数回调用的跳板程序是要跳回
ldr x0, [sp, -0x8]这行指令处做寄存器的恢复,因此这里的跳板目的地址应该是pOriginAddr + 20,跳板代码如下

void* pOriginPos = pOriginAddr + 20;
unsigned  char jumpCode[20] = {0xe1, 0x03, 0x3f, 0xa9, 0x40, 0x00, 0x00, 0x58, 0x00, 0x00, 0x1f, 0xd6};
memcpy(jumpCode + 12, &pOriginPos, 8);
memcpy(pNewOriginAddr + 24, jumpCode, 20);
*pOriginHookAddr = pNewOriginAddr;

最后pNewOriginAddr就是新的源函数指针,我们修改build_shellcode函数,传入一个指针的指针参数用来存放新的源函数地址,最后代码如下:

typedef void (*pOrigin_print)(int a, int b, std::string str);

pOrigin_print pOriginPrint;

void hook_print(int a, int b, std::string str) {
    __android_log_print(ANDROID_LOG_INFO, "nls", "hook_print invoke a: %d, b: %d, str: %s", a, b, str.c_str());
    //a等于20的时候我们回调源函数
    if (pOriginPrint != nullptr && a == 20) {
        (pOriginPrint)(a, b, str);
    }
}

void simpleHook(void* originAddr, void* hookAddr) {
    // stp x1, x0, [sp, #-0x10]
    // ldr x0, 8
    // br x0
    // ldr x0, [sp, -0x8]
    void* pOriginHookAddr;
    unsigned  char jumpCode[24] = {0xe1, 0x03, 0x3f, 0xa9, 0x40, 0x00, 0x00, 0x58, 0x00, 0x00, 0x1f, 0xd6};
    void* pShellCodeAddr = build_shellcode(hookAddr, originAddr, &pOriginHookAddr);
    if (pShellCodeAddr != nullptr) {
        memcpy(jumpCode + 12, &pShellCodeAddr, 8);
        jumpCode[20] = 0xE0;
        jumpCode[21] = 0x83;
        jumpCode[22] = 0x5F;
        jumpCode[23] = 0xF8;
        changePageProperty(originAddr, 24);
        memcpy(originAddr, jumpCode, 24);
        clear_cache_addr(originAddr, 24);
        pOriginPrint = (pOrigin_print)(pOriginHookAddr);
        __android_log_print(ANDROID_LOG_INFO, "nls", "simpleHook 2");
    }
}

// 测试代码如下
extern "C"
JNIEXPORT void JNICALL
Java_com_nls_nativelib_NativeLib_originPrint(JNIEnv* env,jobject thiz) {
__android_log_print(ANDROID_LOG_INFO, "nls", "call originPrint");
    origin_print(10, 20, "123abc");
    origin_print(20, 20, "123abc");
}

最终效果如下,当a == 20的时候成功的调回到origin_print去了

指令修复

细心的读者应该发现了,在hook函数里面用新的pNewOriginAddr函数地址回调源函数少打印了一行日志

__android_log_print(ANDROID_LOG_INFO, "nls", "origin_print invoke 1");

这是因为备份的6字节指令是直接在shellcode里执行的,如果备份指令里有PC+偏移地址 这类操作数指令的话执行必然会出错,因为当前的PC指令是在shellcode里,因此要么就把备份指令拷贝回源函数里再执行,要么就进行指令的修复工作。不考虑多线程调用的情况下,还原备份指令到源函数理论上也是可行的,但除非你能保证源函数不存在着多线程调用的情况,不然的话依然会出现调用失败的情况,因此普片做法是进行shellcode备份指令的修复工作。

有几类指令是需要修复的,譬如B BL 等跳转指令、LDR 指令、ADR ADRP等等,以跳转指令为例子,修复过程大致如下

imm26 = instruction & 0xFFFFFF;
value = pc + imm26*4;
trampoline_instructions[trampoline_pos++] = 0x5800007E; //LDR LR, 12
trampoline_instructions[trampoline_pos++] = 0xD63F03C0; //BLR LR
trampoline_instructions[trampoline_pos++] = 0x14000003; //B 12
trampoline_instructions[trampoline_pos++] = (uint32_t)(value & 0xffffffff);
trampoline_instructions[trampoline_pos++] = (uint32_t)(value >> 32);

原理就是把偏移地址换算成绝对地址,再用BR指令去跳转,对于BL指令的话还修复下LR寄存器。其他指令的修复原理实现这里就不再一一去解析了,想深入研究的话可以看下GToad跟Ele7enxxh写的博客

参考文献
Registers in AArch64 state
ARMv8指令集介绍
Program Counter in AArch64 state
Android Inline Hook中的指令修复详解
Android Arm Inline Hook

你可能感兴趣的:(Android Arm Inline Hook)