基于LLVM IR的防Hook方案

1. 什么是LLVM IR

当我们点击Xcode进行编译时,查看日志可以看到每一个编译单元都有指定大量的编译参数,我们跳过编译前的预处理和语法分析,使用 clang -emit-llvm XXX -S -o XXX.ll 直接导出查看其生成的IR(Intermediate Representation)。

也许你对于IR很陌生,但是Bitcode肯定会知道 。实际上,当我们设置了 Enable Bitcode=YES ,进行Archive时,Bitcode会被嵌入到链接后的Mach-O中,用于提交到App Store。实际上,Bitcode就是二进制格式的IR。

非Archive编译时,Enable Bitcode 将只增加一个编译参数 -fembed-bitcode-marker, 该参数用于在Mach-O中作为占位。因为本地编译调试时并不需要bitcode,去掉这个步骤可以大大加快编译速度。

对于静态库等打开了Bitcode编译,通过MachOview查看会发现有一个__LLVM, __bitcode 段;而全工程编译出来对应的是 __LLVM, __bundle 段;可以使用 segedit 命令将指定的Section导出:

segedit XXX.o -extract __LLVM __bitcode result.bc


2. IR文件结构

如下,IR的结构可分为3部分。

1.Module

可以理解为一个类文件对应一个Module,作为一个独立的编译单元。其内部包含声明以及定义的函数,全局变量等,以及架构信息等。

2.Function

Function相当于C里面的方法,其必须存在于Module中,内部由参数,返回类型以及多个BasicBlock组成,每个Function的起始block是一个EntryBlock,也是列表的第一个BasicBlock。

3.Basic Block

BasicBlock则是Instruction存放的地方,Instruction对应的就是我们真正的可执行代码。Instruction可分为普通指令以及Terminator指令,并且BasicBlock都是以Terminator Instruction结尾,包括跳转,返回,异常等。


3. 语法格式

以下是一些基础的语法,可以帮助我们大致看懂一些简单的实现。

  1. 以@开头为全局标识符(函数,全局变量);以%开头为局部变量。
  2. %a = alloca i32, align 4 ,alloca相当于malloc,用于内存分配且自动释放;i32为占有几位,此为4个字节;align字节对齐。
  3. label 严格的讲它也是一种数据类型(type),但它可以标识入口,相当于代码标签。
  4. 函数的声明使用declare,函数的定义使用define。
  5. 数组类型用[count x ix]表示,其中count表示数组的大小,ix表示数组中每一个元素对应的数据类型,比如字符串”Hello IR”表示为[9 x i8],9表示该字符串包含9个元素(末尾包含一个\0),每个元素大小为i8即c语言中的char类型大小。

接着,我们可以通过 clang -emit-llvm XXX -S -o XXX.ll 导出一个OC类用Sublime或其他文本编辑器打开来看看更深入的结构。

  1. target datalayout: 该字符串指定如何在内存中布局数据,例如:
target datalayout = "e-m:o-p:32:32-Fi8-f64:32:64-v64:32:64-v128:32:128-a:0:32-n32-S32"
    // e表示小端对齐
  // m指定在输出中进行名字重整,以混乱的转义字符\01为前缀的符号将直接传递给汇编程序,而不包含转义字符。 m:o Mach-O mangling风格,私有符号添加L前缀,其他符号 _前缀
  // p:32:32 32-bit的指针进行32bit对齐
  // Fi8 指定函数指针的对齐方式,i表示函数指针的对齐与函数本身是独立的,8则函数指针的对齐方式是函数上指定的显式对齐方式的倍数,即8倍
  // f64:32:64 double类型有32bits的ABI对齐但是优先64Bits对齐
  // v64:32:64 64-bit vector同上
  // v128:32:128 同上
  // a:0:32 聚合类型(数组和结构体)32位对齐
  // n32 指定目标CPU本地整数宽度为32bits
  // S32 未指定的堆栈对齐为32bits
  1. Opaque Structure Types: 不透明结构类型用于表示没有指定主体的已命名结构类型。
%0 = type opaque
  1. Attribute groups:IR中对象引用的属性组。它们对于保持.ll文件可读性很重要,因为许多功能将使用同一组属性。在与.ll单个.c文件对应的文件的退化情况下 ,单个属性组将捕获用于构建该文件的重要命令行标志。
