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

在软件安全分析、逆向工程和漏洞研究中,监控软件对关键函数的调用(如 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【程序猿编码

posted @ 2025-07-31 15:06  yfceshi  阅读(32)  评论(0)    收藏  举报