Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
DeathSleep — Una implementación de PoC de una técnica de evasión para terminar el hilo actual y restaurarlo antes de reanudar la ejecución, mientras se implementan cambios de protección de páginas durante la no ejecución. | Kitploit
Herramientas/GitHubGitHub/janoglezcampos/deathsleep
ExplotaciónAnálisis de MalwareRed TeamingDesarrollo de Payloads
GitHubjanoglezcampos/deathsleep

DeathSleep

Una implementación de PoC de una técnica de evasión para terminar el hilo actual y restaurarlo antes de reanudar la ejecución, mientras se implementan cambios de protección de páginas durante la no ejecución.

Ver Repositorio
538774hace 4 añosRevisado por Kitploit

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir

██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗ ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗ ██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝ ██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝ ██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝

Una implementación PoC de una técnica de evasión para terminar el hilo actual y restaurarlo antes de reanudar la ejecución, mientras se implementan cambios de protección de página durante la no ejecución.

Introducción

Los métodos de suspensión y ofuscación son bien conocidos en la comunidad de maldev, con diferentes implementaciones, tienen el objetivo de ocultarse de los escáneres de memoria mientras se duerme, generalmente cambiando las protecciones de página e incluso agregando características interesantes como cifrar el shellcode, pero hay otro punto importante para ocultar nuestro shellcode, y es ocultar el hilo de ejecución actual. Spoofear la pila está bien, pero después de pensarlo un poco, pensé que no es necesario spoofear la pila… si no hay pila :)

La usabilidad de esta técnica queda a criterio del lector, pero en cualquier caso, creo que es una forma interesante de repasar algunos temas y aprender algo de maldev para aquellos que, como yo, están comenzando en este mundo.

La implementación principal mostrada aquí contiene todo lo que necesitamos para sacar de la pila en la sección de datos, como variables globales, pero pronto se publicará una implementación moviendo todo al heap. Su objetivo es mostrar algunas modificaciones clave que deben realizarse para que este código sea pic e inyectable.

Este repositorio se refleja entre GitHub y GitLab.


¿Qué está pasando?

Ante todo

Todo lo expuesto aquí proviene de mi comprensión de los diferentes temas tratados, ya sea por lectura o experiencia durante el desarrollo. Soy consciente de que no soy un experto y lo último que quiero es difundir información errónea, así que si crees que algo no es correcto, me encantaría que me lo hicieras saber, puedes contactarme en twitter, o abriendo issues en este repositorio. Muchas gracias por tu comprensión. :)

Conceptos básicos

El objetivo principal de esta técnica es claro, terminar el hilo actual y restaurarlo antes de reanudar la ejecución, pero ¿qué significa exactamente esto y qué nuevas restricciones impone?

Para poder restaurar la ejecución, necesitamos guardar dos cosas antes de terminar el hilo: primero, el estado de la CPU, y segundo, la pila, y configurarlos nuevamente después de que se lance el nuevo hilo.

Hablé sobre nuevas restricciones que aparecerán en esta técnica, y hay dos grandes: Primero, necesitamos almacenar fuera de la pila cualquier cosa que se necesite desde el momento en que el hilo termina hasta que se restaura la pila, y como verás, crea algunos nuevos desafíos.

Segundo, siempre necesitamos al menos otro hilo ejecutándose en nuestro proceso, ya que estamos terminando nuestro hilo, si no hay otros hilos, el proceso finalizará. No creo que esto sea un gran problema, ya que la mayoría de los agentes se inyectan en otros procesos, podemos suponer que ese proceso mantendrá al menos un hilo ejecutándose.

Componentes de DeathSleep:

Podemos ver en este PoC 4 funciones principales:

  • Programa principal: Aquí es donde escribirías el código de tu agente, y es la parte del código que hará uso de DeathSleep
  • Función Awake: este es el punto de entrada de todos nuestros hilos, y se encarga de guardar el punto de inicio de la pila que restauraremos. También se encarga de restaurar la pila y el contexto de CPU cuando sea necesario, o simplemente lanzar nuestro programa principal.
  • DeathSleep: esta es la función principal de esta técnica, y se encarga de realizar una copia de seguridad del contexto del hilo y la pila, y también de configurar todo para que ocurra la magia.
  • Rebirth: Una función simple solo encargada de lanzar nuestros nuevos hilos.

Guardando la pila.

Cuando estamos a punto de guardar la pila, surge una pregunta: ¿cuánto de la pila necesita ser guardado?

Revisemos primero qué hay en la pila después de que llamamos a la función DeathSleep (esta es la función que guarda el contexto, la pila y prepara todo para la ofuscación y restauración)

Como podemos ver, cada función tiene tres partes:

  • Shadow space: este es un espacio de 32 bytes, asignado por la persona que llama, pero utilizado por la persona llamada. Hasta donde sé y pude ver, su función principal es contener, si es necesario, los argumentos pasados a la función llamada en los registros, pero puede ser utilizado para cualquier cosa que la función llamada decida.
  • Dirección de retorno: esta es la dirección de la siguiente instrucción a ejecutar en la función que llama, insertada por la instrucción CALL, por lo que la instrucción RET en la función llamada simplemente tomará esta dirección y "saltará" a ella cuando termine.
  • Espacio de pila de la función: Este es el espacio reservado por la función llamada para almacenar el valor de los registros que deben restaurarse y el valor de sus variables locales.

La porción mínima de la pila que obviamente necesitamos guardar es todo lo que está dentro de nuestro programa principal, eso significa su shadow space, su dirección de retorno y todo hasta la función DeathSleep. Cualquier cosa anterior no es realmente necesaria (guardar la pila utilizada por la función de entrada tiene sus ventajas, pero discutiremos eso más adelante), ya que esa es la pila utilizada por las rutinas de Windows para lanzar nuestro nuevo hilo. Aparte de esto, también decidí almacenar el shadow space de la función DeathSleep (no es realmente necesario, pero facilita el cálculo de Rsp en el momento de despertar).

Entonces, al final, estamos guardando esto:

Cómo encontrar nuestras direcciones de pila:

Cada función en una compilación estándar debe estar compuesta por 3 partes: el prólogo, el código de la función y el epílogo.

Rsp (puntero de pila) solo debe modificarse en el prólogo y epílogo de la función. El prólogo incrementa el puntero de la pila (recuerda que incrementar la pila significa reducir direcciones, ya que van en direcciones opuestas), para guardar registros, para contener todas sus variables locales y luego para contener el shadow space, y el epílogo hace exactamente lo contrario.

Esto significa que el puntero de pila dentro del código de la función siempre debe apuntar al final del shadow space (púrpura en la imagen anterior), y la suma del tamaño de pila de la función y el shadow space se puede encontrar calculando cuánto incrementa el puntero de la pila el prólogo. Este valor se puede calcular fácilmente utilizando la información contenida en las tablas de desenrollado (unwind tables), una explicación sobre su uso es algo que no cubriremos aquí, pero en resumen, esas tablas se utilizan para permitir que cualquier otro hilo o proceso se mueva correctamente a través de la pila para ver su contenido, manejar excepciones o analizarla.

Capturando y preparando el contexto para restaurar.

Capturar el contexto es probablemente una de las cosas más fáciles de hacer, ya que simplemente podemos hacer RtlCaptureContext() en la primera línea de DeathSleep, antes de cualquier modificación a los registros no volátiles. Todavía necesitamos hacer dos modificaciones al contexto donde restauraremos la ejecución.

La primera será modificar su Rip, como recuerdas, este es el registro que contiene la siguiente instrucción a ejecutar, y si simplemente lo dejamos sin modificar, la ejecución se reanudará dentro de la función DeathSleep. Lo que haremos será cambiar Rip para que contenga la dirección de retorno de DeathSleep, a la que apunta el Rsp actual desplazado por el tamaño incrementado en el epílogo (áreas verde + púrpura en las imágenes anteriores).

