← iOS

Hikari Source Analysis — AntiDebug

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 — AntiDebug

This is the second post in my Hikari source-analysis series (the first covered AntiClassDump). This one dissects the AntiDebug pass. Source: https://github.com/61bcdefg/Hikari-LLVM15-Core/blob/main/AntiDebugging.cpp

Framework analysis

I won’t re-derive why anti-debugging works — the focus here is the pass’s concrete implementation. The pass raises the compiled program’s resistance to debugging through two main mechanisms:

1. Linking precompiled anti-debugging IR

The pass tries to load a precompiled anti-debugging IR file from a path supplied by the adbextirpath option. If it loads successfully, it’s linked into the current module via Linker::linkModules. This precompiled IR typically contains the anti-debugging functions (ADBCallBack and InitADB) and structures, for example:

  • Code that detects a debugger.
  • Code that modifies its own execution path to prevent a debugger from tracing normally.
  • Instrumentation that detects anomalous behavior likely to occur in a debug environment.

2. Platform-specific inline assembly injection

For Darwin AArch64 targets, if ADBCallBack and InitADB aren’t found, the pass injects inline assembly directly. It uses a probabilistic approach, calling cryptoutils->get_range(2) to randomly pick which inline assembly snippet to inject:

  • The generated assembly may use a system call to trigger anti-debugging behavior — e.g., a ptrace call to detect a debugger.
  • It builds an InlineAsm object and inserts it before the function’s last instruction (typically before the return).

Code analysis

0. config

Two static global command-line options are defined at the top of the file:

  1. PreCompiledIRPath:

    static cl::opt<std::string> PreCompiledIRPath(
        "adbextirpath",
        cl::desc("External Path Pointing To Pre-compiled AntiDebugging IR"),
        cl::value_desc("filename"), cl::init(""));
    • cl::opt<std::string> defines a command-line option of type std::string.
    • "adbextirpath" is the option name — the flag used on the command line.
    • cl::desc provides the description: this option points to an external precompiled anti-debugging IR file.
    • cl::value_desc describes the expected argument (a filename).
    • cl::init("") sets the default to an empty string (no path by default).
  2. ProbRate:

    static cl::opt<uint32_t> ProbRate(
        "adb_prob",
        cl::desc("Choose the probability [%] For Each Function To Be "
                 "Obfuscated By AntiDebugging"),
        cl::value_desc("Probability Rate"), cl::init(40), cl::Optional);
    • cl::opt<uint32_t> defines an unsigned 32-bit integer option.
    • "adb_prob" is the option name.
    • cl::desc sets a percentage deciding how likely each function is to be anti-debug-obfuscated.
    • cl::init(40) defaults it to 40%.
    • cl::Optional marks the option optional.

In short, the user can pass -adbextirpath to point at a precompiled anti-debugging IR file and -adb_prob to control the per-function obfuscation probability.

1. initialize

1. Check the precompiled IR path. First it checks whether PreCompiledIRPath is empty. If so, it builds a default path, assuming a “Hikari” folder under the user’s home directory, and constructs the filename from the module’s target architecture and OS.

if (PreCompiledIRPath == "") {
  SmallString<32> Path;
  if (sys::path::home_directory(Path)) {
    sys::path::append(Path, "Hikari");
    Triple tri(M.getTargetTriple());
    sys::path::append(Path, "PrecompiledAntiDebugging-" +
                              Triple::getArchTypeName(tri.getArch()) +
                              "-" + Triple::getOSTypeName(tri.getOS()) +
                              ".bc");
    PreCompiledIRPath = Path.c_str();
  }
}

2. Link the precompiled IR. Uses an ifstream to check whether the file exists. If it does, it links the precompiled IR; otherwise it prints an error.

std::ifstream f(PreCompiledIRPath);
if (f.good()) {
  errs() << "Linking PreCompiled AntiDebugging 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::LinkOnlyNeeded);
  // ... (partially elided)
} else {
  errs() << "Failed To Link PreCompiled AntiDebugging IR From:" << PreCompiledIRPath << "\n";
}

