
CVE-2026-43783の概念実証および技術解説。DesktopServicesHelper XPCの任意chownを悪用してroot権限を取得するmacOSのローカル権限昇格脆弱性。
初出は PT SWARM または andrd3v.github.io
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 サービスを登録し、受信接続の待ち受けを開始します。この名前を知っているプロセスは誰でも接続してリクエストを送信できます。ここが重要な部分です:
// 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 という名前で登録されることを意味します。
const char *__fastcall sub_1000709F4(int a1)
{
if ( a1 != 0 )
return "com.apple.DesktopServicesScriptingHelper";
else
return "com.apple.DesktopServicesHelper";
}
stru_1000B4B48 は、デーモンが受信イベントごとに呼び出すハンドラブロック(コールバック)です。XPC はメッセージベースです。クライアントはキーと値を持つ辞書を構築してデーモンに送信し、デーモンはその内容を解析して何を行うかを決定します。受信するすべてのイベントは最終的にこのハンドラに到達します。内部を見てみましょう。

このブロックの invoke 関数は sub_100032044 です。新しい接続が確立されると、デーモンはまずクライアントの euid と egid(実効ユーザー ID とグループ ID、つまり接続元プロセスが実行されているユーザーとグループの識別子)を取得します:
euid = xpc_connection_get_euid(connection: v8);
egid = xpc_connection_get_egid(connection: v8);
sub_100071268(a1: euid, a2: egid);
次に、接続上の受信メッセージに対するハンドラを割り当てます:
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" 文字列を取得します。これはクライアントが実行したいコマンド名です:
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のアプリケーション分離機構です。ここで処理が分岐します:
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
}
}
クライアントがサンドボックス化されている場合、リクエスト名は7つの文字列の許可リストと照合されます。一致すればチェックは通過し、リクエストは通されます。一致しなければ、sub_100034250 がデーモンを強制終了します。
クライアントがサンドボックス化されていない場合、デーモンは値 kTCCServiceSystemPolicyAllFiles を持つ com.apple.private.tcc.allow エンタイトルメントをチェックします。エンタイトルメントがない場合も同様に終了します。
チェックを通過した後、リクエストは2つのハンドラテーブルを順に通過します。まず sub_10002F7C8、次に sub_10002A194 です:
// 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)は次のとおりです。
__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;
}
ここには特に興味深いものはありません。8つのハンドラ(OperationSizing、SetChildPermissions、...)はすべて、エンタイトルメントチェックの背後で制御されています。
しかし、2つ目のテーブルがあります - v27 = sub_10002A194(a1: buf):