这篇文章记录 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 中的 addSubviewbringSubviewToFront 来操控视图

addSubview 用于添加视图

bringSubviewToFront 用于将视图调整到最上方

也就是说,我们只要hook这两个方法,在原始方法调用后,需要立即调用 bringSubviewToFront 将我们的视图重新调整到最上方

幸运的是,使用 Objective-C 的runtime机制,动态修改objc_msgSend函数中的id``selector参数,来改变idselector之间的对应关系就可以实现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方法通常有以下几种

  1. 基于重绑定符号表的 Hook (可调用原始函数,但目标函数必须存在于系统库中)

  2. 基于硬件断点的 Hook (不可调用原始函数,但是什么都能hook)

  3. OC运行时 Hook (只能hook OC,例如上述绘制UI时调整层级就使用了这种技术)

  4. 内联 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,然后匹配 ADRPADD指令。

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对应的函数,SpriteKitupdate方法中更新每一帧,在每一帧中,它可以设置场景速度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