
Tutorial de CVE-2022-37969 centrado en la metodología de explotación del Kernel, no en las causas internas de la CVE
Esto fue creado para aclarar aspectos generales relacionados con la explotación de Windows. Explica conceptos básicos aplicados a CVE-2022-37969. El resultado final es un PoC funcional. No aclara todos los aspectos sobre el CVE, pero proporciona piezas de código reutilizables y explica mecanismos que se pueden encontrar en muchos exploits generales.
El usuario objetivo sería un ingeniero inverso principiante, un desarrollador de exploits que busca un código fuente de prueba de concepto funcional para probar y entender conceptos básicos de Internals de Windows. Proporciona un punto de referencia para un aprendizaje posterior.
Requisitos: Depuración básica del kernel, Ingeniería Inversa básica, conocimientos básicos de internals de Windows, habilidades de programación en c/c++
Un programa es un fragmento de código que se ejecuta en una máquina. Generalmente, un programa recibe datos (entrada), realiza cálculos con las entradas y genera datos (salida). La mayoría de los programas son escritos por humanos y, por lo tanto, tienen errores. Un error se genera por un código fuente que no fue escrito correctamente (el programador quería hacer algo con la entrada, el código resultante fue diferente del resultado deseado). La mayoría de los errores se corrigen antes de lanzar el producto, pero algunos permanecen. Esto sucede porque existen diferentes tipos de errores, algunos más difíciles de detectar que otros.
Windows es un programa de computadora, fue escrito por humanos y, por lo tanto, tiene errores. ¿Por qué es importante? Porque los sistemas Windows pueden ejecutar programas que manejan datos sensibles como cuentas bancarias, bases de datos de atención médica, entre otros. Algunos errores pueden utilizarse para obtener acceso ilegal a datos restringidos (este es un buen caso de uso para un exploit).
Existen múltiples tipos de errores, algunos útiles y otros no. Generalmente, los errores se generan por entradas al programa que, en conjunto con líneas de código escritas incorrectamente, producen un comportamiento o salida malformada del programa. Encontrar esa entrada es el trabajo del especialista en seguridad (o hacker). El siguiente paso es evaluar el comportamiento/salida malformada resultante y responder a la pregunta: "¿Se puede utilizar de manera útil?". Aquí es donde los errores se clasifican en diferentes categorías. Por ejemplo, un error puede generar un comportamiento que corrompe algunas estructuras de datos y provoca que la computadora objetivo se reinicie. Su utilidad es limitada. Un error puede hacer que la entrada se escriba en una zona de memoria que controla los permisos de acceso a archivos restringidos. Este tipo de error es más útil.
Entonces, del conjunto de todos los errores posibles, el hacker busca el subconjunto más útil para su propósito. En términos generales, el problema es: "¿Puedo darle al programa objetivo una entrada especialmente diseñada para no romper el sistema pero poder elevar mi nivel de acceso y beneficiarme?"
Después de esta introducción no técnica, se puede formular el alcance del tutorial: ¿Podemos encontrar un programa de Windows que acepte una entrada malformada y, como resultado de un código de desarrollador incorrecto, pueda elevar ilegalmente nuestros permisos de usuario normal a administrador?
Programa objetivo: Windows CLFS (Common Log File System Driver)
Nombre del exploit: CVE-2022-37969
Tipo: Escalada de Privilegios Local
DESCARGA DEL ISO VULNERABLE: Descargar aquí
El espacio de direcciones de Windows se divide aproximadamente entre el espacio de usuario (ejecución de programas generales) y el espacio de kernel (ejecución del sistema operativo en sí y de los componentes de hardware --> controladores). Un usuario normal no debe acceder al espacio de kernel, pero existen mecanismos mediante los cuales los programas de usuario normal pueden acceder a partes del código del kernel (llamadas al sistema, procedimientos de controladores). ¿Por qué necesitamos acceso? Para interactuar con el sistema operativo de manera segura y controlada, tal como lo proporcionan los diseñadores del sistema operativo.
Algunos controladores utilizan entradas de datos proporcionadas por el usuario para operar sobre estructuras de datos del espacio de kernel. Si la entrada genera un error, entonces el kernel podría corromperse. Un caso es el Common Log File System Driver. Al usar alguna entrada especial, podemos forzar al controlador a alterar estructuras de datos del kernel que contienen el nivel de acceso de privilegios para el usuario y sobrescribir usuario normal con administrador.
¿Qué necesita ser modificado para elevar el privilegio a administrador?
Comenzamos con el objetivo final en mente. Windows almacena dentro de una estructura de datos del kernel llamada _EPROCESS información para cada proceso que se ejecuta en el sistema. Ejemplo de _Eprocess
Un campo importante es struct _EX_FAST_REF Token. Esta es otra estructura de datos que apunta además a datos que hacen referencia al nivel de privilegio de ese proceso respectivo. En la siguiente imagen, el proceso System tiene un token de sistema y el proceso Explorer tiene un token de usuario normal.