attributes #2 = { nounwind readnone speculatable willreturn }
attributes #3 = { nounwind }
  1. Module Flags Metadata: 整个模块的信息如果仅仅依靠IR是很难传递给LLVM的子系统的。llvm.module.flags 的元数据就是为了解决这个问题,这些标志以键/值对的形式出现,类似于字典,使得任何关心标志的子系统都可以很容易地进行查找。
// 三元组的第一个元素是行为标志,指定当多个模块合并在一起时的行为,并且元数据是相同的ID
        1 表示Error,当两个值不同时发出错误,否则结果值为操作数
    2 表示Warning,如果两个值不一致,则发出警告。结果值将是被链接的第一个模块的标志的操作数,或者如果其他模块使用max,则为max(在这种情况下,结果标志将是max)
        。。。

!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 14, i32 0]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
// !0的ID为!"SDK Version",值为2个数组元素分别为14和0,行为则是如果出现两个以上的!"SDK Version"并且他们的值不相等,则抛出error
// !1的ID为!"Objective-C Version",值为2,当出现多个!"Objective-C Version"且值不同时则发出warning
      
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
  1. DICompileUnit:表示一个编译单元,enums:,retainedTypes:,globals:,macros: 这些字段是一些内部包含与编译单元相关调试信息的元组,与代码优化无关(有些节点只有在指令引用它们时才会发出)。
!11 = distinct !DICompileUnit(language: DW_LANG_ObjC, file: !12, producer: "Apple clang version 12.0.0 (clang-1200.0.32.2)", isOptimized: false, runtimeVersion: 2, emissionKind: FullDebug, enums: !13, retainedTypes: !14, imports: !23, nameTableKind: None, sysroot: "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk", sdk: "iPhoneOS14.0.sdk")

  // DIFile节点表示文件
!12 = !DIFile(filename: "/Users/XXX/Desktop/TestSpeed/TestSpeed/AppDelegate.m", directory: "/Users/XXX/Desktop/bcTest/vm")

!13 = !{}
!14 = !{!15}
// DICompositeType 表示由其他类型组成的类型,如结构体,unions
!15 = !DICompositeType(tag: DW_TAG_structure_type, name: "AppDelegate", scope: !17, file: !16, line: 11, size: 32, flags: DIFlagObjcClassComplete, elements: !18, runtimeLang: DW_LANG_ObjC)
  
  // Represents a module in the programming language, for example, a Clang module, or a Fortran module.
!22 = !DIModule(scope: null, name: "UIKit", configMacros: "\22-DNS_BLOCK_ASSERTIONS=1\22 \22-DOBJC_OLD_DISPATCH_PROTOTYPES=0\22", includePath: "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/UIKit.framework")
!23 = !{!24}
// DIImportedEntity节点表示导入到编译单元的实体
!24 = !DIImportedEntity(tag: DW_TAG_imported_declaration, scope: !11, entity: !22, file: !16, line: 9)
  
  // 编译单元的描述符则由!llvm.dbg.cu收集,用于跟踪全局变量,类型信息 & 导入的实体(声明和namespace)
!llvm.dbg.cu = !{!11, !25, !27, !29}
  1. Automatic Linker Flags Named Metadata: 一些目标支持在单个对象文件中嵌入标记到链接器,通常,它与语言扩展一起使用,语言扩展允许源文件包含链接器命令行选项,并通过目标文件将这些选项自动传输到链接器。这些标志使用 !llvm.link .options 的命名元数据在IR中编码。每个操作数都应该是一个元数据节点,而元数据节点应该是其他元数据节点的列表,每个元数据节点应该是定义链接器选项的元数据字符串列表。
//如下,指定了几组linker options,链接iOS相关库
!llvm.linker.options = !{!31, !32, !33, !34, !35, !36}
!31 = !{!"-framework", !"UIKit"}
!32 = !{!"-framework", !"FileProvider"}
!33 = !{!"-framework", !"UserNotifications"}
!34 = !{!"-framework", !"CoreText"}
!35 = !{!"-framework", !"QuartzCore"}
!36 = !{!"-framework", !"CoreImage"}
  1. DISubprogram:表示来自源语言的函数,可以使用!dbg元数据将一个不同的DISubprogram附加到函数定义中,唯一的DISubprogram可以附加到用于call site调试信息的函数声明中。
