“影子插桩”:利用 LLVM 在二进制层面对 dlsym 调用做无痕监控(C/C++实现)

发布于:2025-07-30 ⋅ 阅读:(16) ⋅ 点赞:(0)

在软件安全分析、逆向工程和漏洞研究中,监控程序对关键函数的调用(如 dlsym)是获取程序行为信息的重要手段。然而,目标程序可能部署了反调试技术来阻止这类监控。本文介绍一种基于 LLVM 中间表示(IR)进行静态插桩的技术,其核心思路是在程序编译的中间环节注入监控代码,实现对 dlsym 函数获取的地址进行记录,并最终生成一个能绕过部分反调试检查的可执行文件。

一、为什么要对 dlsym 做“影子插桩”?

现代反调试、反沙箱、反篡改方案往往会在运行时通过 dlsym 动态解析敏感符号(如 ptraceforkgetenv 等)。
如果安全研究者(或逆向工程师)能够在程序真正执行之前安静地把所有将要解析的符号打印出来,就相当于提前拿到了对方的“底牌”。

传统做法是在 dlsym 出口处下断点、做 inline-hook,但这些手段:

  • 需要修改 GOT/PLT,容易被反 hook 检测到;
  • 在静态链接或 LTO 场景下失效;
  • 无法覆盖 JIT 或自修改代码路径。

于是诞生另一种思路:在编译阶段把监控逻辑“织”进目标程序本身,运行时无需任何额外注入,自然也不会触发反调试检查。
本文讨论的 LLVM Pass/工具链正是为此而生——它把对 dlsym 的监控逻辑提升并固化到二进制中,最终得到一个干净的可执行文件,可随意投放至任何环境,静默运行。


二、整体技术路线(从 IR 到可执行文件)

  1. 获取 IR
    目标程序先用 Clang 以 -emit-llvm 方式编译成 .ll.bc 中间文件。
    这一步把“机器码世界”拉回到“可分析的 LLVM IR 世界”。

  2. 插桩
    用 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 痕迹;对反调试逻辑来说是“原生指令”。
  1. 重新编译
    把插桩后的 IR 丢给 llc + clanglld,生成新的 ELF/Mach-O/PE。
    由于 IR 已包含 printfprint_checked 的声明,链接器只需把 libc 和自定义 runtime 链接进来即可。

  2. 投放与取证
    最终得到的可执行文件在任意环境运行都会把 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 的调用点出发,逆着指令链找最近一次对 %rsistore
  • 一旦找到,就拿到了符号名指针。

该策略对大多数 -O0/-O1/-O2 代码都有效;若遇到极端优化(值被传播到寄存器),可扩展为数据流分析(使用 MemorySSADominatorTree)。

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

五、可扩展方向

  1. 多架构支持
    只要 ABI 约定清楚,同样的思路可移植到 AArch64(x1 寄存器)、RISC-V(a1 寄存器)。

  2. 符号名过滤
    在 IR 阶段就判断符号名是否匹配黑名单,只插桩关键符号,减少性能开销。

  3. 日志加密
    print_checked 换成自定义加密通道,防止目标程序检测 stdout 写入。

  4. JIT 场景
    若对方用 LLVM-JIT 动态生成代码,可把该 Pass 注册到 JIT 的 IRCompileLayer,实现“在线插桩”。


总结

把监控逻辑提前到编译期完成,运行时只留下“正常”指令流,这便是 LLVM 插桩在反反调试领域的“影子艺术”。

Welcome to follow WeChat official account【程序猿编码


网站公告

今日签到

点亮在社区的每一天
去签到