Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
DeathSleep — Une implémentation de preuve de concept pour une technique d'évasion visant à terminer le thread actuel et à le restaurer avant de reprendre l'exécution, tout en appliquant des modifications de protection de page pendant l'absence d'exécution. | Kitploit
Outils/GitHubGitHub/janoglezcampos/deathsleep
ExploitationAnalyse de MalwareRed TeamingDéveloppement de Charges Utiles
GitHubjanoglezcampos/deathsleep

DeathSleep

Une implémentation de preuve de concept pour une technique d'évasion visant à terminer le thread actuel et à le restaurer avant de reprendre l'exécution, tout en appliquant des modifications de protection de page pendant l'absence d'exécution.

Voir le dépôt
53877il y a 4 ansVérifié par Kitploit

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager

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

Une implémentation POC d'une technique d'évasion pour terminer le thread actuel et le restaurer avant de reprendre l'exécution, tout en modifiant les protections de pages pendant l'absence d'exécution.

Introduction

Les méthodes de sommeil et d'obfuscation sont bien connues dans la communauté maldev, avec différentes implémentations, elles ont pour objectif de se cacher des scanners mémoire pendant le sommeil, en changeant généralement les protections de pages et même en ajoutant des fonctionnalités intéressantes comme le chiffrement du shellcode, mais il y a un autre point important pour cacher notre shellcode : cacher le thread d'exécution actuel. Spoofer la stack est cool, mais après y avoir réfléchi un peu, je me suis dit qu'il n'est pas nécessaire de spoof la stack… s'il n'y a pas de stack :)

L'utilité de cette technique est laissée à l'appréciation du lecteur, mais dans tous les cas, je pense que c'est une façon intéressante de revoir certains sujets et d'apprendre un peu de maldev pour ceux qui, comme moi, débutent dans ce monde.

L'implémentation principale présentée ici contient tout ce que nous devons sortir de la stack dans la section data, sous forme de variables globales, mais une implémentation déplaçant tout vers le heap sera publiée prochainement. Elle vise à montrer quelques modifications clés nécessaires pour rendre ce code pic et injectable.

Ce dépôt est dupliqué entre GitHub et GitLab.


Qu'est-ce qui se passe ?

Tout d'abord

Tout ce qui est indiqué ici provient de ma compréhension des différents sujets abordés, que ce soit par la lecture ou l'expérience lors du développement. Je suis conscient que je ne suis pas un expert et la dernière chose que je souhaite est de propager des informations erronées, donc si vous pensez que quelque chose n'est pas correct, je serais ravi que vous m'en fassiez part, vous pouvez me contacter sur twitter, ou ouvrir des issues dans ce repo. Merci beaucoup pour votre compréhension. :)

Bases

L'objectif principal de cette technique est clair : terminer le thread actuel et le restaurer avant de reprendre l'exécution, mais qu'est-ce que cela signifie exactement, et quelles nouvelles contraintes cela impose-t-il ?

Pour pouvoir restaurer l'exécution, nous devons sauvegarder deux choses avant de terminer le thread : d'abord l'état du CPU, et ensuite la stack, et les remettre en place efficacement après le lancement du nouveau thread.

J'ai parlé de nouvelles contraintes qui apparaîtront dans cette technique, et il y en a deux principales : premièrement, nous devons stocker en dehors de la stack tout ce qui est nécessaire depuis le moment où le thread se termine jusqu'à ce que la stack soit restaurée, et comme vous le verrez, cela crée de nouveaux défis.

Deuxièmement, nous avons toujours besoin d'au moins un autre thread en cours d'exécution dans notre processus, car nous terminons notre thread ; s'il n'y a pas d'autres threads, le processus se terminera. Je ne pense pas que ce soit un gros problème, car la plupart des agents sont injectés dans d'autres processus, nous pouvons supposer que ce processus conservera au moins un thread en cours d'exécution.

Composants de DeathSleep :

Nous pouvons voir dans cette POC 4 fonctions principales :

  • Programme principal : C'est ici que vous écririez votre code d'agent, et c'est la partie du code qui utilisera DeathSleep
  • Fonction Awake : c'est le point d'entrée de tous nos threads, et elle est chargée de sauvegarder le point de départ de la stack que nous allons restaurer. Elle est également chargée de restaurer la stack et le contexte CPU lorsque nécessaire, ou simplement de lancer notre programme principal.
  • DeathSleep : c'est la fonction principale de cette technique, et elle est chargée de sauvegarder le contexte du thread et la stack, et aussi de tout préparer pour que la magie opère.
  • Rebirth : Une fonction simple chargée uniquement de lancer nos nouveaux threads.