!48 = distinct !DISubprogram(name: "-[AppDelegate application:didFinishLaunchingWithOptions:]", scope: !17, file: !17, line: 19, type: !49, scopeLine: 19, flags: DIFlagPrototyped, spFlags: DISPFlagLocalToUnit | DISPFlagDefinition, unit: !11, retainedNodes: !13)
 
 // DIFile节点表示文件
!17 = !DIFile(filename: "TestSpeed/TestSpeed/AppDelegate.m", directory: "/Users/XXX/Desktop")

  //DISubroutineType节点表示子例程类型,types字段引用一个元组,第一个操作数为返回类型,其次依次为形参的类型,即!50。 如果第一个参数为null,则表示函数的返回值为void
!49 = !DISubroutineType(types: !50)
!50 = !{!51, !56, !58, !61, !64}

//DIDerivedType节点表示从其他类型(比如限定类型)派生的类型。DW_TAG_typedef用于为baseType提供一个名称
!51 = !DIDerivedType(tag: DW_TAG_typedef, name: "BOOL", scope: !53, file: !52, line: 81, baseType: !55)
//DIBasicType节点表示基本类型,比如int、bool和float。标签:默认为DW_TAG_base_type。
!55 = !DIBasicType(name: "signed char", size: 8, encoding: DW_ATE_signed_char)

  1. getelementptr: 用于获取聚合数据结构(数组或结构体)的子元素的地址。它只执行地址计算,不访问内存。该指令也可用于计算vector的地址。例如:
struct RT {
  char A;
  int B[10][20];
  char C;
};
struct ST {
  int X;
  double Y;
  struct RT Z;
};
///定义了RI ST结构体并在foo中使用
int *foo(struct ST *s) {
  return &s[1].Z.B[5][13];
}

///在IR中表示
%struct.RT = type { i8, [10 x [20 x i32]], i8 }
%struct.ST = type { i32, double, %struct.RT }

define i32* @foo(%struct.ST* %s) nounwind uwtable readnone optsize ssp {
entry:
//第一个参数i64 1指向struct.ST类型,即%struct.ST*结构体的一个指针
//第二个参数i32 2表示指向ST结构体的第2个元素,即RT
//第三个参数i32 1表示指向RT的第一个元素,array B[10][20]
//最后两个则就是取出数组的对应下标的值
  %arrayidx = getelementptr inbounds %struct.ST, %struct.ST* %s, i64 1, i32 2, i32 1, i64 5, i64 13
  ret i32* %arrayidx
}
//于是上面的arrayidx拆分下来等价于如下:第一步拿到struct.ST,然后取出ST位于index 2处的struct.RT,随后struct.RT的index 1处为int二维数组,最后对B[5][13]进行设置偏移
  %t1 = getelementptr %struct.ST, %struct.ST* %s, i32 1     
  %t2 = getelementptr %struct.ST, %struct.ST* %t1, i32 0, i32 2  
  %t3 = getelementptr %struct.RT, %struct.RT* %t2, i32 0, i32 1     
  %t4 = getelementptr [10 x [20 x i32]], [10 x [20 x i32]]* %t3, i32 0, i32 5 
  %t5 = getelementptr [20 x i32], [20 x i32]* %t4, i32 0, i32 13        
  ret i32* %t5


4. 修改OC的消息发送为直接调用

我们知道在OC中方法调用都是通过runtime进行msgSend调用的,那么能否对一些编译期间已经确定了的调用规则改为直接调用的方式来避免被hook呢?

//OC代码如下:
- (void)runTestOne {
    [self runTestTwo];
    [self runTestThree:10];
    int a = [self runTestFour];
    NSLog(@"%d", a);
}

- (void)runTestTwo {
    NSLog(@"call TestTwo");
}

- (void)runTestThree:(int)value {
    NSLog(@"call TestThree %d", value);
}

- (int)runTestFour {
    return 1;
}

//------ clang -S -fobjc-arc -emit-llvm TestIR.m -o TESTIR.ll 导出IR ------

