Abstract: The industry of Swift Hook most need to rely on the message forwarding feature of OC to achieve. This paper introduces a new Hook idea from the perspective of modifying Swift virtual function table. And as the main line, focus on the detailed structure and application of Swift.
The introduction
Due to historical baggage, the mainstream large apps are basically objective-C as the main development language. However, sharp students should have noticed that after THE stability of Swift’s ABI, major manufacturers began to increase their investment in Swift one after another. Although Swift cannot replace Objective-C in the short term, the trend of its parallel with Objective-C is becoming more and more obvious, which can be seen from the perspective of recruitment. During the recruitment process over the past year, we found a significant number of candidates who only knew Swift development and were not familiar with Objective-C development, and most of these candidates were young. Also, new frameworks like RealityKit, for example, support Swift only but not Objective-C. All of this means that, over time, recruitment costs and application innovation will come to the fore if the project does not support Swift development well. Therefore, 58.com launched a cross-department collaborative project within the group in Q4, 2020, to create a mixed ecological environment of Objective-C and Swift from all levels — the project code name is “Mixed Sky”. Once the mixed ecological construction is perfected, many problems will be solved.
The principle is briefly
The article is long in length and boring in content. In order to facilitate readers’ reading, conclusions and principles are first presented. If you are interested in the code, you can download the Demo by searching SwiftVTHook on Github
The technical solution in this article only hooks functions called through the virtual function table, and does not involve direct address calls and objc_msgSend calls. Note also that the Swift Compiler set to Optimize for speed (default Release) clears the VTable function address for TypeContext. Set it to Optimize for size and Swfit may be converted to direct address calls. The preceding two configurations cause the scheme to become invalid. Therefore, this paper focuses on technical details rather than program promotion.
If Swift implements method calls through virtual table hops, method substitution can be implemented by modifying the virtual table. Change the function address of a particular virtual function table to the address of the function to be replaced. However, since the virtual function table does not contain the mapping between address and symbol, we cannot obtain the corresponding function address based on the function name like objective-C, so the virtual function of Swift is modified by relying on the function index. If we have a FuncTable[], we can change the address of the function only by index value, as inFuncTable[index] = replaceIMP
. But there’s A problem. We can’t guarantee that the code will stay the same during version iteration, so the index function in this version might be function A, and the index function in the next version might be function B. Obviously this has a big impact on the substitution of functions.
To do this, we use Swift’s OverrideTable to solve the index change problem. In Swift’s OverrideTable, each node records which function of which class the current function overrides, as well as the function pointer to the overridden function. So as long as we can get the OverrideTable that means we can get the overwritten function pointer IMP0 and the overwritten function pointer IMP1. Simply find IMP0 in FuncTable[] and replace it with IMP1 to complete the method substitution.
The details of Swift’s function calls, TypeContext, Metadata, VTable, OverrideTable, and how they relate to each other follow. For easy reading and understanding, all the code and results in this article are based on the ARM64 architecture
Swift function call
First we need to understand how Swift functions are called. Different from Objective-C, Swift functions can be called in three ways: Objective-C based message mechanism, virtual function table-based access, and direct address call.
- Objective-c messaging mechanism
First we need to understand when Swift function calls are made using objective-C messaging mechanisms. If the method is decorated with @objc Dynamic, the function will be called via objc_msgSend after compilation. Suppose you have the following code
class MyTestClass :NSObject { @objc dynamic func helloWorld() { print("call helloWorld() in MyTestClass") } } let myTest = MyTestClass.init() myTest.helloWorld()Copy the code
The corresponding assembly after compilation is
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:
Copy the code
It is easy to see from the above assembly code that the objc_msgSend function at address 0x1042bce88 was called.
- Access to virtual function tables
Access to a virtual table is also a form of dynamic invocation, but by accessing the virtual table. So let’s say we’ve got the same code, and we’ve got rid of @objc Dynamic, and we no longer inherit from NSObject.
class MyTestClass {
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}
let myTest = MyTestClass.init()
myTest.helloWorld()
Copy the code
The assembly code becomes 👇
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
Copy the code
As you can see from the above assembly code, the function stored in the X8 register is finally called by BLR instructions after compilation. Where the data in the X8 register comes from will be explained in a later section.
- Direct address call
Optimize for Size[-osize] Swift Compiler-code Generaation -> Optimization Level Optimize for Size The assembly code becomes 👇
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>
Copy the code
After this is everybody will find bl instructions followed is a constant address, and is SwiftDemo MyTestClass. The helloWorld () function address.
thinking
Since distribution based on the virtual function table is also a dynamic call, is it assumed that if we change the function address in the virtual function table, we will achieve the function replacement?
Method exchange based on TypeContext
In Mach-O files, __swift5_types can be used to find the ClassContextDescriptor of each Class. In addition, the virtual function table corresponding to the current class can be found through the ClassContextDescriptor, and functions in the table can be dynamically called.
(in Swift, Class/Struct/Enum is collectively called Type, and for convenience we refer to TypeContext and ClassContextDescriptor in this article as ClassContextDescriptor).
First, let’s review the structure description of the Swift class. The structure ClassContextDescriptor is the storage structure of the Swift class in 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; < > generic signature / / the number of bytes is associated with the number of parameters and constraints of the generic < MaybeAddResilientSuperclass > / / have add 4 bytes < MaybeAddMetadataInitialization > / / have you add 4 * 3 bytes VTableList[]// Use 4 bytes to store offset/pointerSize, 4 bytes to describe the number, and N 4+4 bytes to describe the function type and function address. OverrideTableList[]// Use 4 bytes to describe the number, followed by N 4+4+4 bytes to describe the class being overwritten, the function being overwritten, and the address of the function being overwritten. }Copy the code
As you can see from the above structure, the length of ClassContextDescriptor is not fixed, and different classes may have different lengths of ClassContextDescriptor. So how do YOU know if the current class is generic? And whether there are ResilientSuperclass and MetadataInitialization features? As explained in the previous article “On the Storage differences between Swift and OC from a Mach-O Perspective”, we can obtain this information through the Flag bits. For example, Flag is generic if the generic Flag bit is 1.
| | TypeFlag (16 bit) version (8 bit) | generic (1 -) | unique (1 -) | unknow bi (1) | Kind (5 -) | / / judging generic (Flag & 0 x80) = = 0x80Copy the code
So how many bytes does a generic signature take up? Swift’s genmeta. CPP file explains the storage of generics as follows:
Suppose you have a generic with paramsCount as an argument, Have requeireCount constraints / * * 16 b = 4 + 4 + 2 b + b b 2 b + 2 b + 2 b addMetadataInstantiationCache - > 4 b addMetadataInstantiationPattern -> 4B GenericParamCount -> 2B GenericRequirementCount -> 2B GenericKeyArgumentCount -> 2B GenericExtraArgumentCount -> 2B */ short pandding = (unsigned)-paramsCount & 3; Number of generic signature bytes = (16 + paramsCount + pandding + 3 * 4 * (requeireCount) + 4);Copy the code
Therefore, as long as the meaning of each Flag bit and the rule of the storage length of the generic type are clear, the position of the virtual function table VTable and the byte position of each function can be calculated. Knowing the layout of generics and the location of the VTable, does that mean you can change the function pointer? The answer is of course no, because VTable is stored in __TEXT, which is read-only and cannot be modified directly. Eventually we remap the code snippet and change the address of the function in the VTable, only to find that the function is not replaced by the one we changed at runtime. So what’s going on?
Metadata-based method exchange
The failure of the above experiment is of course caused by our imprudence. So at the beginning of the project we looked at the type store description TypeContext, basically the class store description ClassContextDescriptor. So when we find the VTable we assume that the runtime Swift is calling the function by accessing the VTable in the ClassContextDescriptor. But that’s not the case.
VTable function call
Next we will answer the question from Swift’s function call section, where the function address of register X8 comes from. Again in the previous Demo, we interrupt before the helloWorld() function is called
let myTest = MyTestClass.init()
-> myTest.helloWorld()
Copy the code
The breakpoint stays at 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
Copy the code
X0 = 0x0000000280D08EF0, LDR x8, [x0] = 0x280D08EF0, [x0] = 0x280D08EF0, Instead of storing 0x280D08EF0 into X8). X8 stores the address of type metadata, not the address of TypeContext, after stepping through the data in each register through re read.
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
Copy the code
LDR x8, [x8, #0x50] stores type metadata at 0x50 to X8. This step is the hop table, which means that after this step, the address of helloWorld() is stored in the X8 register.
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
Copy the code
Is that really the case? LDR x8, [x8, #0x50] after execution, we look at X8 again to see if the register is the function address 👇
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
Copy the code
It turns out that x8 does store the function address of helloWorld(). The above experiment shows that after jumping to 0x50, the program finds the address of the helloWorld() function. The Metadata of the class is in the __DATA section and is read and written. Its structure is as follows:
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]
....
};
Copy the code
The above code is exactly at func[0] after being offset by 0x50 bytes. Therefore, to dynamically modify a function, you need to modify the data in Metadata. After testing, it was found that the modified function was indeed changed after running. But that doesn’t end there, because virtual function tables are different from message sending. There is no mapping between function names and function addresses in virtual function tables. We can only change function addresses by offsets. For example, if I want to change the first function, I go to Meatadata and change the 8-byte data at 0x50. Similarly, to change the second function, I need to change the 8 bytes at 0x58. This brings up the problem that any time the number or order of functions changes, the offset index needs to be corrected again. For example, assume that the current version 1.0 code is
class MyTestClass {
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}
Copy the code
At this point we modify the function pointer at 0x50. When version 2.0 changed to the following code, at this point our offset should be changed to 0x58, otherwise our function replacement would be wrong.
class MyTestClass {
func sayhi() {
print("call sayhi() in MyTestClass")
}
func helloWorld() {
print("call helloWorld() in MyTestClass")
}
}
Copy the code
To solve the problem of virtual function changes, we need to understand the relationship between TypeContext and Metadata.
TypeContext Relationship with Metadata
The nominalTypeDescriptor in the Metadata structure refers to TypeContext, which means that when we get the Metadata address, offset 0x40 bytes to get the TypeContext address of the current class. So how do you find Metadata through TypeContext? Let’s go back to the Demo, where we hit a breakpoint on init(), and we want to see where MyTestClass Metadata comes from.
-> let myTest = MyTestClass.init()
myTest.helloWorld()
Copy the code
Now when we expand into assembly, we see that our program is ready to call a function.
-> 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
Copy the code
Register X0 is 0 until the BL 0x1040F16A8 instruction is executed.
x0 = 0x0000000000000000
Copy the code
At this time, through si single step debugging, it will be found that the function 0x1040F16a8 has few function instructions, as shown below 👇
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
Copy the code
After executing 0x1040F16a8, the X0 register stores the Metadata address of MyTestClass.
x0 = 0x00000001047e6708 type metadata for SwiftDemo.MyTestClass
Copy the code
So what is this function labeled type metadata Accessor for SwiftDemo.mytestClass at? AccessFunction (struct ClassContextDescriptor) AccessFunction (descriptor) AccessFunction (descriptor) This is actually very easy to verify. The metadata accessor is 0x1047d96a8. The metadata address is 0x1047e6708.
x0 = 0x00000001047e6708 type metadata for SwiftDemo.MyTestClass
Copy the code
View 0x1047E6708 and offset 0x40 bytes to obtain the address 0x1047E6708 + 0x40 = 0x1047E6748 of the nominalTypeDescriptor in the Metadata structure. 0x1047E6748 The data stored is 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 .. }... }...Copy the code
The AccessFunction in ClassContextDescriptor is at byte 12, so the position of the AccessFunction is 0x1047DF4A0 + 12. 0x1047DF4AC stores data
(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 ................Copy the code
Because in ClassContextDescriptor, AccessFunction is relative address, so we do an address calculation 0x1047DF4AC + 0xFFffa1FC-0x10000000 = 0x1047D96a8, As with metadata accessor 0x1047d96a8, TypeContext retrieves the address of the corresponding metadata via AccessFunction. There are, of course, practical exceptions. Sometimes the compiler will use the address of the cached cache Metadata directly instead of retrieving the class Metadata through AccessFunction.
Method exchange based on TypeContext and Metadata
Now that we know the relationship between TypeContext and Metadata, we can make some assumptions. Metadata stores the address of a function, but we do not know the type of the function. Function types here mean that functions are normal functions, initializer functions, getters, setters, and so on. In TypeContext’s VTable, the method store is 8 bytes in total, the Flag of the first 4-byte stored function, and the relative address of the second 4-byte stored function.
struct SwiftMethod {
uint32_t Flag;
uint32_t Offset;
};
Copy the code
Flag makes it easy to know if it’s dynamic, if it’s an instance method, and if the function type is Kind.
| ExtraDiscriminator(16bit) |... | Dynamic(1bit) | instanceMethod(1bit) | Kind(4bit) |
Copy the code
The enumeration of Kind is as follows 👇
typedef NS_ENUM(NSInteger, SwiftMethodKind) {
SwiftMethodKindMethod = 0, // method
SwiftMethodKindInit = 1, //init
SwiftMethodKindGetter = 2, // get
SwiftMethodKindSetter = 3, // set
SwiftMethodKindModify = 4, // modify
SwiftMethodKindRead = 5, // read
};
Copy the code
It is clear from the Swift source that the functions overwritten by the class are stored separately, i.e. there is a separate OverrideTable. And OverrideTable is stored after VTable. Unlike the method structure in VTable, the function in OverrideTable requires three 4-byte descriptions:
struct SwiftOverrideMethod { uint32_t OverrideClass; // Record which class function to override, pointing to TypeContext uint32_t OverrideMethod; Uint32_t Method; // function relative address};Copy the code
That is, SwiftOverrideMethod can contain binding relationships between two functions, regardless of the order or number of functions compiled. If Method records the address of the function used in the Hook, and OverrideMethod is used as the function to be hooked, does that mean that the order and number of the virtual function table are changed anyway? As long as Swift still calls the function through the hop table, then we do not need to pay attention to the function change. To verify this, let’s write a Demo test:
Class MyTestClass {func helloWorld() {print("call helloWorld() in MyTestClass")} <---------------------------------------------------> class HookTestClass: MyTestClass { override func helloWorld() { print("\n********** call helloWorld() in HookTestClass **********") Super. The helloWorld () print (" * * * * * * * * * * call helloWorld () in HookTestClass end * * * * * * * * * * \ n ")}} / / by way of inheritance and rewrite the hooks <---------------------------------------------------> let myTest = MyTestClass.init() myTest.helloWorld() //do hook print("\n------ replace MyTestClass.helloWorld() with HookTestClass.helloWorld() -------\n") WBOCTest.replace(HookTestClass.self); / / hook effect myTest. The helloWorld ()Copy the code
After running, you can see that helloWorld() has been replaced successfully 👇
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 * * * * * * * * * *Copy the code
conclusion
This paper introduces the memory structure of Swift Mach-O and some debugging skills at runtime by introducing the Hook idea of Swift virtual function list. Swift’s Hook scheme has always been of interest to students who switched from Objective-C to Swift development. In this article, we want to introduce you to some of the deeper aspects of Swift. The solution itself may not be the most important, but we hope to find more application scenarios from the Swift binary. For example, Swift calls are not stored in classrefs, so how can a static scan tell which Swift classes or structs are called? The solution is implicit in this article.
About the author:
Deng Zhuli: User Value Growth Center — Platform Technology Department — iOS Technology Department Senior development engineer, author of WBBlades open source tool Jiang Yan: User Value Growth Center — Platform technology Department — iOS Technology Department Architect 58APP-ios version requirement leader
References:
Github.com/apple/swift… www.jianshu.com/p/158574ab8… www.jianshu.com/p/ef0ff6ee6… Mp.weixin.qq.com/s/egrQxxJSy… Github.com/alibaba/Han…