1 背景
软件加密技术不断更新迭代,攻防双方水平不断提升,单纯的静态级别的安全对抗在实际工作场景中已经很少出现,分析人员面对得更多的是高强度的代码加密技术和程序防篡改技术。在此背景下,新的软件分析技术 —— Hook与注入技术应运而生。
反编译APK、修改或添加代码后将APK重新打包,都会改变原文件的散列值和签名信息。软件防篡改技术即通过在软件运行时检查原文件的散列值和签名等手段判断程序是否遭到破坏。Hook技术也叫钩子技术,原理是先将要修改的函数“钩住”,然后用自定义的函数将其替换,让程序在运行时执行自定义的函数,达到动态修改软件的目的。以Hook防篡改技术为例,防篡改系统在检测程序的散列值和签名时,会调用系统API读取APK签名信息,使用Hook技术,可以“钩住”这些系统API,直接返回原程序的签名信息,从而有效“欺骗”防篡改系统,解决代码重新打包后的签名检查问题。如此就涉及两个技术点:如何实施“钩住”这个动作;如何编写自定义的函数。
如图1所示,按Java层与Native层分类,Hook技术可以分为Java层的Hook和Native的Hook。根据代码的执行环境,Java层的Hook可以分为Dalvik Hook和ART Hook。根据ELF文件的特点,Native的Hook可以分为基于动态库加载劫持的LD_PRELOAD Hook、针对.got.plt节区的GOT Hook及针对汇编指令级别的Inline Hook。
2 Dalvik Hook
Dalvik 虚拟机运行的系统低于Android 5.0,早期安全研究人员对Java层的Hook的集中研究在Dalvik虚拟机上。那个时期的Hook技术能实现,得益于如下三个方面:
Java语言的反射机制。对Dalvik虚拟机中任何一个Java类的方法进行Hook,其本质即对Java语言中的Method类进行Hook。通过Java的反射机制,DEX文件中的代码很容易就能访问自己内存空间中的任何类的方法信息。在Dalvik虚拟机中,每个Java类的方法都对应java/lang/reflect/Method类;
Dalvik虚拟机底层的Java方法实现。在Dalvik虚拟机中,Java方法的定义位于Android源码文件dalvik/vm/oo/Object中。method 结构体中的字段完整地描述了一个Java方法的信息。可将它们的值修改为目标方法的相应字段,实现“移花接木”的效果。Android 4.4中其定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| struct method { ClassObject* clazz; u4 accessFlags; u2 methodIndex; u2 registersSize; u2 outsSize; u2 insSize; const char* name; DexProto Prototype; const char* shorty; /* the actual code */ const u2* insns; /* JNI: cached argument and return-type hints */ int jniArgInfo; DalvikBridgeFunc nativeFunc; bool fastJni; bool noRef; bool shouldTrace; const RegisterMap* registerMap; bool inProfile; };
|
- 可修改自身进程空间的内存。这既是Linux类操作系统进程的特点,即进程对自身内存空间的数据有绝对的控制权,也是各类Android热补丁插件得以实现的基石。
有了这些条件,实现Dalvik Hook的思路就清晰了。ddi工具的Dalvik Hook的核心实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| void* dalvik_hook(struct dexstuff_t* dex, struct dalvik_hook_t* h) { if(h->debug_me) log("dalvik_hook: class %s\n", h->clname); void* target_cls = dex->dvmFindLoadedClass_fnPtr(h->clname); if(h->debug_me) log("class = 0x%x\n", target_cls); if(h->dump && dex && target_cls) dex->dvmDumpClass_fnPtr(target_cls, (void*)1); if(!target_cls) { if(h->debug_me) log("target_cls == 0\n"); return; } h->method = dex->dvmFindVirtualMethodHierByDescriptor_fnPtr(target_cls,h->method_name, h->method_sig); if(h->method == 0) { h->method = dex->dvmFindDirectMethodByDescriptor_dnPtr(target_cls, h->method_name, h->method_sig); } if(!h->resolvm) { h->cls = target_cls; h->mid = (void*)h->method; } if(h->debug_me) log("%s(%s) = 0x%x\n", h->method_name, h->method_sig, h->method); if(h->method) { h->insns = h->method->insns; if(h->debug_me) { log("nativeFunc %x\n", h->method->nativeFunc); log("insSize = 0x%x registersSize = 0x%x outsSize = 0x%x\n", h->method-insSize, h->method-registersSize, h->method->outsSize); } h->iss = h->method->insSize; h->rss = h->method->registersSize; h->oss = h->method->outsSize; h->method->insSize = h->n_iss; h->method->registersSize = h->n_rss; h->method->outsSize = h->n_oss; if(h->debug_me) { log("shorty %s\n", h->method->shorty); log("name %s\n", h->method->name); log("arginfo %s\n", h->method->jniArgInfo); } h->method->jniArgInfo = 0x80000000; // <--- also important if(h->debug_me) { log("noref %c\n", h->method->noRef); log("access %x\n", h->method->a); } h->access_flags = h->method->a; h->method->a = h->method->a | h->af; // make method native if(h->debug_me) log("access %x\n", h->method->a); dex->dvmUseJNIBridge_fnPtr(h->method, h->native_func); if(h->debug_me) log("patched %s to: 0x%x\n", h->method_name, h->native_func); return (void*)1; } else { if(h->debug_me) log("could NOT patch %s\n", h->method_name); } return (void*)0; }
|
这段代码就是通过修改method结构体的字段数据进行Hook的。
【注意】Android系统每次升级,method结构体都可能变化,即使是高版本Android用ART虚拟机的情况下,Dalvik虚拟机对应的method结构体也会有变化,因此对 Dalvik进行Hook要考虑版本的兼容性。
3 ART Hook
Android 5.0和更高版本的Android中,因ART虚拟机出现,对ART Hook的需求也出现。ART Hook和Dalvik Hook在技术原理上有很多相似处,唯一的区别是方法结构体不同。在API23和之前版本的Android中,一个类通过getDeclaredMethod()调用后,返回的仍是java/lang/reflect/Method;在API24和之后版本的Android中,返回的则是一个AbstractMethod抽象方法对象,要在获取这个实例对象后,通过调用getDeclaredField()获取其artMethod字段,进而获取其具体的方法类。Java层的方法在底层最终会对应一个ArtMethod类,方法的定义位于Android系统源码文件 art/runtime/art_method.h中。不同版本的ART虚拟机,其具体实现及各字段在类中的偏移量可能不一样,因此实现ART Hook时也要考虑版本的兼容性。
Lody编写的Legend框架中,hookMethodArt()的实现代码如下,这些代码是ART Hook技术原理的精髓:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| private static Method hookMethodArt(Method origin, Method hook) { ArtMethod artOrigin = ArtMethod.of(origin); ArtMethod artHook = ArtMethod.of(hook); Method backup = artOrigin.backup().getMethod(); backup.setAccessible(true); long originPointFromQuickCompiledCode = artOrigin.getEntryPointFromQuickCompiledCode(); long originEntryPointFromJni = artOrigin.getEntryPointFromJni(); long originEntryPointFromInterpreter = artOrigin.getEntryPointFromInterpreter(); long originDeclaringClass = artOrigin.getDeclaringClass(); long originAccessFlags = artOrigin.getAccessFlags(); long originDexCacheResolvedMethods = artOrigin.getDexCacheResolvedMethods(); long originDexCacheResolvedTypes = artOrigin.getDexCacheResolvedTypes(); long originDexCodeItemOffset = artOrigin.getDexCodeItemOffset(); long originDexMethodIndex = artOrigin.getDexMethodIndex(); long hookPointFromQuickCompiledCode = artHook.getEntryPointFromQuickComiledCode(); long hookEntryPointFromJni = artHook.getEntryPointFromJni(); long hookEntryPointFromInterpreter = artHook.getEntryPointFromInterpreter(); long hookDeclaringClass = artHook.getDeclaringClass(); long hookAccessFlags = artHook.getAccessFlags(); long hookDexCacheResolvedMethods = artHook.getDexCacheResolvedMethods(); long hookDexCacheResolvedTypes = artHook.getDexCacheResolvedTypes(); long hookDexCodeItemOffset = artHook.getDexCodeItemOffset(); long hookDexMethodIndex = artHook.getDexMethodIndex(); ByteBuffer hookInfo = ByteBuffer.allocate(ART_HOOK_INFO_SIZE); hookInfo.putLong(originPointFromQuickCompiled); hookInfo.putLong(originEntryPointFromJni); hookInfo.putLong(originEntryPointFromInterpreter); hookInfo.putLong(originDeclaringClass); hookInfo.putLong(originAccessFlags); hookInfo.putLong(originDexCacheResolvedMethods); hookInfo.putLong(originDexCacheResolvedTypes); hookInfo.putLong(originDexCodeItemOffset); hookInfo.putLong(originDexMethodIndex); hookInfo.putLong(hookPointFromQuickCompiledCode); hookInfo.putLong(hookEntryPointFromJni); hookInfo.putLong(hookEntryPointFromInterpreter); hookInfo.putLong(hookDeclaringClass); hookInfo.putLong(hookAccessFlags); hookInfo.putLong(hookDexCacheResolvedMethods); hookInfo.putLong(hookDexCacheResolvedTypes); hookInfo.putLong(hookDexCodeItemOffset); hookInfo.putLong(hookDexMethodIndex); artOrigin.setEntryPointFromQuickCompiledCode(hookPointFromQuickCompiledCode); artOrigin.setEntryPointFromInterpreter(hookEntryPointFromInterpreter); artOrigin.setDeclaringClass(hookDeclaringClass); artOrigin.setDexCacheResolvedMethods(hookDexCacheResolvedMethods); artOrigin.setDexCacheResolvedTypes(hookDexCacheResolvedTypes); artOrigin.setDexCodeItemOffset((int) hookDexCodeItemOffset); artOrigin.setDexMethodIndex((int) hookDexMethodIndex); int accessFlags = origin.getModifiers(); if (Modifier.isNative(accessFlags)) { accessFlags &= ~ Modifier.NATIVE; artOrigin.setAccessFlags(accessFlags); } long memoryAddress = Memory.alloc(ART_HOOK_INFO_SIZE); Memory.write(memoryAddress, hookInfo.array()); artOrigin.setEntryPointFromJni(memoryAddress); return backup; }
|
4 LD_PRELOAD HOOK
LD_PRELOAD是Linux系统中的一个环境变量,LD_PRELOAD Hook即针对LD_PRELOAD环境变量的特殊Hook技术。
和Windows平台的程序一样,Linux平台的程序也分为静态链接和动态链接两种。静态链接把所有引用的函数全编译到可执行文件中,程序在运行时可直接调用这些函数;动态链接没把函数编译到可执行文件中,而是在程序运行时动态载入函数库。在进行动态链接时,要查找动态链接库的位置,以便从动态链接库中查找函数的地址供程序使用。对动态链接的程序来说,它并不关心动态链接库具体在哪及如何计算函数地址,而是把这个工作交给动态加载器(又称“链接器”,32位Android的链接器为/system/bin/linker,64位的为/system/bin/linker64)完成。
动态链接库为多个程序共用一份代码提供可能,节省了程序占用的磁盘空间。同时,在不重新编译程序的情况下,仅升级动态库中的函数即可升级整个程序,大大降低维护成本。但是,若进行动态链接时动态加载的函数不是开发人员而是一些别有用心之人编写,那程序的流程或返回值就可能被恶意修改,即程序被Hook。
LD_PRELOAD就是这样的存在,它可影响程序运行时的动态链接。只要通过该环境变量预先指定想要加载的动态链接库文件列表,程序运行时,系统就会优先加载这个动态链接库列表中的动态库,并通过它的函数信息设置程序的.plt节区中的函数。这是加载器提供的一个巧妙的“偷梁换柱”手法。
本节的示例代码app.c如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| #include <stdio.h> #include <string.h> #include <stdbool.h> bool checkSN(const char* sn) { char realCode[] = "ilikeandroid"; return !(strcmp(realCode, sn)); } int main(int argc, char** argv) { if (argc != 2) { printf("usage: app sn\n"); return 0; } if (!checkSN(argv[1])) { printf("The sn was invalid!\n"); return 0; } printf("Thank you for your registration\n"); return 0; }
|
这是一个命令行的可执行程序,启动时会判断传入的参数是否为ilikeandroid字符串,进而确定此注册码是否正确。整个程序模拟了软件的授权注册过程,代码的核心判断通过由strcmp()进行的字符串比较实现。编译程序并push,运行,输入123456,会提示The sn was invalid!。
按一般思路,这时候就会开始找注册码验证算法,但LD_PRELOAD Hook提供了一种简单的程序破解之法。编写动态库libhook.so,在其中编写代码Hook strcmp(),让程序在输入字符串“123”时返回0,以骗过程序的strcmp()检查。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| #include <stdio.h> #include <dlfcn.h> void* getrealaddr() { void* lib = dlopen("libc.so", RTLD_NOW | RTLD_GLOBAL); if (lib == NULL) { fprintf(stderr, "could not open file with dlopen()!!: %s\n", dlerror()); return 0; } void* symbol = dlsym(lib, ".strcmp"); dlclose(lib); return symbol; } int (*src_strcmp)(const char* s1, const char* s2); int strcmp(const char* s1, const char* s2) { src_strcmp = (int (*)(const char*, const char*))getrealaddr; if ((0 == src_strcmp("123456", s1)) || (0 == src_strcmp("123456", s2))) { return 0; } return src_strcmp(s1, s2); }
|
将hook.c编译为动态库并libhook.so并push,执行图3中的命令测试。
从输出可看出,被执行的是libhook.so中的函数,如此,针对strcmp()的Hook成功。
【注意】进行LD_PRELOAD Hook的前提是程序中使用的函数必须是从外部导出的,对非导出的函数,这种Hook方式无效。
5 GOT Hook
为让ELF能加载内存中的任意地址,Android的so在编译时默认指定了-fPIC参数,使用了PIC(Position Independent Code)技术,ELF可执行文件也通过-fPIE -pie参数生成了与位置无关的代码。这些技术都用了过程链接表(Procedure Linkage Table,PLT)和全局偏移量表(Global Offset Table,GOT)。PLT存放在.text节区中,是一小段代码集合,意即其在内存中为只读,不会被修改。GOT存放在.data节区中,其中包含动态链接器在进行函数符号动态链接时使用的所有外部符号信息,其内容为可读可写。GOT Hook的思路:修改内存中ELF与so的符号指针,达到“钩住”函数的目的。
32位ARM ELF中,GOT的内容存放在.got节区;64位AArch64中,使用.got和.got.plt节区存放GOT的内容,与外部符号相关的信息则位于.got.plt节区。
实施GOT Hook时,出于对兼容性的考虑,要同时在.got和.got.plt节区中查找要Hook的函数,对自身程序的GOT Hook,可按照如下步骤实施:
在自身程序代码中编写要替换的函数;
待程序加载后,读取自身内存中的Section Header Table,定位.got和.got.plt节区头的信息;
根据.got和.got.plt节区头的信息定位具体的节区;
查找要替换的函数在内存中的地址;
用自己编写的函数的地址替换原函数的地址,完成Hook工作。
对外部程序的GOT Hook,可按照如下步骤实施:
编写要替换的函数,并将其编译成so动态库;
将so注入目标程序进程;
根据目标内存中的Section Header Table,定位.got和.got.plt节区头的信息;
根据.got和.got.plt节区头的信息定位具体的节区;
查找要替换的函数在目标内存中的地址;
用自己编写的so中要替换的函数的地址替换原函数的地址,完成Hook工作。
6 Inline Hook
Inline Hook又称“内联 Hook”,其内联特性主要体现在修改目标函数开始处的汇编指令上,和GOT Hook相比,Inline Hook实现难度更大,主要体现在如下方面:
对不同目标指令集的处理。Android平台的原生程序支持多种指令集架构,若想实现全平台支持,要对每一种指令集分别进行处理。而GOT Hook不用跟指令集打交道,一般通过一次Hook操作即可实现全平台应用。
对跳转指令的处理。以ARM为例,CPU支持Thumb和ARM两种执行模式,且两种模式下的跳转指令不同,实施Hook时,要判断当前指令的处理器模式,及当前函数开始处的指令在被修改后是否会影响后面指令的执行结果(在构造Hook跳转指令时,要充分考虑这些因素)
对多线程的支持。若处理不够好,Inline Hook后的指令可能会影响代码在多线程环境中的执行。尤其是系统库函数,若处理不好,会严重影响系统稳定性;
小函数指令。若函数中的指令较少,如函数的指令小于5字节,但构造了大于5字节的指令进行Hook跳转,会破坏原程序中其他函数的代码;
指令的更新。不同的平台架构,对内存中的数据及指令的处理都可能存在差异,如,在ARM平台,对函数的指令的修改不会实时更新到内存,要调用cecheflush()更新缓存。
从上述因素可看出,要想实现一个高效稳定的Inline Hook方案,难度不小。不过上述因素主要针对外部的Inline Hook,若只对自身进行Hook,又知道自身代码运行的处理器模式和指令集,编写一个Inline Hook会简单些。
若想通过Inline Hook技术Hook程序自身的一个函数(程序代码以ARM模式编译,且最终编译出的是一个32位ELF),只要几十行代码即可实现一个精简版本的Hook框架。编写它的原理和步骤如下:
在程序中编写替换函数;
用mprotect()将目标函数代码段的属性改为可写;
保存“现场”。定位到要修改的函数的起始处,保存Inline Hook起始处的指令(保存的长度为Inline Hook跳转指令的长度)。同时保存当前寄存器的状态;
修改函数起始处的指令。用一个跳转指令去执行替换函数。执行后,根据需求选择跳转回来或放弃执行原函数。如,对ARM模式下的ARM指令,可用ldr pc, xxx指令替换(xxx表示要去执行的函数地址,即第一步所写函数的地址)。修改后,要更新缓存(ARM平台执行cacheflush()更新修改后的目标函数的缓存)
用mprotect()将目标函数代码段的属性改为只读和可执行(即把第二步做过的事还原)。若不改回去,在开启了SELinux的Android设备上,程序的运行可能会因代码段为可写可执行而失败。
7 总结
本文主要介绍了Hook的类型,根据Java层与Native层分类、代码的执行环境、ELF文件的特点,可以分为不同种类的Hook技术。同时,在第4节中通过示例程序app.c和hook.c简单演示了LD_PRELOAD的原理和LD_PRELOAD Hook的过程。
接下来,准备向第十章《注入框架Frida的安装配置与基本使用》进发。
8 参考文献
[1]丰生强. Android软件安全权威指南[M]. 电子工业出版社, 2019.
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达,可以邮件至 xingshuaikun@163.com。