









讨论 Dopamine roothide 时,三个项目地址经常被混淆。它们实际指向两个不同的项目:
| 地址 | 实际身份 | RootHide | 越狱痕迹 |
|---|---|---|---|
| github.com/opa334/Dopamine | 原版 Dopamine 的源码仓库(opa334 开发的 rootless 越狱) | ❌ | 固定 /var/jb 路径,任何进程可见 |
| ellekit.space/dopamine | 原版 Dopamine 的官方网站(下载入口) | ❌ | 同上——与源码仓库是同一个项目 |
| github.com/roothide/Dopamine2-roothide | roothide 团队对 Dopamine 2.x 的魔改 fork | ✅ | 随机 jbroot + 选择性注入 |
三者的关系:
opa334/Dopamine(原版源码)
│
│ 官方网站(同一项目,托管在 ElleKit 域名下,
│ 下载按钮直接指向 opa334/Dopamine/releases)
▼
ellekit.space/dopamine
opa334/Dopamine(原版源码)
│
│ fork + 加入 RootHide(jbroot 随机化、
│ 选择性注入、用户态路径翻译)
▼
roothide/Dopamine2-roothide ← 本文分析对象
结论:
opa334/Dopamine 的 README 明确标注 "Official website / download: https://ellekit.space/dopamine/"。官网恰好托管在 ElleKit(Dopamine 默认的 tweak 注入库)的域名下,但它分发的就是原版 IPA,不是 roothide 版本。/var/jb 路径全局可见、无选择性注入,第 1.1 节中 access("/var/jb", F_OK) 这一条检测对它即可生效。roothide/Dopamine2-roothide 实现了本文描述的全部隐藏机制。需要抗越狱检测能力时,必须从 roothide/Dopamine2-roothide/releases 下载,而不是官网。早期越狱(如 evasi0n、unc0ver、checkm8 系列)的特征非常明显:
固定路径:
/var/jb/ ← 越狱根目录
/var/jb/usr/lib/libsubstrate.dylib ← Substrate 注入库
/Library/MobileSubstrate/ ← Tweak 配置目录
/var/jb/usr/lib/TweakInject/ ← Tweak 注入目录
检测只需:
// 检查固定路径
if (access("/var/jb", F_OK) == 0) { /* 越狱存在 */ }
if (access("/Library/MobileSubstrate/MobileSubstrate.dylib", F_OK) == 0) { /* 越狱存在 */ }
// 检查固定进程
if (pidof("substituted") > 0) { /* 越狱存在 */ }
// 检查 mach 服务
mach_port_t port;
if (bootstrap_look_up(bootstrap_port, "cy:com.saurik.Cydia", &port) == 0) { /* 越狱存在 */ }
问题:越狱痕迹以固定路径、固定名称存在于文件系统和进程空间中,任何进程都可以检测。
Dopamine(1.x/2.x)、palera1n-rootless 等引入了"无根"概念。需要澄清动机:rootless 是为了适配 iOS 只读系统卷(SSV),不是为了对抗越狱检测——iOS 15+ 系统分区签名只读、无法写入,越狱文件只能放到用户可写区域,tweak 也必须重编译为 rootless 格式。
原版 Dopamine 的实际布局(源码 DOEnvironmentManager.m / DOBootstrapper.m):
真实 jbroot:/private/preboot/<UUID>/dopamine-<6位随机>/procursus/ ← 随机目录名
/var/jb → 指向 jbroot 的全局 symlink ← 固定存在,任何进程可见
// 原版 DOBootstrapper.m:每次安装都固定重建全局 symlink
// Remove /var/jb as it might be wrong
[self deleteSymlinkAtPath:@"/var/jb" error:&error];
...
error = [self createSymlinkAtPath:@"/var/jb" toPath:JBROOT_PATH(@"/") ...];
对检测者而言,原版 rootless Dopamine 的检测面一目了然:
/var/jb symlink 固定存在且全局可见:access("/var/jb", F_OK) 一条检测即可命中,根本不需要知道真实 jbroot 在哪里_dyld_get_image_name() 即可发现(对原版最可靠的流程级指纹)/usr/lib 上做伪装(源码 DOJailbreaker.m 的 applyProtection,UI 日志即 "Applying Bind Mount"),getmntinfo 中可见挂载项问题:rootless 解决的是"往哪里写文件",而不是"如何不被发现"——固定 symlink、全进程注入、bind mount 对检测者全部可见。
RootHide 的核心目标是:让不同进程看到不同的系统环境。
┌─────────────────────────────────────────────────┐
│ 同一台设备 │
│ │
│ 被注入的进程 未被注入的进程 │
│ (如 mediaserverd) (如银行 App) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 看到越狱环境 │ │ 看到正常环境 │ │
│ │ tweak 生效 │ │ 无任何越狱痕迹 │ │
│ │ 路径被翻译 │ │ 路径正常 │ │
│ │ 文件系统被修改 │ │ 文件系统正常 │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ 两者运行在同一内核上,但用户态环境完全隔离 │
└─────────────────────────────────────────────────┘
这解决了两个核心矛盾:
RootHide 的抗检测能力来自三个层面的协同:
第一重:随机 jbroot 路径
↓
你不知道越狱文件在哪
第二重:iOS App Sandbox
↓
你不能枚举父目录去找
第三重:选择性注入
↓
目标 App 根本没被注入,进程空间完全干净
从源码 generate_sandbox_extensions() 可以看到 jbroot 的实际路径格式:
// BaseBin/libjailbreak/src/roothider/common.m
snprintf(jbroot_base, sizeof(jbroot_base),
"/private/var/containers/Bundle/Application/.jbroot-%016llX/",
jbinfo(jbrand));
%016llX 是一个 64 位随机值(jbrand),每次越狱重新生成.jbroot- 为前缀,伪装成隐藏文件示例:
/private/var/containers/Bundle/Application/.jbroot-CFB53A009C72E54B/
/private/var/containers/Bundle/Application/.jbroot-A46D62A9AA6B0DCE/
iOS App Sandbox 限制了 App 对文件系统的访问:
普通 App 只能看到:
/private/var/containers/Bundle/Application/
└── <自己的UUID>/ ← 只能看到自己
不能看到:
/private/var/containers/Bundle/Application/
├── <其他App UUID>/
├── <其他App UUID>/
└── .jbroot-XXXXXXXX/ ← 看不到这个
readdir() 对父目录会返回空或错误。App 无法枚举兄弟目录来发现 .jbroot-*。
从源码 roothide_launchd___posix_spawn_prehook() 可以看到注入决策逻辑:
// BaseBin/launchdhook/src/roothider.m
bool roothideBlacklisted = isBlacklistedPath(path);
if (roothideBlacklisted)
{
// 黑名单 App:不注入 systemhook
// 进程完全干净
envbuf_unsetenv(&envc, "_SafeMode");
envbuf_unsetenv(&envc, "_MSSafeMode");
ret = __posix_spawn_orig_wrapper(blacklistedPidp, path, desc, argv, envc);
}
else
{
// 非黑名单 App:正常注入
return __posix_spawn_hook(pidp, path, desc, argv, envp);
}
注入决策由 isBlacklistedPath(path) 做出(上方代码块所示)——它检查用户在 RootHide 管理器中手动配置的 RootHideConfig.plist(appconfig 字典)。配置文件不存在时返回 false = 默认所有进程都被注入,只有用户手动添加的 App 才走原版 posix_spawn 不注入。
is_safe_bundle_identifier() 不参与注入决策,它的实际用途是在 xpc_hook.m 中对已被黑名单的进程隐藏越狱相关的 job/coalition 信息:
bool is_safe_bundle_identifier(const char* identifier)
{
// com.apple.* 开头的是安全的(Apple 自家应用)
if (string_has_prefix(identifier, "com.apple.")) {
if (!is_apple_internal_identifier(identifier)) {
return true;
}
}
// 已知的 App Store 应用 Bundle ID
if ([StoredAppIdentifiers containsObject:@(identifier)]) {
return true;
}
return false;
}
结果:默认配置下所有 App(包括 App Store 应用)都被注入 systemhook;只有用户主动加入黑名单的 App 才不被注入,其进程空间完全干净。
与之前的越狱不同,RootHide 不在内核层做 bind mount。
从实际设备的 getmntinfo 输出来看,挂载表完全干净:
apfs / disk1s1
devfs /dev devfs
apfs /private/preboot disk1s6
apfs /private/var disk1s2
bindfs /usr/standalone/firmware preboot/... ← Apple 自己的
bindfs /System/Library/Pearl/... hardware/... ← Apple 自己的
没有 /usr/lib 的 bind mount,没有 .jbroot- 相关路径。
原因:RootHide 的路径翻译在用户态完成(通过 systemhook 注入后的 API hook),不需要内核层 bind mount。
对于被注入的进程,路径翻译流程如下:
App 调用 open("/usr/lib/libsubstrate.dylib", ...)
↓
systemhook 拦截 open()
↓
判断路径是否需要翻译
↓
翻译为 /private/var/.../jbroot-XXX/usr/lib/libsubstrate.dylib
↓
调用真实的 open()
↓
返回文件描述符
对于未被注入的进程:
App 调用 open("/usr/lib/libsubstrate.dylib", ...)
↓
没有 systemhook,直接走真实系统调用
↓
文件不存在(因为 libsubstrate.dylib 在 jbroot 里,不在 /usr/lib/)
↓
返回 ENOENT
关键:未被注入的 App 调用 access("/usr/lib/libsubstrate.dylib") 会得到 ENOENT,和正常设备一致。
┌─────────────────────────────────────────────────────────┐
│ 内核层 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ • 内核漏洞利用(物理内存读写) │ │
│ │ • Trust Cache 修改(允许未签名代码执行) │ │
│ │ • AMFI patch(代码签名验证绕过) │ │
│ │ • sysctl OID 交换(隐藏 developer_mode_status) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ launchd hook │
│ ┌──────────────────────────────────────────────────┐ │
│ │ • posix_spawn hook(进程启动拦截) │ │
│ │ • 黑名单判断(是否注入 systemhook) │ │
│ │ • DYLD_INSERT_LIBRARIES 设置 │ │
│ │ • Sandbox extension 发放 │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ systemhook(被注入的进程) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ • 文件系统 API hook(open/access/stat/readlink) │ │
│ │ • 路径翻译(真实路径 ↔ jbroot 路径) │ │
│ │ • Substrate/Substitute 加载 │ │
│ │ • Tweak 注入 │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 未被注入的进程 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ • 完全干净 │ │
│ │ • 无任何 hook │ │
│ │ • 看到正常系统环境 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
jbroot 路径在越狱激活时生成并存储在内核信息中:
// BaseBin/libjailbreak/src/jbroot.h
extern char *get_jbroot(void);
// BaseBin/libjailbreak/src/jbroot.c
char *get_jbroot(void) {
return jbinfo(rootPath); // 从内核信息中读取
}
路径格式固定为:
/private/var/containers/Bundle/Application/.jbroot-{jbrand}/
其中 jbrand 是 64 位随机数,每次越狱重新生成。
对于非黑名单进程,launchd 在 spawn 时设置 DYLD_INSERT_LIBRARIES(注入决策逻辑见 §3.1.3):
// BaseBin/systemhook/src/common.c
// 注入路径格式:
// /usr/lib/systemhook-{jbrand}.dylib
envbuf_setenv(&envc, "DYLD_INSERT_LIBRARIES", HOOK_DYLIB_PATH);
systemhook 加载后,hook 文件系统 API 实现路径翻译。
systemhook 通过 hook 以下函数实现路径翻译:
open() / openat()access() / faccessat()stat() / lstat() / fstatat()readlink() / realpath()opendir() / readdir()翻译逻辑:
// 伪代码
const char* translated_path = path;
// 检查路径是否在 jbroot 内
if (path_is_in_jbroot(path)) {
// 翻译为真实路径
translated_path = translate_to_real_path(path);
} else if (path_should_be_hidden(path)) {
// 路径应该被隐藏(如 /var/jb)
// 返回 ENOENT
errno = ENOENT;
return -1;
}
// 调用原始函数
return orig_open(translated_path, flags);
RootHide 的黑名单由用户在 RootHide 管理器中手动配置,存储在 RootHideConfig.plist 的 appconfig 字典中。注入决策由 isBlacklistedPath() 做出(见 §3.1.3):
posix_spawn,不注入is_safe_bundle_identifier()(§3.1.3)不参与注入决策,它的实际用途是在 xpc_hook.m 中对已被黑名单的进程隐藏越狱信息(job/coalition 等):
// BaseBin/libjailbreak/src/roothider/common.m
bool is_safe_bundle_identifier(const char* identifier)
{
// Apple 自家应用(com.apple.*)
if (string_has_prefix(identifier, "com.apple.")) {
if (!is_apple_internal_identifier(identifier)) {
return true;
}
}
// App Store 下载的应用(通过扫描 iTunesMetadata.plist 识别)
if ([StoredAppIdentifiers containsObject:@(identifier)]) {
return true;
}
return false;
}
StoredAppIdentifiers 在越狱激活时通过扫描所有 App 容器构建(提取有 iTunesMetadata.plist 的容器的 Bundle ID)。该列表用于识别 App Store 下载的应用,但仅服务于信息隐藏逻辑,不决定注入与否。
虽然 RootHide 在用户态做了大量隐藏工作,但以下修改是内核级的、全局生效的:
为隐藏 developer_mode_status(开发模式标志),RootHide 将其与 launch_env_logging 的 OID 交换。此操作仅在 iOS 16.0+ 执行(roothider.m:110 有 __builtin_available(iOS 16.0, *) 守卫,iOS 15 跳过整个隐藏逻辑):
// BaseBin/libjailbreak/src/roothider.common.m
void hideDeveloperMode()
{
// 从内核内存中读取两个 sysctl_oid 结构体
uint64_t developer_mode_status_oidp = ksymbol(developer_mode_status) - ...;
uint64_t launch_env_logging_oidp = ksymbol(launch_env_logging) - ...;
// 交换 oid_name(名称)
kwrite64(developer_mode_status_oidp + offsetof(oid_name), launch_env_logging.oid_name);
kwrite64(launch_env_logging_oidp + offsetof(oid_name), developer_mode_status.oid_name);
// 交换 oid_number(编号)
kwrite32(developer_mode_status_oidp + offsetof(oid_number), launch_env_logging.oid_number);
kwrite32(launch_env_logging_oidp + offsetof(oid_number), developer_mode_status.oid_number);
// 注意:oid_handler(处理函数)没有交换
}
效果:
sysctlbyname("developer_mode_status", ...) 返回 launch_env_logging 的数据sysctlbyname("launch_env_logging", ...) 返回 developer_mode_status 的数据RootHide 通过内核漏洞修改 trust cache,允许未签名代码执行:
// BaseBin/libjailbreak/src/trustcache.c
// 将 tweak dylib 的 cdhash 加入 trust cache
trustcache_file_upload_with_uuid(tcFile, uuid);
修改 Apple Mobile File Integrity (AMFI) 的行为,放宽代码签名验证。
核心结论:App 被用户加入 roothide 黑名单后,其进程内目前没有任何可靠方式能识别出 roothide 越狱——不是"特征还没找到",而是"可观测差异不存在":
posix_spawn,不注入 dylib、不 hook 函数、csflags 与正常进程物理一致(roothider.m:378-435)注:默认配置下所有 App(含 App Store 应用)都被注入;黑名单需用户在 RootHide APP 中手动添加,属定向对抗,此时把防线移到进程外(服务端风控、行为分析)是行业共识。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。