暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

iOS开发中常用的AOP方式

技术对话 2023-01-06
489

本文介绍在iOS中常用的AOP方法,每种AOP方式对应的使用及优缺点,如何在网易支付APP中解决相关合规问题。

iOS中常用的AOP方案

Aspects

什么是Aspects

Aspects
是一个开源库,是一个简洁高效的用于使iOS支持AOP
面向切面编程的库,可以在不改变一个类或类实例的代码的前提下,有效更改类的行为。

Aspects使用方式

Aspects
提供了2个AOP
方法,一个用于类,一个用于实例。在确定需要hook
的方法之后, Aspects
允许我们选择hook
的时机是在方法执行之前,还是方法执行之后,甚至可以直接替换掉方法的实现。

Aspects实现原理

Aspects
主要是利用 runtime
的消息转发机制,runtime
消息转发机制如下:

当向一个对象发送消息时,在这个类对象的类继承链中未找到该方法的时候,就会直接Crash
,抛出unrecognized selector sent to …
异常,在抛出异常之前,runtime
给我们三次拯救的机会,在Crash
之前会依次执行下面的方法:

  1. resolveInstanceMethod
    (或者类方法resolveClassMethod
    ):实现该方法,通过class_addMethod
    添加方法,如果返回YES,系统在运行时就会重新启动一次消息发送的过程,返回NO,会继续执行下一个方法

  2. forwardingTargetForSelector
    :实现该方法,可以将消息转发给其他对象,只要这个方法返回的不是nil或self,也会重启消息发送的过程,将消息转发给其他对象来处理。

  3. methodSignatureForSelector
    :如果第二步没有备用接收者,则会继续进行消息转发,执行此方法,去获取一个方法签名,如果没有获取到的话就回直接挑用doesNotRecognizeSelector
    ,如果能获取的话系统就会创建一个NSInvocation
    传给forwardInvocation
    方法。

  4. forwardInvocation
    :该方法是上一个步传进来的NSInvocation
    ,然后调用NSInvocation
    invokeWithTarget
    方法,转发到对应的Target。

  5. doesNotRecognizeSelector
    :抛出unrecognized selector sent to …
    异常。

以上是大家熟悉的runtime
消息转发的整个流程,Aspects
主要是利用forwardInvocation
这一步来进行消息转发。

Aspects主要流程

  1. 使用关联对象添加Container
    ,在这个过程中会进行一些前置条件的判断,例如这个方法是否支持被hook
    等,如果条件验证通过,就会把这次hook
    的信息保存起来,在方法调用的时候,查询出来使用。

  2. 动态创建子类,如果是针对类的hook
    ,则不会走这一步。

  3. 替换这个类的forwardInvocation:
    方法为ASPECTS_ARE_BEING_CALLED
    ,这个方法内部会查找到之前创建的Container
    ,然后根据Container
    中的逻辑进行实际的调用。

  4. 将原有方法的IMP改为_objc_msgForward
    ,改完后当调用原有方法时,就会调用_objc_msgForward
    ,从而触发forwardInvocation:
    方法。

整体流程如下:

性能问题

由于Aspects
使用消息转发,到真正的IMP对应的函数被执行前,经历了对其他多个消息的处理,invoke block
也需要额外的invocation
构建开销。而且使用类似kvo
的方式来实现方法的替换,所有性能比较差,Github
官方也是强烈不建议在生产环境中使用,如官方所说:

Aspects uses Objective-C message forwarding to hook into messages. This will create some overhead. Don't add aspects to methods that are called a lot. Aspects is meant for view/controller code that is not called a 1000 times per second.

Aspects hooks deep into the class hierarchy and creates dynamic subclasses, much like KVO. There's known issues with this approach, and to this date (February 2019) I STRICTLY DO NOT RECOMMEND TO USE Aspects IN PRODUCTION CODE. We use it for partial test mocks in, PSPDFKit, an iOS PDF framework that ships with apps like Dropbox or Evernote, it's also very useful for quickly hacking something up.

Stinger