La segunda modificación se realizará cuando se restaure el hilo, e implica establecer Rsp para que apunte a la parte superior de nuestra pila restaurada; esto se hará durante la fase de restauración, ya que no sabemos dónde se colocará nuestra nueva pila. El valor será simplemente la dirección final de nuestra pila restaurada, ya que como discutimos más tarde, también copiamos el shadow space reservado por la persona que llamó a DeathSleep, y ese es exactamente el valor de RSP antes de la llamada a DeathSleep.

Restaurando nuestra pila:

Una vez que llegamos al punto de despertar, justo antes de reanudar la ejecución, debemos colocar nuestra pila guardada en su lugar. Como ya sabemos, nuestra pila guardada comienza en la dirección capturada por la función awake, por lo que la nueva dirección capturada será el punto de partida donde colocaremos nuestra pila guardada, pero esto plantea un problema: cualquier llamada a una función después de que coloquemos nuestra pila antigua la modificaría y la rompería, y hacer la limpieza aquí es realmente conveniente, especialmente liberar el heap utilizado para contener nuestra copia de seguridad de la pila. Esto significa que necesitamos mover nuestro Rsp actual y también las partes de la pila que estamos usando actualmente a un lugar fuera de donde colocaremos nuestra pila restaurada. Intentando aclararlo, aquí está el problema:

Y aquí está mi solución, simplemente mover todo lejos:

Restaurando el contexto:

Después de todo el trabajo duro, lo último que queda es usar NtContinue, esta función nos permite cambiar el contexto actual con nuestro contexto previamente capturado y modificado, estableciendo RIP justo después de la llamada a DeathSleep, todos los registros deben tener los mismos valores que tenían al llamar a DeathSleep, y RSP debe apuntar a la parte superior de la pila.

Programando el proceso de restauración: Usando Thread Pool API.

Bien, sabemos los conceptos básicos de lo que necesitamos hacer para almacenar y restaurar el hilo actual, pero necesitamos de alguna manera poder ejecutar todo esto incluso cuando no tenemos hilos. Aquí es donde conocemos a nuestra querida Thread Pool API, una herramienta proporcionada por Windows, que nos permitirá poner en cola tareas (funciones con un argumento como máximo) a un grupo de hilos (un pool) que serán completamente administrados por el sistema operativo. Si has visto Ekko, puedes ver que utiliza esta API, así que… implementémoslo de la misma manera.

Todo funcionó bien, pero había un problema: un worker seguía activo incluso después de finalizar la ejecución de sus tareas en cola. Esto era un problema, ya que quería destruir todos los hilos que nuestro programa pudiera generar, así que esta no era la forma de hacerlo.

Después de investigar un poco, descubrí que la Thread Pool API, utilizada en Ekko, era una versión antigua, y había una nueva con algunas capacidades adicionales, y, entre ellas, una función que resolvería efectivamente nuestro problema: CloseThreadPool(). Esta nueva API nos permite crear nuestro propio pool y destruirlo después de usarlo, terminando todos los workers utilizados. También ofrece otras dos ventajas: establecer un número máximo de hilos y grupos de limpieza. Establecer un número máximo de hilos nos permitiría ejecutar todas nuestras tareas secuencialmente, siempre que se pongan en cola con cualquier diferencia de tiempo. Los grupos de limpieza son útiles para facilitar la limpieza una vez que todo está hecho.

Entonces… ¿todo está hecho? Bueno, en este punto, el hilo termina y estamos poniendo en cola la función rebirth, que crea el nuevo hilo con awake como punto de entrada, restaura el estado anterior y cierra el pool, ¡hasta ahora todo bien!

Cambiando los permisos de memoria, redirigiendo la ejecución.