3. Tune the ADBCallBack and InitADB function attributes. If ADBCallBack is found, it asserts the function isn’t a declaration (i.e., it’s defined), then adjusts its visibility, linkage, and attributes so it behaves correctly during optimization and linking.

// ... (earlier linking code)
Function *ADBCallBack = M.getFunction("ADBCallBack");
if (ADBCallBack) {
  assert(!ADBCallBack->isDeclaration() && "AntiDebuggingCallback is not concrete!");
  ADBCallBack->setVisibility(GlobalValue::VisibilityTypes::HiddenVisibility);
  ADBCallBack->setLinkage(GlobalValue::LinkageTypes::PrivateLinkage);
  ADBCallBack->removeFnAttr(Attribute::AttrKind::NoInline);
  ADBCallBack->removeFnAttr(Attribute::AttrKind::OptimizeNone);
  ADBCallBack->addFnAttr(Attribute::AttrKind::AlwaysInline);
}
// ... (similar handling for InitADB)

4. Set the initialized flag and target triple. After successfully linking the precompiled IR, it sets initialized = true and stores the module’s triple.

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

Finally initialize returns true. If the pass is included during compilation, it initializes and links the precompiled IR per module, planting anti-debugging code. On failure it prints an error and may halt further execution.

2. runOnModule & runOnFunction

2.1 runOnModule

runOnModule is straightforward: it uses a probability value to decide whether to apply anti-debug obfuscation to each function. It first validates the probability is in range (0–100), then iterates all functions and, via toObfuscate and the probability check, decides whether to obfuscate non-special functions (i.e., not ADBCallBack/InitADB).

bool runOnModule(Module &M) override {
    if (ProbRate > 100) {
      errs() << "AntiDebugging application function percentage "
                "-adb_prob=x must be 0 < x <= 100";
      return false;
    }
    for (Function &F : M) {
      if (toObfuscate(flag, &F, "adb") && F.getName() != "ADBCallBack" &&
          F.getName() != "InitADB") {
        errs() << "Running AntiDebugging On " << F.getName() << "\n";
        if (!this->initialized)
          initialize(M);
        if (cryptoutils->get_range(100) <= ProbRate)
          runOnFunction(F);
      }
    }
    return true;
  }

2.2 runOnFunction

This is the core of the pass.

1. Get the function F’s entry block.

BasicBlock *EntryBlock = &(F.getEntryBlock());

2. Try to get ADBCallBack and InitADB references.

Function *ADBCallBack = F.getParent()->getFunction("ADBCallBack");
Function *ADBInit = F.getParent()->getFunction("InitADB");

3. Handle ADBCallBack / InitADB. If both are found, it creates a call to InitADB in the entry block. If either is missing, it prints an error and returns false when F’s return type isn’t void.

