2020-05-30

ARM下高级内联挂钩(Inline Hook)方案

作者:好中文的样子 所属分类 - 安全 - 干货 - 黑科技

在ARM的多数内联HOOK方案中,基本上都是在函数代码段起始部插入自己的跳转代码,达到直接跳转的目的。与X86类似,插入的代码均为强制跳转(汇编为B、BX、BL、BLX或者BXJ)。这样固然实现了简单的强制跳转Inline Hook。但是有的时候,我们没法通过新开辟代码区域实现跳转,或者函数代码段不足以写入跳转代码,或者由于一些检测的原因,没法直接插入内联挂钩代码。在这些情况之下,就需要高级内联挂钩方案。

简易的跳转汇编器(框架)部分源码

目前已知众多Android的HOOK框架,都是不支持进程直接创建时HOOK或者销毁的。只能通过挂钩Zygote的创建并注入到Zygote当中,因此这里会存在一个非常严重的问题。不过目前来看,这个非常严重的HOOK真空,并没有被多数反外挂方案利用,这个区域即使无法HOOK,利用这个区域领空实现注入检测的方案也十分罕见。当然SK团队已经很好解决了这个问题,已经实现了钩子真空领域的成功挂钩(欢迎咨询QQ384550791或者邮箱king#die.lu)。

我们暂不讨论注入的检测与反检测,这里只是介绍一种新的内联挂钩方案。目前已知微信、抖音和别的一些内含大量应用完整性校验方案的应用,会对传统内联挂钩进行检测。目前来看,微信安卓版、iOS版对此检测并不强,可能是因为更换了安全工程师所致。而抖音的检测有点意思,下面会介绍这种检测与反检测。

一个简单的安卓程序

在使用内联方案或者复写方案替换了原函数,必定会无法绕过两座大山,即为Link重定位(直接更改函数地址)或者函数地址头写入数据。这样就十分好检测了。这里我们说说检测的方法。

int mprotect(const void *start, size_t len, int prot); // mprotect

取得函数地址以后,我们将函数头设置为可读可执行(不需要可写),然后直接CopyMemory(memcpy),将函数头的内存Dump出来。这个跟Windows NT的操作基本上是一样的。一般来说,我们只需要将一个我们怀疑经常被黑客或者外挂作者hook的函数,头8字节dump出来即可。

int memcmp(const void *buf1, const void *buf2, unsigned int count); // memcmp函数原型
auto isModify = memcmp ( buffer1, buffer2, sizeof(buffer1) ); // 比较buffer 1、buffer 2的内容

代码当中包含了检测函数,这里其实很好理解。首先我们把函数头内存dump出来,作为buffer 1,然后比对各种HOOK框架的特征头部,例如SandHook、Substrate、Epic等等,就可以直接判断这个函数是否被挂钩了。如果被挂钩,直接上报服务器,封号即可。

实际上并不需要比对8字节,部分HOOK框架只修改了4字节,因此只需要比对4字节即可。经过HOOK框架修改的头部,必定与原函数头部4字节不相同,为何这样说?请自行看编译原理与CLANG-LLVM的Gen部分源码。

就是要这样封你号

哟呵,完蛋!那么是不是就说这种手段就没法绕过,死一大片内联HOOK框架呢?当然不是。不着急,我们先看看下面一段代码。

typedef struct {
    int a;
    int b;
} data_t;

data_t test(data_t c) {
    return (data_t){c.a + c.b, c.a - c.b};
}

int test2(int a, int b) {
    data_t x = {a, b};
    data_t y = test(x);
    return y.a + y.b;
}

这里是一个简单的测试代码,可以理解为一个游戏当中的玩家信息。下面我们将这段代码编译器编译以后,反编译一下,看看变成了什么样子(ARM汇编,代码经过人工优化)。

test:
add   r3, r1, r2       ;r3 = c.a + c.b
str   r3, [r0]         ;r0->a = c.a + c.b
sub   r3, r1, r2       ;r3 = c.a - c.b
str   r3, [r0, #4]     ;r0->b = c.a - c.b
bx    lr               ;返回

test2:
push  {lr}             ;保存寄存器上下文
push  {r1, r0}         ;x = {a, b}
sub   sp, sp, #8       ;y = {    }
mov   r0, sp           ;r0 = &y
ldr   r1, [sp, #8]     ;r1 = x.a
ldr   r2, [sp, #12]    ;r2 = x.b
bl    test             ;调用test
ldr   r1, [r0]         ;r1 = y.a
ldr   r2, [r0, #4]     ;r2 = y.b
add   r0, r1, r2       ;r0 = y.a + y.b
add   sp, sp, #16      ;退栈,释放x和y的空间
pop   {pc}             ;恢复寄存器上下文并返回

这里可以很明显看见,test2通过bl命令调用了testl。同样,我们要hook类似sys_read这类的函数的时候,实际上hook的是vfs_read的wrapper函数。如果我们hook的不是一个wrapper函数,那么我们可以hook调用这个函数的函数。于是思路就变得简单了。我们来复习一下ARM汇编的BL指令:

高级语言的函数调用在arm汇编中通过bl和bxl指令来实现。bl和bxl在执行时只做了两件事:1)pc值存入lr;2)更新pc。因此,诸如寄存器上下文保存和恢复等操作必须由被调用者完成。被调用函数保存寄存器上下文时,只需保存自己将要使用的寄存器的值即可。r0~r3无需作为上下文进行保存。

这里要注意的是如果被调用者同时也调用了其他函数的话必须保存lr(返回地址),因为lr将在内部的调用中被改写。在函数返回时,如果lr被保存,将该值从内存中读出赋给pc即可;否则当前lr即为返回地址,直接bx lr。

我们是否可以改变bl跳转执行的函数呢?例如我们创建了一个test3函数,看起来像是这样子的。

data_t test3 (data_t v0)
{
    auto ret = test(v0);
    // Here hook logic.
    return ret;
}

答案是当然可以,我们只需要修改跳转到test3即可,对于一个test函数的wrapper,我们可以在这里实现自己想要的HOOK功能。

手工替换会很累的,特别是有的函数是被OLLVM之类的混淆了,人眼看着会非常累。于是我自己做了一个批量替换器,例如func1函数是func2的一个wrapper,我们只需要hook func2即可。对于一些软件对函数头的检测,这个方案可以说是直接通杀。而隐秘性较强的方案,通常需要动手能力好的人去实现。

经过这样的处理,只替换bl执行的内容,我们的HOOK框架变得更隐秘了,常规的检测手段是没法检测出来的,当然这种方案的兼容性并不好,也较为复杂,因此可以作为传统方案的一种补充。

对于这类技术的研究,欢迎添加我的企鹅讨论。

麦科技原创 转载请说明出处