Hikari Source Analysis — AntiHook

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Hikari Source Analysis — AntiHook

The third — and likely final — installment of the Hikari source-analysis series, which has covered AntiClassDump, AntiDebug, and now AntiHook, all focused on security hardening. Source: https://github.com/61bcdefg/Hikari-LLVM15-Core/blob/main/AntiHooking.cpp

Implementation principle

The pass offers three main capabilities: inline-hook detection, runtime protection, and defense against symbol-rebinding attacks:

  1. Detect and disrupt potential inline hooks. By analyzing instruction sequences in the target program, the pass identifies possible hook code — code an attacker may have planted to control program behavior. Once it detects such an instruction pattern, it inserts additional checks; on an illegal operation it diverts to an error-handling flow, protecting the program from further tampering.

  2. Protect the Objective-C runtime. On Objective-C environments like iOS and macOS, method implementations can be replaced or hooked at runtime — a common dynamic-modification technique. The AntiHook pass inserts specific checks to ensure Objective-C methods haven’t been illegally replaced or modified when they execute.

  3. Counter dynamic-library symbol rebinding. Rebinding symbols in dynamic libraries (e.g., via fishhook) is a common way attackers hijack normal calls. The pass analyzes external function calls and rewrites them to point at safe stubs or alternative implementations, blocking this attack class.

Code analysis

The source is relatively small — about three hundred lines — so I’ll walk through it directly.

0. config

First, the opcode signatures for the ARM A64 instruction set — the binary encodings of specific instructions. These come from the instruction-encoding section of the ARM architecture reference; every instruction in the ARM A64 A-profile has a specific bit pattern.

// Arm A64 Instruction Set for A-profile architecture 2022-12, Page 56
#define AARCH64_SIGNATURE_B 0b000101
// Arm A64 Instruction Set for A-profile architecture 2022-12, Page 75
#define AARCH64_SIGNATURE_BR 0b1101011000011111000000
// Arm A64 Instruction Set for A-profile architecture 2022-12, Page 79
#define AARCH64_SIGNATURE_BRK 0b11010100001
  1. AARCH64_SIGNATURE_B is the opcode signature of the B instruction (unconditional branch). It’s 0b000101 in the A64 reference (0b prefix = binary).
  2. AARCH64_SIGNATURE_BR is the opcode of the BR instruction (branch to register), which jumps to an address held in a register. Its binary pattern is 0b1101011000011111000000.
  3. AARCH64_SIGNATURE_BRK is the opcode of the BRK instruction (breakpoint/interrupt), which raises a synchronous exception, usually for debugging. 0b11010100001 is its unique identifier.

These opcodes define the basic instruction types; compilers/assemblers use the signatures to construct correct binary instructions. Here they’re used to recognize specific binary instruction patterns — mainly for inline-hook detection later.

The config options are self-explanatory from their descriptions:

static cl::opt<std::string>
    PreCompiledIRPath("adhexrirpath",
                      cl::desc("External Path Pointing To Pre-compiled Anti "
                               "Hooking Handler IR"),
                      cl::value_desc("filename"), cl::init(""));

static cl::opt<bool> CheckInlineHook("ah_inline", cl::init(true), cl::NotHidden,
                                     cl::desc("Check Inline Hook for AArch64"));
static bool CheckInlineHookTemp = true;

static cl::opt<bool>
    CheckObjectiveCRuntimeHook("ah_objcruntime", cl::init(true), cl::NotHidden,
                               cl::desc("Check Objective-C Runtime Hook"));
static bool CheckObjectiveCRuntimeHookTemp = true;

static cl::opt<bool> AntiRebindSymbol("ah_antirebind", cl::init(false),
                                      cl::NotHidden,
                                      cl::desc("Make fishhook unavailable"));
static bool AntiRebindSymbolTemp = false;

1. initialize

1. Get target architecture info. Reads the module’s Triple (architecture, OS, etc.).

this->triple = Triple(M.getTargetTriple());

2. Build a default precompiled-IR path. If PreCompiledIRPath isn’t set, it builds a default path under the user’s home directory, embedding the architecture and OS names.

if (PreCompiledIRPath.empty()) {
  SmallString<32> Path;
  if (sys::path::home_directory(Path)) {
    sys::path::append(Path, "Hikari");
    sys::path::append(Path, "PrecompiledAntiHooking-" +
                            Triple::getArchTypeName(triple.getArch()) + "-" +
                            Triple::getOSTypeName(triple.getOS()) + ".bc");
    PreCompiledIRPath = Path.str();
  }
}

