initial commit

This commit is contained in:
2026-09-22 18:48:00 +08:00
commit ea043dc7e0
12 changed files with 1215 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
*.p12
*.mobileprovision
*.ipa
certs/password.txt
build/
tools/
src/__pycache__/
/.idea/
+40
View File
@@ -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` 的那一份。
+2
View File
@@ -0,0 +1,2 @@
把自己的 .p12 和 .mobileprovision 放在这个目录,各一份。
p12 密码为空则不用额外文件。有密码时,把密码写在同目录的 password.txt 第一行。
+3
View File
@@ -0,0 +1,3 @@
# 只改写游戏 API 主机。资源 CDN(assets.link-like-lovelive.app)和官网不要写进来。
# 格式:原主机 替换后的源(含 https://,不要末尾斜杠)
api.link-like-lovelive.app https://api-alfa-l4.hasu-link.club
+1
View File
@@ -0,0 +1 @@
python src/build.py 生成的安装包会放在这里。安装带 signed 的那一份。
+498
View File
@@ -0,0 +1,498 @@
/* JIT-free API host rewrite. Swizzles NSURL construction only. */
#include <ctype.h>
#include <dlfcn.h>
#include <mach-o/dyld.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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");
}
+214
View File
@@ -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("<I", 0xFEEDFACF):
raise SystemExit("签名后的 ApiHook.dylib 不是 Mach-O")
ncmds = struct.unpack_from("<I", dylib, 16)[0]
off = 32
signature = None
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", dylib, off)
if cmd == 0xE:
raise SystemExit("签名后的动态库仍带有 LC_LOAD_DYLINKER")
if cmd == 0x19:
vmsize = struct.unpack_from("<Q", dylib, off + 32)[0]
filesize = struct.unpack_from("<Q", dylib, off + 48)[0]
if vmsize % 0x4000 or filesize > vmsize:
raise SystemExit("签名后的动态库段对齐不正确")
if cmd == 0x1D:
signature = struct.unpack_from("<II", dylib, off + 8)
off += cmdsize
if not signature:
raise SystemExit("签名后的动态库没有代码签名")
dataoff, datasize = signature
blob = dylib[dataoff : dataoff + datasize]
count = 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()
+140
View File
@@ -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("<I", MH_MAGIC_64):
raise SystemExit("main executable is not a 64-bit Mach-O")
header = struct.unpack_from("<IIIIIIII", binary, 0)
magic, cputype, cpusub, filetype, ncmds, sizeofcmds, flags, reserved = header
if filetype != 2:
raise SystemExit(f"unexpected Mach-O filetype {filetype}")
encoded = name.encode("ascii") + b"\x00"
cmdsize = align8(24 + len(encoded))
header_end = 32 + sizeofcmds
first_section = None
off = 32
for _ in range(ncmds):
cmd, size = struct.unpack_from("<II", binary, off)
if cmd == 0x19:
nsects = struct.unpack_from("<I", binary, off + 64)[0]
sect = off + 72
for _s in range(nsects):
sect_off = struct.unpack_from("<I", binary, sect + 48)[0]
if sect_off and (first_section is None or sect_off < first_section):
first_section = sect_off
sect += 80
if cmd == LC_LOAD_DYLIB:
nameoff = struct.unpack_from("<I", binary, off + 8)[0]
existing = binary[off + nameoff : off + size].split(b"\x00", 1)[0]
if existing.decode("ascii", "replace") == name:
print("LC_LOAD_DYLIB already present")
return binary
off += size
if first_section is None or header_end + cmdsize > first_section:
raise SystemExit(
f"no header padding for load command ({header_end}+{cmdsize} > {first_section})"
)
cmd = struct.pack("<IIIIII", LC_LOAD_DYLIB, cmdsize, 24, 0, 0x00010000, 0x00010000)
cmd += encoded
cmd += b"\x00" * (cmdsize - len(cmd))
binary[header_end : header_end + cmdsize] = cmd
struct.pack_into("<II", binary, 16, ncmds + 1, sizeofcmds + cmdsize)
print(f"added LC_LOAD_DYLIB {name} at offset {header_end}")
return binary
def patch_metadata(data: bytes, old: bytes, new: bytes) -> 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("<I", MH_MAGIC_64)):
raise SystemExit("ApiHook.dylib is not a 64-bit Mach-O")
config_bytes = config.read_bytes()
with zipfile.ZipFile(ipa) as src:
binary = add_load_dylib(bytearray(src.read(MAIN_BIN)), DYLIB_NAME)
metadata = patch_metadata(src.read(METADATA), old, new)
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
out.unlink()
with zipfile.ZipFile(out, "w") as dst:
for info in src.infolist():
if info.filename in (MAIN_BIN, METADATA):
continue
if info.filename.endswith("/") or info.is_dir():
continue
payload = src.read(info.filename)
new_info = zipfile.ZipInfo(filename=info.filename, date_time=info.date_time)
new_info.compress_type = info.compress_type
new_info.external_attr = info.external_attr
dst.writestr(new_info, payload)
for name, payload, mode in (
(MAIN_BIN, bytes(binary), 0o100755),
(METADATA, metadata, 0o100644),
(APP_DYLIB, dylib_bytes, 0o100755),
(APP_CONFIG, config_bytes, 0o100644),
):
info = zipfile.ZipInfo(filename=name)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = mode << 16
dst.writestr(info, payload)
print(f"wrote {out} ({out.stat().st_size} bytes)")
def main() -> 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()
+67
View File
@@ -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("<I", MH_MAGIC_64):
raise SystemExit(f"{path} is not a 64-bit Mach-O")
ncmds, sizeofcmds = struct.unpack_from("<II", data, 16)
off = 32
cmds = []
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", data, off)
cmds.append((cmd, bytearray(data[off : off + cmdsize])))
off += cmdsize
segments = []
for cmd, chunk in cmds:
if cmd != LC_SEGMENT_64:
continue
name = bytes(chunk[8:24]).split(b"\x00", 1)[0].decode()
vmaddr, vmsize, _fileoff, filesize = struct.unpack_from("<QQQQ", chunk, 24)
segments.append((name, vmaddr, vmsize, filesize, chunk))
for index, (name, vmaddr, vmsize, filesize, chunk) in enumerate(segments):
if name == "__LINKEDIT":
aligned = 0x3000
while aligned < filesize:
aligned += PAGE
else:
aligned = (vmsize + PAGE - 1) & ~(PAGE - 1)
next_vm = segments[index + 1][1]
if vmaddr + aligned > 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("<Q", chunk, 32, aligned)
print(f"{name} vmsize {vmsize:#x} -> {aligned:#x}")
rebuilt = bytearray()
removed_dylinker = False
patched_platform = False
for cmd, chunk in cmds:
if cmd == LC_BUILD_VERSION:
struct.pack_into("<III", chunk, 8, 2, 0x000F0000, 0x000F0000)
patched_platform = True
if cmd == LC_LOAD_DYLINKER:
removed_dylinker = True
print("removed LC_LOAD_DYLINKER")
continue
rebuilt += chunk
if not removed_dylinker:
raise SystemExit("LC_LOAD_DYLINKER was not found; the dylib layout changed")
if not patched_platform:
raise SystemExit("LC_BUILD_VERSION was not found")
data[32 : 32 + len(rebuilt)] = rebuilt
data[32 + len(rebuilt) : 32 + sizeofcmds] = b"\x00" * (sizeofcmds - len(rebuilt))
struct.pack_into("<II", data, 16, ncmds - 1, len(rebuilt))
path.write_bytes(data)
+138
View File
@@ -0,0 +1,138 @@
"""Download a release archive and extract it. A complete local file is extracted without downloading again."""
import tarfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
UA = "LLLL-tools"
def _archive_name(path: Path) -> 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")
+52
View File
@@ -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()
+52
View File
@@ -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()