摘要:业界对Swift的Hook大多都需求依托OC的音讯转发特性来完结,本文从修正Swift的虚函数表的视点,介绍了一种新的Hook思路。并以此为主线,要点介绍Swift的具体结构以及使用。

导言

因为历史包袱的原因,现在干流的大型APP基本都是以Objective-C为主要开发语言。可是敏锐的同学应该能发现,从Swift的ABI安稳今后,各个大厂开端连续加大对Swift的投入。虽然在短期内Swift还难以取代Objective-C,可是其与Objective-C并驾齐驱的趋势是越来越明显,从招聘的视点就即可管中窥豹。在过去一年的招聘过程中咱们总结发现,有相当数量的提名人只把握Swift开发,对Objective-C开发并不熟悉,而且这部分提名人大多数比较年轻。别的,以RealityKit等新框架为例,其只支撑Swift不支撑Objective-C。上述种种现象意味着随着时间的推移,假如项目不能很好的支撑Swift开发,那么招聘成本以及使用立异等一系列问题将会凸显出来。因而,58同城在2020年Q4的时候在集团内发起了跨部门协同项目,从各个层面打造Objective-C与Swift的混编生态环境——项目代号 ”混天“。一旦混编生态构建完善,那么很多问题将便利的处理。

原理简述

文章篇幅较长,且内容较为单调,为了便利读者阅览,先抛出结论及原理。假如您对相关代码感兴趣,能够在Github上查找SwiftVTHook下载Demo

本文的技能计划仅针对经过虚函数表调用的函数进行Hook,不涉及直接地址调用和objc_msgSend的调用的状况。别的需求注意的是,Swift Compiler设置为Optimize for speed(Release默认)则TypeContext的VTable的函数地址会清空。设置为Optimize for size则Swfit或许会转变为直接地址调用。以上两种配置都会形成计划失效。因而本文要点在介绍技能细节而非计划推行。

计划简图
假如Swift经过虚函数表跳表的办法来完结办法调用,那么能够凭借修正虚函数表来完结办法替换。即将特定虚函数表的函数地址修正为要替换的函数地址。可是因为虚函数表不包括地址与符号的映射,咱们不能像Objective-C那样依据函数的姓名获取到对应的函数地址,因而修正Swift的虚函数是依托函数索引来完结的。简略了解便是将虚函数表了解为数组,假定有一个FuncTable[],咱们修正函数地址只能经过索引值来完结,就像FuncTable[index] = replaceIMP 。可是这也涉及到一个问题,在版别迭代过程中咱们不能保证代码是一层不变的,因而这个版别的第index个函数或许是函数A,下个版别或许第index个函数就变成了函数B。明显这对函数的替换会发生重大影响。

为此,咱们经过Swift的OverrideTable来处理索引改动的问题。在Swift的OverrideTable中,每个节点都记载了当时这个函数重写了哪个类的哪个函数,以及重写后函数的函数指针。因而只需咱们能获取到OverrideTable也就意味着能获取被重写的函数指针IMP0以及重写后的函数指针IMP1。只需在FuncTable[]中找到IMP0并替换成IMP1即可完结办法替换。

接下来将具体介绍Swift的函数调用TypeContextMetadataVTableOverrideTable等细节,以及他们彼此之间有何种相关。为了便利阅览和了解,本文一切代码及运转成果,都是根据arm64架构

Swift的函数调用

首要咱们需求了解Swift的函数怎么调用的。与Objective-C不同,Swift的函数调用存在三种办法,分别是:根据Objective-C的音讯机制、根据虚函数表的拜访、以及直接地址调用。

  • Objective-C的音讯机制

首要咱们需求了解在什么状况下Swift的函数调用是凭借Objective-C的音讯机制。假如办法经过@objc dynamic润饰,那么在编译后将经过objc_msgSend的来调用函数。
假定有如下代码

class MyTestClass :NSObject {
@objc dynamic func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}
let myTest = MyTestClass.init()
myTest.helloWorld()

编译后其对应的汇编为

    0x1042b8824 <+120>: bl     0x1042b9578               ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
