Back to Blog
Arcaea iOS 逆向工程

Arcaea iOS 逆向工程

Athmyx11 min read
iOSReverse EngineeringMach-OObjC RuntimeARM64

注:本文仅用于个人学习、技术研究与过程记录,严禁将文中内容用于任何未经授权的测试、攻击、绕过平台限制、数据获取或其他可能影响游戏正常运营的行为。

环境配置

类别配置 / 说明
操作系统macOS 15 (Sequoia) / Windows 11
目标设备iPad (iOS 18.6.2, 非越狱)
目标应用Arcaea 6.13.2 (iOS)
编译工具Xcode 16.3 / xcrun clang (arm64)
签名安装Sideloadly
服务器Ubuntu 22.04 LTS (GCP) + Nginx + Let's Encrypt

Arcaea 是 lowiro 开发的一款创新3D音乐节奏游戏。本文将简要描述如何通过改包实现全曲解锁并连接私服。本研究原先使用6.12.10版本,后续移植到6.13.2版本。除用户登录接口不一致外,其余内容可完整适配。假设理论在未来一定时间内应该可适配更高版本。

IPA 结构分析

IPA 本质上是一个 ZIP 压缩包:

Arcaea_6.12.10.ipa
└── Payload/
    └── Arc-mobile.app/
        ├── Arc-mobile          Mach-O arm64 主二进制
        ├── Info.plist          Bundle ID: moe.low.arc
        ├── Frameworks/
        ├── songs/              歌曲资源
        └── ...

用于分析的 IPA 通过 DumpDecrypter 从越狱设备上提取,同时解除 App Store 的 FairPlay DRM 加密。

解析 Mach-O 头部可以得到关键信息:

字段
Magic0xFEEDFACF (arm64 little-endian)
Load Commands82 条, 9768 bytes
PIE (ASLR)已启用
__TEXT18.5 MB
__DATA2.1 MB

分析得到游戏使用 Cocos2d-x 引擎(OpenGL ES 渲染路径),网络层通过 TrustKit v1.7.0 实现 SSL Pinning,第三方 SDK 包括 Firebase、Facebook SDK、Google Sign-In。

Dylib 注入:Frida 失败后使用纯 ObjC

原始方案

最初选择了 Frida Gadget 方案。这是一个成熟的 iOS 动态插桩工具。原理是向 Mach-O 二进制注入 LC_LOAD_DYLIB,让 dyld 在启动时自动加载 Frida。

LC_LOAD_DYLIB 注入

Mach-O 的 Load Commands 之后、实际代码段之前有大量零填充对齐字节(本例中有 14,264 字节),足以容纳一条 72 字节的 LC_LOAD_DYLIB

偏移   大小  含义
0      4    cmd = 0x0000000C (LC_LOAD_DYLIB)
4      4    cmdsize = 72
8      4    name offset = 24
24     48   "@executable_path/Frameworks/ArcCore.dylib\0..."

写入后更新头部的 ncmds (82→83) 和 sizeofcmds (9768→9840) 即可。

Frida 在 iOS 18 上的失败

在非越狱 iPad (iOS 18.6.2) 上,Frida Gadget 启动即崩溃:

Exception Type:  EXC_BAD_ACCESS (SIGKILL - CODESIGNING)
Termination Reason: Namespace CODESIGNING, Code 0x2

Frida 的核心 GumJS 引擎需要 JIT 编译能力(MAP_JIT + com.apple.security.cs.allow-jit entitlement)。iOS 18 收紧了代码签名验证,Sideloadly 签名的应用不具备 JIT entitlement,内核直接强杀进程。

纯 ObjC Dylib 方案

放弃 Frida,直接编写 Objective-C 动态库:

纯 ObjC Dylib 方案使用编译时生成静态 ARM64 机器码,不存在JIT依赖,且__attribute__((constructor)) 在 dyld 加载后、main() 之前自动执行。

__attribute__((constructor))
static void overlay_init(void) {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
        (int64_t)(1.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        // UIKit 操作
    });
}

延迟 1.2 秒是因为 constructor 执行时 UIApplication 尚未完成初始化。

使用 install name:

xcrun install_name_tool -id \
  "@executable_path/Frameworks/ArcCore.dylib" OverlayDylib.dylib

UIWindow 触摸穿透

Overlay UIWindow 需要同时满足两个需求:FAB 按钮和 Debug 控制台可交互,但空白区域的触摸事件必须穿透到游戏。

UIKit 会在 UIWindow 和 rootViewController 之间自动插入中间层(UITransitionViewUIDropShadowView),这些层会拦截触摸事件。经过多次失败尝试后,找到的方案是在 UIWindow 层级直接重写 pointInside:withEvent:,短路 UIKit 的整个 hit test 链:

@implementation ArcOverlayWindow
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *rootView = self.rootViewController.view;
    if (!rootView) return NO;
    CGPoint converted = [rootView convertPoint:point fromView:self];
    return [rootView pointInside:converted withEvent:event];
}
@end

pointInside: 返回 NO 时,UIKit 的 hitTest: 立即返回 nil,不会查询任何子视图,触摸事件直接落到下面的游戏窗口。

服务器重定向与 SSL Pinning 绕过

TrustKit Bypass

Arcaea 使用 TrustKit 进行 SPKI SHA-256 证书 Pinning,预埋了 8 个公钥 hash。将 +[TrustKit initSharedInstanceWithConfiguration:] 替换为空函数,整个 pin 验证机制永远不会安装:

static void bypass_trustkit(void) {
    Class tkClass = NSClassFromString(@"TrustKit");
    SEL sel = NSSelectorFromString(@"initSharedInstanceWithConfiguration:");
    Method m = class_getClassMethod(tkClass, sel);
    IMP noopImp = imp_implementationWithBlock(^(id _self, NSDictionary *config) {
        // TrustKit 从未初始化 → pin 验证从未安装
    });
    method_setImplementation(m, noopImp);
}