Sauvegarde de la stack.

Lorsque nous sommes sur le point de sauvegarder la stack, une question se pose : quelle quantité de la stack doit être sauvegardée ?

Revoyons d'abord ce qui se trouve dans la stack après avoir appelé la fonction DeathSleep (c'est la fonction qui sauvegarde le contexte, la stack et prépare tout pour l'obfuscation et la restauration)

Comme nous pouvons le voir, chaque fonction a trois parties :

  • Shadow space : c'est un espace de 32 octets, alloué par l'appelant, mais utilisé par l'appelé. Pour autant que je sache et que j'aie pu voir, sa fonction principale est de contenir, si nécessaire, les arguments passés à la fonction appelée dans les registres, mais il peut être utilisé pour tout ce que la fonction appelée décide.
  • Adresse de retour : c'est l'adresse de la prochaine instruction à exécuter dans la fonction appelante, poussée par l'instruction CALL, de sorte que l'instruction RET dans la fonction appelée prendra simplement cette adresse et "sautera" dessus lorsqu'elle se terminera.
  • Espace stack de la fonction : c'est l'espace réservé par l'appelé pour stocker la valeur des registres qui doivent être restaurés et la valeur de ses variables locales.

La partie minimale de la stack que nous devons évidemment sauvegarder est tout ce qui se trouve à l'intérieur de notre programme principal, c'est-à-dire son shadow space, son adresse de retour et tout jusqu'à la fonction DeathSleep. Tout ce qui précède n'est pas vraiment nécessaire (sauvegarder la stack utilisée par la fonction d'entrée a ses avantages, mais nous en discuterons plus tard), car c'est la stack utilisée par les routines Windows pour lancer notre nouveau thread. En plus de cela, j'ai décidé de stocker également le shadow space de la fonction DeathSleep (pas vraiment nécessaire, mais cela facilite le calcul du Rsp au moment du réveil).

Donc au final, nous sauvegardons ceci :

Comment trouver nos adresses de stack :

Chaque fonction dans une compilation standard devrait être composée de 3 parties : le prologue, le code de la fonction, et l'épilogue.

Le Rsp (pointeur de stack) ne devrait être modifié que dans le prologue et l'épilogue de la fonction. Le prologue augmente le pointeur de stack (rappelez-vous qu'augmenter la stack signifie réduire les adresses, car elles vont dans des directions opposées), pour sauvegarder les registres, contenir toutes ses variables locales, puis contenir le shadow space, et l'épilogue fait exactement le contraire.

Cela signifie que le pointeur de stack à l'intérieur du code de la fonction devrait toujours pointer vers la fin du shadow space (violet dans l'image ci-dessus), et la somme de la taille de la stack de la fonction et du shadow space peut être trouvée en calculant de combien le prologue augmente le pointeur de stack. Cette valeur peut être facilement calculée en utilisant les informations contenues dans les tables de déroulement (unwind tables), une explication de leur utilisation n'est pas couverte ici, mais en résumé, ces tables sont utilisées pour permettre à tout autre thread ou processus de se déplacer correctement à travers la stack pour voir son contenu, gérer les exceptions ou l'analyser.

Capture et préparation du contexte à restaurer.

Capturer le contexte est probablement l'une des choses les plus faciles à faire, car nous pouvons simplement faire RtlCaptureContext() à la première ligne de DeathSleep, avant toute modification des registres non volatils. Nous devons encore faire deux modifications au contexte où nous restaurerons l'exécution.

La première sera de modifier son Rip, comme vous vous en souvenez, c'est le registre qui contient la prochaine instruction à exécuter, et si nous le laissons inchangé, l'exécution reprendra à l'intérieur de la fonction DeathSleep. Ce que nous allons faire est de changer le Rip pour qu'il contienne l'adresse de retour de DeathSleep, qui est pointée par le Rsp actuel décalé de la taille augmentée dans l'épilogue (zones verte + violette dans les images ci-dessus).

La deuxième modification sera effectuée lorsque le thread sera restauré, et elle implique de définir le Rsp pour qu'il pointe vers le haut de notre stack restaurée ; cela sera fait pendant la phase de restauration, car nous ne savons pas où notre nouvelle stack sera placée. La valeur sera simplement l'adresse de fin de notre stack restaurée, car comme nous l'avons vu plus tard, nous avons également copié le shadow space réservé par l'appelant de DeathSleep, et c'est exactement la valeur de RSP avant l'appel à DeathSleep.

Restauration de notre stack :

Une fois que nous arrivons au point du réveil, juste avant de reprendre l'exécution, nous devons remettre notre stack sauvegardée en place. Comme nous savons déjà que notre stack sauvegardée commence à l'adresse capturée par la fonction awake, la nouvelle adresse capturée sera le point de départ où nous placerons notre stack sauvegardée, mais cela pose un problème : tout appel à une fonction après avoir placé notre ancienne stack la modifiera et la cassera, et faire le nettoyage ici est vraiment pratique, notamment pour libérer le heap utilisé pour contenir notre sauvegarde de stack. Cela signifie que nous devons déplacer notre Rsp actuel ainsi que les parties de la stack que nous utilisons actuellement vers un endroit en dehors de l'endroit où nous placerons notre stack restaurée. Pour essayer de rendre cela plus clair, voici le problème :

Et voici ma solution, simplement tout déplacer ailleurs :

Restauration du contexte :

Après avoir fait tout le travail acharné, la dernière chose à faire est d'utiliser NtContinue, cette fonction nous permet de changer le contexte actuel avec notre contexte précédemment capturé et modifié, en définissant le RIP juste après l'appel à DeathSleep, tous les registres doivent avoir les mêmes valeurs qu'ils avaient lors de l'appel à DeathSleep, et RSP doit pointer vers le haut de la stack.

Planification du processus de restauration : Utilisation de l'API Thread Pool.

D'accord, nous connaissons les bases de ce que nous devons faire pour stocker et restaurer le thread actuel, mais nous devons d'une manière ou d'une autre être capables d'exécuter tout cela même lorsque nous n'avons pas de threads. C'est là que nous rencontrons notre charmante API Thread Pool, un outil fourni par Windows, qui nous permet de mettre en file d'attente des tâches (fonctions avec un argument maximum) à un groupe de threads (un pool) qui sera entièrement géré par le système d'exploitation. Si vous avez vu Ekko, vous pouvez voir qu'il utilise cette API, alors… implémentons-la de la même manière.

Tout a bien fonctionné, mais il y avait un problème : un worker restait actif même après la fin de l'exécution de ses tâches en file d'attente. C'était un problème, car je voulais détruire tous les threads que notre programme pourrait générer, donc ce n'était pas la bonne voie.

Après avoir creusé un peu, j'ai découvert que l'API Thread Pool, utilisée dans Ekko, était une ancienne version, et qu'il y en avait une nouvelle avec quelques capacités supplémentaires, et, entre elles, une fonction qui résoudrait efficacement notre problème : CloseThreadPool(). Cette nouvelle API nous permet de créer notre propre pool, et de le détruire après utilisation, en terminant tous les workers utilisés. Elle offre deux autres avantages : définir un nombre maximum de threads et nettoyer les groupes. Définir un nombre maximum de threads nous permettrait d'exécuter toutes nos tâches séquentiellement, tant qu'elles sont mises en file d'attente avec un certain décalage temporel. Les groupes de nettoyage sont utiles pour faciliter le nettoyage après que tout est terminé.

Donc… tout est fait ? Eh bien, à ce stade, le thread est terminé, et nous mettons en file d'attente la fonction rebirth qui crée le nouveau thread avec awake comme point d'entrée, restaure l'état précédent et ferme le pool, tout va bien pour l'instant !

Modification des permissions mémoire, redirection de l'exécution.

Lorsque j'ai terminé avec tout ce dont nous avons parlé précédemment, j'ai pensé que la partie difficile était résolue, car cette partie avait déjà été résolue par des techniques précédentes, mais oh là là, je ne savais pas ce qui m'attendait.

Le principal problème est que nous devons décharger cela en dehors de notre code, car nous changeons la protection mémoire en RW (lecture-écriture), si nous appelons VirtualProtect(), lorsque la fonction retourne, notre processus plantera (nous ne pouvons pas exécuter d'instructions dans des pages RW), nous devons donc trouver un moyen d'exécuter cela depuis ailleurs, et de le faire retourner également vers des pages RX (lecture-exécution) (et la même chose se produit au retour). Évidemment, nous utiliserons à nouveau l'API Thread Pool pour cela, mais il y a un problème : nous ne pouvons donner qu'un seul argument à nos tâches, et VirtualProtect() en prend 4.

Pour cela, nous utiliserons à nouveau NtContinue(), la première fois que j'ai vu cette utilisation pour cette fonction était sur Foliage, mais elle est également utilisée dans Ekko. NtContinue(), comme nous l'avons vu précédemment, nous permet de définir un contexte pour le thread qui l'appelle, et avec quelques astuces intelligentes, elle peut "appeler" une fonction avec plusieurs arguments, en n'en utilisant qu'un seul (très pratique pour l'API Thread Pool). L'idée principale est de définir le RIP sur l'adresse de début de la fonction, et, puisque la convention d'appel Windows x64 passe les quatre premiers arguments dans les registres (rcx, rdx, r8, r9, dans cet ordre), il suffit de placer vos arguments dans la structure de contexte que vous passerez à NtContinue, et cela simulera efficacement un appel à une fonction. La dernière chose dont nous devons nous occuper lors de l'utilisation de NtContinue est le Rsp, car, comme nous l'avons vu précédemment, cette adresse doit contenir l'adresse de retour lorsqu'une fonction est appelée.

Donc la première chose dont nous avons besoin pour que NtContinue fonctionne est d'obtenir un contexte, nous pourrions le fabriquer manuellement, mais nous rencontrerions un problème : trouver la valeur pour Rsp, qui, lorsqu'elle est passée à notre fonction, pointera vers l'adresse qui sera utilisée par RET pour revenir. Nos tâches fonctionneront dans un thread différent, donc nous ne savons pas où sa stack sera placée. La solution (soigneusement volée à Ekko, merci beaucoup :P) est de prendre une copie du contexte à l'intérieur d'un worker avec RtlCaptureContext(), et d'augmenter le pointeur de stack du contexte obtenu de 8, afin qu'il pointe vers l'adresse introduite dans la stack par CALL RtlCaptureContext(), et qui est l'adresse de retour de cette dernière fonction, et nous pouvons l'utiliser comme adresse de retour pour toutes nos fonctions.

D'accord, c'est bien, mais que se passe-t-il lorsque nous ne pouvons pas faire cette modification du Rsp ? C'est ce qui se produit lorsque nous désobfusquons, nous serons dans un nouveau thread, donc l'ancien Rsp du contexte est inutile. Nous avons besoin d'un nouveau contexte, pris à partir du nouveau thread, mais nous ne pouvons pas utiliser l'ancienne astuce de modifier le Rsp pour pointer vers la bonne adresse.

Chaînes Rop

Donc nous ne pouvons pas modifier le contexte obtenu, mais cela ne signifie pas qu'il est inutile ; en réalité, nous allons l'utiliser, mais d'une manière différente. Si nous restaurons simplement ce contexte avec NtContinue(), sans modifier son Rip, cela redirigera simplement l'exécution vers l'instruction suivante après l'appel à RtlCaptureContext(), et avec un Rsp correct, nous pouvons donc l'utiliser après nos appels à NtContinue() avec des contextes modifiés, pour pouvoir terminer correctement l'exécution de nos tâches. Pour ce faire, nous utiliserons une chaîne Rop, en définissant le Rsp de notre premier contexte pour qu'il pointe vers une stack fabriquée manuellement, qui contiendra tout ce dont nous avons besoin pour rediriger l'exécution jusqu'au deuxième appel à NtContinue() qui définira le contexte correct pour terminer.

Voici à quoi devrait ressembler notre stack fabriquée :

Nous utilisons 2 gadgets rop, un pour corriger ou "sauter" par-dessus le shadow space de notre fonction, et le second est chargé de placer l'argument pour NtContinue dans rcx, puis de retourner à celui-ci.

