commit ea043dc7e0cc71eab0d6f60e90e0e6e7f94931d9 Author: zhushenwudi <55681140@163.com> Date: Tue Sep 22 18:21:03 2026 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41a20e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.p12 +*.mobileprovision +*.ipa +certs/password.txt +build/ +tools/ +src/__pycache__/ +/.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..21e0c0f --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# L4 API Hook + +把已解密的 Link! Like! Love Live! iOS 包接到 `https://api-alfa-l4.hasu-link.club`,并用自己的开发者证书重签。Bundle ID 保持 `com.oddno.lovelive`。开屏时顶部居中显示小号白字 `API-HOOKED`,大约 5 秒后消失。 + +## 准备 + +需要 Python 3。Zig 和 zsign 放在 `tools/`,这两个目录被 git 忽略,不会进提交。本机已经放好的话,打包时直接使用。 + +如果换了一台电脑、这两个工具不在,在项目根目录执行: + +``` +python tools/zig.py +python tools/zsign.py +``` + +脚本会按当前系统打印下载地址,并自动下载、解压。压缩包已经下完时,直接解压。Zig 解压到 `tools/`,zsign 解压到 `tools/zsign/`。 + +`tools/zsign/zsign-noent.exe` 会在第一次打包时自动生成。它和原版的差别是:动态库和 UnityFramework 不再被写上空的 entitlements,否则 iOS 27 会在启动前杀掉进程。 + +## 放入自己的文件 + +1. 开发证书 `.p12` 放到 `certs/`。 +2. 描述文件 `.mobileprovision` 放到 `certs/`。 +3. 证书密码为空就不用管。有密码时,把密码写在 `certs/password.txt` 的第一行。这个文件不会进 git。 + +已解密的游戏包在打包时检查。`build/LLLL_5.1.0.ipa` 已存在就直接使用,没有则自动下载。每个证书目录只放一份对应文件。手机必须在这份描述文件的设备列表里。不要改 Bundle ID。 + +API 改写规则在 `config/api-hook.txt`。只改游戏 API 主机,资源 CDN 和官网不要写进去。新旧地址必须一样长。 + +## 打包 + +在项目根目录执行: + +``` +python src/build.py +``` + +成功后,可安装包在 `output/`,文件名是原来的 IPA 名加上 `-apihook-signed`。装之前先删掉手机上的旧包。 + +`output/` 里还会留下一份未签名的中间包,安装时用带 `signed` 的那一份。 diff --git a/certs/README.txt b/certs/README.txt new file mode 100644 index 0000000..66de481 --- /dev/null +++ b/certs/README.txt @@ -0,0 +1,2 @@ +把自己的 .p12 和 .mobileprovision 放在这个目录,各一份。 +p12 密码为空则不用额外文件。有密码时,把密码写在同目录的 password.txt 第一行。 diff --git a/config/api-hook.txt b/config/api-hook.txt new file mode 100644 index 0000000..81f073c --- /dev/null +++ b/config/api-hook.txt @@ -0,0 +1,3 @@ +# 只改写游戏 API 主机。资源 CDN(assets.link-like-lovelive.app)和官网不要写进来。 +# 格式:原主机 替换后的源(含 https://,不要末尾斜杠) +api.link-like-lovelive.app https://api-alfa-l4.hasu-link.club diff --git a/output/README.txt b/output/README.txt new file mode 100644 index 0000000..629a49c --- /dev/null +++ b/output/README.txt @@ -0,0 +1 @@ +python src/build.py 生成的安装包会放在这里。安装带 signed 的那一份。 diff --git a/src/api_hook.c b/src/api_hook.c new file mode 100644 index 0000000..91c7498 --- /dev/null +++ b/src/api_hook.c @@ -0,0 +1,498 @@ +/* JIT-free API host rewrite. Swizzles NSURL construction only. */ +#include +#include +#include +#include +#include +#include +#include + +#define RTLD_NOW 0x2 +#define RTLD_GLOBAL 0x8 + +typedef void *id; +typedef void *Class; +typedef void *SEL; +typedef void *IMP; +typedef void *Method; +typedef struct { + double x; + double y; + double w; + double h; +} CGRect; + +typedef id (*msg_id_t)(id, SEL); +typedef id (*msg_id_id_t)(id, SEL, id); +typedef id (*msg_id_cstr_t)(id, SEL, const char *); +typedef id (*msg_id_f64_t)(id, SEL, double); +typedef id (*msg_id_rgba_t)(id, SEL, double, double, double, double); +typedef const char *(*msg_cstr_t)(id, SEL); +typedef double (*msg_f64_t)(id, SEL); +typedef void (*msg_void_id_t)(id, SEL, id); +typedef void (*msg_void_bool_t)(id, SEL, signed char); +typedef void (*msg_void_long_t)(id, SEL, long); +typedef void (*msg_void_rect_t)(id, SEL, CGRect); +typedef id (*msg_id_long_t)(id, SEL, long); +typedef unsigned long (*msg_ulong_t)(id, SEL); +typedef id (*msg_id_ulong_t)(id, SEL, unsigned long); + +static Class (*p_objc_getClass)(const char *); +static SEL (*p_sel_registerName)(const char *); +static Method (*p_class_getClassMethod)(Class, SEL); +static Method (*p_class_getInstanceMethod)(Class, SEL); +static IMP (*p_method_getImplementation)(Method); +static IMP (*p_method_setImplementation)(Method, IMP); +static id (*p_objc_msgSend)(id, SEL, ...); + +typedef uint64_t dispatch_time_t; +typedef void *dispatch_queue_t; +static dispatch_time_t (*p_dispatch_time)(dispatch_time_t, int64_t); +static void (*p_dispatch_after_f)(dispatch_time_t, dispatch_queue_t, void *, void (*)(void *)); +static dispatch_queue_t (*p_dispatch_get_main_queue)(void); + +#define DISPATCH_TIME_NOW ((dispatch_time_t)0) +#define NSEC_PER_SEC 1000000000ll +#define MAX_RULES 8 +#define LABEL_TAG 0x41504948L + +static char g_from_host[MAX_RULES][256]; +static char g_to_origin[MAX_RULES][512]; +static int g_rule_count = 0; + +static SEL s_UTF8String; +static SEL s_stringWithUTF8String; + +static IMP orig_url_with_string; +static IMP orig_url_with_string_rel; +static IMP orig_url_with_string_enc; +static IMP orig_init_string; +static IMP orig_init_string_rel; +static IMP orig_req_with_url; +static IMP orig_req_init; +static IMP orig_req_init_policy; +static IMP orig_set_url; +static IMP orig_task_url; +static IMP orig_task_url_block; +static IMP orig_task_req; +static IMP orig_task_req_block; + +static int load_symbol(void *handle, const char *name, void **out) { + void *sym = dlsym(handle, name); + if (!sym) { + fprintf(stderr, "[ApiHook] missing symbol %s\n", name); + return 0; + } + *out = sym; + return 1; +} + +static SEL sel(const char *name) { return p_sel_registerName(name); } + +static id cls(const char *name) { return (id)p_objc_getClass(name); } + +static id nsstr(const char *utf8) { + return ((msg_id_cstr_t)p_objc_msgSend)(cls("NSString"), s_stringWithUTF8String, utf8); +} + +static const char *cstr(id string) { + if (!string) return NULL; + return ((msg_cstr_t)p_objc_msgSend)(string, s_UTF8String); +} + +static void trim(char *s) { + size_t n = strlen(s); + size_t i = 0; + while (i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n')) i++; + if (i > 0) memmove(s, s + i, n - i + 1); + n = strlen(s); + while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n')) { + s[--n] = 0; + } +} + +static int host_eq(const char *host, size_t len, const char *rule) { + size_t n = strlen(rule); + if (n != len) return 0; + for (size_t i = 0; i < n; i++) { + if (tolower((unsigned char)host[i]) != tolower((unsigned char)rule[i])) return 0; + } + return 1; +} + +static id rewrite_string(id string) { + const char *src = cstr(string); + if (!src || !src[0] || g_rule_count == 0) return string; + const char *sep = strstr(src, "://"); + if (!sep) return string; + + const char *host = sep + 3; + const char *host_end = host; + while (*host_end && *host_end != '/' && *host_end != '?' && *host_end != '#') host_end++; + + const char *host_begin = host; + for (const char *p = host; p < host_end; p++) { + if (*p == '@') host_begin = p + 1; + } + const char *colon = host_begin; + while (colon < host_end && *colon != ':') colon++; + size_t host_len = (size_t)(colon - host_begin); + + for (int i = 0; i < g_rule_count; i++) { + if (!host_eq(host_begin, host_len, g_from_host[i])) continue; + size_t origin_len = strlen(g_to_origin[i]); + size_t rest_len = strlen(host_end); + char *buf = (char *)malloc(origin_len + rest_len + 1); + if (!buf) return string; + memcpy(buf, g_to_origin[i], origin_len); + memcpy(buf + origin_len, host_end, rest_len + 1); + id replaced = nsstr(buf); + free(buf); + return replaced ? replaced : string; + } + return string; +} + +static id rewrite_url(id url) { + if (!url) return url; + id abs = ((msg_id_t)p_objc_msgSend)(url, sel("absoluteString")); + id replaced = rewrite_string(abs); + if (replaced == abs) return url; + return ((id (*)(id, SEL, id))orig_url_with_string)(cls("NSURL"), sel("URLWithString:"), replaced); +} + +static id rewrite_request(id request) { + if (!request) return request; + id url = ((msg_id_t)p_objc_msgSend)(request, sel("URL")); + id new_url = rewrite_url(url); + if (new_url == url) return request; + id mutable = ((msg_id_t)p_objc_msgSend)(request, sel("mutableCopy")); + if (!mutable) return request; + ((void (*)(id, SEL, id))orig_set_url)(mutable, sel("setURL:"), new_url); + return mutable; +} + +static id hook_url_with_string(id self, SEL _cmd, id string) { + return ((id (*)(id, SEL, id))orig_url_with_string)(self, _cmd, rewrite_string(string)); +} +static id hook_url_with_string_rel(id self, SEL _cmd, id string, id base) { + return ((id (*)(id, SEL, id, id))orig_url_with_string_rel)(self, _cmd, rewrite_string(string), base); +} +static id hook_url_with_string_enc(id self, SEL _cmd, id string, signed char invalid) { + return ((id (*)(id, SEL, id, signed char))orig_url_with_string_enc)(self, _cmd, rewrite_string(string), invalid); +} +static id hook_init_string(id self, SEL _cmd, id string) { + return ((id (*)(id, SEL, id))orig_init_string)(self, _cmd, rewrite_string(string)); +} +static id hook_init_string_rel(id self, SEL _cmd, id string, id base) { + return ((id (*)(id, SEL, id, id))orig_init_string_rel)(self, _cmd, rewrite_string(string), base); +} +static id hook_req_with_url(id self, SEL _cmd, id url) { + return ((id (*)(id, SEL, id))orig_req_with_url)(self, _cmd, rewrite_url(url)); +} +static id hook_req_init(id self, SEL _cmd, id url) { + return ((id (*)(id, SEL, id))orig_req_init)(self, _cmd, rewrite_url(url)); +} +static id hook_req_init_policy(id self, SEL _cmd, id url, unsigned long policy, double timeout) { + return ((id (*)(id, SEL, id, unsigned long, double))orig_req_init_policy)(self, _cmd, rewrite_url(url), policy, timeout); +} +static void hook_set_url(id self, SEL _cmd, id url) { + ((void (*)(id, SEL, id))orig_set_url)(self, _cmd, rewrite_url(url)); +} +static id hook_task_url(id self, SEL _cmd, id url) { + return ((id (*)(id, SEL, id))orig_task_url)(self, _cmd, rewrite_url(url)); +} +static id hook_task_url_block(id self, SEL _cmd, id url, id block) { + return ((id (*)(id, SEL, id, id))orig_task_url_block)(self, _cmd, rewrite_url(url), block); +} +static id hook_task_req(id self, SEL _cmd, id request) { + return ((id (*)(id, SEL, id))orig_task_req)(self, _cmd, rewrite_request(request)); +} +static id hook_task_req_block(id self, SEL _cmd, id request, id block) { + return ((id (*)(id, SEL, id, id))orig_task_req_block)(self, _cmd, rewrite_request(request), block); +} + +static int swizzle(const char *class_name, const char *sel_name, int instance, IMP repl, IMP *orig_out) { + Class c = p_objc_getClass(class_name); + if (!c) return 0; + SEL s = sel(sel_name); + Method m = instance ? p_class_getInstanceMethod(c, s) : p_class_getClassMethod(c, s); + if (!m) return 0; + *orig_out = p_method_getImplementation(m); + p_method_setImplementation(m, repl); + fprintf(stderr, "[ApiHook] swizzled %s[%s %s]\n", instance ? "-" : "+", class_name, sel_name); + return 1; +} + +static void add_default_rule(void) { + snprintf(g_from_host[0], sizeof g_from_host[0], "api.link-like-lovelive.app"); + snprintf(g_to_origin[0], sizeof g_to_origin[0], "https://api-alfa-l4.hasu-link.club"); + g_rule_count = 1; +} + +static void load_config(void) { + char exe[1024]; + uint32_t sz = (uint32_t)sizeof exe; + if (_NSGetExecutablePath(exe, &sz) != 0) { + add_default_rule(); + return; + } + char *slash = strrchr(exe, '/'); + if (!slash) { + add_default_rule(); + return; + } + *slash = 0; + char path[1200]; + snprintf(path, sizeof path, "%s/api-hook.txt", exe); + FILE *fp = fopen(path, "r"); + if (!fp) { + fprintf(stderr, "[ApiHook] no config, using built-in host\n"); + add_default_rule(); + return; + } + char line[800]; + while (fgets(line, sizeof line, fp) && g_rule_count < MAX_RULES) { + trim(line); + if (line[0] == 0 || line[0] == '#') continue; + char *sp = strchr(line, ' '); + if (!sp) continue; + *sp = 0; + char *origin = sp + 1; + trim(origin); + if (line[0] == 0 || origin[0] == 0) continue; + snprintf(g_from_host[g_rule_count], sizeof g_from_host[0], "%s", line); + snprintf(g_to_origin[g_rule_count], sizeof g_to_origin[0], "%s", origin); + g_rule_count++; + } + fclose(fp); + if (g_rule_count == 0) add_default_rule(); + fprintf(stderr, "[ApiHook] %d rewrite rule(s)\n", g_rule_count); +} + +static id g_banner_label; +static id g_banner_window; +static int g_banner_tries; +static int g_banner_done; +static int g_owns_window; + +typedef struct { + double top; + double left; + double bottom; + double right; +} Insets; + +static CGRect obj_bounds(id obj) { + return ((CGRect (*)(id, SEL))p_objc_msgSend)(obj, sel("bounds")); +} + +static double top_inset(id view) { + Insets insets = ((Insets (*)(id, SEL))p_objc_msgSend)(view, sel("safeAreaInsets")); + if (insets.top >= 20.0 && insets.top < 160.0) return insets.top; + return 54.0; +} + +static dispatch_queue_t main_queue(void) { + if (p_dispatch_get_main_queue) { + dispatch_queue_t q = p_dispatch_get_main_queue(); + if (q) return q; + } + return (dispatch_queue_t)dlsym(((void *)(intptr_t)-2), "_dispatch_main_q"); +} + +static id foreground_scene(id app) { + id scenes = ((msg_id_t)p_objc_msgSend)(app, sel("connectedScenes")); + if (!scenes) return (id)0; + id all = ((msg_id_t)p_objc_msgSend)(scenes, sel("allObjects")); + if (!all) return (id)0; + unsigned long count = ((msg_ulong_t)p_objc_msgSend)(all, sel("count")); + Class window_scene = p_objc_getClass("UIWindowScene"); + id fallback = (id)0; + for (unsigned long i = 0; i < count; i++) { + id scene = ((msg_id_ulong_t)p_objc_msgSend)(all, sel("objectAtIndex:"), i); + if (!scene || !window_scene) continue; + signed char kind = ((signed char (*)(id, SEL, id))p_objc_msgSend)(scene, sel("isKindOfClass:"), (id)window_scene); + if (!kind) continue; + if (!fallback) fallback = scene; + long state = ((long (*)(id, SEL))p_objc_msgSend)(scene, sel("activationState")); + if (state == 0) return scene; + } + return fallback; +} + +static id host_window(id app, id scene) { + if (scene) { + id window = ((msg_id_t)p_objc_msgSend)(scene, sel("keyWindow")); + if (window) return window; + id windows = ((msg_id_t)p_objc_msgSend)(scene, sel("windows")); + unsigned long count = windows ? ((msg_ulong_t)p_objc_msgSend)(windows, sel("count")) : 0; + if (count > 0) return ((msg_id_ulong_t)p_objc_msgSend)(windows, sel("objectAtIndex:"), 0); + } + if (!app) return (id)0; + id window = ((msg_id_t)p_objc_msgSend)(app, sel("keyWindow")); + if (window) return window; + id windows = ((msg_id_t)p_objc_msgSend)(app, sel("windows")); + unsigned long count = windows ? ((msg_ulong_t)p_objc_msgSend)(windows, sel("count")) : 0; + if (count == 0) return (id)0; + return ((msg_id_ulong_t)p_objc_msgSend)(windows, sel("objectAtIndex:"), 0); +} + +static id make_label(id container) { + id label = ((msg_id_t)p_objc_msgSend)(((msg_id_t)p_objc_msgSend)(cls("UILabel"), sel("alloc")), sel("init")); + if (!label) return (id)0; + ((msg_void_id_t)p_objc_msgSend)(label, sel("setText:"), nsstr("API-HOOKED")); + ((msg_void_long_t)p_objc_msgSend)(label, sel("setTextAlignment:"), 1); + ((msg_void_long_t)p_objc_msgSend)(label, sel("setTag:"), LABEL_TAG); + ((msg_void_long_t)p_objc_msgSend)(label, sel("setAutoresizingMask:"), 37); + ((msg_void_bool_t)p_objc_msgSend)(label, sel("setUserInteractionEnabled:"), 0); + id clear = ((msg_id_t)p_objc_msgSend)(cls("UIColor"), sel("clearColor")); + id white = ((msg_id_t)p_objc_msgSend)(cls("UIColor"), sel("whiteColor")); + id shadow = ((msg_id_rgba_t)p_objc_msgSend)(cls("UIColor"), sel("colorWithRed:green:blue:alpha:"), 0, 0, 0, 0.85); + id font = ((msg_id_f64_t)p_objc_msgSend)(cls("UIFont"), sel("systemFontOfSize:"), 12.0); + ((msg_void_id_t)p_objc_msgSend)(label, sel("setTextColor:"), white); + ((msg_void_id_t)p_objc_msgSend)(label, sel("setBackgroundColor:"), clear); + ((msg_void_id_t)p_objc_msgSend)(label, sel("setShadowColor:"), shadow); + { + typedef struct { double w, h; } CGSize; + ((void (*)(id, SEL, CGSize))p_objc_msgSend)(label, sel("setShadowOffset:"), (CGSize){0.0, 1.0}); + } + if (font) ((msg_void_id_t)p_objc_msgSend)(label, sel("setFont:"), font); + ((void (*)(id, SEL))p_objc_msgSend)(label, sel("sizeToFit")); + + CGRect text = obj_bounds(label); + CGRect screen = obj_bounds(container); + double width = screen.w; + if (width < 200.0 || width > 2000.0) width = 390.0; + double label_w = text.w > 20.0 ? text.w : 88.0; + double label_h = text.h > 8.0 ? text.h : 14.0; + CGRect frame; + frame.w = label_w; + frame.h = label_h; + frame.x = (width - label_w) / 2.0; + frame.y = top_inset(container); + ((msg_void_rect_t)p_objc_msgSend)(label, sel("setFrame:"), frame); + return label; +} + +static void hide_banner(void *unused) { + (void)unused; + if (g_banner_done) return; + g_banner_done = 1; + if (g_banner_label) { + ((void (*)(id, SEL))p_objc_msgSend)(g_banner_label, sel("removeFromSuperview")); + g_banner_label = (id)0; + } + if (g_owns_window && g_banner_window) { + ((msg_void_bool_t)p_objc_msgSend)(g_banner_window, sel("setHidden:"), 1); + } + g_banner_window = (id)0; +} + +static void arm_hide(void) { + dispatch_queue_t q = main_queue(); + if (!q || !p_dispatch_after_f || !p_dispatch_time) return; + dispatch_time_t when = p_dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC); + p_dispatch_after_f(when, q, NULL, hide_banner); +} + +static void show_banner(void *unused) { + (void)unused; + if (g_banner_done) return; + if (g_banner_label) return; + + if (g_banner_tries < 20 && p_dispatch_after_f && p_dispatch_time) { + dispatch_queue_t q = main_queue(); + if (q) { + g_banner_tries++; + dispatch_time_t when = p_dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)); + p_dispatch_after_f(when, q, NULL, show_banner); + } + } + + dlopen("/System/Library/Frameworks/UIKit.framework/UIKit", RTLD_NOW); + if (!p_objc_getClass("UILabel") || !p_objc_getClass("UIApplication") || !p_objc_getClass("UIWindow")) return; + id app = ((msg_id_t)p_objc_msgSend)(cls("UIApplication"), sel("sharedApplication")); + if (!app) return; + id scene = foreground_scene(app); + id host = host_window(app, scene); + if (!scene && !host) return; + + id container = host; + id overlay = (id)0; + if (scene) { + overlay = ((msg_id_id_t)p_objc_msgSend)(((msg_id_t)p_objc_msgSend)(cls("UIWindow"), sel("alloc")), sel("initWithWindowScene:"), scene); + if (overlay) { + ((void (*)(id, SEL, double))p_objc_msgSend)(overlay, sel("setWindowLevel:"), 100000.0); + id clear = ((msg_id_t)p_objc_msgSend)(cls("UIColor"), sel("clearColor")); + ((msg_void_id_t)p_objc_msgSend)(overlay, sel("setBackgroundColor:"), clear); + ((msg_void_bool_t)p_objc_msgSend)(overlay, sel("setUserInteractionEnabled:"), 0); + ((msg_void_bool_t)p_objc_msgSend)(overlay, sel("setHidden:"), 0); + container = overlay; + } + } + if (!container) return; + + id label = make_label(container); + if (!label) return; + ((msg_void_id_t)p_objc_msgSend)(container, sel("addSubview:"), label); + g_banner_label = label; + g_banner_window = container; + g_owns_window = overlay ? 1 : 0; + arm_hide(); + fprintf(stderr, "[ApiHook] API-HOOKED banner added\n"); +} + +static void schedule_banner(void) { + dispatch_queue_t q = main_queue(); + if (!p_dispatch_after_f || !p_dispatch_time || !q) { + fprintf(stderr, "[ApiHook] banner timer unavailable\n"); + return; + } + dispatch_time_t when = p_dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)); + p_dispatch_after_f(when, q, NULL, show_banner); +} + +static void install_hooks(void) { + swizzle("NSURL", "URLWithString:", 0, (IMP)hook_url_with_string, &orig_url_with_string); + swizzle("NSURL", "URLWithString:relativeToURL:", 0, (IMP)hook_url_with_string_rel, &orig_url_with_string_rel); + swizzle("NSURL", "URLWithString:encodingInvalidCharacters:", 0, (IMP)hook_url_with_string_enc, &orig_url_with_string_enc); + swizzle("NSURL", "initWithString:", 1, (IMP)hook_init_string, &orig_init_string); + swizzle("NSURL", "initWithString:relativeToURL:", 1, (IMP)hook_init_string_rel, &orig_init_string_rel); + swizzle("NSURLRequest", "requestWithURL:", 0, (IMP)hook_req_with_url, &orig_req_with_url); + swizzle("NSURLRequest", "initWithURL:", 1, (IMP)hook_req_init, &orig_req_init); + swizzle("NSURLRequest", "initWithURL:cachePolicy:timeoutInterval:", 1, (IMP)hook_req_init_policy, &orig_req_init_policy); + swizzle("NSMutableURLRequest", "setURL:", 1, (IMP)hook_set_url, &orig_set_url); + swizzle("NSURLSession", "dataTaskWithURL:", 1, (IMP)hook_task_url, &orig_task_url); + swizzle("NSURLSession", "dataTaskWithURL:completionHandler:", 1, (IMP)hook_task_url_block, &orig_task_url_block); + swizzle("NSURLSession", "dataTaskWithRequest:", 1, (IMP)hook_task_req, &orig_task_req); + swizzle("NSURLSession", "dataTaskWithRequest:completionHandler:", 1, (IMP)hook_task_req_block, &orig_task_req_block); +} + +__attribute__((constructor)) static void api_hook_init(void) { + void *objc = dlopen("/usr/lib/libobjc.A.dylib", RTLD_NOW | RTLD_GLOBAL); + dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", RTLD_NOW); + dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", RTLD_NOW); + if (!objc) { + fprintf(stderr, "[ApiHook] libobjc failed to load\n"); + return; + } + if (!load_symbol(objc, "objc_getClass", (void **)&p_objc_getClass)) return; + if (!load_symbol(objc, "sel_registerName", (void **)&p_sel_registerName)) return; + if (!load_symbol(objc, "class_getClassMethod", (void **)&p_class_getClassMethod)) return; + if (!load_symbol(objc, "class_getInstanceMethod", (void **)&p_class_getInstanceMethod)) return; + if (!load_symbol(objc, "method_getImplementation", (void **)&p_method_getImplementation)) return; + if (!load_symbol(objc, "method_setImplementation", (void **)&p_method_setImplementation)) return; + if (!load_symbol(objc, "objc_msgSend", (void **)&p_objc_msgSend)) return; + void *self = ((void *)(intptr_t)-2); + p_dispatch_time = dlsym(self, "dispatch_time"); + p_dispatch_after_f = dlsym(self, "dispatch_after_f"); + p_dispatch_get_main_queue = dlsym(self, "dispatch_get_main_queue"); + + s_UTF8String = sel("UTF8String"); + s_stringWithUTF8String = sel("stringWithUTF8String:"); + load_config(); + install_hooks(); + schedule_banner(); + fprintf(stderr, "[ApiHook] loaded\n"); +} diff --git a/src/build.py b/src/build.py new file mode 100644 index 0000000..b6f1af4 --- /dev/null +++ b/src/build.py @@ -0,0 +1,214 @@ +"""Build a signed L4 IPA from the decrypted game package, certs/, and local tools.""" +import os +import shutil +import struct +import subprocess +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CERTS_DIR = ROOT / "certs" +OUTPUT_DIR = ROOT / "output" +BUILD_DIR = ROOT / "build" +TOOLS_DIR = ROOT / "tools" +CONFIG = ROOT / "config" / "api-hook.txt" +ZIG_INCLUDE = "lib/libc/include/any-darwin-any" + +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tools")) +from fetch import archive_complete, ensure_archive # noqa: E402 +from inject_ipa import inject_ipa # noqa: E402 +from macho_fix import patch_dylib # noqa: E402 + +IPA_URL = "https://www.senluopan.com/f/VKd2ha/LLLL_5.1.0.ipa" +IPA_FILE = BUILD_DIR / "LLLL_5.1.0.ipa" + + +def pick_one(folder: Path, suffix: str, title: str) -> Path | None: + found = sorted(p for p in folder.glob(f"*{suffix}") if p.is_file()) + if not found: + return None + if len(found) > 1: + names = "\n".join(f" - {p.name}" for p in found) + raise SystemExit(f"{title} 里有多份 {suffix},请只留一份:\n{names}") + return found[0] + + +def require_signing_material() -> tuple[Path, Path, str]: + CERTS_DIR.mkdir(parents=True, exist_ok=True) + p12 = pick_one(CERTS_DIR, ".p12", "certs") + profile = pick_one(CERTS_DIR, ".mobileprovision", "certs") + if p12 and profile: + password = "" + password_file = CERTS_DIR / "password.txt" + if password_file.is_file(): + text = password_file.read_text(encoding="utf-8") + password = text.splitlines()[0] if text else "" + return p12, profile, password + + print("还不能签名。请把你自己的开发者材料放进 certs/ :") + print(" 1. 证书文件,扩展名是 .p12") + print(" 2. 描述文件,扩展名是 .mobileprovision") + print("密码为空就不用额外操作。如果 p12 有密码,把密码单独写在 certs/password.txt 的第一行。") + print("描述文件的设备列表里必须包含这台手机,Bundle ID 保持游戏原来的 com.oddno.lovelive。") + if not p12: + print("当前缺少 .p12") + if not profile: + print("当前缺少 .mobileprovision") + raise SystemExit(2) + + +def require_ipa() -> Path: + if archive_complete(IPA_FILE): + return IPA_FILE + print(IPA_URL, flush=True) + ensure_archive(IPA_URL, IPA_FILE) + if not archive_complete(IPA_FILE): + raise SystemExit(2) + return IPA_FILE + + +def fetch_tool(script: str) -> None: + result = subprocess.run([sys.executable, str(ROOT / "tools" / script)], check=False) + if result.returncode != 0: + raise SystemExit(2) + + +def find_zig() -> Path: + found = sorted(TOOLS_DIR.glob("zig*/zig.exe")) + if found: + return found[0] + fetch_tool("zig.py") + found = sorted(TOOLS_DIR.glob("zig*/zig.exe")) + if not found: + raise SystemExit(2) + return found[0] + + +def ensure_zsign() -> Path: + folder = TOOLS_DIR / "zsign" + patched = folder / "zsign-noent.exe" + if patched.is_file(): + return patched + stock = folder / "zsign.exe" + if not stock.is_file(): + fetch_tool("zsign.py") + stock = folder / "zsign.exe" + if not stock.is_file(): + raise SystemExit(2) + data = bytearray(stock.read_bytes()) + off = 0xFD45C + expect = bytes([0x41, 0xB8, 0xB5, 0x00, 0x00, 0x00]) + if data[off - 2 : off + 4] != expect: + raise SystemExit("tools/zsign/zsign.exe 不是已验证的 1.1.2,不能自动去掉动态库上的空 entitlements") + data[off] = 0 + patched.write_bytes(data) + print(f"已生成 {patched.name}:非主程序不再写入空 entitlements") + return patched + + +def compile_dylib(zig: Path, output: Path) -> None: + include = zig.parent / ZIG_INCLUDE + if not include.is_dir(): + raise SystemExit(f"Zig 头文件目录不存在:{include}") + output.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + str(zig), + "cc", + "-target", + "aarch64-macos", + "-isystem", + str(include), + "-dynamiclib", + "-fno-stack-protector", + "-O2", + "-Wl,-install_name,@executable_path/ApiHook.dylib", + "-o", + str(output), + str(ROOT / "src" / "api_hook.c"), + ] + subprocess.check_call(cmd) + + +def verify_signed_dylib(ipa: Path) -> None: + with zipfile.ZipFile(ipa) as archive: + dylib = archive.read("Payload/ProductName.app/ApiHook.dylib") + if dylib[:4] != struct.pack(" vmsize: + raise SystemExit("签名后的动态库段对齐不正确") + if cmd == 0x1D: + signature = struct.unpack_from("I", blob, 8)[0] + kinds = [struct.unpack_from(">I", blob, 12 + i * 8)[0] for i in range(count)] + if 5 in kinds or 7 in kinds: + raise SystemExit("签名后的动态库仍带有 entitlements") + + +def main() -> None: + ipa = require_ipa() + p12, profile, password = require_signing_material() + if not CONFIG.is_file(): + raise SystemExit(f"缺少配置:{CONFIG}") + zig = find_zig() + zsign = ensure_zsign() + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + BUILD_DIR.mkdir(parents=True, exist_ok=True) + dylib = BUILD_DIR / "ApiHook.dylib" + unsigned = OUTPUT_DIR / f"{ipa.stem}-apihook-unsigned.ipa" + signed = OUTPUT_DIR / f"{ipa.stem}-apihook-signed.ipa" + + print(f"编译动态库:{zig}") + compile_dylib(zig, dylib) + patch_dylib(dylib) + inject_ipa(ipa, dylib, CONFIG, unsigned) + + if signed.exists(): + signed.unlink() + temp = BUILD_DIR / "zsign-tmp" + if temp.exists(): + shutil.rmtree(temp) + temp.mkdir(parents=True) + cmd = [ + str(zsign), + "-k", + str(p12), + "-p", + password, + "-m", + str(profile), + "-o", + str(signed), + "-t", + str(temp), + "-z", + "6", + str(unsigned), + ] + subprocess.check_call(cmd) + shutil.rmtree(temp, ignore_errors=True) + verify_signed_dylib(signed) + print(f"可安装包:{signed}") + print("Bundle ID 保持 com.oddno.lovelive。装到手机前先删掉旧包。") + + +if __name__ == "__main__": + os.chdir(ROOT) + main() diff --git a/src/inject_ipa.py b/src/inject_ipa.py new file mode 100644 index 0000000..d8f0bcf --- /dev/null +++ b/src/inject_ipa.py @@ -0,0 +1,140 @@ +"""Inject ApiHook.dylib into a decrypted L4 IPA and leave it unsigned.""" +import argparse +import struct +import zipfile +from pathlib import Path + +LC_LOAD_DYLIB = 0xC +MH_MAGIC_64 = 0xFEEDFACF +DYLIB_NAME = "@executable_path/ApiHook.dylib" +METADATA = "Payload/ProductName.app/Data/Managed/Metadata/global-metadata.dat" +MAIN_BIN = "Payload/ProductName.app/ProductName" +APP_DYLIB = "Payload/ProductName.app/ApiHook.dylib" +APP_CONFIG = "Payload/ProductName.app/api-hook.txt" + + +def load_rule(config: Path) -> tuple[bytes, bytes]: + for raw in config.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) + if len(parts) != 2: + raise SystemExit(f"配置行格式应为“原主机 新地址”:{line}") + host, origin = parts + old = host.encode("ascii") if host.startswith("http") else b"https://" + host.encode("ascii") + new = origin.strip().encode("ascii") + if len(old) != len(new): + raise SystemExit( + f"API 地址替换必须等长(现在 {len(old)} 和 {len(new)}):{old.decode()} -> {new.decode()}" + ) + return old, new + raise SystemExit(f"{config} 里没有改写规则") + + +def align8(n: int) -> int: + return (n + 7) & ~7 + + +def add_load_dylib(binary: bytearray, name: str) -> bytearray: + if binary[:4] != struct.pack(" first_section: + raise SystemExit( + f"no header padding for load command ({header_end}+{cmdsize} > {first_section})" + ) + + cmd = struct.pack(" bytes: + count = data.count(old) + if count == 0: + raise SystemExit("global-metadata.dat 里找不到要替换的 API 地址") + print(f"replaced {count} API URL literal(s) in global-metadata.dat") + return data.replace(old, new) + + +def inject_ipa(ipa: Path, dylib: Path, config: Path, out: Path) -> None: + old, new = load_rule(config) + dylib_bytes = dylib.read_bytes() + if not dylib_bytes.startswith(struct.pack(" None: + parser = argparse.ArgumentParser() + parser.add_argument("--ipa", type=Path, required=True) + parser.add_argument("--dylib", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + inject_ipa(args.ipa, args.dylib, args.config, args.out) + + +if __name__ == "__main__": + main() diff --git a/src/macho_fix.py b/src/macho_fix.py new file mode 100644 index 0000000..3b90988 --- /dev/null +++ b/src/macho_fix.py @@ -0,0 +1,67 @@ +"""Make a Zig macOS dylib loadable as an iOS arm64 dylib.""" +import struct +from pathlib import Path + +PAGE = 0x4000 +MH_MAGIC_64 = 0xFEEDFACF +LC_SEGMENT_64 = 0x19 +LC_LOAD_DYLINKER = 0xE +LC_BUILD_VERSION = 0x32 + + +def patch_dylib(path: Path) -> None: + data = bytearray(path.read_bytes()) + if data[:4] != struct.pack(" next_vm: + raise SystemExit(f"{name} cannot be aligned without overlapping the next segment") + if aligned < filesize: + raise SystemExit(f"{name} aligned size {aligned:#x} is smaller than file size {filesize:#x}") + struct.pack_into(" {aligned:#x}") + + rebuilt = bytearray() + removed_dylinker = False + patched_platform = False + for cmd, chunk in cmds: + if cmd == LC_BUILD_VERSION: + struct.pack_into(" str: + name = path.name.lower() + if name.endswith(".part"): + name = name[: -len(".part")] + return name + + +def archive_complete(path: Path) -> bool: + if not path.is_file() or path.stat().st_size == 0: + return False + name = _archive_name(path) + try: + if name.endswith(".zip") or name.endswith(".ipa"): + with zipfile.ZipFile(path) as archive: + return bool(archive.infolist()) + if ".tar." in name or name.endswith(".tgz"): + mode = "r:xz" if name.endswith(".tar.xz") else "r:gz" + with tarfile.open(path, mode) as archive: + for _ in archive: + pass + return True + except (OSError, tarfile.TarError, zipfile.BadZipFile): + return False + return False + + +def _total_size(resp, have: int, code: int) -> int | None: + if code == 206: + content_range = resp.headers.get("Content-Range") + if content_range and "/" in content_range: + total = content_range.rsplit("/", 1)[-1] + if total.isdigit(): + return int(total) + length = resp.headers.get("Content-Length") + if length and length.isdigit(): + return have + int(length) + return None + length = resp.headers.get("Content-Length") + if length and length.isdigit(): + return int(length) + return None + + +def ensure_archive(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + part = dest.with_name(dest.name + ".part") + if archive_complete(dest): + return + if archive_complete(part): + part.replace(dest) + return + if dest.exists() and not part.exists(): + dest.replace(part) + elif dest.exists(): + dest.unlink() + + have = part.stat().st_size if part.exists() else 0 + headers = {"User-Agent": UA} + if have: + headers["Range"] = f"bytes={have}-" + request = urllib.request.Request(url, headers=headers) + try: + response = urllib.request.urlopen(request, timeout=120) + except urllib.error.HTTPError as exc: + if exc.code == 416 and archive_complete(part): + part.replace(dest) + return + print(f"下载失败:{exc.code}") + raise SystemExit(2) + except (urllib.error.URLError, TimeoutError, OSError): + print("下载未完成") + raise SystemExit(2) + + try: + with response: + code = getattr(response, "status", 200) + if code == 200: + have = 0 + elif code != 206: + print(f"下载失败:{code}") + raise SystemExit(2) + total = _total_size(response, have, code) + shown = -1 + with part.open("wb" if code == 200 else "ab") as output: + done = have if code == 206 else 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + done += len(chunk) + if not total: + continue + pct = min(100, done * 100 // total) + if pct // 10 != shown: + shown = pct // 10 + print(f"\r{pct}%", end="", flush=True) + if shown >= 0: + print() + except (urllib.error.URLError, TimeoutError, OSError): + print("下载未完成") + raise SystemExit(2) + + if archive_complete(part): + part.replace(dest) + return + if total and part.exists() and part.stat().st_size == total: + part.unlink(missing_ok=True) + print("下载内容无法解压") + raise SystemExit(2) + print("下载未完成") + raise SystemExit(2) + + +def extract_archive(archive: Path, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + root = dest.resolve() + name = archive.name.lower() + if name.endswith(".zip"): + with zipfile.ZipFile(archive) as zipped: + for info in zipped.infolist(): + target = (dest / info.filename).resolve() + if not target.is_relative_to(root): + raise SystemExit("压缩包路径不安全") + zipped.extractall(dest) + return + with tarfile.open(archive) as packed: + packed.extractall(dest, filter="data") diff --git a/tools/zig.py b/tools/zig.py new file mode 100644 index 0000000..4bd26d0 --- /dev/null +++ b/tools/zig.py @@ -0,0 +1,52 @@ +"""Download and extract Zig for this operating system.""" +import os +import platform +from pathlib import Path + +from fetch import ensure_archive, extract_archive + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools" +VERSION = "0.16.0" +RELEASE = f"https://github.com/ziglang/zig/releases/tag/{VERSION}" + +ARCH = { + "amd64": "x86_64", + "x86_64": "x86_64", + "arm64": "aarch64", + "aarch64": "aarch64", +} +OS_FILE = { + "windows": ("windows", "zip", "zig.exe"), + "darwin": ("macos", "tar.xz", "zig"), + "linux": ("linux", "tar.xz", "zig"), +} + + +def main() -> None: + system = platform.system().lower() + arch = ARCH.get(platform.machine().lower()) + spec = OS_FILE.get(system) + if not arch or not spec: + print(RELEASE) + raise SystemExit(2) + + os_name, ext, binary = spec + folder = f"zig-{arch}-{os_name}-{VERSION}" + if any(TOOLS.glob(f"zig*/{binary}")): + return + url = f"https://ziglang.org/download/{VERSION}/{folder}.{ext}" + archive = TOOLS / f"{folder}.{ext}" + print(url, flush=True) + ensure_archive(url, archive) + extract_archive(archive, TOOLS) + found = sorted(TOOLS.glob(f"zig*/{binary}")) + if not found: + raise SystemExit(2) + if os.name != "nt": + for path in found: + path.chmod(0o755) + + +if __name__ == "__main__": + main() diff --git a/tools/zsign.py b/tools/zsign.py new file mode 100644 index 0000000..4214326 --- /dev/null +++ b/tools/zsign.py @@ -0,0 +1,52 @@ +"""Download and extract zsign for this operating system.""" +import os +import platform +from pathlib import Path + +from fetch import ensure_archive, extract_archive + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools" / "zsign" +VERSION = "1.1.2" +RELEASE = f"https://github.com/zhlynn/zsign/releases/tag/v{VERSION}" + +ARCH = { + "amd64": "x86_64", + "x86_64": "x86_64", + "arm64": "aarch64", + "aarch64": "aarch64", +} +# Official v1.1.2 assets. There is no Windows arm64 or macOS x64 build. +ASSETS = { + ("windows", "x86_64"): ("zsign-windows-x64.zip", "zsign.exe"), + ("darwin", "aarch64"): ("zsign-macos-arm64.tar.gz", "zsign"), + ("linux", "x86_64"): ("zsign-linux-x86_64.tar.gz", "zsign"), + ("linux", "aarch64"): ("zsign-linux-aarch64.tar.gz", "zsign"), +} + + +def main() -> None: + system = platform.system().lower() + arch = ARCH.get(platform.machine().lower()) + asset = ASSETS.get((system, arch)) + if not asset: + print(RELEASE) + raise SystemExit(2) + + filename, binary = asset + if (TOOLS / binary).is_file(): + return + url = f"https://github.com/zhlynn/zsign/releases/download/v{VERSION}/{filename}" + archive = TOOLS.parent / filename + print(url, flush=True) + ensure_archive(url, archive) + extract_archive(archive, TOOLS) + installed = TOOLS / binary + if not installed.is_file(): + raise SystemExit(2) + if os.name != "nt": + installed.chmod(0o755) + + +if __name__ == "__main__": + main()