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
ptracecall to detect a debugger. - It builds an
InlineAsmobject 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:
-
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 typestd::string."adbextirpath"is the option name — the flag used on the command line.cl::descprovides the description: this option points to an external precompiled anti-debugging IR file.cl::value_descdescribes the expected argument (a filename).cl::init("")sets the default to an empty string (no path by default).
-
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::descsets a percentage deciding how likely each function is to be anti-debug-obfuscated.cl::init(40)defaults it to 40%.cl::Optionalmarks 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
andand anicmp:%22 = and i32 %21, 2048and%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_portsto 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
isattyandioctlfor 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() #1declare i32 @getpid() #2declare ptr @malloc(i64) #3declare i32 @task_get_exception_ports(i32, i32, ptr, ptr, ptr, ptr, ptr) #2declare i32 @isatty(i32) #2declare 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:
- Struct definitions — structures for interacting with iOS and organizing memory.
- Global declarations —
@.strstores"ptrace";@mach_task_self_represents the current task. ADBCallBack— callsabort()thenunreachable, a classic anti-debugging exit.InitADB— the core:sysctlprocess queries, state checks viaand/icmp,ADBCallBackon debug detection, dynamic load/unload probing,syscalllow-level checks,task_get_exception_portsfor exception-port probing, plusisatty/ioctlterminal checks.- Declarations — system functions like
getpid,sysctl,dlopen,dlsym,dlclose,syscall,malloc,task_get_exception_ports,isatty,ioctl. - Attributes — compiler optimization attributes (
noinline,nounwind, etc.). - Module flags/identifier — compiler metadata such as
wchar_sizeand 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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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