Trouver ces 2 gadgets rop est assez facile, celui pour corriger le shadow space est simplement l'épilogue de presque n'importe quelle fonction (j'ai trouvé plus de 500 hits uniquement dans Ntdll), car comme nous l'avons vu précédemment, les épilogues sont conçus principalement pour réduire le Rsp, et le second est simplement un pop rcx; ret; qui fait 2 octets, et j'en ai également trouvé quelques-uns entre les dll Ntdll et Kernel32.

Un peu de rétro-ingénierie sur l'API Thread Pool

Comme nous l'avons vu, utiliser NtContinue nécessite seulement que son premier argument soit rempli pour fonctionner, et c'est parfait avec l'ancienne API Thread Pool, mais dans la nouvelle API Thread Pool, les arguments sont passés dans la deuxième position, donc oui, cela seul ne fonctionnera pas.

Après quelques heures sans savoir comment résoudre ce dernier problème, il m'est venu à l'esprit que les deux API utilisaient les mêmes fonctions dans certains cas, et cela m'a fait penser qu'elles pouvaient être plus similaires qu'elles n'y paraissaient, j'ai donc décidé d'étudier la relation entre elles.

Pour l'ancienne API, nous utilisons CreateTimerQueueTimer() pour mettre nos tâches en file d'attente, et dans la nouvelle, nous avons besoin de deux fonctions pour faire la même chose : CreateThreadpoolTimer(), qui prendra la fonction de rappel et l'argument à lui passer, et retournera un pointeur vers une structure TP_TIMER qui décrit la tâche, et une deuxième fonction pour mettre la tâche en file d'attente : SetThreadpoolTimer(), qui prendra le pointeur précédent et un pointeur vers une structure FILETIME qui décrit quand la tâche sera exécutée.

Si nous faisons du reverse sur ces fonctions, nous trouverons ceci :

Donc, comme nous pouvons le voir, CreateThreadpoolTimer() n'est qu'un wrapper sophistiqué pour TpAllocTimer(), et SetThreadpoolTimer() est juste un redirecteur vers TpSetTimer().

Maintenant, regardons l'intérieur de CreateTimerQueueTimer(). Au début, ce n'est qu'un autre wrapper sophistiqué pour une fonction dans Ntdll, RtlCreateTimer(), et c'est là que la magie opère. C'est une fonction plus grande, mais voici l'or que nous cherchions :

Comme vous pouvez le voir, à l'intérieur de cette fonction, il y a effectivement un appel à TpAllocTimer() et à TpSetTimer(), ce qui revient à dire qu'elle appelle CreateThreadpoolTimer() et SetThreadpoolTimer() à l'intérieur. Comme nous pouvons le voir, la fonction que nous mettons en file d'attente n'est pas directement le callback que nous avons donné à la fonction, elle définit RtlpTpTimerCallback() comme callback. Si vous n'avez pas encore réalisé ce que tout cela signifie, c'est que nous utilisons CreateThreadpoolTimer() pour mettre en file d'attente une fonction qui reçoit ses arguments dans la deuxième position, RtlpTpTimerCallback(), qui exécutera une autre fonction avec ses arguments dans la première position.

Donc la seule chose que nous devons encore comprendre est comment les informations du callback sont passées à RtlpTpTimerCallback(), et après quelques recherches, j'ai abouti à la structure suivante, qui, surprise surprise, FONCTIONNE !

Maintenant, nous pouvons appeler des fonctions qui reçoivent leurs arguments dans la première position tout en étant capables de fermer nos pools, et de ne laisser aucun thread en cours d'exécution, gagnant-gagnant. Il est important de noter que cette fonction n'est pas exportée dans Ntdll, j'ai donc décidé de la trouver par sa forme binaire à l'intérieur de la dll.

Donc c'est la fin, et avec tout ce qui a été passé en revue, je pense avoir donné les idées principales qui me sont venues à l'esprit lors du développement de cette POC, et pourquoi tout a été fait de la manière dont je l'ai fait.

J'espère que vous avez apprécié :)


Considérations de test

Ce code a été testé uniquement avec le compilateur et l'éditeur de liens MSCV, car cette POC dépend fortement de la manière dont elle a été compilée. Je recommande d'utiliser le même outil, et je ne garantis pas qu'il fonctionnera avec d'autres compilateurs sans modifications.


Télécharger l’outil