
Elevar un error en el registro de un controlador de Windows a una operación de escritura en el kernel
FUENTE
The Trail of Bits Blog
DATE
READ
14 min de lectura
El Manual de Pruebas ha introducido una lista de verificación de seguridad para C/C++ y ha presentado desafíos relacionados con un programa de ping de Linux y un manejador de registro del sistema Windows. Se invitó a los …
Recientemente añadimos una lista de verificación de seguridad de C/C++ al Manual de Pruebas y desafiamos a los lectores a detectar los errores en dos ejemplos de código: un programa de ping en Linux sorprendentemente simple y un manejador del registro de controladores de Windows. Si encontraste la trampa del búfer global inet_ntoa o la bandera RTL_QUERY_REGISTRY_TYPECHECK ausente, buen trabajo. Si no, aquí tienes un recorrido completo de ambos desafíos, además de un análisis en profundidad de cómo la confusión de tipos del registro de Windows escala de una denegación local de servicio a un primitivo de escritura en el kernel. Desde que lanzamos por primera vez la nueva lista de verificación de seguridad de C/C++, también desarrollamos una nueva habilidad de Claude, c-review. Convierte la lista de verificación en indicaciones para encontrar errores que un modelo grande de lenguaje (LLM) puede usar contra un código base. También es consciente de la plataforma y del modelo de amenazas. Ejecuta estos comandos para instalar la habilidad: claude skills add-marketplace https://github.com/trailofbits/skills claude skills enable c-review –marketplace trailofbits/skills
El desafío del programa de ping en Linux
El desafío de calentamiento de Linux que te mostramos en la última entrada del blog tiene un evidente problema de inyección de comandos. #include #include #include #include #define ALLOWED_IP 127.3.3.1 int main() { char ip_addr[128]; struct in_addr to_ping_host, trusted_host; // obtener la dirección si (!fgets(ip_addr, sizeof(ip_addr), stdin)) return 1; ip_addr[strcspn(ip_addr,
)] = 0; // verificar dirección si (!inet_aton(ip_addr, &to_ping_host)) return 1; char *ip_addr_resolved = inet_ntoa(to_ping_host); // evitar SSRF if ((ntohl(to_ping_host.s_addr) » 24) == 127) return 1; // solo permitido si (!inet_aton(ALLOWED_IP, &trusted_host)) return 1; char *trusted_resolved = inet_ntoa(trusted_host); if (strcmp(ip_addr_resolved, trusted_resolved) != 0) return 1; // ping char cmd[256]; snprintf(cmd, sizeof(cmd), ping ‘%s’, ip_addr); system(cmd); return 0; } There are three validations that have to be bypassed before the system call can be reached with malicious inputs: The inet_aton function “converts the Internet host address from the IPv4 numbers-and-dots notation into binary form” and “returns nonzero if the address is valid, zero if not.” Theoretically, if we provide an invalid IPv4 string as input, then the program should return early. The ntohl call aims to prevent server-side request forgery (SSRF) attacks by disallowing addresses in 127.0.0.0/8 range. The parsed IP address is normalized with an inet_ntoa call and compared against the ALLOWED_IP. We are only allowed to ping localhost, which should not be possible given the SSRF check (making the code effectively broken with this configuration). The issue with the inet_aton function is that it accepts trailing garbage. This behavior is not documented on its man page, making it a likely source of vulnerabilities. In our challenge, one can simply send “127.0.0.1 ‘; anything #” as valid input. The gotcha with inet_ntoa is that it returns a pointer to a global buffer. Therefore, subsequent calls to the function overwrite previous outputs. In the challenge, ip_addr_resolved and trusted_resolved are the same pointer. When we provide “1.2.3.4” as input, ip_addr_resolved points to the string “1.2.3.4”, the SSRF check passes, the second call to inet_ntoa makes the ip_addr_resolved pointer point to “127.3.3.1”, and so the strcmp check passes too. There are a few more functions that return pointers to static buffers; these are documented in the new C/C++ Testing Handbook chapter.
El desafío del registro del controlador de Windows Mostramos este manejador de solicitudes del Windows Driver Framework (WDF) de un controlador de Windows y les pedimos que identificaran los errores.
NTSTATUS InitServiceCallback( In WDFREQUEST Request ) { NTSTATUS status; PWCHAR regPath = NULL; size_t bufferLength = 0; // obtener la ruta del registro del producto desde la solicitud status = WdfRequestRetrieveInputBuffer(Request, 4, ®Path, &bufferLength); if (!NT_SUCCESS(status)) { TraceEvents( TRACE_LEVEL_ERROR, TRACE_QUEUE, %!FUNC! Failed to retrieve input buffer. Status: %d, (int)status ); return status; } /* comprobar que el tamaño del búfer es una cadena Unicode (UTF-16) nula-terminada de un tamaño razonable */ if (bufferLength < 4 || bufferLength > 512 || (bufferLength % 2) != 0 || regPath[(bufferLength / 2) - 1] != L’\0’) { TraceEvents( TRACE_LEVEL_ERROR, TRACE_QUEUE, %!FUNC! Buffer length %d was incorrect., (int)bufferLength ); return STATUS_INVALID_PARAMETER; } ProductVersionInfo version = { 0 }; HandlerCallback handlerCallback = NewCallback; int readValue = 0; // leer la versión mayor desde el registro RTL_QUERY_REGISTRY_TABLE regQueryTable[2]; RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2); regQueryTable[0].Name = LMajorVersion; regQueryTable[0].EntryContext = &readValue; regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT; regQueryTable[0].QueryRoutine = NULL; status = RtlQueryRegistryValues( RTL_REGISTRY_ABSOLUTE, regPath, regQueryTable, NULL, NULL ); if (!NT_SUCCESS(status)) { TraceEvents( TRACE_LEVEL_ERROR, TRACE_QUEUE, %!FUNC! Failed to query registry. Status: %d, (int)status ); return status; } TraceEvents( TRACE_LEVEL_INFORMATION, TRACE_QUEUE, %!FUNC! Major version is %d, (int)readValue ); version.Major = readValue; if (version.Major < 3) { // versiones anteriores a 3.0 necesitan verificación adicional RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2); regQueryTable[0].Name = LMinorVersion; regQueryTable[0].EntryContext = &readValue; regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT; regQueryTable[0].QueryRoutine = NULL; status = RtlQueryRegistryValues( RTL_REGISTRY_ABSOLUTE, regPath, regQueryTable, NULL, NULL ); if (!NT_SUCCESS(status)) { TraceEvents( TRACE_LEVEL_ERROR, TRACE_QUEUE, %!FUNC! Failed to query registry. Status: %d, (int)status ); return status; } TraceEvents( TRACE_LEVEL_INFORMATION, TRACE_QUEUE, %!FUNC! Minor version is %d, (int)readValue ); version.Minor = readValue; if (!DoesVersionSupportNewCallback(version)) { handlerCallback = OldCallback; } } SetGlobalHandlerCallback(handlerCallback); }
La intención del código es leer cierta información de la versión del software desde el registro usando la API RtlQueryRegistryValues, y luego seleccionar una de dos posibles funciones de devolución de llamada dependiendo de esa información de versión. Una ruta de registro controlada por el atacante El primer fallo es que la ruta a la clave de registro se proporciona en la solicitud, sin validar la cadena de ruta ni comprobar que el llamante esté autorizado para acceder a la clave de registro especificada. Esto significa que cualquiera que pueda llamar a este manejador puede elegir qué clave de registro se lee, incluso si normalmente no tendría acceso a esa clave. Cómo se interpreta esta cadena de ruta depende del parámetro RelativeTo de la llamada a RtlQueryRegistryValues. En este caso, RelativeTo está establecido en RTL_REGISTRY_ABSOLUTE, lo que significa que la ruta se tratará como una ruta absoluta a un objeto de clave de registro (p. ej., \Registry\User\CurrentUser). Hay dos razones principales por las que esto es un posible problema de seguridad. Primero, si un atacante puede controlar qué clave de registro se lee, puede apuntarla a una clave de registro cuyo contenido controla, lo que les permite manipular aún más el comportamiento del controlador. Esto puede provocar inconsistencias lógicas (p. ej., que se establezca la devolución de llamada incorrecta) o, como veremos pronto, habilitar la explotación de problemas de seguridad en otras partes del código. Segundo, esto habilita un ataque de deputy confundido que puede usarse para filtrar información de registro que normalmente sería inaccesible para el usuario debido a los controles de acceso. Por ejemplo, una clave de registro podría tener una DACL aplicada que impide a los usuarios normales enumerar sus subclaves o leer cualquiera de los valores dentro de esas claves. Ya que el manejador no verifica si la llamada tiene derechos suficientes para leer la clave, y el código emite un mensaje de trazas y devuelve el código de estado de RtlQueryRegistryValues, puede usarse como un oráculo para verificar la existencia de cualquier clave de registro. También puede usarse para filtrar cualquier valor de registro llamado MajorVersion (y a veces también MinorVersion) en cualquier lugar del registro, pero es poco probable que esto sea particularmente útil en la práctica. Faltas de comprobaciones de tipo con RTL_QUERY_REGISTRY_DIRECT Los fallos más serios en este caso provienen de las banderas configuradas en las estructuras RTL_QUERY_REGISTRY_TABLE. La API RtlQueryRegistryValues toma una matriz de estas estructuras, terminada por una entrada de ceros, para describir qué valores de registro deben leerse de la clave especificada y cómo deben procesarse y devolverse. Hay dos modos de operación principales aquí: devolución de llamada (callback) o directo. En el modo de callback, que es el predeterminado, el campo QueryRoutine de la estructura apunta a una función de devolución de llamada que recibe el valor leído del registro. En el modo directo, el campo QueryRoutine se ignora y el valor se escribe directamente en un búfer cuya ubicación se pasa en el campo EntryContext. El modo directo se selecciona al incluir RTL_QUERY_REGISTRY_DIRECT en el campo Flags. En nuestro ejemplo, el valor MajorVersion se lee usando el siguiente código: HandlerCallback handlerCallback = NewCallback; int readValue = 0; // leer la versión mayor desde el registro RTL_QUERY_REGISTRY_TABLE regQueryTable[2]; RtlZeroMemory(regQueryTable, sizeof(RTL_QUERY_REGISTRY_TABLE) * 2); regQueryTable[0].Name = LMajorVersion; regQueryTable[0].EntryContext = &readValue; regQueryTable[0].Flags = RTL_QUERY_REGISTRY_DIRECT; regQueryTable[0].QueryRoutine = NULL; status = RtlQueryRegistryValues( RTL_REGISTRY_ABSOLUTE, regPath, regQueryTable, NULL, NULL ); Aquí, RTL_QUERY_REGISTRY_DIRECT se usa para seleccionar modo directo, y el búfer apunta a readValue, que es una variable entera en la pila. Podrías notar algo importante, sin embargo: en ningún momento el código ha especificado qué tipo de valor se está leyendo, ni ha especificado el tamaño del búfer. Es claro por el contexto que este código espera leer un REG_DWORD, pero ¿qué pasa si el valor MajorVersion no es un REG_DWORD? Un primer intento de explotación Intentemos explotar esto usando un REG_QWORD. Un valor REG_DWORD es un entero sin signo de 32 bits, mientras que un REG_QWORD es un entero sin signo de 64 bits, por lo que si hacemos que MajorVersion sea un valor REG_QWORD en su lugar, entonces deberíamos poder sobrescribir cuatro bytes inmediatamente después de readValue en la pila. Como HKEY_CURRENT_USER es escribible por usuarios de bajo privilegio, podemos crear una clave allí, colocar un valor REG_QWORD llamado MajorVersion allí y pasar la ruta de esa clave al controlador. Y, ¡éxito, obtenemos una pantalla azul (BSOD)! Excepto… no es exactamente lo que queríamos. El código de verificación de errores es KERNEL_SECURITY_CHECK_FAILURE, que no es realmente lo que esperaríamos si hubiéramos sobrescrito con éxito parte de la pila. ¿Por qué está pasando esto? La respuesta está en la documentación: A partir de Windows 8, si una llamada a RtlQueryRegistryValues accede a una hive no confiable y quien llama establece la bandera RTL_QUERY_REGISTRY_DIRECT para esta llamada, el llamante debe además establecer la bandera RTL_QUERY_REGISTRY_TYPECHECK. Una violación de esta regla por una llamada desde el modo usuario provoca una excepción. Una violación de esta regla por una llamada desde el modo kernel provoca un bugcheck 0x139 (KERNEL_SECURITY_CHECK_FAILURE). Solo las hives del sistema son de confianza. Una llamada a RtlQueryRegistryValues que accede a una hive del sistema no provoca una excepción ni un bugcheck si se establece la bandera RTL_QUERY_REGISTRY_DIRECT y no se establece la bandera RTL_QUERY_REGISTRY_TYPECHECK. Sin embargo, como buena práctica, la bandera RTL_QUERY_REGISTRY_TYPECHECK debería establecerse siempre si se establece la bandera RTL_QUERY_REGISTRY_DIRECT. Del mismo modo, en versiones de Windows anteriores a Windows 8, como buena práctica, una llamada a RtlQueryRegistryValues que establece RTL_QUERY_REGISTRY_DIRECT debería además establecer RTL_QUERY_REGISTRY_TYPECHECK. Sin embargo, no seguir esta recomendación no provoca una excepción ni un bugcheck. Este comportamiento de protección se introdujo como respuesta a MS11-011, en el que se informó por primera vez de este bug de confusión de tipos en el registro. En resumen, si intentas leer desde una hive de registro no confiable usando RtlQueryRegistryValues con RTL_QUERY_REGISTRY_DIRECT establecido pero sin establecer también RTL_QUERY_REGISTRY_TYPECHECK, entonces Windows levantará automáticamente un bugcheck para bloquear el sistema y evitar que la operación tenga éxito. La bandera RTL_QUERY_REGISTRY_TYPECHECK permite al llamante especificar un tipo esperado como parte de la entrada de la tabla de consultas, mitigando así el bug de confusión de tipos. Dado que esta bandera no está establecida en nuestro ejemplo, se activará un bugcheck si intentamos leer desde cualquier hive de registro que no sean las siguientes hives del sistema de confianza: \REGISTRY\MACHINE\HARDWARE \REGISTRY\MACHINE\SOFTWARE \REGISTRY\MACHINE\SYSTEM \REGISTRY\MACHINE\SECURITY \REGISTRY\MACHINE\SAM HKEY_CURRENT_USER no está incluido dentro de este conjunto, lo que explica por qué vimos el bugcheck KERNEL_SECURITY_CHECK_FAILURE cuando intentamos explotarlo de esa manera. Esto nos degrada de una potencial vulnerabilidad de escalamiento de privilegios del kernel a una denegación de servicio local. Aún es un fallo, pero no tan emocionante.
Encontrar claves escribibles en hives de confianza Sin embargo, ¿quién dice que no podemos escribir valores en algún lugar dentro de estos hives de confianza? Todo lo que se necesita es una clave dentro de uno de esos hives con una DACL que permita a un usuario de menor privilegio escribir en ella. Encontrar estas no es muy difícil; el módulo PowerShell NtObjectManager tiene un comando llamado Get-AccessibleKey que es perfecto para la tarea: Get-AccessibleKey \Registry\Machine -Recurse -Access SetValue Este comando busca recursivamente dentro del espacio de nombres de objetos \Registry\Machine claves para las que el proceso actual tiene permiso para establecer valores. Ejecutarlo como un usuario normal de escritorio devuelve miles de opciones que se pueden escribir sin elevación de UAC. Genial. Sin embargo, para sumar puntos de estilo, podemos ir un paso más allá. El control de integridad obligatorio (MIC), una de las características clave de control de acceso en Windows que sustenta UAC, permite que los procesos se ejecuten con privilegios más altos o más bajos de los que normalmente se asignarían al usuario que los ejecutó. La mayoría de los procesos de escritorio se ejecutan a nivel de integridad medio (IL). Elevar un proceso mediante UAC (a menudo llamado “ejecutar como administrador”) normalmente incrementa el IL del proceso a alto. También existe un IL bajo, que a menudo se usa para aislar ciertos procesos por razones de seguridad, limitando significativamente los recursos a los que pueden acceder. Cualquier objeto asegurable en Windows puede tener una etiqueta obligatoria aplicada a su lista de control de acceso al sistema (SACL), y esa etiqueta obligatoria especifica los ILs que están permitidos para acceder al objeto. La SACL se verifica antes de la DACL, lo que significa que la verificación de IL debe pasar incluso si la DACL normalmente otorgaría permisos al usuario para acceder al objeto. Esto significa que un proceso que se ejecuta con un token de seguridad de integridad baja no puede acceder a un objeto de integridad media, y un proceso que se ejecuta con un token de integridad medio no puede acceder a un objeto de integridad alta. Entonces, ¿podemos encontrar casos en los que podamos escribir en una de las hives del sistema de confianza desde un proceso de baja integridad? Para verificar claves que sean accesibles con una IL baja, lo primero que queremos hacer es duplicar el token de nuestro proceso y aplicar a él una etiqueta de integridad baja: $token = Get-NtToken -Primary -Duplicate -IntegrityLevel Low Esto nos da una copia del token de seguridad de nuestro proceso actual que se comporta como si estuviéramos ejecutando con una IL baja. Usándolo, volvemos a ejecutar el escaneo, pasando ese token modificado: Get-AccessibleKey \Registry\Machine -Recurse -Access SetValue -Token $token Esto realmente devuelve algunos resultados, tanto en Windows 10 como en 11. Aquí están dos de los más interesantes: \REGISTRY\MACHINE\SOFTWARE\Microsoft\DRM \REGISTRY\MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\PlayReady\Troubleshooter Ambas claves permiten que un token de baja integridad escriba en ellas. La DACL de la clave DRM tiene permisos bastante complejos, pero otorga el permiso Set Value al grupo Everyone. La DACL de la clave PlayReady\Troubleshooter concede Control Total a Users, ALL APPLICATION PACKAGES y ALL RESTRICTED APP PACKAGES. Cualquiera de estas dos claves puede usarse para plantar valores de registro controlados dentro de una hive del sistema de confianza desde un nivel de privilegio bajo. (Nota: si el endpoint de solicitud del controlador puede llamarse desde una IL baja es un asunto distinto, pero esto es solo por diversión y para sumar puntos de estilo, así que ignoremos eso por ahora). Si establecemos un valor REG_QWORD llamado MajorVersion en la clave DRM, y pasamos la ruta de esa clave al manejador WDF, ahora podemos sobrescribir cuatro bytes de la pila más allá del final de readValue con valores que controlamos. Dado que handlerCallback fue declarado junto a readValue, existe la posibilidad de que podamos sobrescribir la mitad de ese puntero de función. Si esa devolución de llamada se invoca más adelante, obtenemos control parcial sobre el puntero de instrucción, lo que es una primitiva bastante fuerte para la escalada de privilegios en el kernel (LPE). Sin embargo, esto depende de la alineación de la pila, y no sería sorprendente si la variable readValue de 32 bits terminara alineada a 64 bits, dejando un hueco, por lo que este enfoque podría no avanzar mucho en la práctica. ¿Podemos hacer algo mejor? ¿Crees que puedes? ¡Ponte en contacto! Nos encantaría escuchar tus consejos y trucos.
Tu turno Estos desafíos apenas rascan la superficie de lo que cubre el capítulo de Pruebas de C/C++ del Manual de Pruebas, desde escapes de sandbox con seccomp hasta la traversa de rutas de Windows a través de errores Unicode WorstFit. Lee el capítulo y sigue la lista de verificación contra un código base que conozcas bien. Combínalo con una ejecución de la habilidad c-review, si te apetece. Si encuentras un patrón que aún no hemos documentado, abre un PR. Nos encantaría especialmente escuchar a cualquiera que haya encontrado un camino de explotación más limpio para el desafío del controlador que los que mostramos aquí. Y, como siempre, si necesitas ayuda para asegurar tus sistemas C/C++, contáctanos.