之后 NSURLSession 走 iOS 系统默认的 CA 链验证,只要自建服务器有合法 HTTPS 证书即可通过。

NSURLSession URL 重写

Swizzle dataTaskWithRequest:completionHandler: 在每次请求发出前检查 URL host,若为 lowiro 域名则替换为自建服务器:

NSURLComponents *comps = [NSURLComponents componentsWithURL:req.URL
                           resolvingAgainstBaseURL:NO];
comps.host = @"arcaea.athmyx.com";

二进制字符串修补

NSURLSession swizzle 无法覆盖所有网络路径——Arcaea 的 Cocos2d-x 引擎还使用 NSURLConnection 和 CFStream。直接修改 Mach-O 二进制中的域名字符串是更彻底的方案。

关键约束:lowiro.comathmyx.com 都恰好是 10 字节,可以原地替换而不影响任何偏移量。

在二进制中找到并替换了 13 处 lowiro.comathmyx.com

原始域名替换后数量
arcapi-v3.lowiro.comarcapi-v3.athmyx.com1
auth.lowiro.comauth.athmyx.com1
arcaea.lowiro.comarcaea.athmyx.com6
其他位置的 lowiro.comathmyx.com5

全曲解锁

Hash 校验 NOP

songs/unlocks 文件定义歌曲的解锁条件,受 HMAC-SHA256 完整性校验保护。通过定位字符串引用 "LoadUnlocksMap failed hash check" 找到校验代码,将两条条件跳转指令替换为 NOP:

0xA2F138: B.NE  (54000461) → NOP (D503201F)
0xA2F160: CBNZ  (35000320) → NOP (D503201F)

0xD503201F 是 ARM64 的 NOP 指令。校验永远通过后,注入 {"unlocks":[]} 作为新的 unlocks 文件——空数组意味着没有解锁条件,所有歌曲直接可用。

歌曲资源注入

从完整资源包中提取 518 首歌曲(3491 个文件,1.4 GB),按压缩策略注入 IPA:

  • 已压缩格式(.ogg, .jpg, .png)→ STORE(不重复压缩)
  • 文本格式(.aff 谱面文件)→ DEFLATE level 6

最终 IPA 从 902 MB 增长到 2274 MB。

歌曲下载系统分析

下载状态完全由服务器驱动

经过 v11-v18 共 8 个版本的尝试(UIDocumentPicker 导入、NSFileManager hook、POSIX interpose、syscall 直接调用),一个关键发现推翻了之前所有假设:

Arcaea 的下载状态完全由服务器响应决定,不检查本地文件系统。

启动时,游戏通过聚合请求获取下载列表:

GET /compose/aggregate?calls=[
  {"endpoint":"/user/me","id":0},
  {"endpoint":"/serve/download/me/song?url=false","id":2},
  ...
]

id:2 的响应是一个以 song ID 为 key 的字典,包含每首歌的文件 checksum:

{
  "bluerose": {
    "audio": {"checksum": "6d92d0dd..."},
    "chart": {
      "0": {"checksum": "5e5afe30..."},
      "1": {"checksum": "ef0cb08a..."}
    }
  }
}

客户端将本地 Documents/dl/ 目录中文件的 checksum 与服务器返回值对比:匹配则认为已下载,不匹配则触发下载。

remote_dl 标志问题

深入分析 songlist 后发现,118 首歌被标记为 remote_dl: false(意为"资源内置在 app 中,不要从服务器下载")。这些歌在私服环境下永远无法完成下载——游戏期望文件在 IPA 中,但我们没有内置它们。

此外,tempestissimo 还有 hidden_until: "always" 标志,使其即使解锁后也不在歌曲列表中显示。

v1.0.35 的修复:在 dylib constructor 中、游戏加载 songlist 之前,自动修改设备上的 songlist 文件,移除所有 remote_dlhidden_until 标志。

dl/ 孤立条目问题

v1.0.39 发现的另一个问题:dylib 的 earlyCreateDlEntries 函数在启动时从 Bundle/songs/AppSupport/songs/ 创建到 Documents/dl/ 的硬链接,使得 checksum 与服务器匹配。游戏认为文件已下载(error -5),但实际歌曲数据目录不存在,导致下载按钮和"已下载"提示的死循环。

修复:在线模式下完全禁用 earlyCreateDlEntries,让游戏自己管理下载状态。

6.13.2 版本适配

6.13.2 的适配主要是偏移量差异。同样的技术(LC_LOAD_DYLIB 注入、字符串替换、hash check NOP),但 hash 校验被编译器 inline 到了两个位置,需要 4 个 NOP 而非 2 个:

项目6.12.106.13.2
lowiro.comathmyx.com13 处13 处(偏移不同)
Hash check NOP2 个(1 处)4 个(2 处,inline)
ArcCore.dylib同一个运行时 swizzle 不依赖偏移

v1.0.38 还修复了一个 iOS 下载闪退问题:NSURLSession factory swizzle 向所有 session config 注入了自定义 NSURLProtocol,但 background session 不支持自定义协议,导致游戏创建下载任务时崩溃且无 crash log(jetsam kill)。

私服搭建

服务器端基于开源的 Arcaea Server 项目,部署在 GCP Ubuntu 实例上:

  • Nginx 反向代理 + Let's Encrypt HTTPS 证书
  • 三个子域名:arcapi-v3.athmyx.comauth.athmyx.comarcaea.athmyx.com
  • Waitress WSGI 服务器,端口 8091
  • SQLite 数据库存储用户数据
  • database/songs/ 目录存储所有歌曲文件,用于生成 checksum 和提供下载