3. Check/parse/link the precompiled IR. Uses std::ifstream to check the file exists and is readable, then parses it into a Module via parseIRFile and links it with Linker::linkModules.

std::ifstream f(PreCompiledIRPath);
if (f.good()) {
  errs() << "Linking PreCompiled AntiHooking IR From:" << PreCompiledIRPath
         << "\n";
  SMDiagnostic SMD;
  std::unique_ptr<Module> ADBM(
      parseIRFile(StringRef(PreCompiledIRPath), SMD, M.getContext()));
  Linker::linkModules(M, std::move(ADBM), Linker::Flags::OverrideFromSrc);
} else {
  errs() << "Failed To Link PreCompiled AntiHooking IR From:"
         << PreCompiledIRPath << "\n";
}

4. Determine opaque-pointer support.

this->opaquepointers = !M.getContext().supportsTypedPointers();

5. Insert Objective-C function declarations. If the target is Apple-provided and struct._objc_method is defined in the context, it inserts declarations for objc_getClass, sel_registerName, and method_getImplementation — needed for the Objective-C runtime hook checks.

if (triple.getVendor() == Triple::VendorType::Apple &&
        StructType::getTypeByName(M.getContext(), "struct._objc_method")) {
      Type *Int8PtrTy = Type::getInt8PtrTy(M.getContext());
      M.getOrInsertFunction("objc_getClass",
                            FunctionType::get(Int8PtrTy, {Int8PtrTy}, false));
      M.getOrInsertFunction("sel_registerName",
                            FunctionType::get(Int8PtrTy, {Int8PtrTy}, false));
      FunctionType *IMPType =
          FunctionType::get(Int8PtrTy, {Int8PtrTy, Int8PtrTy}, true);
      PointerType *IMPPointerType = PointerType::getUnqual(IMPType);
      M.getOrInsertFunction(
          "method_getImplementation",
          FunctionType::get(IMPPointerType,
                            {PointerType::getUnqual(StructType::getTypeByName(
                                M.getContext(), "struct._objc_method"))},
                            false));

// ...

The initializer sets up the hook-checking mechanism, ensures the precompiled IR file is found and linked, and inserts any missing ObjC runtime function declarations for later safety checks.

2. runOnModule

Flow. runOnModule checks and handles inline hooks, prevents symbol rebinding (fishhook-style attacks), and handles Objective-C runtime hooks. Its rough flow:

  1. Iterate all functions in the module.
  2. For each, check whether it should undergo “antihook” — based on compile-time flags and function attributes.
  3. If initialized is false, call initialize.
  4. Read F’s ah_inline attribute to decide whether to check inline hooks; fall back to the global CheckInlineHook if unset.
  5. If AArch64 and CheckInlineHookTemp is true, run HandleInlineHookAArch64.
  6. Read F’s ah_antirebind attribute; fall back to AntiRebindSymbol.
  7. If AntiRebindSymbolTemp is true, iterate F’s instructions; for each Call/Invoke whose target is an external-linkage declaration, create a new global and rewrite the call target.
  8. Read F’s ah_objcruntime attribute; fall back to CheckObjectiveCRuntimeHook.
  9. If CheckObjectiveCRuntimeHookTemp is false, skip this function.
  10. Iterate F’s users to find an Objective-C method struct (struct._objc_method); if found, follow its users to locate instance/class method-list globals.
  11. If methodListGV and methodStruct are found, get the selector and class names, then call HandleObjcRuntimeHook.
  12. Return true.

Key code.

// Iterate all functions in the module
for (Function &F : M) {
  // Check whether antihook should apply to this function
  if (toObfuscate(flag, &F, "antihook")) {
    errs() << "Running AntiHooking On " << F.getName() << "\n";
    
    // Initialize if not already
    if (!this->initialized)
      initialize(M);
    
    // Decide whether to check inline hooks from the function attribute
    if (!toObfuscateBoolOption(&F, "ah_inline", &CheckInlineHookTemp))
      CheckInlineHookTemp = CheckInlineHook;
    
    // AArch64 + inline-hook check → handle
    if (triple.isAArch64() && CheckInlineHookTemp) {
      HandleInlineHookAArch64(&F);
    }
    
    // Decide whether anti-rebind is active
    if (!toObfuscateBoolOption(&F, "ah_antirebind", &AntiRebindSymbolTemp))
      AntiRebindSymbolTemp = AntiRebindSymbol;
    
    // Anti-rebind symbol check
    if (AntiRebindSymbolTemp) {
      for (Instruction &I : instructions(F)) {
        if (isa<CallInst>(&I) || isa<InvokeInst>(&I)) {
          CallSite CS(&I);
          Function *Called = CS.getCalledFunction();
          if (!Called)
            Called = dyn_cast<Function>(CS.getCalledValue()->stripPointerCasts());
          
          // External-linkage function declaration
          if (Called && Called->isDeclaration() && Called->isExternalLinkage(Called->getLinkage()) && !Called->isIntrinsic() && !Called->getName().startswith("clang.")) {
            // Create a new global
            GlobalVariable *GV = cast<GlobalVariable>(M.getOrInsertGlobal(("AntiRebindSymbol_" + Called->getName()).str(), Called->getType()));
            if (!GV->hasInitializer()) {
              GV->setConstant(true);
              GV->setInitializer(Called);
              GV->setLinkage(GlobalValue::LinkageTypes::PrivateLinkage);
            }
            appendToCompilerUsed(M, {GV});
            Value *Load = new LoadInst(GV->getValueType(), GV, Called->getName(), &I);
            Value *BitCasted = BitCastInst::CreateBitOrPointerCast(Load, CS.getCalledValue()->getType(), "", &I);
            CS.setCalledFunction(BitCasted);
          }
        }
      }
    }
    
    // Decide whether to check ObjC runtime hooks
    if (!toObfuscateBoolOption(&F, "ah_objcruntime", &CheckObjectiveCRuntimeHookTemp))
      CheckObjectiveCRuntimeHookTemp = CheckObjectiveCRuntimeHook;
    
    // Skip if ObjC runtime hook check is off
    if (!CheckObjectiveCRuntimeHookTemp)
      continue;
    
    // Locate the ObjC method struct and its class/instance methods
    GlobalVariable *methodListGV = nullptr;
    ConstantStruct *methodStruct = nullptr;
    // ... elided: iterating the function's users and their users ...
    
    // Found an ObjC method list and method struct
    if (methodListGV && methodStruct) {
      // Get the selector and class names
      GlobalVariable *SELNameGV = cast<GlobalVariable>(methodStruct->getOperand(0)->stripPointerCasts());
      ConstantDataSequential *SELNameCDS = cast<ConstantDataSequential>(SELNameGV->getInitializer());
      bool classmethod = methodListGV->getName().startswith("_OBJC_$_CLASS_METHODS");
      std::string classname = methodListGV->getName().substr(strlen(classmethod ? "_OBJC_$_CLASS_METHODS_" : "_OBJC_$_INSTANCE_METHODS_")).str();
      std::string selname = SELNameCDS->getAsCString().str();
      
      // Handle the ObjC runtime hook
      HandleObjcRuntimeHook(&F, classname, selname, classmethod);
    }
  }
}
return true;

A closer look at AntiRebindSymbol: it prevents symbols from being rebound during dynamic linking, defeating frameworks like fishhook. Key steps:

  1. Find external-linkage function calls. Iterate all instructions looking for CallInst/InvokeInst.
  2. Check whether it’s an external declaration. External functions aren’t defined in this module and are resolved at runtime via dynamic linking.
  3. Create and initialize a global. For each such function, create a new global initialized to point at it, with private linkage so it’s invisible outside the module.
  4. Replace the original call. Load the function address from the new global via a LoadInst, correct the type with a BitCastInst, and substitute it as the call target.

Effectively it works by creating, at compile time, a private global pointing at the original function. Since it’s private and initialized only at compile time, a runtime attacker can’t easily modify the address — preventing hook-by-symbol-address attacks.

3. HandleInlineHookAArch64

Detects and handles inline hooks on AArch64.

1. Get and split the entry block. The entry block A is split at the first non-PHI/non-debug/non-lifetime instruction, creating block C (which holds most of the original entry block’s instructions).

BasicBlock *A = &(F->getEntryBlock()); // Entry block A
BasicBlock *C = A->splitBasicBlock(A->getFirstNonPHIOrDbgOrLifetime()); // Split A at the first non-PHI/Dbg/Lifetime instruction → block C

2. Create the handler and detection blocks. B is the handler run when a hook is detected; Detect and Detect2 hold the detection logic.

BasicBlock *B = BasicBlock::Create(F->getContext(), "HookDetectedHandler", F); // Handler block B
BasicBlock *Detect = BasicBlock::Create(F->getContext(), "", F); // First detection block
BasicBlock *Detect2 = BasicBlock::Create(F->getContext(), "", F); // Second detection block

3. Rewire the entry block. Delete A’s terminator and branch to Detect.

A->getTerminator()->eraseFromParent(); // Remove A's original terminator
BranchInst::Create(Detect, A); // Add a branch to Detect at the end of A

4. First-stage detection. In Detect, check whether F’s first instruction matches AARCH64_SIGNATURE_B or AARCH64_SIGNATURE_BRK. If so, jump to handler B; otherwise continue to Detect2.

IRBuilder<> IRBDetect(Detect); // IRBuilder for Detect

// ... build and configure the detection IR ...
Value *Load = IRBDetect.CreateLoad(Int32Ty, IRBDetect.CreateBitCast(F, Int32PtrTy));
Value *LS2 = IRBDetect.CreateLShr(Load, ConstantInt::get(Int32Ty, 26));
Value *ICmpEQ2 = IRBDetect.CreateICmpEQ(
    LS2, ConstantInt::get(Int32Ty, AARCH64_SIGNATURE_B));
Value *LS3 = IRBDetect.CreateLShr(Load, ConstantInt::get(Int32Ty, 21));
Value *ICmpEQ3 = IRBDetect.CreateICmpEQ(
    LS3, ConstantInt::get(Int32Ty, AARCH64_SIGNATURE_BRK));
Value *Or = IRBDetect.CreateOr(ICmpEQ2, ICmpEQ3);
 // Branch to B (hook handler) or Detect2 (further detection)
IRBDetect.CreateCondBr(Or, B, Detect2);

5. Second-stage detection. In Detect2, check whether the instruction(s) following the detection logic match AARCH64_SIGNATURE_BR. If so, jump to B; otherwise continue to C.

IRBuilder<> IRBDetect2(Detect2); // IRBuilder for Detect2

// ... build further detection IR ...
Value *PTI = IRBDetect2.CreatePtrToInt(F, Int64Ty);
Value *AddFour = IRBDetect2.CreateAdd(PTI, ConstantInt::get(Int64Ty, 4));
Value *ITP = IRBDetect2.CreateIntToPtr(AddFour, Int32PtrTy);
Value *Load2 = IRBDetect2.CreateLoad(Int32Ty, ITP);
Value *LS4 = IRBDetect2.CreateLShr(Load2, ConstantInt::get(Int32Ty, 10));
Value *ICmpEQ4 = IRBDetect2.CreateICmpEQ(
    LS4, ConstantInt::get(Int32Ty, AARCH64_SIGNATURE_BR));
Value *AddEight = IRBDetect2.CreateAdd(PTI, ConstantInt::get(Int64Ty, 8));
Value *ITP2 = IRBDetect2.CreateIntToPtr(AddEight, Int32PtrTy);
Value *Load3 = IRBDetect2.CreateLoad(Int32Ty, ITP2);
Value *LS5 = IRBDetect2.CreateLShr(Load3, ConstantInt::get(Int32Ty, 10));
Value *ICmpEQ5 = IRBDetect2.CreateICmpEQ(
    LS5, ConstantInt::get(Int32Ty, AARCH64_SIGNATURE_BR));
Value *Or2 = IRBDetect2.CreateOr(ICmpEQ4, ICmpEQ5);
// Branch to B (hook handler) or C (normal continuation)
IRBDetect2.CreateCondBr(Or2, B, C);

6. Handler logic. In B, call CreateCallbackAndJumpBack (analyzed below).

IRBuilder<> IRBB(B); // IRBuilder for B

// ... add the handler callback and the jump-back ...

CreateCallbackAndJumpBack(&IRBB, C); // Invoke the callback on hook detection, then jump back to C

4. HandleObjcRuntimeHook

Detects whether an Objective-C method’s implementation (IMP) has been hooked, by comparing the runtime-resolved implementation against the expected one.

  void HandleObjcRuntimeHook(Function *ObjcMethodImp, std::string classname,
                             std::string selname, bool classmethod)

Parameters:

  • ObjcMethodImp: function pointer to the Objective-C method implementation;
  • classname: the class name string;
  • selname: the selector name string;
  • classmethod: whether the method is a class method.

1. Get the module and split the entry block. Creates three blocks: A (runtime hook detection), B (handler), and C (original continuation).

Module *M = ObjcMethodImp->getParent();
BasicBlock *A = &(ObjcMethodImp->getEntryBlock());
BasicBlock *C = A->splitBasicBlock(A->getFirstNonPHIOrDbgOrLifetime());
BasicBlock *B = BasicBlock::Create(A->getContext(), "HookDetectedHandler", ObjcMethodImp, C);
A->getTerminator()->eraseFromParent(); // Remove A's terminator

2. Build instructions in A and B.

IRBuilder<> IRBA(A);
IRBuilder<> IRBB(B);

3. Decide whether the ObjC method is hooked:

  • Get the class object and selector:

    Value *GetClass = IRBA.CreateCall(M->getFunction("objc_getClass"), {IRBA.CreateGlobalStringPtr(classname)});
    Value *GetSelector = IRBA.CreateCall(M->getFunction("sel_registerName"), {IRBA.CreateGlobalStringPtr(selname)});
  • Get the method object depending on class/instance method:

    Value *GetMethod = IRBA.CreateCall(M->getFunction(classmethod ? "class_getClassMethod" : "class_getInstanceMethod"), {GetClass, GetSelector});
  • Get the implementation:

    Value *GetMethodImp = IRBA.CreateCall(M->getFunction("method_getImplementation"), {GetMethod});
  • Compare the resolved implementation against the original:

    Value *IcmpEq = IRBA.CreateICmpEQ(IRBA.CreateBitCast(GetMethodImp, Int8PtrTy), ConstantExpr::getBitCast(ObjcMethodImp, Int8PtrTy));

4. Branch on the comparison.

IRBA.CreateCondBr(IcmpEq, C, B); // Equal → C, otherwise → B

5. On hook detection, invoke the callback and jump back to C.

CreateCallbackAndJumpBack(&IRBB, C);

In short, it fetches the runtime method implementation via the Objective-C runtime and compares it to the current implementation pointer. A mismatch means the method is likely hooked, diverting control to handler block B, which calls CreateCallbackAndJumpBack to run the callback and jump back to C. If they match, execution continues directly at C.

5. CreateCallbackAndJumpBack

Runs exception-handling/cleanup on detection, then ensures execution continues. Depending on the environment it applies different strategies, but always jumps back to C afterward.

// CreateCallbackAndJumpBack: on illegal operation (e.g., inline hook), invoke a
// callback and jump back to the normal execution path.
void CreateCallbackAndJumpBack(IRBuilder<> *IRBB, BasicBlock *C) {
  // Get the module of the current block
  Module *M = C->getModule();

  // Try to get "AHCallBack" — a possibly predefined security-check callback
  Function *AHCallBack = M->getFunction("AHCallBack");

  // If found, emit a call to it
  if (AHCallBack) {
    IRBB->CreateCall(AHCallBack);
  } else {
    // Otherwise, on Darwin AArch64 (iOS/macOS)...
    if (triple.isOSDarwin() && triple.isAArch64()) {
      // ...build an inline-asm exit syscall
      std::string exitsvcasm = "mov w16, #1\n"; // Syscall number 1 (exit)
      exitsvcasm +=
          "svc #" + std::to_string(cryptoutils->get_range(65536)) + "\n"; // Random svc immediate

      // Create the inline-asm call
      InlineAsm *IA =
          InlineAsm::get(FunctionType::get(IRBB->getVoidTy(), false),
                         exitsvcasm, "", true, false);
      IRBB->CreateCall(IA);
    } else {
      // Otherwise, call abort()
      FunctionType *ABFT =
          FunctionType::get(Type::getVoidTy(M->getContext()), false);
      Function *abort_declare =
          cast<Function>(M->getOrInsertFunction("abort", ABFT).getCallee());
      abort_declare->addFnAttr(Attribute::AttrKind::NoReturn); // Mark abort as noreturn

      IRBB->CreateCall(abort_declare);
    }
  }

  // Finally, jump back to C regardless of whether a callback ran
  IRBB->CreateBr(C);
}

Summary

This pass is a carefully designed hardening strategy — highly selective and configurable. Through architecture adaptation and dedicated defenses against hooking and rebinding attacks, it strengthens the code’s resistance. It also accounts for the Objective-C runtime, recognizing and handling class/instance method hooking. Its configurability makes it adaptable to many compilation scenarios while still meeting specific project/developer needs. With some recent downtime, this Hikari source-analysis series wraps up here; if other passes raise questions, feel free to reach out.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch