不越狱给iOS App装Tweak/插件:LiveContainer环境介绍与Tweak编写

自iOS 16.5.1之后,Arm64e设备的越狱一直处于停滞状态,没有新的进展。
不少越狱开发者也逐渐脱离越狱开发,转向JIT等新方式面对iOS的新环境。

iOS App相关的Tweak/插件在越狱鼎盛时代生机勃勃,Flex3的注入和Cydia等包管理商店里的注入层出不穷,如今想起来还觉得是个好时代。

那么如今iOS环境下,还可以写Tweak,还可以注入dylib,还可以Hook吗?

可以的!

项目LiveContainer正为我们提供了这样一个平台。

这几周编写时发现很难找到类似的文章,所以打算在这里记录一下。

LiveContainer环境

LiveContainer为未越狱iOS用户提供了一个不受签名限制,可自由安装App的美妙平台。
并且LiveContainer自带TweakLoader.dylib,可以在App启动时自动加载Tweak进行注入;
同时还内置ElleKit,Tweak不必自己链接CydiaSubstrate或其他Hook框架,直接使用即可。

选择直接将Tweak打包到IPA中侧载虽然也是个选择,但是3 Apps限制还是太难受;
借助LiveContaier,可以安装任意数量的应用,并且这种Tweak载入方式可以让使用者只需要更新IPA,不需要在每次更新时都反反复复给IPA注入一次,开发者也可以省心不少。

虽然LiveContainer可能会遇到一些兼容性问题,但是其带来的利好实在是太过庞大,以至于这些兼容性问题在我看来皆可以忽略。

为LiveContainer环境开发Tweak

插件Android有,iOS没有一直是普遍存在的情况,在网上冲浪时看到LiveContainer的Tweak注入功能后,我就决心打破这一僵局。
这里为大家说明一下在LiveContainer下的Tweak需要注意的部分:

开发环境依然可以选用Theos,并且不需要使用jailed模版(或者说我没用过不知道啊)。

因为LiveContainer环境的特殊性,我们Hook所需要的函数(MSHookFunction/MSHookMessageEx)均已经由TweakLoader加载了,我们的Tweak在加载时,只需要使用dlsym找到对应的函数即可,不需要再链接库。

如果你的Tweak需要链接第三方库,尽量让LiveContainer去加载,然后找标志,而不要自己链接,否则很容易出现各种问题。

MSHookFunction是针对C/C++等无法通过别的方式劫持(fishhook提供了一种以篡改Mach-O导入表Hook函数的方案,但是该方案局限性大,只适用于动态链接库的函数,所以这里不再提及),必须通过篡改可执行文件__TEXT内存段进行Hook的函数,需要绕过Apple内存保护。(这点我们稍后会提及)

MSHookMessageEx则是针对Objective-C函数,通过劫持ObjC的方法信息Hook函数。不需要绕过内存保护,在非越狱环境不需要任何额外权限,可直接运行。

// 这里演示如何使用TweakLoader已经加载好的两个Hook函数
#import <Foundation/Foundation.h>
#import <dlfcn.h>

// 定义将要用到的两个Hook函数的函数指针
typedef void (*MSHookFunction_t)(void *symbol, void *hook, void **old);
static MSHookFunction_t MSHookFunction_p = nullptr;

typedef void (*MSHookMessageEx_t)(Class _class, SEL message, IMP hook, IMP *old);
static MSHookMessageEx_t MSHookMessageEx_p = nullptr;

// 在初始化时通过dlsym找到函数
__attribute__((constructor))
static void tweakConstructor() {
    MSHookFunction_p = (MSHookFunction_t)dlsym(RTLD_DEFAULT, "MSHookFunction");
    if (!MSHookFunction_p) {
        NSLog(@"Failed to find MSHookFunction.");
        return;
    }

    MSHookMessageEx_p = (MSHookMessageEx_t)dlsym(RTLD_DEFAULT, "MSHookMessageEx");
    if (!MSHookMessageEx_p) {
        NSLog(@"Failed to find MSHookMessageEx.");
        return;
    }
}

也正是因为这种导入方式,我们不能使用Logos的快捷语法,否则Theos会自动链接CydiaSubstrate库,导致加载库时找不到路径。

最好是老实使用Objective-C/C++语法,手动进行Hook。(其实也不是太复杂)

// 使用MSHookMessageEx钩取一个ObjC函数(以[AppController didFinishLaunchingWithOptions:]为例)

// 先定义函数指针和Hook后函数
static BOOL (*original_didFinishLaunchingWithOptions)(id self, SEL _cmd, UIApplication *application, NSDictionary *launchOptions) = nullptr;
BOOL hooked_didFinishLaunchingWithOptions(id self, SEL _cmd, UIApplication *application, NSDictionary *launchOptions) {
    // 你自己的实现...
    // 如果要调用原函数:
    original_didFinishLaunchingWithOptions(self, _cmd, application, launchOptions);
}

// 初始化函数或你自己的某个函数:
void hookAppControllerFunc() {
    Class appController = NSClassFromString(@"AppController");

    // 找不到Class的时候应当通过NSLog等方式输出错误信息,并避免下一步执行
    if (!appController) return;

    MSHookMessageEx_p(
        targetClass,
        @selector(application:didFinishLaunchingWithOptions:),
        (IMP)hooked_didFinishLaunchingWithOptions,
        (IMP *)&original_didFinishLaunchingWithOptions
    );
}

绕过内存保护做Inline Hook MSHookFunction

上面我们提到,对于使用C/C++及其他语言编写的程序,并没有ObjC这样不需要高权限的Hook方案,需要直接对内存打补丁。

以往越狱环境下,Tweak一般有权限将R-X内存页设置为RW-再设置回R-X
Inline Hook需要这一过程去写入跳转。

但别忘了,我们现在是在非越狱环境,可没有那个权限。
那么我的Unity IL2CPP应用和其他那些非ObjC应用怎么办?

当然是有办法滴!

iOS 15 ~ iOS 18:JIT

在iOS 26以前的版本中,通过启用JIT就能让应用拿到这个权限,允许程序修改内存后再将内存设置为可执行。

即在LiveContainer中,为你的应用开启“带JIT启动”即可破解这个难关!

如果是侧载应用,在SideStore里对应用启用JIT即可!

虽然要多折腾那么一点,但是除了iOS 17.0 ~ iOS 17.3.1的JIT启用有些烦人,都蛮简单的,在设备上就可以完成。

iOS 26+:JIT+SkitDebug(挂载Geode.js)

iOS 26 RC版本开始,Apple加强了其保护策略,即使应用启用了JIT,也没有权限将已经改为RW的内存改回RX。
导致程序在运行到对应Hook片段时,会因没有执行权限而直接崩溃。

寻找破局之法

我想到SkitDebug对于UTM等虚拟机应用的支持,是通过JS脚本实现的。
那我是否可以借助这个机制,让我的程序可以做Inline Hook呢?

于是我到SkitDebug的源代码中寻找,发现了下面这个文件Geode.js的这个片段:

... else if (brkImmediate === 0x70) { // patching instructs
    log(`Received command to patch instructions (0x70)`);

    let x2Match = /02:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
    let x2 = x2Match ? x2Match.groups['reg'] : null;

    if (!x1 || !x2) {
        log(`Missing x1 or x2 for function patching`);
        continue;
    }

    let destAddr = x0Num;
    let srcAddr = x1Num;
    let size = x2 ? littleEndianHexStringToNumber(x2) : 10n;
    log(`Patching: dest=0x${destAddr.toString(16)}, src=0x${srcAddr.toString(16)}, size=0x${size.toString(16)}`);

    // Unsure exactly why, but anything over 4 MB freezes the app for some reason, so we will set a soft limit
    if (size > 0x400000n) {
        log(`Size too large (0x${size.toString(16)}), skipping`);
        let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
        let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`);
        log(`pcPlus4Response = ${pcPlus4Response}`);
        validBreakpoints++;
        continue;
    }
    try {
        // m (read) = `m${curPointer.toString(16)},<size>`
        // M (write) = `M${curPointer.toString(16)},<size>:<your hex instructions goes here>`
        const CHUNK_SIZE = 0x4000n; // 16 KB
        for (let i = 0n; i < size; i += CHUNK_SIZE) {
            let chunkSize = i + CHUNK_SIZE <= size ? CHUNK_SIZE : size - i;
            let readAddr = srcAddr + i;
            let writeAddr = destAddr + i;
            let readRes = send_command(`m${readAddr.toString(16)},${chunkSize.toString(16)}`);
            if (readRes && readRes.length > 0) {
                let writeResponse = send_command(`M${writeAddr.toString(16)},${chunkSize.toString(16)}:${readRes}`);
                if (writeResponse !== "OK") {
                    log(`Write failed at offset ${i.toString(16)}`);
                    break;
                }
            }
            if (Number(i / CHUNK_SIZE) % 10 === 0) {
                log(`Progress: 0x${i.toString(16)}/0x${size.toString(16)}`);
            }
        }
        log(`Memory write completed!`);
    } catch (e) {
        log(`Memory write failed: ${e}`);
    }

    let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
    let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`);
    log(`pcPlus4Response = ${pcPlus4Response}`);
    validBreakpoints++;
}

这个片段展示了SkitDebug接收到应用程序发出的BRK #0x70断点,从x0x1寄存器中取出地址,x2寄存器中取出长度,依赖调试器实现高权限内存写入的过程。

这一过程可以完全绕开原本的实现途径,由具有高权限的调试器进行内存写入,不需要进行权限修改