Entonces, para elevar el privilegio de Explorer.exe necesitaríamos copiar el valor del Token de _EPROCESS de System al Token de _EPROCESS de Explorer. Lograremos algo similar copiando el Token de System en el Token de nuestro propio programa y lanzando un Símbolo del sistema desde el proceso elevado (los procesos hijos heredan el token del proceso padre).
Para completar estas acciones necesitamos mecanismos para:
Introducción: La naturaleza de Windows a lo largo de los años: Con el descubrimiento de nuevas vulnerabilidades, Windows necesitaba parches para mitigarlas. También, con la aparición de nuevas tecnologías, Windows necesitaba actualizaciones para mantenerse competitivo. Un requisito crucial era la compatibilidad hacia atrás con versiones anteriores. Y a veces la seguridad se lograba mediante la oscuridad. Las estructuras de datos, las definiciones de funciones se eliminaron de los manuales, pero la funcionalidad aún permanecía. Mediante ingeniería inversa, los investigadores pudieron usar esas funcionalidades para diversos fines.
Para encontrar la dirección del kernel de _EPROCESS, usaremos una función no documentada: NtQuerySystemInformation (Ver enlace para parámetros). Usando el parámetro SystemInformationClass podemos especificar qué tipo de información queremos recuperar. Recuperaremos información general del proceso especificando el valor SystemExtendedHandleInformation (#define SystemExtendedHandleInformation 0x40).
Una advertencia al usar NtQuerySystemInformation es que no sabemos de antemano cuál es la longitud de los datos devueltos, pero NtQuerySystemInformation tiene un mecanismo que ayuda. Si se llama con un arreglo de tamaño incorrecto para los datos requeridos, devuelve ERROR y el tamaño de datos correcto que debería haberse solicitado. Esto se puede usar para leer correctamente la Información del Proceso de la siguiente manera:
NtQuerySystemInformation con un parámetro SystemInformationLength ficticio.ReturnLength devuelto.NtQuerySystemInformation nuevamente con el valor correcto de SystemInformationLength devuelto anteriormente.La estructura de datos devuelta es de tipo PSYSTEM_HANDLE_INFORMATION_EX. Esta es una estructura de datos no documentada. (Ver enlace) que lleva a una estructura de datos SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, que contiene en el campo Object la dirección del kernel de la estructura _Eprocess para el proceso correspondiente.
Por lo tanto, la lógica sería iterar sobre todos los elementos PSYSTEM_HANDLE_INFORMATION_EX, comparar el campo UniqueProcessId de SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX con el PID del proceso deseado y seleccionar el campo Object correspondiente para encontrar su dirección _Eprocess en el kernel.
Un fragmento de código:

NtQuerySystemInformation se declara como un puntero a una función y su dirección se obtiene dinámicamente en tiempo de ejecución mediante loadlibrary y getprocaddress.
typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);ntdll=LoadLibrary(L"ntdll.dll");MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");Para leer y escribir el Token, tendremos que confiar en vulnerabilidades dentro de clfsw32.sys y NamedPipes.
Esto es algo así como una caja negra, detallada en otros artículos, pero se explicará el conocimiento mínimo para esta funcionalidad con el fin de obtener una comprensión básica del proceso del exploit. Los pipes son mecanismos de comunicación entre procesos. Los procesos pueden pasar información entre sí usando pipes. Los pipes se representan como estructuras de datos del Kernel que tienen algunos campos que pueden ser completados desde el espacio de usuario. Un ejemplo serían los Atributos de Pipe.
(Más adelante vincularemos esto con la vulnerabilidad de CLFS para obtener lectura/escritura arbitraria desde el espacio de usuario en el espacio de kernel)
Para lectura adicional, consulte fengshui-spraying-big-kids-pool. Una explicación simplificada del mecanismo sería:
El Kernel tiene 2 formas de asignar memoria con respecto al tamaño que queremos asignar: small-pool para objetos <4KB+encabezado y big-pool para objetos >4KB+encabezado. Las páginas de big-pool son importantes porque pueden enumerarse desde el espacio de usuario. Esto significa que un usuario normal puede encontrar todas las direcciones de inicio del kernel que contienen una página de Big-Pool.
¿Cómo? Cada página de Big-Pool tiene un campo llamado Tag (que se puede usar para obtener información sobre el tipo de datos almacenados allí). Todas las páginas de Big-Pool en el sistema se pueden enumerar usando NtQuerySystemInformation con SystemBigPoolInformation como valor para el parámetro SystemInformationClass. Luego, de todas las páginas, podemos filtrar por Tag y obtener la dirección de las páginas de Big-Pool que nos interesan. Por ejemplo, CLFS usa páginas de Big-Pool con la etiqueta 'Clfs'. Podemos obtener la dirección de todas las páginas en el Kernel donde se asignan objetos CLFS.

