Java Hook的实践之路

站在2024这个时间点上,Java Hook可以说是一门过时的技术,好比明日黄花。那还有讨论的必要么?如果只是针对现有方案的原理解析,那确实没有必要。但如果可以针对现有方案提出些建议和创新,或许还有写成文章的价值。

几年前我曾经粗略地看过一些Java Hook的文章,但都浮于表面,浅尝辄止。直到最近机缘巧合地再次碰到这个话题,因此抽空实现了一套新的Hook方案。

虽说万丈高楼平地起,但软件开发还是要学习牛顿,站在巨人的肩膀上。因此,挑选哪个巨人给我站站就显得尤其重要。选来选去,选中了维术的epic和残页(目前刚上大一,初高中就开始研究framework了,属实NB)的pine。他们的源码给了我很多帮助,让我知道一个Hook方案应该由哪些核心部件组成。

构思

动手写代码之前,有三个核心问题需要考虑:

  1. 采用哪种Hook原理?
  2. 最终的API长什么样?
  3. 需要依赖哪些库?

第一个核心问题是Hook原理。基于ART的Java Hook有两个流派,一个是入口替换,另一个是指令替换(也称inline hook)。入口替换即修改ArtMethod的entry_point_from_quick_compiled_code_字段,让它指向别的代码。指令替换指的是修改方法体头部的指令,让它跳转到别的地方。这两个流派在之前的方案中各有优缺点:

  1. 入口替换可能导致栈回溯失败;另外Android N在编译代码时引入了一项优化,可以不寻找entry_point_from_quick_compiled_code_而直接跳转到目标方法中,这样基于入口替换的hook会失效。
  2. 指令替换需要方法编译成机器码,所以这类方案都需要主动JIT。而JIT是有限制的,譬如当方法过大时JIT就会拒绝编译。

我个人的构想是Hook方案要尽量少地改变现有运行机制,所以更加倾向于入口替换。但入口替换存在的问题怎么办?经过一番研究,发现这两个问题都可以解决。

首先是栈回溯的问题。栈回溯之所以失败,是因为指向的跳板函数并非一个真实的Java方法,因此通过ArtMethod::GetOatQuickMethodHeader找到的OatQuickMethodHeader(这里面会保存frame size等关键信息)是一个非法对象。但其实libart.so内部也有很多跳板函数,它们都可能被指定为某个方法的入口。那么ART是如何保证这种情况下栈回溯不出错的呢?

图片

如此一来,只要我们手写的trampoline可以伪装成系统的trampoline,就可以让栈回溯正常运行。ART通过trampoline的地址是否在libart.so的r-x段内来判定它是否是系统trampoline。既然如此,我们可以找一个ART不用的方法,将trampoline写入其中,让其伪装成系统trampoline。同时,为了尽可能地减少对系统的影响,我们可以在libart.so中写入一个最简单的一级跳板,它的唯一作用就是跳转到真实处理逻辑的二级跳板。

图片

至于Android N引入的那项优化,其实它只存活了一个版本。为了配合后续对non-PIC(Position-independent Code)的移除,Google对这种direct call的优化也进行了移除。

至此,横亘在入口替换前的两座大山彻底被搬走。

第二个核心问题是API。现有的方案也分成了两个流派,一派滥觞于Xposed,写法较为飘逸灵动;另一派的代表人物是YAHFA和FastHook,它们需要为每个hook写一个参数匹配的方法。从使用角度来说,Xposed那种API更加方便,因此最终的API设定如下。

xxxHook.setHook(method, new Hook() {
    @Override
    void before(Param param) throws Throwable {
        ...
    }

    @Override
    void after(Param param) throws Throwable {
        ...
    }
});

第三个核心问题是依赖库。一个Hook方案通常需要在两个维度和ART打交道,一是获取ART中重要对象的地址和某些数据结构的布局,二是调用ART的内部方法。这就需要我们有一个稳定的dlsym方案,用于查找libart.so中的全局变量和函数的地址。可是如今的Android版本中,linker namespace和hidden visibility的存在,让传统的dlsym寸步难行。好在开源社区中有一些优秀的方案,它们可以从.gnu_debugdata中解析symbol,让符号的获取变得更加可靠。这里我选择了蔡克伦的xDL,里面的文件不多,方便直接复用。

实现

至此,大的框架出来了,接下来需要进入到每个具体的板块。

图片

首先是Trampoline的设计。它的核心作用是将传入的参数转换为如下格式,然后调用Target Method。

(Object thisObject, Object[] args, long art_method)

thisObject和args是为了方便之后通过Method.invoke调用原方法逻辑的,而art_method则是为了找出调用方法对应的Hook信息的。这个转换过程有两个重要的问题需要考虑:

  1. 转换过程中,有些传递参数的寄存器会被使用,因此这些值需要提前找个地方存一下。
  2. 参数转换的过程中不能进行GC,否则会产生对象的移动或回收。由于转换过程拿到的是对象的原指针,移动或回收会导致原指针失效。

