在软件安全分析、逆向工程和漏洞研究中,监控程序对关键函数的调用(如 dlsym)是获取程序行为信息的重要手段。然而,目标程序可能部署了反调试技术来阻止这类监控。本文介绍一种基于 LLVM 中间表示(IR)进行静态插桩的技术,其核心思路是在程序编译的中间环节注入监控代码,实现对 dlsym 函数获取的地址进行记录,并最终生成一个能绕过部分反调试检查的可执行文件。
一、为什么要对 dlsym 做“影子插桩”?
现代反调试、反沙箱、反篡改方案往往会在运行时通过 dlsym
动态解析敏感符号(如 ptrace
、fork
、getenv
等)。
如果安全研究者(或逆向工程师)能够在程序真正执行之前,安静地把所有将要解析的符号打印出来,就相当于提前拿到了对方的“底牌”。
传统做法是在 dlsym
出口处下断点、做 inline-hook,但这些手段:
- 需要修改 GOT/PLT,容易被反 hook 检测到;
- 在静态链接或 LTO 场景下失效;
- 无法覆盖 JIT 或自修改代码路径。
于是诞生另一种思路:在编译阶段把监控逻辑“织”进目标程序本身,运行时无需任何额外注入,自然也不会触发反调试检查。
本文讨论的 LLVM Pass/工具链正是为此而生——它把对 dlsym
的监控逻辑提升并固化到二进制中,最终得到一个干净的可执行文件,可随意投放至任何环境,静默运行。
二、整体技术路线(从 IR 到可执行文件)
获取 IR
目标程序先用 Clang 以-emit-llvm
方式编译成.ll
或.bc
中间文件。
这一步把“机器码世界”拉回到“可分析的 LLVM IR 世界”。插桩
用 LLVM C++ API 写一个小工具)——- 遍历所有对
function_call
(封装了 dlsym 的桩函数)的调用点; - 在调用之前插入
printf("dlsym => %p\n", rsi)
; - 再插入一个外部
print_checked(void*)
,把地址强转为字符串后二次打印。
- 遍历所有对
void parse(const char* path, std::unique_ptr<Module>& program, LLVMContext& ctx)
{
SMDiagnostic error;
program = llvm::parseIRFile(path, error, ctx);
if (!program)
{
printf("Failed to parse IR file\n");
error.print(path, llvm::errs());
exit(-1);
}
}
void dump(const char* path, std::unique_ptr<Module>& program)
{
std::string ir;
llvm::raw_string_ostream stream(ir);
program->print(stream, nullptr);
std::ofstream output(path);
output << ir;
output.close();
}
Instruction* find_store(Instruction* start, const char* target)
{
auto previous_instruction = start->getPrevNode();
while (previous_instruction != nullptr)
{
// we only want to check store instructions
if (llvm::isa<StoreInst>(previous_instruction))
{
const auto store_instruction = llvm::cast<StoreInst>(previous_instruction);
const auto target_operand = store_instruction->getOperand(1);
const auto operand_name = target_operand->getName().data();
// make sure the operand (register) to be written matches our target
if (strcmp(operand_name, target) == 0)
return previous_instruction;
}
previous_instruction = previous_instruction->getPrevNode();
}
return nullptr;
}
void process(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
const auto function_call = program->getFunction("function_call");
const auto fmt_str = builder.CreateGlobalStringPtr("dlsym => %p\n", "dlsym_fmt", 0, program.get());
const auto print = program->getFunction("printf");
const auto print_checked = program->getFunction("print_checked");
for (const auto& user : function_call->users())
{
// 确保该引用实际上是一条调用指令
if (!llvm::isa<CallInst>(user))
continue;
const auto call_instruction = llvm::cast<CallInst>(user);
const auto store_instruction = find_store(call_instruction, "rsi");
if (store_instruction == nullptr)
continue;
const auto rsi = store_instruction->getOperand(1);
builder.SetInsertPoint(store_instruction->getNextNode());
const auto loaded = builder.CreateLoad(rsi);
builder.CreateCall(print, { fmt_str, loaded });
// 向外部受检查的printf函数发出调用
const auto ptr_type = Type::getIntNPtrTy(program->getContext(), 8);
const auto ptr = builder.CreateCast(Instruction::CastOps::IntToPtr, loaded, ptr_type);
builder.CreateCall(print_checked, { ptr });
}
}
void create_printf(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo(), builder.getInt64Ty() };
auto function_type = FunctionType::get(builder.getInt64Ty(), args, false);
program->getOrInsertFunction("printf", function_type);
}
void create_print_checked(const std::unique_ptr<Module>& program, IRBuilder<>& builder)
{
std::vector<Type*> args = { builder.getInt8Ty()->getPointerTo() };
auto function_type = FunctionType::get(builder.getVoidTy(), args, false);
program->getOrInsertFunction("print_checked", function_type);
}
int main(int argc, char* argv[])
{
LLVMContext context;
std::unique_ptr<Module> program = nullptr;
parse(argv[1], program, context);
printf("Loaded IR: %s\n", program->getModuleIdentifier().data());
IRBuilder builder(context);
create_printf(program, builder);
create_print_checked(program, builder);
process(program, builder);
printf("Verification: %d\n", llvm::verifyModule(*program, &llvm::dbgs()));
dump(argv[2], program);
return 0;
}
If you need the complete source code, please add the WeChat number (c17865354792)
关键点:
- 所有插入都在 SSA IR 级别完成,后续优化器会把冗余代码、常量折叠全部算好,运行时开销趋近于 0。
- 不碰任何 GOT/PLT,也不留 hook 痕迹;对反调试逻辑来说是“原生指令”。
重新编译
把插桩后的 IR 丢给llc
+clang
或lld
,生成新的 ELF/Mach-O/PE。
由于 IR 已包含printf
和print_checked
的声明,链接器只需把 libc 和自定义 runtime 链接进来即可。投放与取证
最终得到的可执行文件在任意环境运行都会把 dlsym 解析出的所有符号地址按顺序打印到 stdout 或日志文件。
研究者可直接用脚本解析日志,与符号表交叉对照,还原对方的反调试/反沙箱策略。
三、设计思想拆解
1. 选点策略 —— 为什么盯上 function_call
而非直接 dlsym
?
- 实际商业程序往往对
dlsym
再做一次封装(为了统一错误处理、日志或加解密),封装函数名相对固定; - 封装层通常只有一个参数(符号名),寄存器布局简单,易于回溯。
本文示例把rsi
视为符号名指针,在 x86_64 ABI 中rsi
正好是第二个整型参数寄存器,与dlsym(handle, symbol)
的symbol
对应。
2. 回溯模型 —— 如何找到“真正”的符号名?
SSA 形式下,变量命名是 %n
风格,但寄存器别名(如 %rsi
)在 LLVM IR 里被降级为 alloca + load/store
。
因此工具采用“向前回溯 store”策略:
- 从
function_call
的调用点出发,逆着指令链找最近一次对%rsi
的store
; - 一旦找到,就拿到了符号名指针。
该策略对大多数 -O0/-O1/-O2
代码都有效;若遇到极端优化(值被传播到寄存器),可扩展为数据流分析(使用 MemorySSA
或 DominatorTree
)。
3. 零感知监控 —— 为什么不会被反调试检测?
- 不修改 PLT/GOT:传统 hook 会改
.got.plt
,而本方案把监控逻辑直接内联到指令流; - 不引入异常段:所有新增指令都是“正常”的 call/printf,不会触发 seccomp、ptrace 或 SIGTRAP;
- 无外部依赖:运行时不需要注入
.so
,也不依赖 LD_PRELOAD,沙箱无法通过白名单拦截。
4. 双层打印 —— 为什么既要 printf
又要 print_checked
?
printf("dlsym => %p\n", rsi)
只打印地址,方便脚本批量处理;print_checked(void*)
把地址强转为char*
再打印字符串,可人工立即确认符号名;- 两层分离的设计让后续分析更灵活:
- 自动化阶段只看地址;
- 人工复核阶段再看字符串。
四、相关技术领域
领域 | 具体技术点 | 本文中的体现 |
---|---|---|
编译器设计 | SSA、IR Pass、指令插入 | 在 LLVM IR 层操作 |
二进制分析 | 回溯 use-def 链、寄存器别名 | 找 store-to-rsi |
反反调试 | 零感知监控、无痕插桩 | 不修改 PLT/GOT |
程序变换 | 自包含可执行文件 | 生成新的 ELF/PE |
五、可扩展方向
多架构支持
只要 ABI 约定清楚,同样的思路可移植到 AArch64(x1
寄存器)、RISC-V(a1
寄存器)。符号名过滤
在 IR 阶段就判断符号名是否匹配黑名单,只插桩关键符号,减少性能开销。日志加密
把print_checked
换成自定义加密通道,防止目标程序检测 stdout 写入。JIT 场景
若对方用 LLVM-JIT 动态生成代码,可把该 Pass 注册到 JIT 的IRCompileLayer
,实现“在线插桩”。
总结
把监控逻辑提前到编译期完成,运行时只留下“正常”指令流,这便是 LLVM 插桩在反反调试领域的“影子艺术”。
Welcome to follow WeChat official account【程序猿编码】