这篇文章记录 LuckySpeeder 的开发过程,是如何在非越狱的 iOS 中实现的。
这篇文章需要懂一些 ARM64架构、Mach-O 文件格式、链接器、内存、UI渲染逻辑、UI层级设计等各种乱七八糟的知识。
1. 让程序执行我们的代码
Mach-O文件由一个模块化结构,由多个 Load Commands 组成,用来描述程序的各种结构,比如段、节、依赖的动态库等。
其中动态库导入表用于告诉程序运行时需要加载哪些动态库。
其中还有一个特殊段,__DATA,__mod_init_func
里面保存了指向构造函数的函数指针数组,在程序启动时会遍历并调用它们,它会在 main()
执行之前就被调用。
也就是说,可以做一个动态库,插入到动态库导入表中,随后程序启动时就会加载此动态库,加载完成后则会遍历并调用 ctor
中的函数指针,以此让程序执行我们的代码。
当然让程序执行特定代码的方法很多,但利用动态库导入表+特殊段的方式是最为通用,最方便简单,也是最优的一种方式。
C 语言中可以通过 GCC/CLANG 的扩展 __attribute__((constructor))
来定义 ctor
。
2. 在程序中绘制UI
iOS中的任何GUI程序,都会依赖系统库倒出的外部接口UIApplication
,它非常重要,在GUI程序中必定存在,所以我们可以直接调用它。
1. UI绘制失败
正常情况下,我们只要在ctor
中通过UIApplication
遍历整个UI结构,获取主控制器,然后在主控制器中添加我们的 View
,就可以绘制UI。
1
2
3
4
5
6
7
8
9
|
extern UIApplication *UIApp;
static void CreateMyView() {
UIWindowScene *windowScene =
(UIWindowScene *)[UIApp.connectedScenes anyObject];
UIViewController *controller =
windowScene.windows.firstObject.rootViewController;
[controller.view addSubview:MyView];
}
__attribute__((constructor)) static void initialize(void) { CreateMyView(); }
|
当然这并不会成功,因为GUI程序被创建,它需要至少经历两个阶段: 创建窗口->绘制UI
ctor
会在 main()
之前被执行,创建窗口的操作又在 main()
中进行,所以绘制UI的操作需要在窗口创建之后才能能成功。
2. 修复UI绘制
我们需要延迟绘制UI,这里使用通知机制来实现这个
1
2
3
4
5
6
7
|
__attribute__((constructor)) static void initialize(void) {
CFNotificationCenterAddObserver(
CFNotificationCenterGetLocalCenter(), NULL,
CreateMyView,
(CFStringRef)UIApplicationDidFinishLaunchingNotification, NULL,
CFNotificationSuspensionBehaviorCoalesce);
}
|
UIApplicationDidFinishLaunchingNotification
是 iOS 中的一个通知Notification
,用于在应用程序完成启动时被发送。它属于 Apple 提供的 通知中心机制NSNotificationCenter
的一部分。
但还有一个问题,程序通常会有多个View
,很可能将我们的View
覆盖,所以需要一种方法来确保它在最上方。
3. 置顶我们的View
iOS 程序通过 UIWindow
中的 addSubview
和bringSubviewToFront
来操控视图
addSubview
用于添加视图
bringSubviewToFront
用于将视图调整到最上方
也就是说,我们只要hook
这两个方法,在原始方法调用后,需要立即调用 bringSubviewToFront
将我们的视图重新调整到最上方
幸运的是,使用 Objective-C 的runtime
机制,动态修改objc_msgSend
函数中的id``selector
参数,来改变id
与selector
之间的对应关系就可以实现hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
static void swizzleMethod(Class class, SEL originalSelector,
SEL swizzledSelector, IMP swizzledImplementation,
IMP *originalImplementation) {
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
if (class_addMethod(class, originalSelector, swizzledImplementation,
method_getTypeEncoding(swizzledMethod))) {
class_replaceMethod(class, swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
*originalImplementation = method_getImplementation(originalMethod);
}
...
Class windowClass = objc_getClass("UIWindow");
swizzleMethod(windowClass, @selector(bringSubviewToFront:),
@selector(swizzled_bringSubviewToFront:),
(IMP)my_bringSubviewToFront,
(IMP *)&original_bringSubviewToFront);
swizzleMethod(windowClass, @selector(addSubview:),
@selector(swizzled_addSubview:), (IMP)my_addSubview,
(IMP *)&original_addSubview);
if (![[UIApp connectedScenes] respondsToSelector:@selector(window)]) {
Class scenesClass = [[UIApp connectedScenes] class];
class_addMethod(scenesClass, @selector(window), (IMP)scenesWindowIMP,
"@@:");
}
|
3. 加速
程序绘制每一帧画面的逻辑通常是基于时间推进
的,并不是按"帧数"固定运行的,而是根据时间流逝来计算的,例如下面伪代码:
1
2
3
4
5
6
7
|
while (isRunning) {
deltaTime = GetTime() - lastTime; // 获取自上次帧以来的时间差
lastTime = GetTime();
updateLogic(deltaTime); // 根据deltaTime推进逻辑
render(); // 绘制画面
}
|
所以,如果我们能加快时间(比如让 1 秒看起来像 2 秒),那么程序每帧获取到的 deltaTime 就变成 2 倍,所有程序逻辑都以更大的步长更新,看起来就像"加速"了,反之亦然。
我们可以采用hook的方式来实现这个,而常用hook方法通常有以下几种
-
基于重绑定符号表的 Hook (可调用原始函数,但目标函数必须存在于系统库中)
-
基于硬件断点的 Hook (不可调用原始函数,但是什么都能hook)
-
OC运行时 Hook (只能hook OC,例如上述绘制UI时调整层级就使用了这种技术)
-
内联 Hook (几乎完美的方案,什么都能hook,可调用原始函数,可惜非越狱环境不可用,唯一的方法是修改Mach-O
文件)
1. 纯粹基于时间推进的Hook
通常程序会使用 gettimeofday
clock_gettime
mach_absolute_time
等方式获取时间,可以采用基于重绑定符号表的 Hook。
原因是它们属于系统接口,并且由于Mach-O
文件的动态绑定机制,苹果的共享缓存库不会被编译进我们的Mach-O
文件,而是在动态链接时才去重新绑定。
编译时在 Mach-O
文件 _DATA
段的符号表中为每一个被引用的系统 C 函数建立一个指针(8字节的数据,放的全是0),这个指针用于动态绑定时重定位到共享库中的函数实现。
在运行时当系统函数被第一次调用时会动态绑定一次,然后将 Mach-O
中的 _DATA
段符号表中对应的指针,指向外部函数(其在共享库中的实际内存地址)。
所以我们只需要将指向系统方法(外部函数)的指针重新进行绑定指向内部函数/自定义函数,然后将内部函数的指针在动态链接时指向系统方法的地址,就可以实现hook。
可以使用 https://github.com/facebook/fishhook 来完成
例子:
1
2
3
|
struct rebinding rebindings = {"mach_absolute_time", my_mach_absolute_time,
(void *)&original_mach_absolute_time};
rebind_symbols(&rebindings, 1);
|
2. Unity Hook
较新版本 Unity 开发的程序,渲染逻辑是每帧驱动的,而不是纯粹由时间推进
。
这种比较复杂,仅仅hook系统时间通常没什么用,不过Unity引擎通常由 set_timeScale
来控制整体动画速度,所以我们只需要hook并调用这个函数就可以了。
当然 set_timeScale
并不在系统函数中,所以无法使用"传统"基于重绑定符号表的Hook,不过我们可以利用 UnityFramework
自身的符号绑定机制进行hook。
首先通过 _dyld_image_count()
获取所有加载的动态库数量,然后找到 UnityFramework.framework
的偏移
1
2
3
4
5
6
7
8
9
10
|
intptr_t unity_vmaddr_slide = 0;
uint32_t image_count = _dyld_image_count();
const char *image_name;
for (uint32_t i = 0; i < image_count; ++i) {
image_name = _dyld_get_image_name(i);
if (strstr(image_name, "UnityFramework.framework/UnityFramework")) {
unity_vmaddr_slide = _dyld_get_image_vmaddr_slide(i);
break;
}
}
|
在 UnityFramework
中 __TEXT
段的 __cstring
节中查找set_timeScale
方法签名
1
2
3
4
|
uint8_t *cstring_section_data = getsectiondata((const struct mach_header_64 *)unity_vmaddr_slide,
"__TEXT", "__cstring", &size);
uint8_t *time_scale_function_address = memmem(cstring_section_data, size,
"UnityEngine.Time::set_timeScale(System.Single)", 0x2F);
|
获取 UnityFramework
中 __TEXT
段的 il2cpp
地址
1
2
3
|
uintptr_t il2cpp_section_base = (uintptr_t)getsectiondata(
(const struct mach_header_64 *)unity_vmaddr_slide, "__TEXT", "il2cpp",
&size);
|
在 il2cpp
中进行指令匹配,检查里面是否有存储set_timeScale
的方法签名的指针
ARM64
中一个指令固定4字节大小,所以只需要step 4
,然后匹配 ADRP
和ADD
指令。
ADRP
指令用于加载函数地址的高位,ADD
指令用于计算完整的函数地址,这种模式在ARM64
中常用于函数调用
0x9F000000
提取指令的高8位, 模式0x90000000
判断ADRP
指令。
0xFF800000
提取指令的高9位, 模式0x91000000
判断ADD
指令。
0xFFFFFFFFFFFFF000LL
提取页偏移
0xC00000
提取ADD
指令中shift
的数值判断是否需要移位
0x1000000
检查第24位,判断是否需要8字节对齐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
while (1) {
second_instruction = *(uint32_t *)(il2cpp_section_base + 4);
if ((first_instruction & 0x9F000000) == 0x90000000 &&
(second_instruction & 0xFF800000) == 0x91000000) {
resolved_address = (il2cpp_section_base & 0xFFFFFFFFFFFFF000LL) +
(uint32_t)((((first_instruction >> 3) & 0xFFFFFFFC) |
((first_instruction >> 29) & 3))
<< 12);
function_offset = (second_instruction >> 10) & 0xFFF;
if ((second_instruction & 0xC00000) != 0)
function_offset <<= 12;
if ((uint8_t *)(resolved_address + function_offset) ==
time_scale_function_address)
break;
}
il2cpp_section_base += 4;
first_instruction = second_instruction;
if ((uint8_t *)(il2cpp_section_base + 8) >= il2cpp_end)
return -1;
}
|
计算代码段地址
1
2
3
4
|
code_section_address = (current_address & 0xFFFFFFFFFFFFF000LL) +
(uint32_t)((((current_instruction >> 3) & 0xFFFFFFFC) |
((current_instruction >> 29) & 3))
<< 12);
|
获取方法数据
1
2
3
4
5
6
7
|
uintptr_t method_data = *(uint32_t *)(current_address + 4);
uintptr_t function_data_offset;
if ((method_data & 0x1000000) != 0)
function_data_offset = 8 * ((method_data >> 10) & 0xFFF);
else
function_data_offset = (method_data >> 10) & 0xFFF;
|
获取原始函数指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
if (*(uintptr_t *)(code_section_address + function_data_offset)) {
original_timeScale =
*(void (**)(float))(function_data_offset + code_section_address);
} else {
uint32_t instruction_operand = *(uint32_t *)(il2cpp_section_base + 8);
uint8_t *code_section_start = (uint8_t *)(il2cpp_section_base + 8);
uintptr_t instruction_offset = (4 * instruction_operand) & 0xFFFFFFC;
uintptr_t address_offset =
(4 * (instruction_operand & 0x3FFFFFF)) | 0xFFFFFFFFF0000000LL;
if (((4 * instruction_operand) & 0x8000000) != 0)
function_offset = address_offset;
else
function_offset = instruction_offset;
original_timeScale = (void (*)(float))((uintptr_t(*)(void *)) &
code_section_start[function_offset])(
time_scale_function_address);
}
|
设置函数指针
1
2
3
4
5
|
if (original_timeScale) {
*(uintptr_t *)(function_data_offset + code_section_address) =
(uintptr_t)my_timeScale;
return 0;
}
|
然后可以设置时间缩放
1
2
3
4
|
void set_timeScale(float value) {
timeScale_speed = value;
my_timeScale();
}
|
3. Apple SpriteKit framework Hook
它的渲染逻辑也是是每帧驱动的,而不是纯粹由时间推进
。
不过它底层是OC运行时,所以我们可以使用运行时Hook对应的函数,SpriteKit
在update
方法中更新每一帧,在每一帧中,它可以设置场景速度SKScene.physicsWorld.speed
以及节点的动作速度SKNode.setSpeed
。
所以我们可以 Hook update
方法。
我们需要先查找 GameScene
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Class gameSceneClass = nil;
int numClasses = objc_getClassList(NULL, 0);
if (numClasses > 0) {
Class *classes = malloc(sizeof(Class) * numClasses);
numClasses = objc_getClassList(classes, numClasses);
for (int i = 0; i < numClasses; i++) {
Class class = classes[i];
if (class_getSuperclass(class) == [SKScene class] &&
[NSStringFromClass(class) hasSuffix:@"GameScene"]) {
if (class_getInstanceMethod(class, @selector(update:))) {
gameSceneClass = class;
break;
}
}
}
free(classes);
}
|
然后替换update
方法
1
2
3
4
5
6
7
8
9
|
Method updateMethod =
class_getInstanceMethod(gameSceneClass, @selector(update:));
if (updateMethod) {
original_SKScene_update = (void (*)(
id, SEL, NSTimeInterval))method_getImplementation(updateMethod);
method_setImplementation(updateMethod, (IMP)my_SKScene_update);
return 0;
}
|
我们的 my_SKScene_update
函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
static void my_SKScene_update(id self, SEL _cmd, NSTimeInterval currentTime) {
if ([self isKindOfClass:[SKScene class]]) {
SKScene *scene = (SKScene *)self;
if (scene.physicsWorld) {
scene.physicsWorld.speed = SKScene_update_speed;
[scene enumerateChildNodesWithName:@"//*"
usingBlock:^(SKNode *node, BOOL *stop) {
if ([node hasActions]) {
[node setSpeed:SKScene_update_speed];
}
}];
}
}
}
|
完整代码
参考 https://github.com/kekeimiku/LuckySpeeder