FastHook——远超其他同类框架的优异稳定性

一、 概述

经过实际项目大量测试验证,FastHook表现出了远超其他同类框架的优异稳定性。用户反馈未出现Hook引发的稳定性问题、压力测试也未发生Hook引发的稳定问题。之所以FastHook拥有优异的稳定性,除了框架实现原理的优越性之外,还得益于FastHook出色的细节处理
本文将通过FastHook实现原理优越性与一些出色的细节处理来解释为何FastHook拥有优异的稳定性,最后对比其他常用同类框架。

二、先天优势

如果你还未了解FastHook,请移步**FastHook——一种高效稳定、简洁易用的Android Hook框架**。
FastHook相较其他框架原理上最大的优势、也是最大的亮点便是:不需要备份原方法!不需要备份原方法!不需要备份原方法!
科学上有一个著名的“奥卡姆剃刀定律”,什么意思呢?如果一个现象有两个或者多个不同的理论解释,那么选最简单的那个。做Hook框架,也可以用剃刀定律来做指导:实现相同的功能,选对系统状态改动最小的
“备份原方法”是一种隐患颇多的方式,引发了诸如方法解析出错、Moving GC空指针等问题。尽管其他框架通过一些手段来提高稳定性,比如保证方法不被再次解析、检查Moving GC是否移动了原方法相关对象等,但是这些都不是理论安全的,就像地上有个坑,你不去补上,而是让人不要去踩
反观FastHook,Hook时对系统原有状态的改变是最小的。

  1. Inline模式改变的仅是几个字节的指令,因平台而异,不篡改任何方法。
  2. EntryPoint模式替换了方法EntryPoint,但是原方法将强制为解释执行,也可等价的看为未做修改。

简而言之,FastHook就是用Hook方法hook原方法,原方法hook Forward方法来实现最小改动hook。完美地从实现层面解决了其他框架不能解决的问题,而且无需做一些其他操作,其他框架都需要一些其他的操作来提高稳定性,而FastHook不需要做任何其他处理,更简洁、更优雅

三、比其他框架更出色的细节处理

3.1 JIT状态检查

如果你看过其他框架代码,你会发现没有一个框架做了JIT状态检查。JIT状态检查的目的是为了保证hook的安全性,但这也不是理论安全的,也无法做到理论安全。这是为什么呢?

3.1.1 Inline模式

如果原方法未编译则需要进行手动JIT编译。那么问题来了,什么时候编译才是安全的呢。下面列举出所有可能出现的情景:

  1. 原方法未进行JIT编译,此时手动JIT编译时安全的
  2. 原方法未进行JIT编译,即将进入编译等待队列或已进入编译等待队列,此时手动JIT编译是不安全的
  3. 原方法正在JIT编译,此时手动JIT编译是不安全的
  4. 原方法编译完成,此时手动编译是安全的

上述4中情景,其中2、3是不安全的。如果要保证手动JIT编译的安全性,必须做到以下两点:

  1. 禁止JIT编译,防止从1变化到2
  2. 能够判断2、3,当处于2、3状态时,等待其变化到4

现在来看看FastHook到底是怎么处理的

int CheckJitState(JNIEnv *env, jclass clazz, jobject target_method) {
    void *art_method = (void *)(*env)->FromReflectedMethod(env, target_method);
    //添加kAccCompileDontBother,禁止JIT、AOT编译
    AddArtMethodAccessFlag(art_method, kAccCompileDontBother);
    uint32_t hotness_count = GetArtMethodHotnessCount(art_method);
    if(hotness_count >= kHotMethodThreshold) {
        //hotness_count >= hot_threshold,肯定就不是1了,看看是2、3、4中的哪一个
        long entry_point = (long)GetArtMethodEntryPoint(art_method);
        if((void *)entry_point == art_quick_to_interpreter_bridge_) {
            void *profiling = GetArtMethodProfilingInfo(art_method);
            void *save_entry_point = GetProfilingSaveEntryPoint(profiling);
            if(save_entry_point) {
                //JIT垃圾回收会改变方法EntryPoint,虽然方法已经编译了,但是EntryPoint也可能是art_quick_to_interpreter_bridge
                return kCompile;
            }else {
                //JIT状态保存在profiling中,通过其来判断是否是正在编译,如果不是可能是正在等待或者已经编译失败。
                bool being_compiled = GetProfilingCompileState(profiling);
                if(being_compiled) {
                    return kCompiling;
                }else {
                    return kCompilingOrFailed;
                }
            }
        }
        return kCompile;
    }else {
        //hotness_count < hot_threshold,可能是1,也可能是2,即将进入编译等待队列,统一加一个增量,如果此时大于hot_threshold,就认为是2,反之是1
        uint32_t assumed_hotness_count = hotness_count + kHotMethodMaxCount;
        if(assumed_hotness_count > kHotMethodThreshold) {
            return kCompiling;
        }
    }
    return kNone;
}
class ProfilingInfo {
 private:
  ProfilingInfo(ArtMethod* method, const std::vector<uint32_t>& entries);

