该篇文章是Hikari源码分析的第二篇,第一篇在这里(juejin.cn/post/732131...),该篇主要针对AntiDebug功能的实现进行分析,源码位置: github.com/61bcdefg/Hi...,话不多说,我们开始。
框架分析
如何反debug的原理不再赘述,我们主要针对PASS的具体实现进行分析。该PASS旨在增加编译后程序的抵抗调试能力,先梳理它的整体实现逻辑有个第一印象,后续我们会展开详解。它通过两个主要方式实现反调试功能:
1. 链接预编译的反调试IR代码
代码中尝试从指定的路径(由adbextirpath
选项提供)加载预编译的反调试IR文件。如果文件加载成功,它会通过Linker::linkModules
函数被链接到当前的模块中。这个预编译的IR可能包含了一系列用于反调试的函数(ADBCallBack
和InitADB
)和结构,例如:
- 检测调试器的代码。
- 修改自身执行路径以防止调试器正常跟踪。
- 插桩代码以检测在调试环境中可能发生的异常行为。
2. 特定于平台的内联汇编注入
对于Darwin操作系统的AArch64架构,如果没有找到ADBCallBack
和InitADB
函数,pass会尝试直接注入内联汇编代码。采用了一种基于概率的方法,通过cryptoutils->get_range(2)
随机选择一种内联汇编代码注入:
- 生成的内联汇编代码可能使用系统调用尝试触发反调试行为,例如,通过
ptrace
调用来检测是否被调试。 - 使用
InlineAsm::get
创建内联汇编对象,然后将其插入到函数的最后一条指令之前,通常是函数的返回指令之前。
代码分析
0. config
我们先对配置进行简单的解读,代码开始位置定义了两个静态全局命令行选项:
1. PreCompiledIRPath
命令行选项:
c++
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>
定义了一个类型为std::string
的命令行选项。"adbextirpath"
是命令行选项的名称,在命令行中指定该选项时使用的标志。cl::desc
提供了该选项的描述,告诉用户这个选项是用来指定预编译反调试IR文件的外部路径。cl::value_desc
是命令行参数的描述,告诉用户这个参数应该是一个文件名。cl::init("")
初始化了这个选项的默认值,这里是一个空字符串,表示默认不指定任何路径。
2. ProbRate
命令行选项:
c++
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>
定义了一个类型为uint32_t
(无符号32位整数)的命令行选项。"adb_prob"
是该命令行选项的名称。cl::desc
这个参数设定了一个百分比,用于决定每个函数被反调试混淆的概率。cl::value_desc
用于描述该命令行选项所期望的值类型,在这个例子中,用户应该提供一个"概率率"。cl::init(40)
表示这个选项的默认值是40,即如果用户没有在命令行中指定该选项,它的值将自动设为40%。cl::Optional
表示这个命令行选项是可选的,用户可以选择是否提供这个选项。
总的来说,允许用户在命令行中通过-adbextirpath
选项指定预编译反调试IR文件的路径,以及用-adb_prob
选项指定每个函数被混淆的概率。
1. initialize
接下来我们详细地解析initialize
函数的代码,并梳理它的整体逻辑。
1. 检查预编译IR路径:
首先判断了PreCompiledIRPath
是否为空。如果是,就尝试构建一个默认的路径。它假定有一个名为"Hikari"的文件夹在用户的home_directory
目录下,然后根据当前模块的目标架构和操作系统类型来构建文件名称。
c++
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. 链接预编译的IR:
在这个部分,首先使用一个ifstream
对象f
来检查文件是否存在。如果存在,就尝试链接预编译的IR文件。如果文件不存在或不可读,就输出一条错误信息。
c++
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);
// ... (省略了一部分代码)
} else {
errs() << "Failed To Link PreCompiled AntiDebugging IR From:" << PreCompiledIRPath << "\n";
}
3. 修改ADBCallBack
和InitADB
函数的属性:
如果找到了ADBCallBack
函数,就断言它不是一个声明(即它已经被定义了),然后改变它的可见性、链接属性以及函数属性,保证它在优化和链接期间的行为。
c++
// ... (前面的链接代码)
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);
}
// ... (类似地对InitADB处理)
4. 设置初始化标志和目标三元组信息:
在成功链接预编译IR之后,设置了initialized
标志为true
,并且将模块的triple
信息存储起来。
c++
this->initialized = true;
this->triple = Triple(M.getTargetTriple());
最终,initialize
方法在完成它的任务后返回true
。通过这种方式,如果在程序编译时包含了这个LLVM Pass,它会为每个模块提供一个初始化和链接预编译IR的过程,从而植入防调试代码。如果初始化失败,它将输出错误,并且可能停止Pass的进一步执行。
2. runOnModule&runOnFunction
2.1 runOnModule
runOnModule
函数比较简单,整体逻辑也很清晰,通过使用一个设定的概率值来决定是否对模块中的各个函数应用反调试混淆。它首先确保用户输入的概率值在合理范围内(0到100),然后遍历模块的所有函数,并通过toObfuscate
函数和概率判断来决定是否对非特定函数(即非ADBCallBack
和InitADB
)应用混淆。如果是,则进行相应的混淆处理,并在处理过程中初始化必要的数据结构。
c++
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
该函数为整个PASS的核心函数,我们也将针对该函数进行详细解析。
1. 获取函数F
的入口基本块EntryBlock
获取了函数的第一个基本块,通常用于插入初始化代码或其他前置逻辑。
c++
BasicBlock *EntryBlock = &(F.getEntryBlock());
2. 尝试获取ADBCallBack
和InitADB
函数的引用
尝试从当前函数所在的模块(F.getParent()
)中获取名为ADBCallBack
和InitADB
的函数。
c++
Function *ADBCallBack = F.getParent()->getFunction("ADBCallBack");
Function *ADBInit = F.getParent()->getFunction("InitADB");
3. ADBCallBack
和InitADB
函数的处理
如果找到这两个函数的处理,则在入口基本块中创建对InitADB
的调用。
如果ADBCallBack
或InitADB
没有找到,则输出错误消息,并且如果函数F
的返回类型不是void
,则返回false
。
c++
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. 检查目标操作系统和架构并构建内联汇编代码字符串
如果目标系统是 Darwin(例如 macOS 或 iOS)且架构是 AArch64(ARM64),则继续执行,初始化一个空的字符串,用于后续构建内联汇编代码。
c++
if (triple.isOSDarwin() && triple.isAArch64()) {
errs() << "Injecting Inline Assembly AntiDebugging For:"
<< F.getParent()->getTargetTriple() << "\n";
std::string antidebugasm = "";
5. 根据随机数决定使用哪组指令填充antidebugasm
通过一个随机函数get_range(2)
来选择不同的代码路径。
c++
switch (cryptoutils->get_range(2)) {
6. 随机选择指令片段并拼接到antidebugasm
使用循环和随机选择的方法,确保每组指令都至少使用一次,然后拼接到antidebugasm
字符串。
c++
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. 创建InlineAsm
对象IA
并在函数终结指令前插入
创建一个内联汇编对象,其中包含了字符串antidebugasm
中的汇编代码。
c++
InlineAsm *IA = InlineAsm::get(FunctionType::get(Type::getVoidTy(EntryBlock->getContext()), false), antidebugasm, "", true, false);
8. 在函数的每个基本块的末尾添加内联汇编
遍历函数中的所有基本块,并在每个基本块的终止指令前插入内联汇编调用,并在内部进行了版本适配。
c++
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. 如果操作系统和架构不支持,则输出错误信息
如果不是预期的操作系统和架构,输出一个错误消息。
c++
} else {
errs() << "Unsupported Inline Assembly AntiDebugging Target: " << F.getParent()->getTargetTriple() << "\n";
}
通过上述代码,大致流程主要是先进行ADBCallBack
和InitADB
函数的获取以及调用,之后针对Darwin系统ARM64架构进行了内联汇编的插入,通过汇编实现svc ptrace的调用,在过程中采用了随机数填充等安全手段。
3. 预编译的反调试IR文件
在上面的分析中我们可知,代码逻辑通过PreCompiledIRPath
参数设置了包含ADBCallBack
和InitADB
函数的IR文件,在此文件中进行了一下反调试的逻辑。所以接下来我们针对该文件进行分析。该IR文件Hikari原作者已经提供,地址为:github.com/HikariObfus... ,文件结构如下:
plain
PrecompiledAntiDebugging-aarch64-ios.bc
PrecompiledAntiDebugging-thumb-ios.bc
PrecompiledAntiDebugging-x86_64-macosx.bc
SymbolConfig.json
我们仅针对PrecompiledAntiDebugging-aarch64-ios.bc
文件进行分析,.bc
文件是LLVM bitcode文件格式,它包含了LLVM的中间表示的编译后的二进制形式。要查看.bc
文件的内容,需要将其转换成文本形式的LLVM IR。使用LLVM工具链中的llvm-dis
工具来完成这个转换。转换后的文件通常具有.ll
扩展名,这是一个可读的LLVM IR文件。
shell
llvm-dis <input.bc> -o <output.ll>
读者可以自行去转换一下,由于代码量较大,在此处就不提供对应代码,我们接下来针对该IR文件进行分析。
1. 结构体定义:
代码的开头定义了多个结构体,其中包括 %struct.kinfo_proc
、%struct.extern_proc
、%union.anon
、%struct.itimerval
、%struct.timeval
、%struct.eproc
、%struct._pcred
、%struct._ucred
、%struct.vmspace
和 %struct.ios_execp_info
。
2. 全局声明:
@.str
是字符串 "ptrace" 的全局声明:@.str = private unnamed_addr constant [7 x i8] c"ptrace\00", align 1
@mach_task_self_
是一个外部全局变量声明:@mach_task_self_ = external global i32, align 4
3. 函数 ADBCallBack:
ADBCallBack
函数比较简单,调用 abort()
函数终止程序,然后执行一个无法到达的指令(unreachable
)。
c
define void @ADBCallBack() #0 {
call void @abort() #4
unreachable
}
4. 函数 InitADB:
这个函数包含了多个系统调用和检查,主要逻辑如下:
- 使用
sysctl
查询进程信息:%18 = call i32 @sysctl(ptr %16, i32 4, ptr %17, ptr %3, ptr null, i64 0)
- 检查进程的某些状态(通过执行位运算
and
和比较指令icmp
):%22 = and i32 %21, 2048
与%23 = icmp ne i32 %22, 0
- 如果检测到调试状态,调用
ADBCallBack
函数:call void @ADBCallBack()
- 尝试动态加载和卸载库,可能是尝试检测是否有调试器干预动态链接过程:
dlopen
和dlsym
调用:%26 = call ptr @dlopen(ptr null, i32 10)
- 使用
syscall
进行系统调用,进行更底层的检查:syscall
调用:%34 = call i32 (i32, ...) @syscall(i32 26, i32 31, i32 0, i32 0)
- 动态分配内存,调用
task_get_exception_ports
来检查异常端口,这可能用于确定是否有调试器附加:%52 = call i32 @task_get_exception_ports(i32 %37, i32 7166, ptr %40, ptr %42, ptr %45, ptr %48, ptr %51)
- 检查
isatty
和ioctl
是否表现异常,这些通常用于检查程序是否在终端上运行,以及终端的状态。:%81 = call i32 @isatty(i32 1)
与%85 = call i32 (i32, i64, ...) @ioctl(i32 1, i64 1074295912)
5. 系统调用和声明:
函数声明部分包含了多个系统调用,例如:
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 关键字定义:
c
attributes #0 = { noinline nounwind optnone ssp uwtable ... }
attributes #1 = { noreturn "correctly-rounded-divide-sqrt-fp-math"="false" ...}
attributes #2 = ...
7. 模块标志和标识:
模块的编译器标志和识别信息在代码末尾给出:
c
!llvm.module.flags = !{!0, !1}
!llvm.ident = !{!2}
以上 IR 代码设计用来检测和防止调试。一旦它检测到某些条件符合调试器运行或者与正常运行程序的预期不符,它会通过调用 ADBCallBack
来终止程序。我们做一下代码的总体分析:
-
结构体定义:代码以多个结构体的定义开始,这些结构体可能用于与 iOS 操作系统的交互和内存数据的组织。
-
全局声明 :
@.str
是一个私有的、未命名的地址常量,用于存储字符串 "ptrace"。@mach_task_self_
是一个外部全局变量,它可能表示当前任务的标识。 -
函数 ADBCallBack :这个函数非常简单,它调用
abort()
函数终止程序,然后执行一个无法到达的指令(unreachable
),这通常是反调试逻辑的一部分。 -
函数 InitADB:这个函数是反调试逻辑的核心。它进行了一系列的系统调用和检查:
- 使用
sysctl
查询进程信息。 - 检查进程的某些状态(通过执行位运算
and
和比较指令icmp
)。 - 如果检测到调试状态,调用
ADBCallBack
函数。 - 尝试动态加载和卸载库,可能是尝试检测是否有调试器干预动态链接过程。
- 使用
syscall
进行系统调用,可能是进行更底层的检查。 - 动态分配内存,调用
task_get_exception_ports
来检查异常端口,这可能用于确定是否有调试器附加。 - 循环遍历一些检查,每次循环中调用
ADBCallBack
如果发现异常。 - 最后,检查
isatty
和ioctl
是否表现异常,这些通常用于检查程序是否在终端上运行,以及终端的状态。
- 使用
-
系统调用和声明 :代码中声明了一系列系统函数,如
getpid
、sysctl
、dlopen
、dlsym
、dlclose
、syscall
、malloc
、task_get_exception_ports
、isatty
和ioctl
。这些函数用于执行各种系统级别的操作,很多与防止调试有关。 -
属性 :这些定义了函数的编译器优化属性,如不内联(
noinline
)、不抛出异常(nounwind
)等。 -
模块标志和标识 :声明了一些编译器相关的元数据,比如
wchar_size
和 PIC(位置无关代码)等级。
总结
这篇文章我们通过详细的代码分析以及IR文件解读了解了基于LLVM PASS的AntiDebug是如何实现的,最后我们总结一下相较于源代码实现AntiDebug采用PASS的形式两者之间有什么不同。
在项目中直接实现AntiDebug通常意味着在源代码层面增加检测调试器的逻辑,而基于LLVM Pass实现AntiDebug则是在编译器优化阶段插入这类逻辑。两者的优势可以从以下几个方面进行比较:
-
隐蔽性:
- 源代码实现:在源代码中实现反调试,逻辑对于有经验的开发者或攻击者是可见的,可能通过阅读源代码被发现和绕过。
- LLVM Pass实现:通过LLVM Pass插入的反调试逻辑是在编译后的二进制中实现的,这使得检测和逆向工程变得更加困难,提高了反调试措施的隐蔽性。
-
可移植性:
- 源代码实现:基于源代码的反调试需要针对不同的平台和编译器进行适配和修改。
- LLVM Pass实现:LLVM作为一个跨平台编译器,支持多种目标架构,使用LLVM Pass可以保证反调试逻辑在不同平台上的一致性和可移植性。
-
灵活性和复用性:
- 源代码实现:需要在代码中手动添加反调试代码,对于大型项目,这可能意味着多个地方需要重复添加类似的代码。
- LLVM Pass实现:可以作为编译流程的一部分,自动化地向目标程序的多个部分插入反调试代码,更容易在多个项目之间复用。
-
维护性:
- 源代码实现:随着项目的发展,维护和更新嵌入在源代码中的反调试逻辑可能会变得复杂。
- LLVM Pass实现:反调试逻辑与应用逻辑分离,使得维护起来更简单。如果有新的反调试技术出现,只需要更新LLVM Pass即可。
-
性能:
- 源代码实现:可能会因为增加额外的检查而影响程序性能。
- LLVM Pass实现:编译期间可以更智能地选择何时何地插入反调试代码,可能会有更好的性能优化空间。
-
混淆程度:
- 源代码实现:通常比较直接,容易被逆向。
- LLVM Pass实现:可以结合编译器的优化和混淆策略,生成更加复杂难以分析的二进制代码。
总而言之,基于LLVM Pass实现AntiDebug可以提供更好的隐蔽性、可移植性、灵活性、维护性,同时可能带来性能和混淆程度方面的优势。然而,这种方法需要对LLVM框架有深入的了解,并且可能需要面对更复杂的构建和调试过程。