Volviendo a los Pipes, podemos operar de la misma manera, asignar un pipe lo suficientemente grande como para usar el mecanismo de Big-Pool, enumerar las páginas de big pool, buscar la etiqueta específica del pipe y filtrar esas páginas.
Por lo tanto, podemos filtrar en el espacio de usuario el lugar donde el kernel asignó nuestros pipes. ¿Qué hay de controlar los datos que van al Kernel y leerlos?
Para esto, confiamos en los Atributos de Pipe. Al igual que la etiqueta de Big-Pool, el Atributo de Pipe es un arreglo que puede contener información que describe el pipe (completado por el usuario). Usando la función no documentada NtFsControlFile podemos leer y escribir arbitrariamente en la estructura de datos del atributo de pipe. Al no estar documentada, solo se proporciona una prueba de concepto que establece un vector PipeAttribute y luego lo lee. La única modificación permitida a las primitivas de lectura y escritura es controlar el contenido del búfer de entrada/salida y su tamaño.
Aquí asignamos un pipe lo suficientemente grande para usar las páginas de big-pool (0x2000), establecemos un búfer de entrada y salida a un valor controlado. Precaución: los primeros 2 bytes del valor de entrada DEBEN ser 0x5a 0x00 para que funcione.

Aquí leemos lo que habíamos escrito antes en el kernel, nuevamente solo cambiamos el búfer de salida.

Y el resultado:

Aquí están los contenidos de la página de big-pool del pipe en el kernel: Al consultar las páginas de big-pool, encontramos el comienzo de la estructura de datos del pipe en el kernel. En la dirección pipe_begin+0x20 encontramos un puntero a nuestro búfer de entrada+0x2.

Cuando llamamos a la función de lectura del atributo del pipe, el sistema operativo hará lo siguiente:
¿Por qué es útil? Imagina si podemos reemplazar el puntero en pipe_begin+0x20 con la ubicación del token de seguridad del proceso System y luego llamar a la lectura del atributo del pipe. Filtraríamos su valor en el espacio de usuario. El reemplazo se logra mediante la vulnerabilidad de CLFS.SYS.
Entender esta técnica es crucial para entender el exploit.
La mayoría de los exploits no son de naturaleza determinista sino probabilística. Incluso si el código que aprovecha la vulnerabilidad es correcto, el exploit podría no funcionar. Al poner el programa objetivo en un estado inestable, el desarrollador del exploit debe asegurarse de que después de ejecutar el exploit, el sistema no se bloquee. Imagina un programa que, al ser explotado, da la capacidad de leer desde una dirección que depende del valor de una variable dentro del programa.
Ej.:
Digamos que target=0x1000000+ var_1&0xff+ var_2&0xff00.
El hacker no puede controlar target, var_1 ni var_2.
Pero el exploit permite leer desde la dirección target.
Podríamos leer desde cualquier lugar entre 0x1000000 y 0x100FFFF, lo que en algunos casos podría ser útil o no.
Ej2:
Digamos que un exploit permite leer un QWORD de una dirección que coincide con un cierto patrón y colocar el contenido en el valor de su token de seguridad. Digamos que previamente hemos obtenido el valor del token de seguridad para System.exe. ¿Cómo podemos aprovechar el exploit para elevar privilegios?
read_addr=0x1000000+alfa&0xFFFF00
No podemos controlar el parámetro alfa.
FUERA DEL TEMA PERO MUY IMPORTANTE: El kernel puede acceder al espacio de usuario correspondiente al proceso que está ejecutando código del kernel en ese momento.
¿Qué dirección podemos leer? Bueno, 0x1000000, 0x1000100(alfa=1),0x1000200(alfa=2),....,0x1FFFF00(alfa=ffff00).
Para asegurarse de que el exploit tenga éxito, el programador debería hacer lo siguiente:
memory=virtualalloc(dest=0x1000000,size=0x1000000,....)for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;Esto es de hecho memory spraying. Manipulación de memoria siguiendo un patrón específico que coincide con todos los valores posibles de una expresión probabilística que es el resultado de un exploit.
Un requisito para que esto funcione en nuestro caso es que la memoria se pueda asignar en la dirección 0x1000000. Esta es una limitación con la que también debemos trabajar en el caso del exploit real.
Con algunos aspectos técnicos aclarados, ahora se necesita una comprensión básica de CLFS. Enlace de Microsoft. CLFS se utiliza para registros de aplicaciones, registros de bases de datos, transacciones, etc.
Los exploits generalmente requieren un cierto diseño de memoria para funcionar. La forma del diseño está dictada por los valores de las variables dentro del programa en el momento exacto de la explotación.
Este tutorial no explicará en detalle el código vulnerable, ni el formato del sistema de archivos de registro. Ofrecerá una comprensión básica de los procesos que generan el exploit.
Los archivos de registro son un tipo especial de archivos que tienen un formato determinado y con los que se puede interactuar mediante las API del controlador y DLL de CLFS. Dentro de este sistema también existe el concepto de contenedores de registro. Los contenedores de registro también son archivos de registro, pero están vinculados en memoria a un archivo de registro principal (el archivo de registro al que se agregan).
Esta construcción funciona de la siguiente manera:
Esta operación fuerza la asignación de nuevo espacio de memoria dentro del archivo de registro principal y modificará diferentes objetos dentro del diseño de memoria del archivo de registro principal. La modificación del diseño de memoria, conceptualmente, se vería así:

Como con toda estructura/archivo de datos importante antes de usarlo, el controlador CLFS realiza comprobaciones de integridad contra el formato del archivo de registro:
El segundo punto es el punto de partida de la vulnerabilidad. El atacante puede modificar un archivo de registro previamente creado, recalcular su hash y editar el campo hash para que el controlador pase la prueba de integridad. Las modificaciones están relacionadas con longitudes de encabezados de archivo. Un conjunto cuidadosamente elaborado de valores determina que una comprobación de rango-longitud, que de otro modo fallaría y lanzaría un error, sea superada. El controlador acepta así la longitud falsa como válida y continúa la ejecución del código con normalidad. La longitud falsa se usa para calcular un desplazamiento dentro del archivo donde se escribirá una dirección codificada. Esto le da al atacante la capacidad de controlar la dirección donde ocurrirá la operación de escritura anterior.
Esta vulnerabilidad debe encadenarse con pipe-kernel-read-write para completar el exploit. Prueba visual:

En la figura anterior vemos la función AllocSymbol dentro de CLFS.sys que es responsable de la comprobación de rango defectuosa. La comprobación de longitud vulnerable es la que devolvería el código de error 0xC0000023. (BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68)) Su entrada a la condición está en parte controlada por el atacante. Es posible inyectar un valor en la fórmula que haga que la comprobación se supere aunque normalmente fallaría. Al controlar v8 y v9, podemos manipular el valor de verdad de la condición para que sea FALSO, evitar la devolución de error y llevar al cálculo arbitrario del valor de la variable v10, utilizando el valor v9 controlado por el atacante.
V10 se usa además para un memset con 0 como valor a establecer. Por lo tanto, el atacante obtiene la capacidad de establecer arbitrariamente memoria a 0. Esto no es todo. Antes de la devolución final, *a3=v10. A3 es un parámetro enviado por dirección a la función AllocSymbol. El llamador de AllocSymbol recibe así el valor de V10 después de regresar de AllocSymbol.
El llamador de AllocSymbol es FindSymbol.

Dentro de FindSymbol, V33 es el parámetro llamado a3 en AllocSymbol. Entonces v33 obtiene el valor de v10. Luego, el valor en la dirección v33 se establece en una constante. (0xc1fdf006) y además se prefija con el valor 0x30.

Por lo tanto, el atacante obtiene la capacidad de establecer una ubicación de memoria arbitraria con una constante.
¿Por qué sería esto importante? Si podemos sobrescribir el valor predeterminado de un puntero de función con una dirección constante accesible tanto desde el espacio de usuario como desde el espacio de kernel, y tener la garantía de que ese puntero será llamado, obtenemos control de la ejecución del código. Aunque esta es la idea general, hay contratiempos técnicos que se explicarán más adelante.
CClfsContainer* pContainer;. Cuando el contenedor respectivo se desasigna, en el archivo padre se realizan operaciones de limpieza y se desreferencia pContainer y se usan los valores [[pContainer]+0x18] y [[pContainer]+0x8] como punteros a función.Por lo tanto, la capacidad de controlar pContainer y ejecutar las API específicas del contenedor para adición y eliminación garantiza la redirección del flujo de control.
¿Dónde se encuentra pContainer?
La estructura del archivo CLFS no está documentada, pero existen algunos intentos individuales de realizar ingeniería inversa de la estructura del archivo.
Una vista general de la estructura de un archivo de registro:

Y una vista detallada del bloque base:

Diferencias entre la estructura y la vista de memoria:
Los archivos CLFS se asignan en páginas de gran tamaño (big-pool) con un valor de etiqueta de Clfs. Cuando se obtiene la dirección de registro de un clfs en el espacio de usuario (el mismo método utilizado para obtener la dirección del espacio del kernel de un objeto pipe), el kernel devuelve LA DIRECCIÓN DONDE COMIENZA EL BLOQUE BASE. (en el desplazamiento 0x800 en la imagen anterior). En el desplazamiento 0xb98 vemos una estructura de datos llamada regContainers. Este es un arreglo de valores de 32 bits. Cada valor está relacionado con un contenedor y representa el desplazamiento (contando desde 0x870) donde se encuentran las estructuras de datos internas de un contenedor en el archivo de bloque base.
El diseño de memoria de un archivo contenedor es el siguiente:
Estructura CLFS_CONTAINER_CONTEXT
En el desplazamiento 0x18 dentro de la estructura CLFS_CONTAINER_CONTEXT se encuentra nuestro objetivo pContainer.
Aparentemente, el desplazamiento a pContainer es constante, es decir, el primer elemento del arreglo regContainers. Y su valor es 0x1468.
pContainer se desreferencia y se usa como puntero a función:Desreferenciar y acceder a pContainer como puntero a función es la esencia del exploit. Esto ocurre dentro de la función Remove container en el controlador CLFS.SYS. Por lo tanto, el exploit se activará cuando se elimine el contenedor.
Prueba:

Revisemos los pasos para eliminar un contenedor y mostrar que efectivamente el puntero pContainer se accede como un puntero a una función.
GetBaseLogRecord que devuelve la dirección del kernel del bloque base + 0x70 (mueve el puntero del archivo más allá del encabezado).BaseRecord_1).a4 como v10.LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); Si hay un solo contenedor agregado al archivo, entonces StartingIndex_1 es cero. El valor devuelto por GetBaseLogRecord se convierte en un vector DWORD (32 bits) y se indexa por 0xCA elementos. Nótese que (_DWORD*)BaseLogRecord_1 + 0 + 0xCA no simplemente suma el valor 0xCA a BaseLogRecord_1. Debido a la conversión, esto equivale a BaseLogRecord_1[0xCA]. Esto se refleja en el código desensamblado correspondiente: mov eax, [rdi+r12*4+328h] donde rdi es la base, r12 es StartingIndex_1 (nótese que se multiplica por 4, que es la longitud de un elemento de 32 bits (DWORD)) y se suma con 0x328 que es 0xCA*0x4.El valor de a4 es al final BaseBlock+0x70+0+328=(BaseBlock+0x398) que nos llevaría al primer valor de RgContainers (0x398+0x800=0xB98).
_CLFS_CONTAINER_CONTEXT. Entonces v10 es el tercer campo de la estructura que corresponde a pContainer.82 & 83 se desreferencia pContainer, se suma con 0x18 y 0x8, se interpreta como un puntero a una función y se ejecuta. (call cs:__guard_dispatch_icall_fptr es de hecho una llamada a una instrucción jmp eax)Esto prueba que al reescribir el valor de pContainer podemos alterar el flujo de control del controlador y dirigirlo a direcciones controladas por el atacante.
Anteriormente se explicó que a veces es necesario llenar un trozo de memoria con un patrón predeterminado de valores para garantizar la ejecución exitosa de un exploit. Esto se debe a que el hacker solo puede controlar un subconjunto de las variables que componen el estado del programa en el momento de la explotación. En el ejemplo simple de memory spraying anterior, usamos solo valores escritos en un vector para satisfacer una condición determinada.
En el caso real, las condiciones son más complicadas. Necesitamos organizar los archivos de registro en la memoria del kernel en un orden específico con un desplazamiento conocido entre ellos.
Primero, creemos muchos archivos de registro en un bucle y estudiemos cómo asigna el sistema operativo memoria para ellos. El experimento utilizará el siguiente patrón:
El siguiente fragmento de código logra esto:

Y el resultado:

Ordenemos las direcciones:

Al inspeccionar el desplazamiento entre las direcciones, surge un pseudo patrón con respecto a las asignaciones: hay asignaciones continuas que tienen un desplazamiento constante entre ellas. Por ejemplo, desde ffffd80faf444000 hasta ffffd80faf4ee000, el desplazamiento entre dos asignaciones consecutivas es 0x11000.
Esta suposición es crucial para el funcionamiento del exploit. Se basará en ella.
Otra suposición: Tomemos algunas páginas que están separadas por 0x11000. Por ejemplo: ffffd80faf444000, ffffd80faf455000, ffffd80faf466000, ffffd80faf477000, ffffd80faf488000, ffffd80faf499000. Todas las páginas corresponden a archivos CLFS abiertos. Si cerramos un archivo, la página se desasignará y la memoria en esa dirección quedará libre.
Esto se vería así en memoria (digamos que cerramos el archivo correspondiente a la página en ffffd80faf466000):
ffffd80faf444000, ffffd80faf455000, xxxxxxxxxxxxxxxx, ffffd80faf477000, ffffd80faf488000,
SI volvemos a abrir el archivo, el SO con un alto grado de certeza asignará una página en la misma dirección (ffffd80faf466000) para llenar el hueco y hacer la memoria continua. --> Esto también es crucial para la ejecución del exploit.
Ahora introducimos un esquema de la estrategia que emplearemos para asegurarnos de sobrescribir el puntero *pContainer con una dirección bajo nuestro control, en el espacio de usuario.


En la imagen anterior, start(aux1)+0x11000=start(A), start(A)+0x11000=start(B), start(B)+0x11000=start(aux2).
Usaremos Logfile A para sobrescribir el puntero *pContainer de Logfile B, y activaremos el exploit cerrando Logfile B usando un código especial de eliminación después del cierre.
Agregar un contenedor de registro a Logfile B, para asignar y actualizar los campos que indican que B tiene un archivo contenedor correcto. Podemos agregar aux2 como contenedor a B.
Cerrar LogfileA para poder editarlo en disco. Es importante elegir A y B en medio de una secuencia máxima de archivos espaciados por 0x11000, para crear un hueco en memoria que el SO priorice llenar. Si no lo hace, el exploit fallará.
Recalcular el hash de A, editar su campo de hash para mantener la integridad y abrir A nuevamente con la esperanza de que el Kernel lo coloque en la misma dirección, o el exploit fallará.
Llamar a AddLogContainer en A con un archivo de registro normal para activar la sobrescritura del puntero *pContainer de B.
Eliminar el archivo B para que se llame a RemoveContainer y la ejecución se transfiera a *pContainer de B, que ahora está corrupto y apunta a código asignado por el usuario.
La siguiente imagen representa los pasos mencionados anteriormente.