上文分析了Aspects
实现AOP
的原理,而由饿了么开源的组件Stinger
同样是一个用于AOP
的组件,并且饿了么宣称Stinger
在性能上能够吊打Aspects
,并且给出了测试的数据。Stinger
究竟是如何实现性能的飞跃的呢?

Stinger接口设计

Stinger 提供了同 Aspects 类似的接口,分别用于 Hook 一个类以及 Hook 一个实例对象:

Stinger实现原理

Stinger 其实是和 Aspects 在 hook Class 大致上原理实现是一样的。差异就是 Stinger 并没有利用 Objective-C 中的 @selector(forwardInvocation:) 做消息转发。而是利用了 libffi 这个库去完成了消息的转发,在速度上有提升。

通过源码我们知道,hookMethod
Stinger
的核心:

hookMethod
方法内部首先拿到被hook
selector
的原始实现originalImp
,然后通过类STHookInfoPool
的实例hookInfoPool
拿到stingerIMP
,之后通过class_addMethod
class_replaceMethod
来交换selector
对应的实现。hookInfoPool
实例,先尝试通过hookedClass
selector
来获取:

如果获得不到,则传入selector
selector
对应的原方法的IMP
type encoding
来创建一个新的实例,并保存类对象和类的元类对象:

在方法交换后,将hookInfoPool
对象关联到hookedClass
上。最后,通过st_isInstanceHook
来判断是不是对hookedCls
类实例的hook
,是的话直接返回,否则生成一个hookInfo
实例,加入hookInfoPool
中。

总结Stinger
实现AOP
的基本流程如下:

  • 将被hook
    selector
    的实现交换为stingerIMP

  • 使用libffi
    的创建函数闭包的能力,将stingerIMP
    _st_ffi_function
    进行绑定。

  • 执行被hook
    selector
    的时候,转为执行stingerIMP
    方法,最终执行_st_ffi_function

  • _st_ffi_function
    中,通过ffi_call
    来执行被hook
    selector
    对应的原始实现,并根据设置在合适时机执行hook
    的逻辑。

Method Swizzling

说到iOS中AOP
的方案第一个想到的应该就是 Method Swizzling

Method Swizzling的使用

由于Objective-C
的动态特性,可以让程序在运行时做出一些改变,调用自己定义的方法。使用runtime
交换方法的核心就是:method_exchangeImplementations
, 它实际上将两个方法的实现进行交换

Method Swizzling存在的问题

下面是iOS中Method Swizzling
的一种实现:

这种写法在一些时候能正常工作,但实际上有些问题,为了说明这个问题,假设一个场景:

Son2的方法交换成功,但当执行[Son1 foo]
时,发现Crash
了。
'-[Son1 new_foo]: unrecognized selector sent to instance 0x600002d701f0'

我们HOOK的是Son2的方法,怎么会产生Son1的崩溃?

为什么会发生Crash

首先,class_getInstanceMethod
会查找父类的实现。在上例中,foo
是在父类中实现的,执行method_exchangeImplementations
Father
foo
Son2
new_foo
进行了方法交换。交换之后,当Son1
(Father的子类)执行foo
方法时,会通过「消息查找」找到Father的foo
方法实现。

重点来了!由于已经发生了方法交换,实际上执行的是Son2
new_foo
方法,
但是在在new_foo
中执行了[self new_foo]
。此时这里的self
Son1
实例,但Son1
及其父类Father
中并没有new_foo
SEL
,找不到对应的SEL
,自然就会Crash

什么情况下不会有问题

上面这种写法在一些时候能正常工作,那么,到底什么时候直接执行method_exchangeImplementations
不会有问题呢?

至少在下面几种场景中都不会有问题:

  • Son2
    中有foo
    的实现,在上例中,如果我们在Son2
    中重写了foo
    方法,执行class_getInstanceMethod(class, fromSelector)
    获取到的是Son2
    foo
    实现,而不是Father
    的。这样,执行method_exchangeImplementations
    后,不会影响到Father
    的实现。

  • new_foo
    实现改进,在这个场景中,由于不会执行[self new_foo]
    ,也不会有问题。但这样就达不到hook
    的效果。

