FastHook——遠超YAHFA的優異穩定性

圖靈技師發表於2019-03-22

一、 概述

經過實際專案大量測試驗證,FastHook表現出了遠超YAHFA的優異穩定性。使用者反饋未出現Hook引發的穩定性問題、壓力測試也未發生Hook引發的穩定問題。之所以FastHook擁有優異的穩定性,除了框架實現原理的優越性之外,還得益於FastHook出色的細節處理

本文將通過FastHook實現原理優越性與一些出色的細節處理來解釋為何FastHook擁有優異的穩定性,最後對比YAHFA框架。

二、先天優勢

如果你還未了解FastHook,請移步FastHook——一種高效穩定、簡潔易用的Android Hook框架。 FastHook相較YAHFA框架原理上最大的優勢、也是最大的亮點便是:不需要備份原方法!不需要備份原方法!不需要備份原方法!

科學上有一個著名的“奧卡姆剃刀定律”,什麼意思呢?如果一個現象有兩個或者多個不同的理論解釋,那麼選最簡單的那個。做Hook框架,也可以用剃刀定律來做指導:實現相同的功能,選對系統狀態改動最小的

“備份原方法”是一種隱患頗多的方式,引發了諸如方法解析出錯、Moving GC空指標等問題。儘管其他框架通過一些手段來提高穩定性,比如保證方法不被再次解析、檢查Moving GC是否移動了原方法相關物件等,但是這些都不是理論安全的,就像地上有個坑,你不去補上,而是讓人不要去踩

反觀FastHook,Hook時對系統原有狀態的改變是最小的。

  1. Inline模式改變的僅是幾個位元組的指令,因平臺而異,不篡改任何方法。
  2. EntryPoint模式替換了方法EntryPoint,但是原方法將強制為解釋執行,也可等價的看為未做修改。

簡而言之,FastHook就是用Hook方法hook原方法,原方法hook Forward方法來實現最小改動hook。完美地從實現層面解決了YAHFA框架不能解決的問題,而且無需做一些其他操作,YAHFA框架都需要一些其他的操作來提高穩定性,而FastHook不需要做任何其他處理,更簡潔、更優雅

三、比YAHFA更出色的細節處理

3.1 JIT狀態檢查

如果你看過YAHFA框架程式碼,你會發現沒有一個框架做了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編譯方法時不做其他處理。但是後來專案上有反饋,有概率出現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指令集還需額外判斷指令型別。

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-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狀態檢查 EntryPoint檢查(JIT) 執行緒狀態恢復 指令檢查 mprotect失效處理 注入安全 防止內聯 防止backup/forword內聯
YAHFA - - - -
FastHook 是(高效) JIT內聯

4.4 小結

從上述對比可以看出,FastHook與YAHFA框架的本質區別是不備份原方法,在細節上的處理也比YAHFA要嚴謹高效其他框架在細節處理上都有所欠缺

五、結語

由於專案原因,主要維護arm平臺,其他平臺暫時不支援,後續再計劃加入,目前主要關注arm平臺的穩定性。如果有興趣,對穩定性有要求的朋友,歡迎使用,本專案長期維護

六、 參考

FastHook:github.com/turing-tech…

FastHook系列

  1. FastHook——一種高效穩定、簡潔易用的Android Hook框架
  2. FastHook——巧妙利用動態代理實現非侵入式AOP
  3. FastHook——如何使用FastHook免root hook微信
  4. FastHook——實現.dynsym段和.symtab段符號查詢

相關文章