  // Number of instructions we are profiling in the ArtMethod.
  const uint32_t number_of_inline_caches_;

  // Method this profiling info is for.
  // Not 'const' as JVMTI introduces obsolete methods that we implement by creating new ArtMethods.
  // See JitCodeCache::MoveObsoleteMethod.
  ArtMethod* method_;

  // Whether the ArtMethod is currently being compiled. This flag
  // is implicitly guarded by the JIT code cache lock.
  // TODO: Make the JIT code cache lock global.
  bool is_method_being_compiled_;
  bool is_osr_method_being_compiled_;

  // When the compiler inlines the method associated to this ProfilingInfo,
  // it updates this counter so that the GC does not try to clear the inline caches.
  uint16_t current_inline_uses_;

  // Entry point of the corresponding ArtMethod, while the JIT code cache
  // is poking for the liveness of compiled code.
  const void* saved_entry_point_;

  // Dynamically allocated array of size `number_of_inline_caches_`.
  InlineCache cache_[0];
};
  1. AddArtMethodAccessFlag(art_method, kAccCompileDontBother),设置kAccCompileDontBother禁止JIT、AOT。防止1变化到2
  2. 如果hotness_count > hot_threshold,这时肯定就不是1了,还需要判断是2、3、4中哪一个。
  3. 通过判断entry point是否为解释执行入口来判断是否是4,因为entry point不是解释执行入口肯定不会是2和3
  4. 这里有个关键点一定要注意,即使JIT编译后entry point也有可能为解释执行入口,因为JIT垃圾回收会将entry point设置为解释执行入口,将实际入口保存在save_entry_point。如果save_entry_point不为空,那证明已经编译过了。
  5. 怎么判断2、3呢?每个方法都有一个profiling info,保存一些运行过程信息和JIT编译信息,其中就有是否在JIT编译的信息。如果为true,则为3,如果为false,则为2(这里也可能是编译失败了的,为了简便都做2看待)
  6. 如果hotness_count < hot_threshold,能说明一定是1吗?答案是不能,也有可能是2。这是为什么呢?有一种罕见的情况,当我们检查状态时,hotness_count还未执行到更新的代码,而当其更新之后大于hot_threshold,那么实际就是2。因此假设hotness_count会更新,给一个增量(理论上给不了准确的数值,因为其增量受权重影响,也可能是批量处理的增量,因此这不是理论安全的),这里给一个比较大的值(50),如果此时大于hot_threshold,就认为是2(这个也不是完全准确的,因为可能hotness_count根本不会更新)。

3.1.2 小结

  1. hook之前先做JIT状态检查,如果安全就立即hook,反之放入一个异步队列延迟hook
  2. 上述分析可知,该检查也不是绝对安全的,但是已经将出现问题的场景缩小到一个可以忽略不计的范围
  3. EntrypPoint替换模式的检查与Inline模式一致,不做重复分析

3.2 判断方法是否需要编译

其他框架只是简单用entry point与解释入口比较来判断,通过3.1的分析可知这是不完备的
JIT垃圾回收会改变entry point为解释入口,必须做进一步判断是否为JIT编译方法。FastHook的做法很简单,判断hotness_count是否小于hot_threshold,如果其小于hot_threshold,那肯定还未被JIT编译,因此可以判定其需要进行手动JIT编译
并且,这一步是在JIT检查成功基础上进行的,可以不用担心JIT状态的影响。

bool IsCompiled(JNIEnv *env, jclass clazz, jobject method) {
    bool ret = false;
    void *art_method = (void *)(*env)->FromReflectedMethod(env, method);
    void *method_entry = (void *)ReadPointer((unsigned char *)art_method + kArtMethodQuickCodeOffset);
    int hotness_count = GetArtMethodHotnessCount(art_method);
   if(method_entry != art_quick_to_interpreter_bridge_)
        ret = true;
    if(!ret && hotness_count >= kHotMethodThreshold)
        ret = true;
    return ret;
}

3.3 线程状态恢复

当一个java方法进入JNI时,线程状态由runnable状态变为native状态,返回java前恢复为runable状态。而JIT编译方法会将参数thread的状态转变为runnable状态
其他框架在手动JIT编译方法时不做其他处理,最开始FastHook也是这么处理的。但是后来项目上有反馈,有概率出现crash,出现的位置正好是编译完成后返回java的地方,异常原因是线程状态错误。
FastHook之前的解决方案是:新建native线程用于JIT编译,避免当前线程编译。这时出现了新的问题,如何获取native线程的thread对象?
通过研究android代码发现,art获取线程thread对象是通过TLS来获取的,thread存储在TLS固定位置。但实际上,这种方案虽然解决了crash的问题,但也导致了新的问题:线程错误地等待
究其缘由,都是线程状态异常引起的,因此根治的方法便是恢复线程状态。通过研究Thread代码发现,线程状态是一个union结构体StateAndFlags,保存在thread对象里,因此可以通过偏移的方式来访问。

static inline void *CurrentThread() {
    return __get_tls()[kTLSSlotArtThreadSelf];
}
#if defined(__aarch64__)
# define __get_tls() ({ void** __val; __asm__("mrs %0, tpidr_el0" : "=r"(__val)); __val; })
#elif defined(__arm__)
# define __get_tls() ({ void** __val; __asm__("mrc p15, 0, %0, c13, c0, 3" : "=r"(__val)); __val; })
#endif
class Thread {
  union PACKED(4) StateAndFlags {
    struct PACKED(4) {
      volatile uint16_t flags;
      volatile uint16_t state;
    } as_struct;
    AtomicInteger as_atomic_int;
    volatile int32_t as_int;
  };
struct PACKED(4) tls_32bit_sized_values {
    typedef uint32_t bool32_t;
    union StateAndFlags state_and_flags;
    int suspend_count GUARDED_BY(Locks::thread_suspend_count_lock_);
    int debug_suspend_count GUARDED_BY(Locks::thread_suspend_count_lock_);
    uint32_t thin_lock_thread_id;
    uint32_t tid;
    const bool32_t daemon;
    bool32_t throwing_OutOfMemoryError;
    uint32_t no_thread_suspension;
    uint32_t thread_exit_check_count;
    bool32_t handling_signal_;
    bool32_t is_transitioning_to_runnable;
    bool32_t ready_for_debug_invoke;
    bool32_t debug_method_entry_;
    bool32_t is_gc_marking;
    Atomic<bool32_t> interrupted;
    bool32_t weak_ref_access_enabled;
    uint32_t disable_thread_flip_count;
    int user_code_suspend_count GUARDED_BY(Locks::thread_suspend_count_lock_);
  } tls32_;
bool CompileMethod(JNIEnv *env, jclass clazz, jobject method) {
    bool ret = false;

    void *art_method = (void *)(*env)->FromReflectedMethod(env, method);
    void *thread = CurrentThread();
    int old_flag_and_state = ReadInt32(thread);

    ret = jit_compile_method_(jit_compiler_handle_, art_method, thread, false);
    memcpy(thread,&old_flag_and_state,4);

    return ret;
}

3.4 指令检查

Inline模式下需要注入代码,那么就必须确保被覆盖的指令不包含pc相关的指令
这是为什么呢?pc寄存器存储的是当前执行的指令,如果以pc寄存器来做寻址就跟当前地址息息相关了,如果我们覆盖的指令包含pc相关的指令,那么寻址将出错。
需要注意的是,Thumb2有16位和32位两种指令,因此对于Thumb2指令集还需额外判断指令类型。
目前在其他框架中只发现SandHook做了相似的检查

static inline bool IsThumb32(uint16_t inst, bool little_end) {
	if(little_end) {
		return ((inst & 0xe000) == 0xe000 && (inst & 0x1800) != 0x0000);
	}
	return ((inst & 0x00e0) == 0x00e0 && (inst & 0x0018) != 0x0000);
}
static inline bool HasThumb16PcRelatedInst(uint16_t inst) {
	uint16_t mask_b1 = 0xf000;
	uint16_t op_b1 = 0xd000;
	uint16_t mask_b2_adr_ldr = 0xf800;
	uint16_t op_b2 = 0xe000;
	uint16_t op_adr = 0xa000;
	uint16_t op_ldr = 0x4800;
	uint16_t mask_bx = 0xfff8;
	uint16_t op_bx = 0x4778;
	uint16_t mask_add_mov = 0xff78;
	uint16_t op_add = 0x4478;
	uint16_t op_mov = 0x4678;
	uint16_t mask_cb = 0xf500;
	uint16_t op_cb = 0xb100;

	if((inst & mask_b1) == op_b1)
		return true;
	if((inst * mask_b2_adr_ldr) == op_b2 || (inst * mask_b2_adr_ldr) == op_adr || (inst * mask_b2_adr_ldr) == op_ldr)
		return true;
	if((inst & mask_bx) == op_bx)
		return true;
	if((inst & mask_add_mov) == op_add || (inst & mask_add_mov) == op_mov)
		return true;
	if((inst & mask_cb) == op_cb)
		return true;
	return false;
}
static inline bool HasThumb32PcRelatedInst(uint32_t inst) {
	uint32_t mask_b = 0xf800d000;
	uint32_t op_blx = 0xf000c000;
	uint32_t op_bl = 0xf000d000;
	uint32_t op_b1 = 0xf0008000;
	uint32_t op_b2 = 0xf0009000;
	uint32_t mask_adr = 0xfbff8000;
	uint32_t op_adr1 = 0xf2af0000;
	uint32_t op_adr2 = 0xf20f0000;
	uint32_t mask_ldr = 0xff7f0000;
	uint32_t op_ldr = 0xf85f0000;
	uint32_t mask_tb = 0xffff00f0;
	uint32_t op_tbb = 0xe8df0000;
	uint32_t op_tbh = 0xe8df0010;

	if((inst & mask_b) == op_blx || (inst & mask_b) == op_bl || (inst & mask_b) == op_b1 || (inst & mask_b) == op_b2)
		return true;
	if((inst & mask_adr) == op_adr1 || (inst & mask_adr) == op_adr2)
		return true;
	if((inst & mask_ldr) == op_ldr)
		return true;
	if((inst & mask_tb) == op_tbb || (inst & mask_tb) == op_tbh)
		return true;
	return false;
}
static inline bool HasArm64PcRelatedInst(uint32_t inst) {

	uint32_t mask_b = 0xfc000000;
	uint32_t op_b = 0x14000000;
	uint32_t op_bl = 0x94000000;
	uint32_t mask_bc = 0xff000010;
	uint32_t op_bc = 0x54000000;
	uint32_t mask_cb = 0x7f000000;
	uint32_t op_cbz = 0x34000000;
	uint32_t op_cbnz = 0x35000000;
	uint32_t mask_tb = 0x7f000000;
	uint32_t op_tbz = 0x36000000;
	uint32_t op_tbnz = 0x37000000;
	uint32_t mask_ldr = 0xbf000000;
	uint32_t op_ldr = 0x18000000;
	uint32_t mask_adr = 0x9f000000;
	uint32_t op_adr = 0x10000000;
	uint32_t op_adrp = 0x90000000;

	if((inst & mask_b) == op_b || (inst & mask_b) == op_bl)
		return true;
	if((inst & mask_bc) == op_bc)
		return true;
	if((inst & mask_cb) == op_cbz || (inst & mask_cb) == op_cbnz)
		return true;
	if((inst & mask_tb) == op_tbz || (inst & mask_tb) == op_tbnz)
		return true;
	if((inst & mask_ldr) == op_ldr)
		return true;
	if((inst & mask_adr) == op_adr || (inst & mask_adr) == op_adrp)
		return true;
	return false;
}

主要是几类指令:

  1. 分支跳转指令
  2. 比较分支指令
  3. 条件分支指令
  4. load指令

而Thumb2需要特别注意,因为其有16位和32位两种模式,而跳转指令长度是8字节,如果固定复制8字节,有可能会把指令截断,例如4-2-2-4,最后4字节指令将会被截断,因此需要做判断,以确定需要复制8字节还是10字节

int original_prologue_len = 0;
    while(original_prologue_len < jump_trampoline_len) {
        if(IsThumb32(ReadInt16((unsigned char *)target_code + original_prologue_len),IsLittleEnd())) {
            original_prologue_len += 4;
        }else {
            original_prologue_len += 2;
        }
    }

3.5 指令注入

Inline模式下,需要向目标方法代码段注入一段跳转指令,而代码段是不可写。一般解决方案是使用mprotect修改访问权限。其他框架都采用这个方案
而从实际项目测试来看,mprotect可能是无效的。mprotect执行成功了,但是还是出现了SEGV_ACCERR
FastHook的解决方案是先捕获出错信号,再使用mprotect修改访问权限。如果修改无效,则一直会修改直到生效未知。指令注入后恢复默认信号处理。捕获信号处理之后,再无crash的反馈。

void SignalHandle(int signal, siginfo_t *info, void *reserved) {
    ucontext_t* context = (ucontext_t*)reserved;
    void *addr = (void *)context->uc_mcontext.fault_address;

    if(sigaction_info_->addr == addr) {
        void *target_code = sigaction_info_->addr;
        int len = sigaction_info_->len;
        long page_size = sysconf(_SC_PAGESIZE);
        unsigned alignment = (unsigned)((unsigned long long)target_code % page_size);
        int ret = mprotect((void *) (target_code - alignment), (size_t) (alignment + len),
                           PROT_READ | PROT_WRITE | PROT_EXEC);
    }
}
    sigaction_info_->addr = target_code;
    sigaction_info_->len = original_prologue_len;
    if(current_handler_ == NULL) {
        default_handler_ = (struct sigaction *)malloc(sizeof(struct sigaction));
        current_handler_ = (struct sigaction *)malloc(sizeof(struct sigaction));
        memset(default_handler_, 0, sizeof(sigaction));
        memset(current_handler_, 0, sizeof(sigaction));
        current_handler_->sa_sigaction = SignalHandle;
        current_handler_->sa_flags = SA_SIGINFO;
        sigaction(SIGSEGV, current_handler_, default_handler_);
    }else {
        sigaction(SIGSEGV, current_handler_, NULL);
    }