Primero comenzamos asignando 50 archivos CLFS. Después de cada asignación, consultamos todas las páginas CLFS en la memoria del kernel y hacemos una lista con todas las direcciones asignadas para cada archivo. Luego, identificamos 2 archivos que estén separados por 0x11000 entre sí (A y B en el esquema). (y los almacenamos en las variables first, second).

Después de identificar la dirección, buscar dos archivos separados por 0x11000: A -> first, B -> second.

A continuación, cerrar A (first) y editarlo en disco (modificar sus encabezados) y abrirlo nuevamente.

Este paso necesita una explicación adicional porque debemos calcular algunos valores exactos que, al activar el código explotable en AllocSymbol, darán lugar a la sobrescritura del puntero *pContainer de B.
Del análisis anterior de las funciones AllocSymbol y FindSymbol, sabemos que debemos modificar el archivo A de tal manera que sobrescriba el valor lógico de la condición IF a FALSE e inyecte un valor en la variable v9 que conduzca a una escritura en la ubicación del puntero *pContainer de B.
Primero, ¿qué valor debe tener v9?
AllocSymbol calcula v10 (dirección objetivo de la escritura) como:
v10 = BaseLogRecord + v9 + 0x1338. Esto se calcula en el contexto del espacio de direcciones de A. Entonces BaseLogRecord es: Dirección de página del kernel de A + 0x70. v9 está controlado por el atacante y 0x1338 es constante.
¿Dónde está *pContainer de B en relación con su dirección de página del kernel?
Esto se detalló anteriormente, por lo que desde la dirección de página del kernel de B debemos agregar 0x398 para llegar al vector regContainer de B e indexar con el primer elemento para obtener el desplazamiento a la primera estructura CONTAINER_CONTEXT. Como se indicó anteriormente, para el primer contenedor, el índice se determinó como 0x1468 (contando desde B BaseBlock + 0x70).
Entonces, la ubicación de *pContainer de B es B's Kernel_Address + 0x70 + 0x1468 + 0x18 (tercer elemento en la estructura CONTAINER_CONTEXT).
B's Kernel_Address = A's Kernel_Address + 0x11000 (porque construimos la memoria de esta manera).
A's BaseRecordAddress = A's Kernel_Address + 0x70.
V10 = A's Kernel_Address + 0x70 + v9 + 0x1338.
v10 necesita sobrescribir *pContainer de B.
V10 debe ser: v10 = B's Kernel_Address + 0x70 + 0x1468 + 0x18 (tercer elemento en la estructura CONTAINER_CONTEXT).
Sustituyendo B's Kernel_Address: v10 = A's Kernel_Address + 0x11000 + 0x70 + 0x1468 + 0x18.
Reduciendo v10: A's Kernel_Address + 0x70 + v9 + 0x1338 = A's Kernel_Address + 0x11000 + 0x70 + 0x1468 + 0x18.
Resolviendo para v9 = 0x11000 + 0x70 + 0x1468 + 0x18 - 0x1338 - 0x70 = 0x11148.
Ahora, ¿qué campos en A necesitan ser sobrescritos? --> Hay 2 categorías de campos:
condición IF en AllocSymbol se evalúe como FALSE.Los campos de la categoría 1 no se detallarán. V9 corresponde al campo en el desplazamiento 0x1b98 en disco dentro del archivo A. No se darán más detalles sobre por qué estos campos activan el exploit; queda a criterio del lector leer sobre esto, si está interesado.
Estos son los valores que deben modificarse:

Nótese que (little endian), el valor utilizado es 0x11149 en lugar de 0x11148. Esto se debe a que necesitamos controlar los octetos de orden superior en *pContainer. El octeto menos significativo será de la forma X0 (10 o 20 o 30...). Podemos realizar un memory spray en cada uno de los valores para tener en cuenta esta condición.
Nótese que v10 también se usará para hacer memset con 00 en una región de memoria de longitud 0xa0.

Luego, en la misma dirección, se sobrescribe con una constante.

Nótese el valor constante que tiene la forma 0x30c1fdf006X0.
Esto ocurre cuando agregamos un contenedor de registro al archivo A, pero no cubrimos el proceso de recálculo del hash después de la modificación de los campos de A.
La vista del archivo de registro en disco es algo diferente de la vista en memoria. Estamos modificando solo el contenido del bloque base, que tiene una longitud de 0x7a00 y comienza en disco en el desplazamiento de archivo 0x800. El algoritmo hash utilizado para calcular el hash del bloque base es CRC32. El campo que contiene el valor del hash también se almacena dentro del bloque base en el desplazamiento 0x80c.
Procedimiento para recalcular el hash:
0x80c.0x800 con una longitud de 0x7a00.Para obtener la ejecución de código redirigida al espacio de usuario, necesitamos:
*pContainer de B (second) con un valor constante de la forma 0x30c1fdf006X0.RemoveContainer.
Hay algunas condiciones impuestas en la memoria de usuario que es el objetivo para redirigir el flujo de ejecución usando *pContainer. No podemos simplemente comenzar a ejecutar instrucciones desde el espacio de usuario con el programa en modo kernel.
El propósito de esta sección es filtrar el valor de SystemToken en el espacio de usuario, sin causar un bloqueo del sistema (BSOD).
¿Cómo se puede leer desde una dirección en el kernel y almacenar el resultado en el espacio de usuario? Un recordatorio rápido sobre la sección de Pipes en el tutorial:

Aquí:
La idea de vincular el objeto Pipe con nuestro objetivo de obtener el valor del token del sistema es usar NtFsControlFile PipeReadAttribute para leer desde la dirección del token del sistema en lugar del comienzo del búfer que contiene la información que pasamos al kernel usando PipeWrite Attribute.
Esto significa que debemos modificar el valor del puntero en la dirección PIPE_BEGIN+=0x20 para que contenga la dirección en la que se encuentra el token del sistema.
La dirección es conocida, obtenida en las etapas anteriores del tutorial, cuando localizamos y analizamos la estructura EPROCESS.
Aquí necesitamos encontrar un mecanismo que nos permita escribir en la estructura del pipe en una ubicación fija.
Para esto, usamos la redirección de código obtenida al explotar la función CLFS AddLogContainer. Se podría pensar que es suficiente escribir un shellcode que haga el reemplazo directo, pero (aunque no se probó) ciertamente no funcionaría. Esto se debe a que el controlador se ejecuta en contexto de kernel y ejecuta código desde el área de un proceso de usuario.
Para evitar esta limitación, debemos encontrar ROPs del kernel que realicen una escritura en un valor arbitrario. Es decir, encontrar algunos fragmentos de código del kernel que estén al final de una función y terminen con una instrucción ret, y proporcionar sus direcciones como objetivos para la redirección. De esta manera, el código sigue siendo ejecutado por el kernel.
Esta es la idea principal, pero surgen limitaciones de la forma en que el controlador CLFS llama al código dentro de *pContainer:

Para crear un patrón de memoria utilizable, debemos estudiar la forma en que la función RemoveContainer accede al código de *pContainer.
En la imagen anterior, resaltamos las porciones de código que desreferencian *pContainer y lo usan como puntero a función.
RDI es la constante que se escribió dentro de *pContainer al explotar AllocSymbol. Como se puede ver, la constante no es completamente fija en valor, varía en el primer byte (la forma es 6X0, donde X puede ser cualquier cosa).
mov rax, [rdi] desreferencia el valor constante. Para no causar una lectura no válida (y BSOD), debemos asegurarnos de que rdi contenga un puntero a una dirección válida. Para hacer esto, debemos rociar la memoria de usuario desde 0x30C1FDF00000 hasta al menos 0x30C1FDF006FF. Esto se hace asignando un trozo de memoria con VirtualAlloc. Por supuesto, si el sistema no puede asignar memoria por alguna razón, el exploit falla.
LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);
Luego, para cualquier valor posible de X, almacenamos en 0x30C1FDF000X0 a 0x30C1FDF00FX0 otro valor que corresponda a memoria de usuario válida, eligiendo un valor arbitrario, digamos 0x5000000. De esta manera, nos aseguramos de que rax sea igual a 0x5000000 para cualquier rdi de la forma 0x30C1FDF006X0.
Después de este paso, vemos dos operaciones de desreferencia más: mov rax, [rax+0x18] y mov rax ,[rax+0x8].
Para asegurar que la memoria sigue siendo coherente en estas direcciones, debemos almacenar en 0x5000008 y 0x5000018 punteros a funciones del kernel (ROP1 y ROP2, que se calcularán en el futuro).
Este es el algoritmo para generar el patrón de spray.

Nota: El patrón evolucionará cuando introduzcamos los ROPs porque también tienen parámetros que se tendrán en cuenta, pero esto es el mínimo hasta ahora para evitar direccionamiento de memoria no válido.
Así es como se ve la memoria rociada de la primera etapa en el depurador (observa dónde se coloca el valor 0x5000000 -> alineado a X0):

Esto es lo que parece la memoria en 0x5000000 (los ROPs que se detallarán):

Y el proceso de redirección de código en el depurador del kernel:

Nota el valor del registro RDI y el puntero de instrucción en el depurador. RDI desreferenciado una vez es 0x5000000 y se almacena en RAX. [RAX+0x18] es el segundo ROP, y más abajo [RAX+0x8] será la dirección del primer ROP.
En esta etapa, tenemos las siguientes piezas del exploit que debemos enlazar:
El alcance en este punto es enlazar la redirección de código con un trozo de código que sobrescribirá el puntero del pipe al búfer de atributos con la dirección del token del sistema y usar Pipe read attribute para obtener la información de vuelta en el espacio de usuario.Como hemos mencionado, el código que se redirige debe estar también en el kernel. Además, solo tenemos 2 funciones para usar. El tutorial no cubrirá cómo se encontraron estas 2 funciones específicas, pero presumiblemente existe una lista de candidatos ROP que se utilizan a menudo.
Estudiaremos 2 funciones:
SeSetAccessStateGenericMapping en ntoskrnl.exe llamada en segundo lugar ([rax+0x8])ClfsEarlierLsn en CLFS.SYS llamada en primer lugar ([rax+0x18])Análisis de ClfsEarlierLSn:

El único rol de esta función cuando se llama con parámetros incorrectos es establecer EDX en 0xFFFFFFFF y retornar. Por qué es necesario esto quedará claro al analizar el segundo ROP.
Análisis de SeSetAccessStateGenericMapping:

Esto debe discutirse línea por línea en detalle.
Entradas: Al ingresar a esta función:
0x30C1FDF006X0.0x30C1FDF00YX8, siempre alineado a 0x8 y no entrará en conflicto con 0x30C1FDF006X0 que está alineado a 0 y contiene 0x5000000.Ejecución del código de la función:
mov rax, [rcx+48h] desreferenciará desde 0x30C1FDF00YX8 y moverá el valor a RAX.movups xmm0, xmmword ptr [rdx] moverá 16 bytes desde RDX al registro XMM0. Recuerde que aquí, desde ClfsEarlierLSn, RDX es 0xFFFFFFFF.movdqu xmmword ptr [rax+8], xmm0 moverá a [RAX+0x8] el valor de XMMO.Esencialmente, esto lee desde una dirección y almacena dentro del kernel. Puede interpretarse así:

Por lo tanto, para sobrescribir el puntero del búfer de atributos de la tubería (pipe) con un puntero al token del sistema, debemos asignar 16 octetos en 0xFFFFFFFF y almacenar allí la dirección del token del sistema. Luego, debemos rociar la memoria en 0x30C1FDF00YX8 repetidamente con el valor de la dirección de destino menos 0x8. Es decir, el valor del offset al búfer de atributos de la tubería menos 0x8. Esto es PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8.
Así es como se logra esto en el código real:

Otro aspecto que no se explicó: ¿cómo se obtienen las direcciones del kernel de los ROP en el espacio de usuario?
Es un truco. Primero, se puede obtener la dirección base de cualquier módulo del kernel usando NtQuerySystemInformation con los siguientes parámetros: NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen).
Luego, se filtra por el valor de ModuleInfo->Modules[i].Name para que coincida con la biblioteca requerida. Esto obtiene la dirección base en el kernel.
Luego, aprovechamos el hecho de que el desplazamiento entre la dirección base y la dirección de exportación es constante independientemente del espacio de direcciones donde esté cargado el ejecutable.
Cargamos el módulo en el espacio de usuario (se puede hacer) usando LoadLibrary y obtenemos la dirección de la exportación usando GetprocAddress. Calculamos el delta entre la exportación y la base de usuario y lo sumamos a la base del kernel (encontrada usando NtQuerySystemInformation). Así obtenemos en el espacio de usuario la dirección de la exportación que está cargada en el espacio del kernel.
Una vez que el puntero al atributo ha sido corrompido y configurado para apuntar a la ubicación del token del sistema, una llamada a MyNtFsControlFile con los parámetros correctos debería leer desde la dirección y filtrar el valor del token del sistema en el espacio de usuario.

Teniendo el valor correcto para el token del sistema, para completar el exploit debemos escribirlo en lugar del nuestro propio proceso. Es decir, sobrescribir el token del proceso que ejecuta el exploit con el valor del token del sistema.
Los pasos necesarios para realizar este cambio no incluyen nuevas técnicas o metodologías, sino que se basan en la reutilización del código anterior. El algoritmo para sobrescribir nuestro propio valor de token implica una operación de escritura en el kernel en una dirección determinada. Esto se logra mediante las funciones ROP SeSetAccessStateGenericMapping y ClfsEarlierLSn. Esto implica que NECESITAMOS ACTIVAR EL EXPLOIT DE CLFS POR SEGUNDA VEZ. Así es, debemos realizar la asignación de contenedores y el rociado de memoria por segunda vez y esperar que el sistema operativo no se bloquee.
Pasos para sobrescribir nuestro propio valor de token:
0xFFFFFFFF) sea el valor del token del sistema obtenido de la primera ejecución del exploit.Como no necesitamos una lectura desde el kernel la segunda vez, esta vez no utilizamos la tubería (pipe).
Como los pasos no requieren conocimiento adicional, el tutorial puede terminar aquí con una prueba final de un cmd.exe con nt authority\system

El enfoque principal de este tutorial no fueron los detalles internos del CVE en sí, sino una descripción amigable para el usuario del proceso de creación de un ejemplo de escalada de privilegios en Windows. Muchos exploits comparten los mismos métodos y bloques de construcción, como el rociado de memoria o el trabajo con ciertas estructuras de datos de Windows. Y la mayoría de las veces, hay una gran brecha entre tener conocimientos teóricos internos de Windows y escribir efectivamente un algoritmo que aproveche una vulnerabilidad.
ffffd80faf499000