if (ADBCallBack && ADBInit) {
    CallInst::Create(ADBInit, "",
                     cast<Instruction>(EntryBlock->getFirstInsertionPt()));
  } else {
    errs() << "The ADBCallBack and ADBInit functions were not found\n";
    if (!F.getReturnType()
             ->isVoidTy()) // We insert InlineAsm in the Terminator, which
                           // causes register contamination if the return type
                           // is not Void.
      return false;

4. Check the target OS/architecture and build the inline asm string. If the target is Darwin (macOS/iOS) and AArch64 (ARM64), it continues, initializing an empty string for the assembly.

if (triple.isOSDarwin() && triple.isAArch64()) {
        errs() << "Injecting Inline Assembly AntiDebugging For:"
               << F.getParent()->getTargetTriple() << "\n";
        std::string antidebugasm = "";

5. Randomly choose which instruction set fills antidebugasm.

switch (cryptoutils->get_range(2)) {

6. Randomly select and concatenate instruction fragments. Uses a loop and random selection to ensure each instruction group is used at least once, then concatenates them into antidebugasm.

case 0: {
    std::string s[] = {"mov x0, #31\n", "mov w0, #31\n", "mov x1, #0\n",
                             "mov w1, #0\n",  "mov x2, #0\n",  "mov w2, #0\n",
                             "mov x3, #0\n",  "mov w3, #0\n",  "mov x16, #26\n",
                             "mov w16, #26\n"}; // svc ptrace
          bool c[5] = {false, false, false, false, false};
          while (c[0] != true || c[1] != true || c[2] != true || c[3] != true ||
                 c[4] != true) {
        // ...
    }

7. Create the InlineAsm object IA and insert it before the terminator.

InlineAsm *IA = InlineAsm::get(FunctionType::get(Type::getVoidTy(EntryBlock->getContext()), false), antidebugasm, "", true, false);

8. Add the inline assembly at the end of every basic block. Iterates all basic blocks and inserts the asm call before each block’s terminator, with LLVM-version adaptation.

Instruction *I = nullptr;
for (BasicBlock &BB : F)
    I = BB.getTerminator();
CallInst::Create(IA, std::nullopt, "", I);
#if LLVM_VERSION_MAJOR >= 16
        CallInst::Create(IA, std::nullopt, "", I);
#else
        CallInst::Create(IA, None, "", I);
#endif

9. Error on unsupported targets.

} else {
    errs() << "Unsupported Inline Assembly AntiDebugging Target: " << F.getParent()->getTargetTriple() << "\n";
}

To summarize the flow: it first obtains and calls ADBCallBack/InitADB, then for Darwin ARM64 it injects inline assembly implementing an svc ptrace call, using random filling as an additional security measure.

3. The precompiled anti-debugging IR file

As established, PreCompiledIRPath points to an IR file containing ADBCallBack and InitADB. The Hikari authors provide it at https://github.com/HikariObfuscator/Resources:

PrecompiledAntiDebugging-aarch64-ios.bc
PrecompiledAntiDebugging-thumb-ios.bc
PrecompiledAntiDebugging-x86_64-macosx.bc
SymbolConfig.json

I’ll only analyze PrecompiledAntiDebugging-aarch64-ios.bc. A .bc file is LLVM bitcode — the compiled binary form of LLVM IR. To read it, convert it to textual LLVM IR with llvm-dis:

llvm-dis <input.bc> -o <output.ll>

The result is large, so I won’t paste it all; here’s the analysis.

1. Struct definitions. The file opens with several structs, including %struct.kinfo_proc, %struct.extern_proc, %union.anon, %struct.itimerval, %struct.timeval, %struct.eproc, %struct._pcred, %struct._ucred, %struct.vmspace, and %struct.ios_execp_info.

2. Global declarations.

  • @.str — the string "ptrace": @.str = private unnamed_addr constant [7 x i8] c"ptrace\00", align 1
  • @mach_task_self_ — an external global: @mach_task_self_ = external global i32, align 4

3. Function ADBCallBack. Simple — calls abort() then hits an unreachable instruction.

define void @ADBCallBack() #0 {
  call void @abort() #4
  unreachable
}

4. Function InitADB. The core of the anti-debugging logic, containing several syscalls and checks:

  • Query process info via sysctl: %18 = call i32 @sysctl(ptr %16, i32 4, ptr %17, ptr %3, ptr null, i64 0)
  • Check process state with a bitwise and and an icmp: %22 = and i32 %21, 2048 and %23 = icmp ne i32 %22, 0
  • If a debug state is detected, call ADBCallBack.
  • Dynamically load/unload libraries (possibly detecting debugger interference with dynamic linking): %26 = call ptr @dlopen(ptr null, i32 10)
  • Perform lower-level checks via syscall: %34 = call i32 (i32, ...) @syscall(i32 26, i32 31, i32 0, i32 0)
  • Allocate memory and call task_get_exception_ports to check exception ports (possibly to detect an attached debugger): %52 = call i32 @task_get_exception_ports(i32 %37, i32 7166, ptr %40, ptr %42, ptr %45, ptr %48, ptr %51)
  • Check isatty and ioctl for anomalies (whether the program runs on a terminal and its state): %81 = call i32 @isatty(i32 1) and %85 = call i32 (i32, i64, ...) @ioctl(i32 1, i64 1074295912)

5. System calls and declarations. The declarations include several system functions:

  • declare void @abort() #1
  • declare i32 @getpid() #2
  • declare ptr @malloc(i64) #3
  • declare i32 @task_get_exception_ports(i32, i32, ptr, ptr, ptr, ptr, ptr) #2
  • declare i32 @isatty(i32) #2
  • declare i32 @ioctl(i32, i64, ...) #2

6. Attributes. Function attributes are defined at the end via the attributes keyword:

attributes #0 = { noinline nounwind optnone ssp uwtable ... }
attributes #1 = { noreturn "correctly-rounded-divide-sqrt-fp-math"="false" ...}
attributes #2 = ...

7. Module flags and identifier.

!llvm.module.flags = !{!0, !1}
!llvm.ident = !{!2}

This IR is designed to detect and prevent debugging. When it detects a condition consistent with a debugger — or inconsistent with a normally running program — it terminates the program via ADBCallBack. Overall:

  1. Struct definitions — structures for interacting with iOS and organizing memory.
  2. Global declarations — @.str stores "ptrace"; @mach_task_self_ represents the current task.
  3. ADBCallBack — calls abort() then unreachable, a classic anti-debugging exit.
  4. InitADB — the core: sysctl process queries, state checks via and/icmp, ADBCallBack on debug detection, dynamic load/unload probing, syscall low-level checks, task_get_exception_ports for exception-port probing, plus isatty/ioctl terminal checks.
  5. Declarations — system functions like getpid, sysctl, dlopen, dlsym, dlclose, syscall, malloc, task_get_exception_ports, isatty, ioctl.
  6. Attributes — compiler optimization attributes (noinline, nounwind, etc.).
  7. Module flags/identifier — compiler metadata such as wchar_size and PIC level.

Summary

This post walked through the code and the IR file to explain how an LLVM-pass-based AntiDebug is implemented. Let’s close by comparing it to implementing anti-debugging directly in source code.

Implementing AntiDebug directly in a project means adding debugger-detection logic at the source level; doing it via an LLVM Pass inserts that logic during the compiler’s optimization phase. The advantages compare as follows:

  1. Stealth:

    • Source-level: the logic is visible to experienced developers/attackers and can be found and bypassed by reading source.
    • LLVM Pass: the anti-debugging logic lives in the compiled binary, making detection and reverse engineering much harder.
  2. Portability:

    • Source-level: needs per-platform/per-compiler adaptation.
    • LLVM Pass: LLVM is cross-platform and targets many architectures, so the anti-debugging logic stays consistent and portable.
  3. Flexibility and reuse:

    • Source-level: anti-debugging code must be added by hand — in a large project that means repeated additions in many places.
    • LLVM Pass: part of the build pipeline, it automatically injects code across the program and is easier to reuse across projects.
  4. Maintainability:

    • Source-level: maintaining/updating embedded anti-debugging logic gets complex as the project grows.
    • LLVM Pass: anti-debugging logic is separated from application logic; new techniques only require updating the pass.
  5. Performance:

    • Source-level: extra checks may hurt performance.
    • LLVM Pass: can more intelligently choose when/where to insert code during compilation, leaving more room for optimization.
  6. Obfuscation level:

    • Source-level: usually direct and easy to reverse.
    • LLVM Pass: can combine compiler optimization and obfuscation strategies to produce more complex, harder-to-analyze binaries.

In short, an LLVM-pass-based AntiDebug offers better stealth, portability, flexibility, and maintainability, with potential performance and obfuscation advantages. The trade-off: it requires deep LLVM knowledge and can mean a more complex build/debug process.


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