云加社区

自定义Clang命令,利用LLVM Pass实现对OC函数的静态插桩


导语 | 本文推选自腾讯云开发者社区-【技思广益 · 腾讯技术人原创集】专栏。该专栏是腾讯云开发者社区为腾讯技术人与广泛开发者打造的分享交流窗口。栏目邀约腾讯技术人分享原创的技术积淀,与广泛开发者互启迪共成长。本文作者是腾讯云 客户端开发工程师林肖斌 。


Objective-C在函数hook的方案比较多,但通常只实现了函数切片,也就是对函数的调用前或调用后进行hook,这里介绍一种利用llvm pass进行静态插桩的另外一种思路,希望起到抛砖引玉的作用,拿来实现更多有意思的功能。

Objective-C中的常见的函数Hook实现思路


Objective-C是一门动态语言 ,具有运行时的特性,所以能选择的方案比较多,常用的有:method swizzle,message forward(aspectku),libffi,fishhook。但列举的这些方案只能实现函数切片,也就是在函数的调用前或者调用后进行Hook,但 比如我们想在这函数的逻辑中插入桩函数(如下),常见的hook思路就没办法实现了。


- (NSInteger)foo:(NSInteger)num {    NSInteger result = 0;    if (num > 0) {        // 往这里插入一个桩函数:__hook_func_call(int,...)        result = num + 1;    }else {        // 往这里插入一个桩函数:__hook_func_call(int,...)        result = num + 2;    }    return result;}

为了解决上述问题,接下来介绍如何利用在编译的过程中修改对应的文件,实现把桩函数插入到指定的函数实现中。例如以上的函数,插入桩函数之后的效果(在函数打个断点,然后查看汇编代码,就能看到对应的自定义桩函数)。Clang IR


那么如何自定义Clang命令,利用llvm Pass实现对函数的静态插桩,下面分为两部分,一部分是llvm Pass,另外一部分是自定义Clang的编译参数。两者合起来实现这个功能。

什么是LLVM Pass


LLVM Pass是一个框架设计,是LLVM系统里重要的组成部分,一系列的Pass组合,构建了编译器的转换和优化部分,抽象成结构化的编译器代码。LLVM Pass分为两种Analysis pass(分析流程)和Transformation pass(变换流程)。前者进行属性和优化空间相关的分析,同时产生后者需要的数据结构。两都都是LLVM编译流程,并且相互依赖。
常见的应用场景有代码混淆 、单测代码覆盖率、代码静态分析等等。
编译过程

这里“插桩”的思路就是利用OC编译过程中,使用自定义的Pass(这里使用的是transformation pass),来篡改IR文件。比如上述的代码,如果不加入自定义的Pass(左图)加入自定义的Pass(右图)编译出来的IR文件,可以看到两者在对应的基础块不同的地方。



LLVM IR文件的描述


LLVM IR(Intermediate Representation)直译过来是“中间表示”,它是连接编译器中前端和后端的桥梁,它使得LLVM可以解析多种源语言,并为多个目标机器生成代码。前端产生IR,而后端消费它。更多的介绍看这个视频 LLVM IR Tutorial 。



准备工作


下载LLVM

苹果fork分支 https://github.com/apple/llvm-project 选择一个新apple/main那个分支即可。


clone下来之后,在编译之前,要实现我们想要的效果,需要处理两个问题:

(一)写自定义的Pass

  • 编写插桩的代码


也就是llvm pass,我们这里主要是要插入代码,所以用的是transformation pass

在 llvm/include/llvm/Transforms/ 新增一个文件夹(InjectFuncCall),然后上面放着你的LLVM Pass的头文件声明


新建头文件: llvm/include/llvm/Transforms/InjectFuncCall/InjectFuncCall.h
namespace llvm {class InjectFuncCallPass : public PassInfoMixin {public:    /// 构造函数    /// AllowlistFiles 白名单    /// BlocklistFiles 黑名单    explicit InjectFuncCallPass(const std::vector &AllowlistFiles,const std::vector &BlocklistFiles) {        if (AllowlistFiles.size() > 0)          Allowlist = SpecialCaseList::createOrDie(AllowlistFiles,                                                   *vfs::getRealFileSystem());        if (BlocklistFiles.size() > 0)          Blocklist = SpecialCaseList::createOrDie(BlocklistFiles,                                                   *vfs::getRealFileSystem());    }  PreservedAnalyses run(Module &M, ModuleAnalysisManager &MAM);  bool runOnModule(llvm::Module &M);private:    std::unique_ptr Allowlist;    std::unique_ptr Blocklist;};} // namespace llvm

在llvm/lib/Transforms新增一个文件夹(InjectFuncCall),然后上面放着对应的LLVM Pass的cpp文件

新建cpp文件: llvm/lib/Transforms/InjectFuncCall/InjectFuncCall.cpp


using namespace llvm;bool InjectArgsFuncCallPass::runOnModule(Module &M) {    bool Inserted = false;    auto &CTX = M.getContext();    for (Function &F : M) {        if (F.empty())            continue;;        if (F.isDeclaration()) {            continue;        }        if (F.getLinkage() == GlobalValue::AvailableExternallyLinkage)           continue;        if (isa(F.getEntryBlock().getTerminator()))           continue;;        if (Allowlist && !Allowlist->inSection("Inject-Args-Stub", "fun", F.getName())) {            continue;        }        if (Blocklist && Blocklist->inSection("Inject-Args-Stub", "fun", F.getName())) {            continue;        }        IntegerType *IntTy = Type::getInt32Ty(CTX);        PointerType* PointerTy = PointerType::get(IntegerType::get(CTX, 8), 0);        FunctionType *FuncTy = FunctionType::get(Type::getVoidTy(CTX), IntTy, /*IsVarArgs=*/true);        FunctionCallee FuncCallee = M.getOrInsertFunction("__afp_capture_arguments", FuncTy); // 取到一个callee        for (auto &BB : F) {            SmallVector<value*, 16=""> CallArgs;            for (Argument &A : F.args()) {                CallArgs.push_back(&A);            }            Builder.CreateCall(FuncCallee, CallArgs);        }      Inserted = true;    }    return Inserted;}PreservedAnalyses InjectArgsFuncCallPass::run(Module &M,                                              ModuleAnalysisManager &MAM) {   bool Changed =  runOnModule(M);   return (Changed ? llvm::PreservedAnalyses::none()                   : llvm::PreservedAnalyses::all());


  • CMake相关声明和配置


llvm/utils/gn/secondary/llvm/lib/Transforms/InjectArgsFuncCall/BUILD.gn 中需要添加以下声明,才会创建一个对应的静态库。


static_library("InjectFuncCall") {  output_name = "LLVMInjectFuncCall"  deps = [    "//llvm/lib/Analysis",    "//llvm/lib/IR",    "//llvm/lib/Support",  ]  sources = [ "InjectFuncCall.cpp" ]}

llvm/utils/gn/secondary/llvm/lib/Passes/BUILD.gn 添加一行:“ //llvm/lib/Transforms/InjectFuncCa ll ”


"//llvm/lib/Transforms/Scalar",    "//llvm/lib/Transforms/Utils",    "//llvm/lib/Transforms/Vectorize",    "//llvm/lib/Transforms/InjectFuncCall",  ]  sources = [    "PassBuilder.cpp",

llvm/lib/Transforms/CMakeLists.txt 添加一行代码。cmake声明工程新增一个子目录。


add_subdirectory(ObjCARC)add_subdirectory(Coroutines)add_subdirectory(CFGuard)add_subdirectory(InjectArgsFuncCall)

llvm/lib/Passes/CMakeLists.txt 添加一行代码。声明Pass Build会链接 “InjectFuncCall” COMPONENTS


add_llvm_component_library(LLVMPasses  PassBuilder.cpp  PassBuilderBindings.cpp  PassPlugin.cpp  StandardInstrumentations.cpp  ADDITIONAL_HEADER_DIRS  ${LLVM_MAIN_INCLUDE_DIR}/llvm  ${LLVM_MAIN_INCLUDE_DIR}/llvm/Passes  DEPENDS  intrinsics_gen  LINK_COMPONENTS  AggressiveInstCombine  Analysis  Core  Coroutines  InjectArgsFuncCall  IPO  InstCombine  ObjCARC  Scalar  Support  Target  TransformUtils  Vectorize  Instrumentation  )


(二)自定义Clang命令


如何让Clang识别到自定义的命令和根据我们的需要要加载对应的代码呢,需要修改以下几处地方。

在 llvm-project/clang/include/clang/Driver/Options.td 文件里面


  • 添加命令到Driver

文件很长,一般加在sanitize相关的配置后面。搜索end-fno-sanitize* flags,往下一行插入。
// 开始自定义的命令到Driverdef inject_func_call_stub_EQ : Joined<["-","--"],"add-inject-func-call=">, Flags<[NoXarchOption]>,HelpText<"Add Inject Func Call">;def inject_func_call_allowlist_EQ : Joined<["-","--"],"add-inject-allowlist=">, Flags<[NoXarchOption]>,HelpText<"Enable Inject Func Call From AllowList">;def inject_func_call_blocklist_EQ : Joined<["-","--"],"add-inject-blocklist=">, Flags<[NoXarchOption]>,HelpText<"Disable Inject Func Call From BlockList">;def inject_func_call : Flag<["-","--"],"add-inject-func-call">, Flags<[NoXarchOption]>, Alias, AliasArgs<["none"]>, HelpText<"[None] Add Inject Func Call.">;// 结束自定义的命令到Driver


  • 添加命令到Fronted cc1


//===----------------------------------------------------------------------===//// 自定义插桩 Options//===----------------------------------------------------------------------===//def inject_func_call_type : Joined<["-"],"inject_func_call_type=">, HelpText<"CC1 add args stub [bb,func]">;def inject_func_call_allowlist : Joined<["-"],"inject_func_call_allowlist=">, HelpText<"CC1 add args from allow list">;def inject_func_call_blocklist : Joined<["-"],"inject_func_call_blocklist=">, HelpText<"CC1 add args from block list">;

llvm-project/clang/lib/Driver/ToolChains/Clang.cpp
  • 添加Driver到Fronted之间的命令链接


在ConstructJob这个函数里面 添加Driver到Fronted之间的命令链接


void Clang::ConstructJob(Compilation &C, const JobAction &JA,const InputInfo &Output, const InputInfoList &Inputs,const ArgList &Args, const char *LinkingOutput) const {......const SanitizerArgs &Sanitize = TC.getSanitizerArgs();Sanitize.addArgs(TC, Args, CmdArgs, InputType);/// 添加Driver 到Fronted之间的命令的链接  if(const Arg *arg = Args.getLastArg(options::OPT_inject_func_call_stub_EQ)){        StringRef val = arg->getValue();        if (val != "none") {            CmdArgs.push_back(Args.MakeArgString("-inject_func_call_type=" + Twine(val)));            StringRef allowedFile = Args.getLastArgValue(options::OPT_inject_func_call_allowlist_EQ);            llvm::errs().write_escaped("Clang:allowedFile:") << allowedFile << '\\n';            CmdArgs.push_back(Args.MakeArgString("-inject_func_call_allowlist=" + Twine(allowedFile)));            StringRef blockFile = Args.getLastArgValue(options::OPT_inject_func_call_blocklist_EQ);            llvm::errs().write_escaped("Clang:blockFile:") << blockFile << '\\n';            CmdArgs.push_back(Args.MakeArgString("-inject_func_call_blocklist=" + Twine(blockFile)));        }  }......}

这文件 /llvm-project/clang/lib/Frontend/CompilerInvocation.cpp 中处理第四步



  • 参数赋值给Option


把解析逻辑中,真正拿到clang传进来的参数赋值给Option,需要给Option新增几个变量。

在对应的文件 /clang/include/clang/Basic/CodeGenOptions.h


  /// type of inject func call  std::string InjectFuncCallOption;  /// inject func allow list  std::vector InjectFuncCallAllowListFiles;  /// inject func block list  std::vector InjectFuncCallBlockListFiles;bool CompilerInvocation::ParseCodeGenArgs(CodeGenOptions &Opts, ArgList &Args,                                          InputKind IK,                                          DiagnosticsEngine &Diags,                                          const llvm::Triple &T,                                          const std::string &OutputFile,                                          const LangOptions &LangOptsRef) {...for (const auto &Arg : Args.getAllArgValues(OPT_inject_args_stub_type)) {      StringRef Val(Arg);      Opts.InjectArgsOption = Args.MakeArgString(Val);  }  Opts.InjectArgsAllowListFiles = Args.getAllArgValues(OPT_inject_args_stub_allowlist);  Opts.InjectArgsBlockListFiles = Args.getAllArgValues(OPT_inject_args_stub_blocklist);...}


  • 将自定义的Pass添加到Backend


在emit assembly的时机,判断Option,然后执行Model Pass Manager 的add Pass操作。

对应的文件 /c lang/lib/CodeGen/BackendUtil.cpp


#include "llvm/Transforms/InjectArgsFuncCall/InjectArgsFuncCall.h"// 最后添加 Inject Args Function Pass。if (CodeGenOpts.InjectArgsOption.size() > 0) {      MPM.addPass(InjectArgsFuncCallPass(CodeGenOpts.InjectArgsAllowListFiles, CodeGenOpts.InjectArgsBlockListFiles));}


编译llvm


上述的配置和代码都搞完之后,接下来编译,编译的过程直接看github的readme,安装必要的工具cmake,najia等。
cd llvm-project// 新建一个build文件夹来生成工程mkdir build cd build// -G Xcode会cmake出来一个xcode工程,也可以选择ninjacmake -DLLVM_ENABLE_PROJECTS=clang -G Xcode ../llvm// 执行结束后,会在build文件夹生成完整的工程目录

目前LLVM,只能用Legacy Build System。所以需要在文件→项目设置→构建系统里面切换一下。

执行结果验证

  • 生成IR文件调试效果


打开llvm的工程,选择clang的target,设置Clang的运行参数



  • 把上述的的路径替换成自己的路径


// 指定使用new pass manager,llvm里面有两套写自定pass的接口,现在是使用新的接口。-fexperimental-new-pass-manager// 启动功能,以基础块级别地插入函数-add-inject-func-call=bb// 设置白名单,只有在白名单里面的文件/函数才会插桩-add-inject-allowlist=$(SRCROOT)/config/allowlist.txt// 设置黑名单,黑名单里指定的文件/函数会忽略掉-add-inject-blocklist=$(SRCROOT)/config/blocklist.txt


  • 白名单&黑名单


简单的格式:
#指定对应的section[InjectFuncCallSection]# 指定对应的文件src:/OC-Hook-Demo/OC-Hook-Demo/Foo.m# 指定对应的函数名,*号可支持模糊匹配func:*foo*

白名单和黑名单是参考Clang Sanitizer配置文件的格式,更详细的参考官方 说明 。


  • 在Xcode中应用


第一步,指定使用自定义的Clang

改Build Setting,在User Define新增设置成自定义Clang的地址, 注意路径需要指向llvm工程里的目录,如果想要单独拷贝clang的可执行文件,需要把相关的头文件(include文件夹)一起放到同一个文件夹。


第二步,改构建设置→苹果Clang自定义编译器标志→其他C标志

第三步,在工程中写指定的桩函数,demo中定义的桩函数是“**hook_func_call”
void** hook\_func\_call(int args, ...) {  ...}

第四步,在目标函数上打上断点,然后运行

执行到断点的时候,在XCode->Debug->Debug Workflow->Always Show Disassemby就能看到文章开头处的,在汇编代码中显示插入和调用桩函数。
对于LLVM和Clang还处于学习的过程中,希望有兴趣的开发者一起交流学习。
参考资料:

1. i OS查漏补缺-LLVM& Clang

2. 深入剖析iOS编译Clang/LLVM

3. 编写LLVMPass


作者简介
林肖斌 腾讯云开发者社区【技思广益·腾讯技术人原创集】作者

腾讯客户端开发工程师,macOS&iOS领域有多年的开发经验,目前负责一些工程效能相关的工具研发。喜欢读书、思考,热衷折腾一切有趣的东西。



推荐阅读

深度解读Vite的依赖扫描

TVP 尖峰对话:透过喧嚣探寻低代码的技术本我

Go 1.18 版本新特性详解!

【腾讯云原生】腾讯云跨账号流量统一接入与治理方案


👇 点击 「阅读原文」 , 注册成为社 区创作者,认识大咖,打造你的技术影响力!