
CVE-2026-43783에 대한 Proof-of-concept 및 기술 분석 문서로, DesktopServicesHelper XPC를 통한 임의 chown으로 root 권한을 획득하는 macOS 로컬 권한 상승 취약점입니다.
원문 게시: PT SWARM 또는 andrd3v.github.io
Finder가 iCloud 파일의 권한을 "복구"하는 데 사용하는 데몬이 시스템 디렉터리를 포함한 무엇이든 권한을 복구하려 한다는 사실이 밝혀졌습니다. 이 글에서는 DesktopServicesHelper가 내부적으로 어떻게 동작하는지, 그리고 왜 단 하나의 XPC 요청만으로 루트 권한을 얻을 수 있었는지 살펴봅니다.
저는 kTCCServiceSystemPolicyAllFiles 값을 가진 com.apple.private.tcc.allow를 보유한 대상들을 찾고 있었는데, 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
}
}
클라이언트가 샌드박스 환경인 경우 - 요청 이름이 일곱 개 문자열로 구성된 허용 목록과 대조됩니다. 일치하면 검사를 통과하여 요청이 진행됩니다. 일치하지 않으면 sub_100034250이 데몬을 종료합니다.
클라이언트가 샌드박스 환경이 아닌 경우 - 데몬은 값이 kTCCServiceSystemPolicyAllFiles인 com.apple.private.tcc.allow 엔타이틀먼트를 확인합니다. 엔타이틀먼트가 없는 경우 역시 종료됩니다.
검사를 통과한 후, 요청은 두 개의 핸들러 테이블을 거칩니다. 먼저 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;
}
여기서 흥미로운 점은 없습니다. 여덟 개의 핸들러(OperationSizing, SetChildPermissions, ...)가 모두 자격(entitlement) 검사 뒤에 제한되어 있습니다.
하지만 두 번째 테이블이 있습니다 - v27 = sub_10002A194(a1: buf):