0x1042b8828 <+124>: mov    x20, x0
0x1042b882c <+128>: bl     0x1042b8998               ; SwiftDemo.MyTestClass.__allocating_init() -> SwiftDemo.MyTestClass at ViewController.swift:22
0x1042b8830 <+132>: stur   x0, [x29, #-0x30]
0x1042b8834 <+136>: adrp   x8, 13
0x1042b8838 <+140>: ldr    x9, [x8, #0x320]
0x1042b883c <+144>: stur   x0, [x29, #-0x58]
0x1042b8840 <+148>: mov    x1, x9
0x1042b8844 <+152>: str    x8, [sp, #0x60]
->  0x1042b8848 <+156>: bl     0x1042bce88               ; symbol stub for: objc_msgSend
0x1042b884c <+160>: mov    w11, #0x1
0x1042b8850 <+164>: mov    x0, x11
0x1042b8854 <+168>: ldur   x1, [x29, #-0x48]
0x1042b8858 <+172>: bl     0x1042bcd5c               ; symbol stub for:

从上面的汇编代码中咱们很简单看出调用了地址为0x1042bce88的objc_msgSend函数。

  • 虚函数表的拜访

虚函数表的拜访也是动态调用的一种方法,只不过是经过拜访虚函数表的办法进行调用。
假定仍是上述代码,咱们将@objc dynamic去掉之后,而且不再承继自NSObject。

class MyTestClass {
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}
let myTest = MyTestClass.init()
myTest.helloWorld()

汇编代码变成了下面这样👇

    0x1026207ec <+120>: bl     0x102621548               ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
0x1026207f0 <+124>: mov    x20, x0
0x1026207f4 <+128>: bl     0x102620984               ; SwiftDemo.MyTestClass.__allocating_init() -> SwiftDemo.MyTestClass at ViewController.swift:22
0x1026207f8 <+132>: stur   x0, [x29, #-0x30]
0x1026207fc <+136>: ldr    x8, [x0]
0x102620800 <+140>: adrp   x9, 8
0x102620804 <+144>: ldr    x9, [x9, #0x40]
0x102620808 <+148>: ldr    x10, [x9]
0x10262080c <+152>: and    x8, x8, x10
0x102620810 <+156>: ldr    x8, [x8, #0x50]
0x102620814 <+160>: mov    x20, x0
0x102620818 <+164>: stur   x0, [x29, #-0x58]
0x10262081c <+168>: str    x9, [sp, #0x60]
->  0x102620820 <+172>: blr    x8
0x102620824 <+176>: mov    w11, #0x1
0x102620828 <+180>: mov    x0, x11

从上面汇编代码能够看出,经过编译后最终是经过blr 指令调用了x8寄存器中存储的函数。至于x8寄存器中的数据从哪里来的,留到后边的章节阐述。

  • 直接地址调用

假定仍是上述代码,咱们再将Build SettingSwift Compiler - Code Generaation -> Optimization Level修正为Optimize for Size[-Osize],汇编代码变成了下面这样👇

    0x1048c2114 <+40>:  bl     0x1048c24b8               ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
0x1048c2118 <+44>:  add    x1, sp, #0x10             ; =0x10
0x1048c211c <+48>:  bl     0x1048c5174               ; symbol stub for: swift_initStackObject
->  0x1048c2120 <+52>:  bl     0x1048c2388               ; SwiftDemo.MyTestClass.helloWorld() -> () at ViewController.swift:23
0x1048c2124 <+56>:  adr    x0, #0xc70c               ; demangling cache variable for type metadata for Swift._ContiguousArrayStorage<Any>

这是咱们就会发现bl 指令后跟着的是一个常量地址,而且是SwiftDemo.MyTestClass.helloWorld()的函数地址。

考虑

已然根据虚函数表的派发方法也是一种动态调用,那么是不是以为着只需咱们修正了虚函数表中的函数地址,就完结了函数的替换?

根据TypeContext的办法交流

在上篇文章《从Mach-O视点谈谈Swift和OC的存储差异》咱们能够了解到在Mach-O文件中,能够经过__swift5_types 查找到每个Class的ClassContextDescriptor,而且能够经过ClassContextDescriptor找到当时类对应的虚函数表,并动态调用表中的函数。

(在Swift中,Class/Struct/Enum统称为Type,为了便利起见,咱们在文中说到的TypeContext和ClassContextDescriptor都指的是ClassContextDescriptor)。

首要咱们来回顾下Swift的类的结构描绘,结构体ClassContextDescriptor是Swift类在Section64(__TEXT,__const)中的存储结构。

struct ClassContextDescriptor{
uint32_t Flag;
uint32_t Parent;
int32_t  Name;
int32_t  AccessFunction;
int32_t  FieldDescriptor;
int32_t  SuperclassType;
uint32_t MetadataNegativeSizeInWords;
uint32_t MetadataPositiveSizeInWords;
uint32_t NumImmediateMembers;
uint32_t NumFields;
uint32_t FieldOffsetVectorOffset;
<泛型签名> //字节数与泛型的参数和束缚数量有关
<MaybeAddResilientSuperclass>//有则添加4字节
<MaybeAddMetadataInitialization>//有则添加4*3字节
VTableList[]//先用4字节存储offset/pointerSize,再用4字节描绘数量,随后N个4+4字节描绘函数类型及函数地址。
OverrideTableList[]//先用4字节描绘数量,随后N个4+4+4字节描绘当时被重写的类、被重写的函数描绘、当时重写函数地址。
}

从上述结构能够看出,ClassContextDescriptor的长度是不固定的,不同的类ClassContextDescriptor的长度或许不同。那么怎么才能知道当时这个类是不是泛型?以及是否有ResilientSuperclass、MetadataInitialization特征?其实在前一篇文章《从Mach-O视点谈谈Swift和OC的存储差异》中现已做了阐明,咱们能够经过Flag的标记位来获取相关信息。
例如,假如Flag的generic标记位为1,则阐明是泛型。

|  TypeFlag(16bit)  |  version(8bit) | generic(1bit) | unique(1bit) | unknow (1bi) | Kind(5bit) |
//判别泛型
(Flag & 0x80) == 0x80

那么泛型签名究竟能占多少字节呢?Swift的GenMeta.cpp文件中对泛型的存储做了解说,收拾总结如下:

假定有泛型有paramsCount个参数,有requeireCount个束缚
/**
16B  =  4B + 4B + 2B + 2B + 2B + 2B
addMetadataInstantiationCache -> 4B
addMetadataInstantiationPattern -> 4B
GenericParamCount -> 2B
GenericRequirementCount -> 2B
GenericKeyArgumentCount -> 2B
GenericExtraArgumentCount -> 2B
*/
short pandding = (unsigned)-paramsCount & 3;
泛型签姓名节数 = (16 + paramsCount + pandding + 3 * 4 * (requeireCount) + 4);

因而只需清晰了Flag各个标记位的意义以及泛型的存储长度规律,那么就能计算出虚函数表VTable的方位以及各个函数的字节方位。
了解了泛型的布局以及VTable的方位,是不是就意味着能完结函数指针的修正了呢?答案当然是否定的,因为VTable存储在__TEXT段,__TEXT是只读段,咱们没办法直接进行修正。不过最终咱们经过remap的办法修正代码段,将VTable中的函数地址进行了修正,然而发现在运转时函数并没有被替换为咱们修正的函数。那究竟是怎么一回事呢?

根据Metadata的办法交流

上述试验的失败当然是咱们的不谨慎导致的。在项目一开端咱们先研究的是类型存储描绘TypeContext,主要是类的存储描绘ClassContextDescriptor。在找到VTable后咱们想当然的以为运转时Swift是经过拜访ClassContextDescriptor中的VTable进行函数调用的。可是现实并不是这样。

VTable函数调用

接下来咱们将答复下 Swift的函数调用 章节中提的问题,x8寄存器的函数地址是从哪里来的。仍是前文中的Demo,咱们在helloWorld()函数调用前打断点

	let myTest = MyTestClass.init()
->  myTest.helloWorld()

断点停留在0x100230ab0处👇

    0x100230aac <+132>: stur   x0, [x29, #-0x30]
->  0x100230ab0 <+136>: ldr    x8, [x0]
0x100230ab4 <+140>: ldr    x8, [x8, #0x50]
0x100230ab8 <+144>: mov    x20, x0
0x100230abc <+148>: str    x0, [sp, #0x58]
0x100230ac0 <+152>: blr    x8

此刻x0寄存器中存储的是myTest的地址x0 = 0x0000000280d08ef0 ldr x8, [x0]则是将0x280d08ef0处存储的数据放入x8(注意,这里是只将*myTest存入x8,而不是将0x280d08ef0存入x8)。单步履行后,经过re read检查各个寄存器的数据后会发现x8存储的是type metadata的地址,而不是TypeContext的地址。

        x0 = 0x0000000280d08ef0
x1 = 0x0000000280d00234
x2 = 0x0000000000000000
x3 = 0x00000000000008fd
x4 = 0x0000000000000010
x5 = 0x000000016fbd188f
x6 = 0x00000002801645d0
x7 = 0x0000000000000000
x8 = 0x000000010023e708  type metadata for SwiftDemo.MyTestClass
x9 = 0x0000000000000003
x10 = 0x0000000280d08ef0
x11 = 0x0000000079c00000

经过上步单步履行后,当时程序要做的是ldr x8, [x8, #0x50],即将type metadata + 0x50处的数据存储到x8。这一步便是跳表,也便是说经过这一步后,x8寄存器中存储的便是helloWorld()的地址。

    0x100230aac <+132>: stur   x0, [x29, #-0x30]
0x100230ab0 <+136>: ldr    x8, [x0]
->  0x100230ab4 <+140>: ldr    x8, [x8, #0x50]
0x100230ab8 <+144>: mov    x20, x0
0x100230abc <+148>: str    x0, [sp, #0x58]
0x100230ac0 <+152>: blr    x8

那是否真的是这样呢?ldr x8, [x8, #0x50]履行后,咱们再次检查x8,看看寄存器中是否为函数地址👇

        x0 = 0x0000000280d08ef0
x1 = 0x0000000280d00234
x2 = 0x0000000000000000
x3 = 0x00000000000008fd
x4 = 0x0000000000000010
x5 = 0x000000016fbd188f
x6 = 0x00000002801645d0
x7 = 0x0000000000000000
x8 = 0x0000000100231090  SwiftDemo`SwiftDemo.MyTestClass.helloWorld() -> () at ViewController.swift:23
x9 = 0x0000000000000003

成果表明x8存储的确实是helloWorld()的函数地址。上述试验表明经过跳转0x50方位后,程序找到了helloWorld()函数地址。类的Metadata坐落__DATA段,是可读写的。其结构如下:

struct SwiftClass {
NSInteger kind;
id superclass;
NSInteger reserveword1;
NSInteger reserveword2;
NSUInteger rodataPointer;
UInt32 classFlags;
UInt32 instanceAddressPoint;
UInt32 instanceSize;
UInt16 instanceAlignmentMask;
UInt16 runtimeReservedField;
UInt32 classObjectSize;
UInt32 classObjectAddressPoint;
NSInteger nominalTypeDescriptor;
NSInteger ivarDestroyer;
//func[0]
//func[1]
//func[2]
//func[3]
//func[4]
//func[5]
//func[6]
....
};

上面的代码在经过0x50字节的偏移后正好坐落func[0]的方位。因而要想动态修正函数需求修正Metadata中的数据。经过试验后发现修正后函数确实是在运转后发生了改动。可是这并没有结束,因为虚函数表与音讯发送有所不同,虚函数表中并没有任何函数名和函数地址的映射,咱们只能经过偏移来修正函数地址。比方,我想修正第1个函数,那么我要找到Meatadata,并修正0x50处的8字节数据。同理,想要修正第2个函数,那么我要修正0x58处的8字节数据。这就带来一个问题,一旦函数数量或者次序发生了改动,那么都需求从头进行修正偏移索引。举例阐明下,假定当时1.0版别的代码为

class MyTestClass {
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}

此刻咱们对0x50处的函数指针进行了修正。当2.0版别改动为如下代码时,此刻咱们的偏移应该修正为0x58,否则咱们的函数替换就发生了过错。

class MyTestClass {
func sayhi() {
print("call sayhi() in MyTestClass")
}
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}

为了处理虚函数改动的问题,咱们需求了解下TypeContext与Metadata的联系。

TypeContext与Metadata的联系

Metadata结构中的nominalTypeDescriptor指向了TypeContext,也便是说当咱们获取到Metadata地址后,偏移0x40字节就能获取到当时这个类对应的TypeContext地址。那么怎么经过TypeContext找到Metadata呢?咱们仍是看刚才的那个Demo,此刻咱们将断点打到init()函数上,咱们想了解下MyTestClass的Metadata究竟是哪里来的。

    -> 	let myTest = MyTestClass.init()
myTest.helloWorld()

此刻展开为汇编咱们会发现,程序预备调用一个函数。

->  0x1040f0aa0 <+120>: bl     0x1040f16a8               ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
0x1040f0aa4 <+124>: mov    x20, x0
0x1040f0aa8 <+128>: bl     0x1040f0c18               ; SwiftDemo.MyTestClass.__allocating_init() -> SwiftDemo.MyTestClass at ViewController.swift:22

在履行bl 0x1040f16a8指令之前,x0寄存器为0。

	x0 = 0x0000000000000000

此刻经过si 单步调试就会发现跳转到了函数0x1040f16a8处,其函数指令较少,如下所示👇

SwiftDemo`type metadata accessor for MyTestClass:
->  0x1040f16a8 <+0>:  stp    x29, x30, [sp, #-0x10]!
0x1040f16ac <+4>:  adrp   x8, 13
0x1040f16b0 <+8>:  add    x8, x8, #0x6f8            ; =0x6f8
0x1040f16b4 <+12>: add    x8, x8, #0x10             ; =0x10
0x1040f16b8 <+16>: mov    x0, x8
0x1040f16bc <+20>: bl     0x1040f4e68               ; symbol stub for: objc_opt_self
0x1040f16c0 <+24>: mov    x8, #0x0
0x1040f16c4 <+28>: mov    x1, x8
0x1040f16c8 <+32>: ldp    x29, x30, [sp], #0x10
0x1040f16cc <+36>: ret

在履行0x1040f16a8 函数履行完后,x0寄存器就存储了MyTestClass的Metadata地址。

	x0 = 0x00000001047e6708  type metadata for SwiftDemo.MyTestClass

那么这个被标记为 type metadata accessor for SwiftDemo.MyTestClass at 的函数究竟是什么?在上文介绍的struct ClassContextDescriptor貌似有个成员是AccessFunction,那这个ClassContextDescriptor中的AccessFunction是不是Metadata的拜访函数呢?这个其实很简单验证。咱们再次运转Demo,此刻metadata accessor 为 0x1047d96a8,持续履行后Metadata地址为0x1047e6708。

        x0 = 0x00000001047e6708  type metadata for SwiftDemo.MyTestClass

检查0x1047e6708,持续偏移0x40字节后能够得到Metadata结构中的nominalTypeDescriptor地址0x1047e6708 + 0x40 = 0x1047e6748。
检查0x1047e6748存储的数据为0x1047df4a0。

(lldb) x 0x1047e6748
0x1047e6748: a0 f4 7d 04 01 00 00 00 00 00 00 00 00 00 00 00  ..}.............
0x1047e6758: 90 90 7d 04 01 00 00 00 18 8c 7d 04 01 00 00 00  ..}.......}.....

ClassContextDescriptor中的AccessFunction在第12字节处,因而对0x1047df4a0 + 12 可知AccessFunction的方位为0x1047df4ac。持续检查0x1047df4ac存储的数据为

(lldb) x 0x1047df4ac
0x1047df4ac: fc a1 ff ff 70 04 00 00 00 00 00 00 02 00 00 00  ....p...........
0x1047df4bc: 0c 00 00 00 02 00 00 00 00 00 00 00 0a 00 00 00  ................

因为在ClassContextDescriptor中,AccessFunction为相对地址,因而咱们做一次地址计算0x1047df4ac + 0xffffa1fc – 0x10000000 = 0x1047d96a8,与metadata accessor 0x1047d96a8相同,这就阐明TypeContext是经过AccessFunction来获取对应的Metadata的地址的。当然,实际上也会有例外,有时编译器会直接使用缓存的cache Metadata的地址,而不再经过AccessFunction来获取类的Metadata。

根据TypeContext和Metadata的办法交流

在了解了TypeContext和Metadata的联系后,咱们就能做一些设想了。在Metadata中虽然存储了函数的地址,可是咱们并不知道函数的类型。这里的函数类型指的是函数是一般函数、初始化函数、getter、setter等。在TypeContext的VTable中,method存储一共是8字节,第一个4字节存储的函数的Flag,第二个4字节存储的函数的相对地址。

struct SwiftMethod {
uint32_t Flag;
uint32_t Offset;
};

经过Flag咱们很简单知道是否是动态,是否是实例办法,以及函数类型Kind。

 |  ExtraDiscriminator(16bit)  |... | Dynamic(1bit) | instanceMethod(1bit) | Kind(4bit) |

Kind枚举如下👇

typedef NS_ENUM(NSInteger, SwiftMethodKind) {
SwiftMethodKindMethod             = 0,     // method
SwiftMethodKindInit               = 1,     //init
SwiftMethodKindGetter             = 2,     // get
SwiftMethodKindSetter             = 3,     // set
SwiftMethodKindModify             = 4,     // modify
SwiftMethodKindRead               = 5,     // read
};

从Swift的源码中能够很明显的看到,类重写的函数是独自存储的,也便是有独自的OverrideTable。而且OverrideTable是存储在VTable之后。与VTable中的method结构不同,OverrideTable中的函数需求3个4字节描绘:

struct SwiftOverrideMethod {
uint32_t OverrideClass;//记载是重写哪个类的函数,指向TypeContext
uint32_t OverrideMethod;//记载重写哪个函数,指向SwiftMethod
uint32_t Method;//函数相对地址
};

也便是说SwiftOverrideMethod中能够包括两个函数的绑定联系,这种联系与函数的编译次序和数量无关。假如Method记载用于Hook的函数地址,OverrideMethod作为被Hook的函数,那是不是就意味着无论怎么改动虚函数表的次序及数量,只需Swift仍是经过跳表的办法进行函数调用,那么咱们就无需重视函数变化了。为了验证可行性,咱们写Demo测验一下:

class MyTestClass {
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}//作为被Hook类及函数
<--------------------------------------------------->
class HookTestClass: MyTestClass  {
override func helloWorld() {
print("\n********** call helloWorld() in HookTestClass **********")
super.helloWorld()
print("********** call helloWorld() in HookTestClass end **********\n")
}
}//经过承继和重写的办法进行Hook
<--------------------------------------------------->
let myTest = MyTestClass.init()
myTest.helloWorld()
//do hook
print("\n------ replace MyTestClass.helloWorld() with 	 HookTestClass.helloWorld() -------\n")
WBOCTest.replace(HookTestClass.self);
//hook 收效
myTest.helloWorld()

运转后,能够看出helloWorld()现已被替换成功👇

2021-03-09 17:25:36.321318+0800 SwiftDemo[59714:5168073] _mh_execute_header = 4368482304
call helloWorld() in MyTestClass
------ replace MyTestClass.helloWorld() with HookTestClass.helloWorld() -------
********** call helloWorld() in HookTestClass **********
call helloWorld() in MyTestClass
********** call helloWorld() in HookTestClass end **********

总结

本文经过介绍Swift的虚函数表Hook思路,介绍了Swift Mach-O的存储结构以及运转时的一些调试技巧。Swift的Hook计划一直是从Objective-C转向Swift开发的同学比较感兴趣的工作。咱们想经过本文向咱们介绍关于Swift更深层的一些内容,至于计划本身或许并不是最重要的,重要的是咱们希望是否能够从中Swift的二进制中找到更多的使用场景。比方,Swift的调用并不会存储到classref中,那怎么经过静态扫描知道哪些Swift 的类或Struct被调用了?其实处理计划也是隐含在本文中。

作者简介:

邓竹立:用户价值增加中心-渠道技能部-iOS技能部 资深开发工程师,WBBlades开源东西作者
蒋演:用户价值增加中心-渠道技能部-iOS技能部 架构师 58APP-iOS版别需求负责人

参考文献:

github.com/apple/swift…
www.jianshu.com/p/158574ab8…
www.jianshu.com/p/ef0ff6ee6…
mp.weixin.qq.com/s/egrQxxJSy…
github.com/alibaba/Han…