epic和pine在这两处的处理方法是类似的,不过尚有些改进的空间,所以我采取了一些不同的实现。

针对第一个问题,epic和pine在trampoline的尾部开辟了一块空间,用于存储可能被使用的寄存器的值。但这样需要为每个hook方法都生成一个独立的trampoline;且trampoline的运行过程需要加锁,用来防止多线程对尾部空间的并行访问,而这个加锁的动作甚至会产生一些死锁问题。

图片

我选择将这些参数保存在栈上,这样既没有多线程的问题,也可以让所有的hook方法共用一个trampoline,从而节约内存资源。既然保存在栈上有这么多好处,那为什么之前的方案中没有这么设计?原因是栈上增加额外的空间会导致栈回溯无法工作,因为它改变了栈帧的大小。但其实ART自身也有栈上开辟空间的需求,它通常用于Java到runtime的切换过程。开辟出来的这块空间属于一个Runtime Method(非真实方法,具体类型为CalleeSaveMethod),它会参与栈回溯过程并处理好栈空间的计算,因此我们只需要按照CalleeSaveMethod的栈空间开辟格式进行压栈,就可以解决这个问题。

针对第二个问题,epic和pine并没有很好的解决,原因是它们的参数转换放到了一个Java方法中去进行,而Java方法的入口处会进行suspend check,是有可能被GC挂起来执行GC操作的。好在Android有@FastNative注解,它可以在没有线程状态切换的环境中进行native方法调用。没有线程状态切换,也就意味着没有suspend check。这样一来,只要我们将参数处理放到由@FastNative注解的JNI函数中,就可以避免在参数转换过程中发生GC。

但其实这里还有个更深入的问题:不是说参数会自动作为GC root吗?既然如此,GC还会对它进行移动或回收么?如果是GC root,自然不会。但问题的关键是,方法一旦被hook后,原方法在栈上的痕迹就会被抹去,转而由参数处理的那个方法替代。因此GC在判别哪些参数作为GC root时,是按照参数处理的那个方法去进行的,而不是原方法。这才导致作为原方法的参数不会被判定为GC root。

接着是Target Method的设计。它的核心作用是调用hook逻辑,同时保证返回参数类型的正确。

由于Target Method将直接返回到caller,所以它的返回值类型一定要和原方法一致。返回值类型有10种(void, int, long…),因此Target Method也有10个。那么Trampoline如何选择Target Method呢?这里有个小技巧,参数转换的JNI函数会通过方法的shorty字符串拿到返回值类型,它可以将这个类型存在栈上返回给Trampoline。之后,Trampoline便可以通过tbnz这样的条件跳转指令获取到匹配的Target Method。

最后是Hook的设计。其中最核心也最有难度的就是对原有逻辑的调用。

之所以称为“原有逻辑”而非“原有方法”,是因为“原有方法”的entry_point_from_quick_compiled_code_已经被修改。为了能够调用到原有逻辑,我们需要在修改entry_point_from_quick_compiled_code_之前对原有方法进行备份。epic和pine采用了同样的处理方式,它们在native层对ArtMethod进行了拷贝,然后让拷贝出来的ArtMethod关联上一个新创建的Executable对象。不过这种处理方式有一个缺陷,即拷贝之后的ArtMethod无法被GC感知到,因此GC对Class对象的移动也无法更新到ArtMethod中。所以它们在每次“原有逻辑”调用之前都需要手动更新ArtMethod的declaring_class_,但即便是手动更新,依然会留下导致崩溃的GC窗口。

所以问题的重点在于如何让拷贝的ArtMethod被GC感知到。一种可行的方案是为每一个hook方法都事先准备一个空的方法接收备份,但hook的方法越多,需要准备的方法也就越多,这显然不是一种优雅的方式。更无感的设计应该是动态生成代理类,它里面的方法就可以供我们来接收备份。这样一来,备份的ArtMethod里面的declaring_class_便会在GC时自动更新。

增强兼容性/稳定性的设计

说完主体的设计之后,再补充两个增强方案兼容性/稳定性的设计细节,它们分别是:

  1. 如何找到ART内部的对象?
  2. 如何hook正在JIT的方法?

寻找ART的内部对象, 根源就是确定这些对象在所属类中的偏移。在现有的方案中,这些偏移基本都是依照每个版本的ART源码固定死的。所以它们缺乏一定的灵活性,同时兼容性也有短板。那有没有更加动态的方案?回到libart.so的视角,它里面的机器码(汇编)在运行时一定也需要找到这些对象,如果它可以找到,那我们根据这些汇编指令是不是也可以找到?

以如下代码为例。从代码逻辑可以推断,该函数第一步要做的就是GetClassLinker(),而GetClassLinker()获取的正是Runtime类里的class_linker_字段。