; Function Attrs: nonlazybind  //禁止函数的延迟符号绑定。这可能会更快地调用函数,但如果在程序启动期间没有调用函数,则会付出额外的程序启动时间。
  //声明外部符号,#4对应上面第3点的属性组
declare i8* @objc_msgSend(i8*, i8*, ...) #4 

  //noinline:不内联调用 optnone:跳过optimization pass ssp: 开启堆栈保护
; Function Attrs: noinline optnone ssp
  //名字重整,以转义字符\01为前缀
  //%0则表示上2中的不透明结构类型,也是就msg_send的第一个参数,id self
  //#1为类型组
  //!dbg !80 将元数据!80使用!dbg附加到方法中,!80则是上面提到的DISubprogram对象
  define internal void @"\01-[TestIR runTestOne]"(%0* %0, i8* %1) #1 {
  %3 = alloca %0*, align 8
  %4 = alloca i8*, align 8
  %5 = alloca i32, align 4
  store %0* %0, %0** %3, align 8
  store i8* %1, i8** %4, align 8
  %6 = load %0*, %0** %3, align 8
  // OBJC_SELECTOR_REFERENCES_.2 即为sel,sel是通过OBJC_METH_VAR_NAME_获取到方法的字符串
  %7 = load i8*, i8** @OBJC_SELECTOR_REFERENCES_, align 8, !invariant.load !9
  //将%0*类型的%6 转换为i8*
  %8 = bitcast %0* %6 to i8*
  // i8* (i8*, i8*, ...) 的objc_msgSend方法, 转成void (i8*, i8*) 再进行传参调用
  call void bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to void (i8*, i8*)*)(i8* %8, i8* %7)
  %9 = load %0*, %0** %3, align 8
  %10 = load i8*, i8** @OBJC_SELECTOR_REFERENCES_.2, align 8, !invariant.load !9
  %11 = bitcast %0* %9 to i8*
  // 转成 void (i8*, i8*, i32) 即增加一个入参
  call void bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to void (i8*, i8*, i32)*)(i8* %11, i8* %10, i32 10)
  %12 = load %0*, %0** %3, align 8
  %13 = load i8*, i8** @OBJC_SELECTOR_REFERENCES_.4, align 8, !invariant.load !9
  %14 = bitcast %0* %12 to i8*
  // 转成 i32  (i8*, i8*) 返回值为i32
  %15 = call i32 bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to i32 (i8*, i8*)*)(i8* %14, i8* %13)
  store i32 %15, i32* %5, align 4
  %16 = load i32, i32* %5, align 4
  notail call void (i8*, ...) @NSLog(i8* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_ to i8*), i32 %16)
  ret void
}

可以看到,调用OC方法,即内部都是通过objc_msgSend或其他几个衍生方法来实现的,i8* @objc_msgSend(i8*, i8*, ...)这是一个带变参的C函数,第一个参数表示指向类实例的指针,第二个参数表示方法的选择子,其余则为可变参数列表。换言之,该函数通过向Objective-C运行时传递消息来间接调用,然后通过提供的入参来找到正确调用的真正函数。

尝试直接在IR中修改为直接call真正的调用方法:

//第1处的msgSend调用
call void bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to void (i8*, i8*)*)(i8* %8, i8* %7)
//替换为:
call void @"\01-[TestIR runTestTwo]"(%0* %6, i8* %7)
  
//第2处的msgSend调用
  call void bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to void (i8*, i8*, i32)*)(i8* %11, i8* %10, i32 10)
//替换为:
call void @"\01-[TestIR runTestThree:]"(%0* %9, i8* %10, i32 10)

//第3处的msgSend调用
%15 = call i32 bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to i32 (i8*, i8*)*)(i8* %14, i8* %13)
//替换为:
%15 = call i32 @"\01-[TestIR runTestFour]"(%0* %12, i8* %13)

修改完成执行 llc -filetype=obj TESTIR.ll 生成目标文件,然后通过gcc生成可执行文件,最终执行如下:

./a.out 
a.out[4491:1939294] call TestTwo
a.out[4491:1939294] call TestThree param: 10
a.out[4491:1939294] 1

可以看到,此种直接调用的方案对于明确指定的方法调用是可行的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349