通过这种方式,即使修改内存,也不会引起可感知的内存段权限变化!
(即RX内存写入后仍然为RX)

这不就把权限问题绕开了吗?真巧妙!

随后我查看了这段代码的源头Geode(即Geode SDK iOS),找到了对应的代码片段
dyld_bypass_validation_txm.m

...

// ldr x8, value; br x8; value: .ascii "\x41\x42\x43\x44\x45\x46\x47\x48"
static char patch[] = { 0x88, 0x00, 0x00, 0x58, 0x00, 0x01, 0x1f, 0xd6, 0x1f, 0x20, 0x03, 0xd5, 0x1f, 0x20, 0x03, 0xd5, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41 };

// x0 (dest), x1 (src), x2 (bytes)
__attribute__((noinline,optnone,naked))
void BreakJITWrite(void* dest, void* src, size_t bytes) {
    asm("brk #0x70 \n"
        "ret");
}

static bool redirectFunction(char* name, void* patchAddr, void* target) {
    if (has_txm()) {
        BreakJITWrite(patchAddr, patch, sizeof(patch));
    }
    ...
}

看来Geode SDK似乎利用BreakJITWrite写入了一个无条件跳转,进行函数重定向。
(下面的代码段显示Hook了dyld_mmapdyld_fcntl

这不正是我们要做的吗!?

有了方法,要怎么实现呢?

改写Dobby实现Inline Hook

我也尝试过自己造一个Hook框架,但是太过复杂,我的知识水平和时间都不足以实现这样一个东西。

那——有没有现成的轮子呢?有的!

先后考察了祖师爷CydiaSubstrateSubstituteElleKitlitehook之后,我认为改写起来最简便、功能最全的还是跨平台框架Dobby

那就动手吧!

直接搜索mach_vm寻找内存操作函数,可以发现最底层进行内存操作的是code-patch-tool-darwin.cc文件中的DobbyCodePatch函数

由于代码比较长,原始代码这里就不再粘贴了,总之修改起来非常简单:
保留跨页处理,后面所有的mach_vm操作都可以不要,直接用BreakJITWrite即可

如果彻底不要兼容性(毕竟我只是拿来给iOS 26以上用,以下的有内置的ElleKit)
修改后大概是这个样子:

// code-patch-tool-darwin.cc => DobbyCodePatch
// ...这里是 Cross over page 跨页逻辑部分...

// 采用分块写入方式,每次写page_size以免卡死
  const size_t CHUNK_SIZE = page_size;
  size_t remaining = buffer_size;
  uint8_t *src_ptr = buffer;
  addr_t dest_addr_base = (addr_t)address;

  while (remaining > 0) {
    size_t this_chunk = remaining > CHUNK_SIZE ? CHUNK_SIZE : remaining;
    void *dest_chunk = (void *)(dest_addr_base + (buffer_size - remaining));
    void *src_chunk = (void *)src_ptr;

    BreakJITWrite(dest_chunk, src_chunk, this_chunk);

    src_ptr += this_chunk;
    remaining -= this_chunk;
  }

  ClearCache(address, (void *)((addr_t)address + buffer_size));

  return 0;
}

虽然可能线程不安全,但也没权限做成线程安全啊。

为iOS平台编译之后,导入LiveContainer,成了!

由于DobbyHook的声明和MSHookFunction一模一样,我们可以直接:

if (@available(iOS 26.0, *)) {
    MSHookFunction_p = (MSHookFunction_t)dlsym(RTLD_DEFAULT, "DobbyHook");
} else {
    MSHookFunction_p = (MSHookFunction_t)dlsym(RTLD_DEFAULT, "MSHookFunction");
}

LiveContainer是递归加载Dylib的,可以利用这一点让libdobby.dylib始终早于你的Tweak加载。

LiveContainer的Nightly Release版本已经支持为应用单独指定JIT启动脚本,只要选择Geode.js,配合上面魔改的libdobby.dylib,即可让你的Tweak在iOS 26正常Hook!

至此从iOS 15(LiveContainer最低支持版本)至iOS 26均可以进行Inline Hook。

写在最后

这些内容是我两周的从零开始开发所遇到的,关于Hook的所有问题的集合。

我这方面水平真的还是有所欠缺,如果有什么地方说得不对,还请各位见谅。

开发过程中,真的感受到iOS作为一个封闭系统,社区却有着震撼人心的蓬勃生机,有着各种奇思妙想,想尽办法将iOS给出的限制逐一打破。

写着、思考着,我也感受到了这种快乐,震撼于前辈开发者们的惊人智慧,为自己绞尽脑汁终于成功运行的东西而心花怒放。

只要愿意再深入那么一点点,庞大iOS社区所开拓的一个辽阔的世界就在你的面前。

posted @ 2025-11-06 18:27  yyfll  阅读(366)  评论(0)    收藏  举报