    memcpy(target_code, jump_trampoline, jump_trampoline_len);

    sigaction_info_->addr = NULL;
    sigaction_info_->len = 0;
    sigaction(SIGSEGV, default_handler_, NULL);

3.6 注入安全

在获得写权限之后,注入的时候必须保证没有其他线程同时读需要注入的区域,不然将导致未知错误。
其他框架是如何做的呢?利用art暂停所用线程和恢复所有线程的接口来实现。FastHook并没有采用这种方式,stop the world这种方式太重了,对性能有损耗
FastHook是怎么做的呢?很简单,强制需要注入的方法解释执行,注入完成后恢复。即保证了注入安全,也没有任何性能损失

memcpy((unsigned char *) art_target_method + kArtMethodQuickCodeOffset,&art_quick_to_interpreter_bridge_,pointer_size_);
memcpy(target_code, jump_trampoline, jump_trampoline_len);
memcpy((unsigned char *) art_target_method + kArtMethodQuickCodeOffset,&target_entry,pointer_size_);

3.7 EntryPoint替换安全

EntryPoint替换模式要求原方法以解释模式执行,而JIT垃圾回收会更改方法entry point为解释执行入口,当方法即将进入解释执行时会重新设置为原来的入口,这会导致什么问题呢?
java方法有两种执行模式,一种执行dex字节码,一种执行机器码,art因此需要知道机器码与dex字节码的映射关系,例如执行一条机器码,它对应哪一条dex字节码。而这些映射需要方法entry point作为基址来计算,此时entry point已经被替换,会得出错误的结果
因此,如果监测到上述情况,需要修改save_entry_point为解释执行入口,防止执行JIT编译的机器码

if(art_forward_method) {
        memcpy((unsigned char *) target_trampoline + hook_trampoline_target_index, &art_target_method, pointer_size_);
        memcpy((unsigned char *) target_trampoline + target_trampoline_target_entry_index, &target_entry, pointer_size_);
        if(kTLSSlotArtThreadSelf) {
            uint32_t hotness_count = GetArtMethodHotnessCount(art_target_method);
            if(hotness_count >= kHotMethodThreshold) {
                void *profiling = GetArtMethodProfilingInfo(art_target_method);
                void *save_entry_point = GetProfilingSaveEntryPoint(profiling);
                if(save_entry_point) {
                    SetProfilingSaveEntryPoint(profiling,art_quick_to_interpreter_bridge_);
                }
            }
        }
    }

四、与其他框架比较

4.1 YAHFA

框架 备份原方法 性能 JIT状态检查 防止内联 防止backup/forword内联
YAHFA
FastHook JIT内联

4.2 epic

框架 备份原方法 性能 JIT状态检查 EntryPoint检查(JIT) 线程状态恢复 指令检查 mprotect失效处理 注入安全
epic 是(低效)
FastHook 是(高效)

4.3 SandHook

框架 备份原方法 性能 JIT状态检查 EntryPoint检查(JIT) 线程状态恢复 指令检查 mprotect失效处理 注入安全 防止内联 防止backup/forword内联
SandHook 是(低效) JIT内联
FastHook 是(高效) JIT内联

4.4 小结

从上述对比可以看出,FastHook与其他框架的本质区别是不备份原方法,在细节上的处理也比其他框架要严谨高效其他框架在细节处理上都有所欠缺

五、结语

由于项目原因,主要维护arm平台,其他平台暂时不支持,后续再计划加入,目前主要关注arm平台的稳定性。如果有兴趣,对稳定性有要求的朋友,欢迎使用,本项目长期维护

猜你喜欢

转载自blog.csdn.net/TuringTechnician/article/details/88737754
今日推荐