Cuando terminé con todo lo que discutimos antes, pensé que la parte difícil estaba resuelta, ya que esta parte ya había sido resuelta por técnicas anteriores, pero oh cielos, no sabía lo que se avecinaba.

El problema principal es que necesitamos descargarlo fuera de nuestro código, ya que estamos cambiando la protección de memoria a RW (lectura-escritura). Si llamamos a VirtualProtect(), cuando la función retorna, nuestro proceso se bloqueará (no podemos ejecutar instrucciones en páginas RW), por lo que necesitamos encontrar una forma de ejecutar esto desde otro lugar, y hacer que también retorne a páginas RX (lectura-ejecución) (y lo mismo sucede al regresar). Obviamente, también usaremos la Thread Pool API para esto, pero hay un problema: solo podemos dar un argumento a nuestras tareas, y VirtualProtect() toma 4.

Para esto, volveremos a usar NtContinue(), la primera vez que vi este uso para esta función fue en Foliage, pero también se usa en Ekko. NtContinue(), como vimos antes, nos permite establecer algún contexto en el hilo que lo llama, y con algunos ajustes inteligentes, puede "llamar" a una función con múltiples argumentos, usando solo uno (muy conveniente para la Thread Pool API). La idea principal es establecer RIP en la dirección de inicio de la función, y, dado que la convención de llamadas x64 de Windows pasa los primeros cuatro argumentos en registros (rcx, rdx, r8, r9, en ese orden), simplemente coloca tus argumentos en la estructura de contexto que pasarás a NtContinue, y efectivamente simulará una llamada a una función. Lo último que debemos tener en cuenta al usar NtContinue es Rsp, ya que, como vimos antes, esta dirección debe contener la dirección de retorno cuando se llama a una función.

Entonces, lo primero que necesitamos para que NtContinue funcione es obtener un contexto, podríamos crearlo manualmente, pero nos encontraríamos con un problema: encontrar el valor de Rsp que, cuando se pase a nuestra función, apuntará a la dirección que será utilizada por RET para retornar. Nuestras tareas funcionarán en un hilo diferente, por lo que no sabemos dónde se colocará su pila. La solución (cuidadosamente robada de Ekko, ¡muchas gracias :P) es tomar una copia del contexto dentro de un worker con RtlCaptureContext(), e incrementar el puntero de pila del contexto obtenido en 8, para que apunte a la dirección introducida en la pila por CALL RtlCaptureContext(), y que es la dirección de retorno de esta última función, y podemos usarla como la dirección de retorno de todas nuestras funciones.

Bien, esto está bien, pero ¿qué sucede cuando no podemos hacer esta modificación a Rsp? Eso es lo que sucede cuando desofuscamos, estaremos en un nuevo hilo, por lo que el Rsp del contexto antiguo es inútil. Necesitamos un nuevo contexto, tomado del nuevo hilo, pero no podemos usar el viejo truco de modificar Rsp para que apunte a la dirección correcta.

Rop Chains

Entonces, no podemos modificar el contexto obtenido, pero eso no significa que sea inútil, en realidad lo usaremos, pero de una manera diferente. Si simplemente restauramos ese contexto con NtContinue(), sin modificar su Rip, simplemente redirigirá la ejecución a la siguiente instrucción después de la llamada a RtlCaptureContext(), y con un Rsp correcto, por lo que podemos usarlo después de nuestras llamadas a NtContinue() con contextos modificados, para poder finalizar correctamente la ejecución de nuestras tareas. Para hacer esto, usaremos una cadena ROP, estableciendo Rsp de nuestro primer contexto para que apunte a una pila creada manualmente, que contendrá todo lo que necesitamos para redirigir la ejecución hasta la segunda llamada a NtContinue() que establecerá el contexto correcto para finalizar.

Así es como debería verse nuestra pila creada manualmente:

Estamos utilizando 2 gadgets ROP, uno para arreglar o "saltar" sobre el shadow space de nuestra función, y el segundo se encarga de colocar el argumento para NtContinue en rcx, y luego retornar a él.

Encontrar estos 2 gadgets ROP es bastante fácil, el primero para arreglar el shadow space es el epílogo de casi cualquier función (encontré más de 500 aciertos solo en Ntdll), ya que como vimos antes, los epílogos están diseñados principalmente para reducir Rsp, y el segundo es simplemente pop rcx; ret; que tiene 2 bytes, y también encontré un par entre Ntdll y Kernel32 dlls.

Un poco de ingeniería inversa a la Thread Pool API

Como vimos, usar NtContinue solo necesita tener su primer argumento lleno para funcionar, y esto es perfecto con la API antigua de Thread Pool, pero en la nueva API de Thread Pool, los argumentos se pasan en la segunda posición, así que sí, esto solo no funcionará.

Después de algunas horas sin saber cómo resolver este último problema, se me ocurrió que ambas APIs usaban las mismas funciones en algunos casos, y eso me hizo pensar que podrían ser más similares de lo que parecían, así que decidí investigar cuál era la relación entre ellas.

Para la API antigua, estamos usando CreateTimerQueueTimer() para poner en cola nuestras tareas, y en la nueva, necesitamos dos funciones para hacer lo mismo: CreateThreadpoolTimer(), que tomará la función callback y el argumento a pasarle, y devolverá un puntero a una estructura TP_TIMER que describe la tarea, y una segunda función para poner en cola la tarea: SetThreadpoolTimer(), que tomará el puntero anterior y un puntero a una estructura FILETIME que describe cuándo se ejecutará la tarea.

Si invertimos estas funciones, encontraremos esto:

Entonces, como podemos ver, CreateThreadpoolTimer() es solo un envoltorio elegante para TpAllocTimer(), y SetThreadpoolTimer() es solo un reenviador a TpSetTimer().

Ahora revisemos el interior de CreateTimerQueueTimer(). En primer lugar, es solo otro envoltorio elegante para una función en Ntdll, RtlCreateTimer(), y aquí es donde ocurre la magia. Esta es una función más grande, pero aquí está el oro que estábamos buscando:

Como puedes ver, dentro de esta función hay efectivamente una llamada a TpAllocTimer() y a TpSetTimer(), lo que es similar a decir que está llamando a CreateThreadpoolTimer() y SetThreadpoolTimer() en su interior. Como podemos ver, la función que estamos poniendo en cola no es directamente el callback que le hemos dado a la función, sino que está configurando RtlpTpTimerCallback() como el callback. Si aún no te has dado cuenta de lo que todo esto significa, es que estamos usando CreateThreadpoolTimer() para poner en cola una función que recibe sus argumentos en la segunda posición, RtlpTpTimerCallback(), que ejecutará otra función con sus argumentos en la primera posición.

Entonces, lo único que aún necesitamos entender es cómo se pasa la información del callback a RtlpTpTimerCallback(), y después de un poco de ingeniería inversa, terminé con la siguiente estructura, que ¡sorpresa, sorpresa, FUNCIONA!

Ahora podemos llamar a funciones que reciben sus argumentos en la primera posición y al mismo tiempo podemos cerrar nuestros pools, y no dejar ningún hilo funcionando, beneficio mutuo. Es importante tener en cuenta que esta función no está exportada en Ntdll, así que decidí encontrarla por su forma de bytes dentro de la dll.

Así que este es el final, y con todo lo revisado, creo que he dado las ideas centrales que pasaron por mi mente mientras desarrollaba este PoC, y por qué todo se hizo de la manera en que lo hice.

Espero que hayas disfrutado :)


Consideraciones de prueba

Este código solo se probó con el compilador y enlazador MSCV, ya que este PoC depende en gran medida de cómo se compiló, recomiendo usar esta misma herramienta, y no garantizo que funcione con otros compiladores de inmediato.


Descargar herramienta