开发越狱 App:无网络问题解决

越狱包安装的 App 首次启动往往直接没网:既不弹「允许使用无线数据」,设置里也找不到它的开关。问题不在 ATS、证书或沙盒,而在 CommCenter 里少了它的一行 per-bundle 数据策略。本文记录用 entitlements + ldid + 私有 API 把这行策略补回去的完整过程。

现象:没有弹窗,也没有开关

用 dpkg 安装到越狱设备上的 App,首次启动通常会出现以下现象:

  • 任何请求都失败,URLSession 报超时或「网络连接已中断」;
  • 系统从来没有弹出过「允许 “XX” 使用无线数据?」;
  • 进「设置 → 蜂窝网络」,翻遍列表也找不到这个 App,连手动打开开关的机会都没有。

拦截点既不在应用层,也不在 TCC。

根因:是 CommCenter 的 per-bundle 数据策略

iOS 对「能不能用蜂窝 / WiFi」的控制有两套完全不同的机制,很容易被混为一谈:

层面 机制 作用对象 越狱 App 的情况
沙盒层 network-outbound / network-inbound 受 sandbox profile 约束的进程 破沙盒后基本不构成阻碍
数据策略层 CommCenter 维护的 per-bundle 蜂窝数据策略 所有 App 没走系统登记流程,策略缺失,默认被拒

App Store 安装的 App,首次联网时由系统负责把它「登记」进 CommCenter 的使用策略表,用户点一次「允许」,系统就写入一条 AlwaysAllow。而越狱包 / 侧载包绕开了这条安装链路,策略表里根本没有它的记录,CommCenter 便按「未授权即拒绝」处理,该 bundle 的所有 socket 全被拦。

问题的本质是一行策略记录缺失:替系统补上这条记录,并且要在 App 发出第一个网络请求之前完成。

整体思路可以概括为三层修复:

  1. 用 entitlements 拿到「修改策略」的资格;
  2. 用 ldid 把资格真正写进二进制;
  3. 在程序入口最早期写入策略。

第一层:用 entitlements 换一张「修改许可证」

想改别人的数据策略,先得让系统相信你有这个资格。项目里的 Application/entitlements.xml 申请了三组关键权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 脱离沙盒,网络不再受 profile 约束 -->
<key>platform-application</key><true/>
<key>com.apple.private.security.no-sandbox</key><true/>
<key>com.apple.private.security.no-container</key><true/>
<key>com.apple.private.security.container-required</key><false/>

<!-- 基础网络能力 -->
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>com.apple.private.network.socket-delegate</key><true/>
<key>com.apple.private.network.privileged</key><true/>

<!-- 核心:CommCenter 数据策略的写权限 -->
<key>com.apple.CommCenter.fine-grained</key>
<array>
<string>spi</string>
<string>phone</string>
<string>identity</string>
<string>data-usage</string>
<string>data-allowed</string>
<string>data-allowed-write</string>
</array>

其中 com.apple.CommCenter.fine-grained 的 data-allowed-write 才是真正决定成败的一项。没有它,后面即便私有 API 返回成功,CommCenter 也可以不认账;有了它,写策略才是一次合法的操作,而不是越权。

另外还声明了 com.apple.private.tcc.allow 中的 kTCCServiceNetwork 等条目,作用是让系统在涉及网络类 TCC 服务时跳过询问——系统认识这些服务就免弹窗,不认识也不会产生副作用。

第二层:让 entitlements 真正生效

entitlements.xml 只是一个文件,必须由签名把它编码进二进制,内核校验时才会把它当作权限。具体位置是 Mach-O 里 LC_CODE_SIGNATURE 指向的签名数据,其中的 entitlements blob 以明文 plist 存放;用 ldid -e <binary> 或 codesign -d --entitlements :- <binary> 就能把这段内容读出来。

这个项目的做法是构建阶段完全不签名,打包阶段统一补签:

1
2
3
4
5
6
7
8
9
10
11
# Application/build_ios.sh
xcodebuild clean build \
-project BDTools.xcodeproj \
-scheme BDTools \
-configuration Release \
-arch arm64 \
-sdk iphoneos \
CONFIGURATION_BUILD_DIR=$(PWD)/output \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
1
2
3
4
5
6
# Makefile 的 internal-stage 阶段
rsync -av --delete $(XCODE_APP_PATH) $(THEOS_STAGING_DIR)/Applications/
rm -rf $(THEOS_STAGING_DIR)/Applications/BDTools.app/_CodeSignature
rm -f $(THEOS_STAGING_DIR)/Applications/BDTools.app/embedded.mobileprovision
ldid -S Application/entitlements.xml \
$(THEOS_STAGING_DIR)/Applications/BDTools.app/BDTools