C++代码

ArtMethod* Runtime::CreateResolutionMethod() {
  auto* method = CreateRuntimeMethod(GetClassLinker(), GetLinearAlloc());
  ...
}

ClassLinker* GetClassLinker() const {
  return class_linker_;
}

编译后的汇编代码

0000000000813320 <art::Runtime::CreateResolutionMethod()>:
  813320: d503233f      paciasp
  813324: a9bd7bfd      stp x29, x30, [sp, #-0x30]!
  813328: f9000bf5      str x21, [sp, #0x10]
  81332c: a9024ff4      stp x20, x19, [sp, #0x20]
  813330: 910003fd      mov x29, sp
  813334: d0001068      adrp    x8, 0xa21000 <SAVE_SIZE+0xa20f50>
  813338: aa0003f3      mov x19, x0
  81333c: f941d108      ldr x8, [x8, #0x3a0]
  813340: f9412c00      ldr x0, [x0, #0x258]     //x0是this指针,也即Runtime*

从汇编代码可以看到,函数里第一个从x0中load的动作(813340处)就是获取class_linker_的动作,而load时的0x258这个偏移,也即class_linker_在Runtime类中的偏移。对于这种各个版本相对稳定且OEM不太去改的方法,就可以从它的汇编代码中抽取出我们想要的偏移。那有人会问,为什么不直接从GetClassLinker()方法中抽取,这样不是更直接?原因是这种函数体很小的方法基本都被inline了,我们找不到它独立的汇编。

另外关于ArtMethod里entry_point_from_quick_compiled_code_的偏移,这里也介绍一种方法。

我们可以通过Instrumentation::InitializeMethodsCode为一个方法指定入口,譬如art_quick_to_interpreter_bridge,接着再遍历ArtMethod的内部字段,当找到某个字段和art_quick_to_interpreter_bridge相同时,该字段便为entry_point_from_quick_compiled_code_。

Hook正在JIT的方法, 似乎不太可能,因为JIT编译完成后会再次修改ArtMethod的entry_point_from_quick_compiled_code_。

一种优雅的方式是为SetHook增加boolean返回值,告知调用方是否hook成功。当遇到JIT这种情况时,直接返回false,而不是等待JIT完成。至于是否重新发起hook,由调用方决定。因此,问题的关键变为如何判断hook是否成功?

运行过程中,每一个线程都可以触发JIT,但进行JIT编译的只有一个线程。所以这里需要有task queue来记录编译的任务。JIT编译有三种模式,分别为baseline、optimized和osr。

对于方法的替换而言,我们只关心baseline和optimized这两种。所以我们可以遍历这两种类型的task queue,如果其中包含我们正在hook的方法,则表明该方法等待被JIT编译,因此可以判定此次hook失败。否则我们可以hook成功。

至此,一个完整的基于ART的Java Hook方案便被设计出来。

这个设计考虑了GC、JIT、WalkStack及各种优化可能对Hook方案产生的影响,最大程度地去保证了稳定性和兼容性,同时尽可能少地更改运行机制,做到性能和内存的最小牺牲。但它仍然具有缺陷。首先是我个人的理解一定会有短板和盲区,其次是现有的方案有一个问题无法解决:JIT/AOT编译过程中被inline的方法无法被hook。Deoptimize方法的caller或许可以,但由于深度inline(inline了好几层)和caller众多等问题的存在,这显然不是一个合适的解决方案。所以这个问题,我目前也没有更好的想法。

原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/78904.html

(0)
guozi的头像guozi
上一篇 2024年5月30日 下午2:16
下一篇 2024年5月30日

相关推荐

  • dns污染域名,怎么清除dns污染

    您是否想过互联网上的信息是如何被屏蔽的?作为互联网世界不可或缺的一部分,我们不能忽视DNS服务器的作用和重要性。然而,有些人滥用这个关键的网络设备来达到自己的目的。那么什么是DNS…

    行业资讯 2024年5月9日
    0
  • 东莞网站建设的重要性及建设流程

    今天,我们来谈论一个越来越受关注的话题——东莞网站建设。不知道大家是否有过这样的疑问,为什么东莞网站建设如此重要?是因为它能给企业带来什么好处吗?还是因为它的建设流程与众不同?或许…

    行业资讯 2024年4月6日
    0
  •    免责声明 请您仔细阅读以下声明: 您在AtomsTeam查看信息以及使用AtomsTeam服务,表明您对以下内容的接受: AtomsTeam提供程序(方法)可能带有…

    2024年3月20日
    0
  • ip被墙如何解决,ip被墙了怎么处理

    首先,我们需要明确哪些IP被封锁。简单来说,当我们访问某个网站时,我们的服务器会根据我们的IP地址来确定我们的地理位置,如果发现该IP地址属于封锁区域,我们的访问请求就会被拒绝。 …

    行业资讯 2024年5月8日
    0

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注