改进优化

  • dispatch_once
    ,尽管dyld
    能够保证调用Class
    load
    时是线程安全的,但还是推荐使用dispatch_once
    做保护,防止极端情况下load被显示强制调用时,重复交换(第一次交换成功,下次又换回来了…),造成逻辑混乱。

  • 增加了class_addMethod
    判断,给指定Class
    添加一个SEL
    的实现(或者说是SEL和指定IMP的绑定),添加成功返回YES
    SEL
    已经存在或添加失败返回NO
    。执行class_addMethod
    能避免干扰到父类,因为runtime
    消息传递机制的影响,只执行method_exchangeImplementations
    操作时可能会影响到父类的方法。基于这个原理,如果hook
    的就是本类中实现的方法,那么直接用method_exchangeImplementations
    也是完全没问题的。

  • class_replaceMethod
    ,如果该Class不存在指定SEL,则class_replaceMethod的作用就和class_addMethod一样;如果该Class存在指定的SEL,则class_replaceMethod的作用就和method_setImplementation一样。

Aspects & Method Swizzling & Stinger对比


AspectsMethod SwizzlingStinger
性能极快
API友好度较差非常好非常好
类的hook支持支持支持
对象的hook不支持支持支持
调用原方法时改变selector修改修改不修改
方法可能因命名冲突不会不会
支持多线程增加hook自己加锁支持支持
hook可预见性,可追溯性较差
修改父类方法实现可能会不会不会

fishhook

什么是fishhook

它是Facebook提供的一个动态修改链接Mach-O
文件的工具。利用 Mach-O
文件加载原理,通过修改懒加载和非懒加载两个表的指针达到c函数hook
的目的。

fishhook的使用

这里hook
了系统的open,close
这两个c函数。

fishhook原理

上述两种方案都是基于iOS runtime
在运行时去hook Objc
方法,ObjC
的方法调用在底层都是objc_msgSend(id, SEL)
的形式,为我们提供了交换方法实现(IMP
)的机会,但c函数在编译链接时就确定了函数指针的地址偏移量(Offset
),这个偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令image List获取)是不断变化的。运行中的静态函数指针地址就等于上述Offset + Mach0
文件在内存中的首地址。

既然c函数的指针地址是相对固定且不可修改的,那么fishhook
又是怎么实现对c函数的hook
呢?其实内部/自定义的c函数fishhook
hook
不了,它只能hook Mach-O
外部(共享缓存库中)的函数,比如NSLog、objc_msgSend
等动态符号表中的符号。

fishhook
利用Mach-O
的动态绑定机制,苹果的共享缓存库不会被编译进Mach-O
文件,而是在动态链接时去重新绑定。苹果采用了PIC(Position-independent code)技术成功让c的底层也能有动态的表现:

  • 编译时在Mach-O
    文件_DATA
    段的符号表中为每一个被引用的系统c函数建立一个指针,这个指针用于动态绑定时重定位到共享库中的函数实现。

  • 在运行时当系统c函数被第一次调用时会动态绑定一次,然后将Mach-O
    中的_DATA
    段符号表中对应的指针,指向外部函数(其在共享库中的实际内存地址)。

引用Facebook
提供的官方示意图:

流程如下:

  • 懒加载符号表Lazy Symbol Pointer Table
    与间接符号表Indirect Symbol Table
    中的符号一一对应;

  • 间接符号表保存了函数符号在符号表Symbol Table
    中的偏移量;

  • 符号表中每项为struct nlist
    结构体,其中保存了函数符号在字符串表String Table
    中的偏移量;

  • 通过字符串表的偏移量就可以找到最终的函数符号对于的函数名称;

通过遍历以上流程就可以建立函数符号与函数名称的对应关系,就可以通过函数名称来找到最终的函数符号,进而就可以修改函数符号的指向,来指向自己的实现。因此,fishhook
提供的函数接口rebind_symbols
中的结构体struct rebinding
需要提供函数名称,如下:

fishhook
能够hook
c函数的原因有以下几点:

  • 函数符号属于外部符号,位于动态链接库,进而位于数据段,只有数据段的内容才能被修改;

  • dyld
    提供了获取镜像信息的接口,如获取Mach-O
    header
    ASLR
    等,进而就可以获取懒加载符号表、非懒加载符号表、间接符号表、符号表、字符串表等符号相关的所有信息;

  • 通过遍历函数符号建立函数符号与函数名称的对应关系,就可以通过函数名称就可以找到最终的函数符号地址,就可以修改函数符号的内容来指向自己的实现。

Clang 插桩

iOSAOP
方案中大多是基于运行时的,fishhook
是基于链接阶段的,而编译阶段能否实现AOP
呢,插入我们想要的代码呢?

作为Xcode
内置的编译器Clang
其实是提供了一套插桩机制,用于代码覆盖检测,官方文档如下:Clang自带的代码覆盖工具。Clang
自带的代码覆盖工具,最终是由编译器在指定的位置帮我们加上了特定的指令,生成最终的可执行文件,编写更多的自定义的插桩规则需要自己手写llvm pass

网易支付APP中的使用

监管要求

  • 在网易支付APP中引入一些二方库或者三方库,存在获取一些隐私信息的情况(IDFA
    IDFV
    、运营商等信息),监管部门对这些信息的获取有严格的要求,如果APP在启动时,用户还未授权统一隐私信息,或者在APP进入后台后任然再获取隐私信息,或者频繁获取隐私信息,则会不符合监管要求,APP有被下架的风险。

  • 监管对于一些公钥或者密钥的长度也是有一定的要求。

解决方案

  • 对于上面存在的隐私合规问题,我们可以通过hook
    系统API的方式发现哪些二方库或者三方库获取隐私信息存在不规范的情况。当APP未授权隐私协议信息或者在APP进入后台是,通过在APP运行时hook
    系统IDFA
    IDFV
    等隐私API,获取当前调用的堆栈信息并进行上报,对上报的信息进行分析,得出哪些库不符合监管规范,违规获取隐私信息。这里我们使用Method Swizzling
    来对系统API进行hook处理。这里有个注意点,如果我们hook
    的方法里又获取了对应的隐私信息,则存在死循环的风险,当然可以通过对后续执行的代码的次数进行控制,当达到一定阈值时,则不再不再执行。

  • Aspects
    在实际使用中,出于对性能以及API友好度来考虑,我们只在网易支付APP大前端故障演练中进行使用,通过服务端下发二方库三方库故障,使用Aspects
    hook
    对应的二方库或者三方库的API,来模拟对应的故障,实现故障演练功能。

  • 在引入三方、二方依赖时,有可能引入不合规的密钥长度,所以要对公钥长度进行检测(RSA密钥最低为2048位),iOS中对应的RSA加密使用的方法是SecKeyEncrypt
    SecKeyCreateEncryptedData
    (iOS 10以上可用),这里我们可以用fishhook
    来对这两个API进行hook
    ,在hook
    的方法中获取到当前公钥长度,对不合规的公钥长度,获取对应的堆栈信息进行上报。

总结

以上介绍了iOS中主流的AOP
的方案和一些知名的框架,有编译期、链接期、运行时的,从源代码到程序装载到内存执行,整个过程的不同阶段都可以有相应的方案进行选择,通过了解这些AOP
方案,可以进一步加深对静态和动态语言的理解,也对程序从静态到动态整个过程理解更加深入,实际使用要根据不同的业务情况来具体选择对应的AOP
方案。

参考资料

https://github.com/steipete/Aspects

https://github.com/facebook/fishhook

https://github.com/eleme/Stinger

https://juejin.cn/post/6857699952563978247#heading-17

https://juejin.cn/post/6985109598135042085#heading-6

https://juejin.cn/post/7038237056614531086#heading-10

-- End --

点击下方的公众号入口,关注「技术对话」微信公众号,可查看历史文章,投稿请在公众号后台回复投稿

文章转载自技术对话,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论