这里有两个不能漏的细节:

  1. **先删掉 _CodeSignature 和 embedded.mobileprovision**。Xcode 产物里残留的签名目录会让系统校验时以旧 entitlements 为准,ldid 写进去的内容不生效。
  2. **用 ldid -S 而不是 codesign**。codesign 也能做 ad-hoc 签名(codesign -s -),但它走的是苹果完整的签名体系,要照顾描述文件、Team ID、DER 编码等约束,改过包名或二进制后很容易被校验规则卡住;ldid 只做最基础的 ad-hoc 签名,可以把任意 entitlements 直接塞进去。

第三层:在第一个网络请求之前写入策略

抢时间窗口:绕开 @UIApplicationMain

用默认的 @UIApplicationMain,App 要等 AppDelegate 里才开始跑逻辑,而这时业务组件 Paginator 往往已经发出了第一个列表请求。于是项目改成了手动启动:

1
2
3
4
5
6
7
8
9
10
// main.swift
// 在 UIApplicationMain 之前执行修复
SGNetworkConfigurationModifier.fix()

UIApplicationMain(
CommandLine.argc,
CommandLine.unsafeArgv,
nil,
NSStringFromClass(AppDelegate.self)
)

@main 与 @UIApplicationMain 同样会生成入口,但它们都等价于「在 UIApplicationMain 内部启动」。只有 main.swift 的顶层代码能确定运行在 UIApplicationMain 之前,fix() 放在这里,可以保证任何 URLSession 都不可能在策略就位之前被创建。

写策略:CoreTelephony 私有 API

fix() 只是入口,内部转调同类的 resolveNetworkProblmeForAppWithBundleId:,逻辑都在后者里。私有符号在编译期不可见,所以用 dlopen + dlsym 动态解析,既不引头文件,也不产生链接依赖(省略了 @try/@catch 与日志):

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
// SGNetworkConfigurationModifier.m
+ (void)resolveNetworkProblmeForAppWithBundleId:(NSString *)bundleId {
typedef void* (*CTServerConnectionCreateType)(CFAllocatorRef, void *, void *);
typedef int64_t (*CTServerConnectionSetCellularUsagePolicyType)(void *, NSString *, NSDictionary *);

void *h = dlopen("/System/Library/Frameworks/CoreTelephony.framework/CoreTelephony", RTLD_LAZY);
if (!h) return; // 框架不存在,放弃修复

CTServerConnectionCreateType create =
(CTServerConnectionCreateType)dlsym(h, "_CTServerConnectionCreate");
CTServerConnectionSetCellularUsagePolicyType setPolicy =
(CTServerConnectionSetCellularUsagePolicyType)dlsym(h, "_CTServerConnectionSetCellularUsagePolicy");
if (!create || !setPolicy) return; // 符号被改名或移除

void *ct = create(kCFAllocatorDefault, NULL, NULL);
if (!ct) return;

NSDictionary *policies = @{
@"kCTCellularDataUsagePolicy": @"kCTCellularDataUsagePolicyAlwaysAllow",
@"kCTWiFiDataUsagePolicy": @"kCTCellularDataUsagePolicyAlwaysAllow",
};
int64_t result = setPolicy(ct, bundleId, policies);
if (result != 0) {
NSLog(@"set policy failed: %lld", result);
}
}

注意三处写法:

  • 符号名带下划线前缀(_CTServerConnectionCreate),这是 C 符号在 dyld 里的真实名字;
  • 策略值用的是字符串常量名,不是枚举整数;
  • 返回值为 0 才算成功,非 0 说明策略没被采纳。

整个函数包在 @try/@catch 里,失败只打日志,绝不阻塞启动。

CoreTelephony 这一段参考 TrollStore 的 chineseWifiFixup()(作者 opa334,MIT):符号名、调用参数与策略字典一致;本项目改为 dlopen + dlsym 动态解析,并补上判空、返回值判断与 @try。

两个容易出错的细节:bundleId 必须与 CFBundleIdentifier 严格一致,改包名时要同步改;修复要在每次启动时重跑一遍,写策略是幂等的,但能覆盖用户事后手动关闭的情况。