Skip to content
KitploitKITPLOIT
StrumentiBlog
Invia
StrumentiBlog
Invia

Strumenti di Hacking, PenTest e Cybersecurity per il tuo Arsenale di Sicurezza!

Kitploit è una directory di strumenti di hacking, cybersecurity e pentesting. Scopri gli ultimi aggiornamenti dei progetti per trovare vulnerabilità, analizzare sistemi, automatizzare i test e rafforzare la tua sicurezza.

··Feed·Contatto·Privacy·© 2026 Kitploit

Directory degli strumenti

Categorie

Vedi tutte le categorie
Loading categories
DeathSleep — Un'implementazione PoC per una tecnica di evasione per terminare il thread corrente e ripristinarlo prima di riprendere l'esecuzione, mentre si implementano modifiche alla protezione delle pagine durante la non esecuzione. | Kitploit
Strumenti/GitHubGitHub/janoglezcampos/deathsleep
ExploitAnalisi MalwareRed TeamingSviluppo Payload
GitHubjanoglezcampos/deathsleep

DeathSleep

Un'implementazione PoC per una tecnica di evasione per terminare il thread corrente e ripristinarlo prima di riprendere l'esecuzione, mentre si implementano modifiche alla protezione delle pagine durante la non esecuzione.

Vedi Repository
538774 anni faRevisionato da Kitploit

Più Popolari

Vedi tutti →

Scopri gli strumenti più utilizzati dalla nostra community.

Esplora tutti gli strumenti

Sfoglia la nostra collezione di strumenti

Vedi tutti gli strumenti →
Condividi

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

Una implementazione PoC per una tecnica di evasione che termina il thread corrente e lo ripristina prima di riprendere l'esecuzione, implementando cambiamenti di protezione delle pagine durante la mancata esecuzione.

Introduzione

I metodi di sleep e offuscamento sono ben noti nella comunità maldev, con implementazioni diverse, hanno l'obiettivo di nascondersi dagli scanner di memoria durante il sonno, solitamente cambiando le protezioni di pagina e aggiungendo anche funzionalità interessanti come la crittografia dello shellcode, ma c'è un altro punto importante per nascondere il nostro shellcode: nascondere il thread corrente di esecuzione. Spoofare lo stack è figo, ma dopo averci pensato un po' ho pensato che non c'è bisogno di spoofare lo stack… se non c'è stack :)

L'usabilità di questa tecnica è lasciata alla valutazione del lettore, ma in ogni caso, penso sia un modo interessante per rivedere alcuni argomenti e imparare un po' di maldev per chi, come me, sta iniziando in questo mondo.

L'implementazione principale mostrata qui contiene tutto ciò di cui abbiamo bisogno per estrarre dallo stack nella sezione dati, come variabili globali, ma un'implementazione che sposti tutto nell'heap sarà pubblicata presto. Ha lo scopo di mostrare alcune modifiche chiave che devono essere apportate per rendere questo codice pic e iniettabile.

Questo repository è mirrorato tra GitHub e GitLab.


Cosa sta succedendo?

Prima di tutto

Tutto ciò che è affermato qui deriva dalla mia comprensione dei vari argomenti trattati, sia tramite lettura che esperienza durante lo sviluppo. Sono consapevole di non essere un esperto e l'ultima cosa che voglio è diffondere disinformazione, quindi se pensi che qualcosa non sia corretto, mi piacerebbe che me lo facessi notare, puoi contattarmi su twitter o aprendo issue in questo repo. Grazie mille per la tua comprensione. :)

Basi

L'obiettivo principale di questa tecnica è chiaro: terminare il thread corrente e ripristinarlo prima di riprendere l'esecuzione, ma cosa significa esattamente e quali nuovi vincoli impone?

Per poter ripristinare l'esecuzione, dobbiamo salvare due cose prima di terminare il thread: primo, lo stato della CPU, e secondo, lo stack, e impostarli di nuovo efficacemente dopo che il nuovo thread è stato lanciato.

Ho parlato di nuovi vincoli che appariranno in questa tecnica, e ce ne sono due grandi: Primo, dobbiamo memorizzare al di fuori dello stack tutto ciò di cui abbiamo bisogno dal momento in cui il thread termina fino a quando lo stack viene ripristinato, e come vedrete, crea alcune nuove sfide.

Secondo, abbiamo sempre bisogno di almeno un altro thread in esecuzione nel nostro processo, poiché stiamo terminando il nostro thread, se non ci sono altri thread il processo terminerà. Non penso che questo sia un grosso problema, poiché la maggior parte degli agenti vengono iniettati in altri processi, possiamo presumere che questo processo manterrà almeno un thread in esecuzione.

Componenti di DeathSleep:

Possiamo vedere in questo POC 4 funzioni principali:

  • Programma principale: Qui è dove scriveresti il codice del tuo agente, ed è la porzione di codice che farà uso di DeathSleep
  • Funzione Awake: questo è il punto di ingresso di tutti i nostri thread, ed è responsabile di salvare il punto di partenza dello stack che ripristineremo. Inoltre, è responsabile di ripristinare lo stack e il contesto CPU quando necessario, o semplicemente di avviare il nostro programma principale.
  • DeathSleep: questa è la funzione principale di questa tecnica, ed è responsabile di eseguire il backup del contesto del thread e dello stack, e anche di preparare tutto per la magia.
  • Rebirth: Una semplice funzione responsabile solo di lanciare i nostri nuovi thread.

Salvare lo stack.

Quando stiamo per salvare lo stack, sorge una domanda: quanto dello stack deve essere salvato?

Rivediamo prima cosa c'è nello stack dopo aver chiamato la funzione DeathSleep (questa è la funzione che salva il contesto, lo stack e prepara tutto per l'offuscamento e il ripristino)

Come possiamo vedere, ogni funzione ha tre parti:

  • Shadow space: questo è uno spazio di 32 byte, allocato dal chiamante, ma utilizzato dal chiamato. Per quanto ne so e ho potuto vedere, la sua funzione principale è contenere, se necessario, gli argomenti passati alla funzione chiamata nei registri, ma può essere utilizzato per qualsiasi cosa decida la funzione chiamata.
  • Indirizzo di ritorno: questo è l'indirizzo della prossima istruzione da eseguire nella funzione chiamante, inserito dall'istruzione CALL, quindi l'istruzione RET nella funzione chiamata prenderà semplicemente questo indirizzo e "salterà" ad esso quando termina.
  • Spazio dello stack della funzione: Questo è lo spazio riservato dal chiamato per memorizzare il valore dei registri che devono essere ripristinati e il valore delle sue variabili locali.

La porzione minima dello stack che ovviamente dobbiamo salvare è tutto ciò che è all'interno del nostro programma principale, cioè il suo shadow space, il suo indirizzo di ritorno e tutto fino alla funzione DeathSleep. Tutto ciò che precede non è realmente necessario (salvare lo stack usato dalla funzione di ingresso ha i suoi vantaggi, ma ne discuteremo più avanti), poiché quello è lo stack utilizzato dalle routine di Windows per lanciare il nostro nuovo thread. Oltre a questo, ho deciso di memorizzare anche lo shadow space della funzione DeathSleep (non realmente necessario, ma rende il calcolo dell'Rsp al momento del risveglio più facile).

Quindi alla fine, stiamo salvando questo:

Come trovare i nostri indirizzi di stack:

Ogni funzione in una compilazione standard dovrebbe essere composta da 3 parti: il prologo, il codice della funzione e l'epilogo.

L'Rsp (puntatore allo stack) dovrebbe essere modificato solo nel prologo e nell'epilogo della funzione. Il prologo aumenta il puntatore dello stack (ricorda che aumentare lo stack significa ridurre gli indirizzi, poiché vanno in direzioni opposte), per salvare i registri, per contenere tutte le sue variabili locali e poi per contenere lo shadow space, e l'epilogo fa esattamente l'opposto.

Ciò significa che il puntatore dello stack all'interno del codice della funzione dovrebbe sempre puntare alla fine dello shadow space (viola nell'immagine sopra), e la somma della dimensione dello stack della funzione e dello shadow space può essere trovata calcolando di quanto il prologo aumenta il puntatore dello stack. Questo valore può essere facilmente calcolato usando le informazioni contenute nelle tabelle di unwind, una spiegazione del loro utilizzo non verrà trattata qui, ma come riassunto, queste tabelle vengono utilizzate per permettere a qualsiasi altro thread o processo di muoversi correttamente attraverso lo stack per vedere il suo contenuto, gestire eccezioni o analizzarlo.

Catturare e preparare il contesto da ripristinare.

Catturare il contesto è probabilmente una delle cose più facili da fare, poiché possiamo semplicemente chiamare RtlCaptureContext() alla prima riga di DeathSleep, prima che venga fatta qualsiasi modifica ai registri non volatili. Dobbiamo comunque fare due modifiche al contesto in cui ripristineremo l'esecuzione.

La prima sarà modificare il suo Rip, come ricorderete, questo è il registro che contiene la prossima istruzione da eseguire, e se lo lasciamo non modificato, l'esecuzione riprenderà all'interno della funzione DeathSleep. Quello che faremo è cambiare il Rip in modo che punti all'indirizzo di ritorno di DeathSleep, che è puntato dall'attuale Rsp spostato della dimensione aumentata nell'epilogo (aree verde + viola nelle immagini sopra).

La seconda modifica verrà fatta quando il thread viene ripristinato, e implica impostare l'Rsp in modo che punti alla cima del nostro stack ripristinato; questo verrà fatto durante la fase di ripristino, poiché non sappiamo dove sarà posizionato il nostro nuovo stack. Il valore sarà semplicemente l'indirizzo finale del nostro stack ripristinato, poiché come discusso in seguito, abbiamo copiato anche lo shadow space riservato dal chiamante di DeathSleep, e quello è esattamente il valore di RSP prima della chiamata a DeathSleep.

Ripristinare il nostro stack:

Una volta raggiunto il punto del risveglio, poco prima di riprendere l'esecuzione, dobbiamo mettere a posto il nostro stack salvato. Come già sappiamo, il nostro stack salvato inizia all'indirizzo catturato dalla funzione awake, quindi il nuovo indirizzo catturato sarà il punto di partenza dove posizioneremo il nostro stack salvato, ma questo solleva un problema: qualsiasi chiamata a funzione dopo aver posizionato il nostro vecchio stack lo modificherebbe e lo romperebbe, e fare la pulizia qui è molto conveniente, specialmente liberare l'heap usato per contenere il backup del nostro stack. Ciò significa che dobbiamo spostare il nostro attuale Rsp e anche le parti dello stack che stiamo attualmente utilizzando in un posto al di fuori di dove posizioneremo il nostro stack ripristinato. Cercando di renderlo più chiaro, ecco il problema:

Ed ecco la mia soluzione, spostare tutto via:

Ripristinare il contesto:

Dopo aver fatto tutto il lavoro duro, l'ultima cosa da fare è usare NtContinue, questa funzione ci permette di cambiare il contesto corrente con il nostro contesto precedentemente catturato e modificato, impostando il RIP subito dopo la chiamata a DeathSleep, tutti i registri dovrebbero avere gli stessi valori che avevano quando si chiamava DeathSleep, e l'RSP dovrebbe puntare alla cima dello stack.

Programmazione del processo di ripristino: Utilizzo dell'API Thread Pool.

Ok, conosciamo le basi di ciò che dobbiamo fare per memorizzare e ripristinare il thread corrente, ma dobbiamo in qualche modo essere in grado di eseguire tutto questo anche quando non abbiamo thread. Qui incontriamo la nostra amata API Thread Pool, uno strumento fornito da Windows, che ci permetterà di accodare compiti (funzioni con un massimo di un argomento) a un gruppo di thread (un pool) che sarà completamente gestito dal sistema operativo. Se hai visto Ekko, puoi vedere che utilizza questa API, quindi… implementiamola allo stesso modo.

Tutto funzionava bene, ma c'era un problema: un worker rimaneva attivo anche dopo la fine dell'esecuzione dei suoi compiti accodati. Questo era un problema, poiché volevo distruggere tutti i thread che il nostro programma poteva generare, quindi questa non era la strada giusta.

Dopo aver scavato un po', ho scoperto che l'API Thread Pool, utilizzata in Ekko, era una versione vecchia, e ce n'era una nuova con alcune capacità aggiuntive, e, tra queste, una funzione che avrebbe risolto efficacemente il nostro problema: CloseThreadPool(). Questa nuova API ci permette di creare il nostro pool e distruggerli dopo averli usati, terminando tutti i worker utilizzati. Offre anche altri due vantaggi: impostare un numero massimo di thread e gruppi di pulizia. Impostare un numero massimo di thread ci permetterebbe di eseguire tutti i nostri compiti in sequenza, purché siano accodati con una differenza di tempo qualsiasi. I gruppi di pulizia sono utili per rendere la pulizia più semplice dopo che tutto è finito.

Quindi… è tutto fatto? Beh, a questo punto il thread è terminato e stiamo accodando la funzione rebirth che crea il nuovo thread con awake come punto di ingresso, ripristina lo stato precedente e chiude il pool, fin qui tutto bene!

Modificare i permessi di memoria, reindirizzare l'esecuzione.

Quando ho finito con tutto ciò di cui abbiamo discusso prima, pensavo che la parte difficile fosse risolta, poiché questa parte era già stata risolta da tecniche precedenti, ma ooh ragazzi, non sapevo cosa stava arrivando.

Il problema principale è che dobbiamo spostarlo al di fuori del nostro codice, poiché stiamo cambiando la protezione della memoria in RW (lettura-scrittura), se chiamiamo VirtualProtect(), quando la funzione ritorna, il nostro processo crashirà (non possiamo eseguire istruzioni in pagine RW), quindi dobbiamo trovare un modo per eseguire questo da qualche altra parte e farlo ritornare anche a pagine RX (lettura-esecuzione) (e la stessa cosa accade quando si torna indietro). Ovviamente, utilizzeremo anche l'API Thread Pool per questo, ma c'è un problema: possiamo dare solo un argomento ai nostri compiti, e VirtualProtect() ne prende 4.

Per questo utilizzeremo di nuovo NtContinue(), la prima volta che ho visto questo utilizzo per questa funzione è stato su Foliage, ma è anche usato in Ekko. NtContinue(), come abbiamo visto prima, ci permette di impostare un contesto al thread che lo chiama, e con qualche astuta modifica, può "chiamare" una funzione con più argomenti, utilizzandone solo uno (molto comodo per l'API Thread Pool). L'idea principale è impostare il RIP all'indirizzo di inizio della funzione, e, poiché la convenzione di chiamata Windows x64 passa i primi quattro argomenti nei registri (rcx, rdx, r8, r9, in quest'ordine), basta mettere i tuoi argomenti nella struttura di contesto che passerai a NtContinue, e simulerà efficacemente una chiamata a una funzione. L'ultima cosa di cui dobbiamo occuparci quando si usa NtContinue è l'Rsp, poiché, come abbiamo visto prima, questo indirizzo dovrebbe contenere l'indirizzo di ritorno quando viene chiamata una funzione.

Quindi la prima cosa di cui abbiamo bisogno per far funzionare NtContinue è ottenere un contesto, potremmo costruirlo manualmente, ma incontreremo un problema: trovare il valore per Rsp, che quando passato alla nostra funzione, punterà all'indirizzo che sarà usato da RET per tornare. I nostri compiti funzioneranno in un thread diverso, quindi non sappiamo dove sarà posizionato il suo stack. La soluzione (accuratamente rubata da Ekko, grazie mille :P) è fare una copia del contesto all'interno di un worker con RtlCaptureContext(), e aumentare il puntatore allo stack del contesto ottenuto di 8, in modo che punti all'indirizzo introdotto nello stack da CALL RtlCaptureContext(), e che è l'indirizzo di ritorno di quest'ultima funzione, e possiamo usarlo come indirizzo di ritorno per tutte le nostre funzioni.

Ok, è carino, ma cosa succede quando non possiamo fare questa modifica all'Rsp? Questo è ciò che accade quando deoffuschiamo: saremo in un nuovo thread, quindi l'Rsp del vecchio contesto è inutile. Abbiamo bisogno di un nuovo contesto, preso dal nuovo thread, ma non possiamo usare il vecchio trucco di modificare l'Rsp per puntare all'indirizzo corretto.

Catene ROP

Quindi non possiamo modificare il contesto ottenuto, ma ciò non significa che sia inutile, in realtà lo useremo, ma in modo diverso. Se ripristiniamo semplicemente quel contesto con NtContinue(), senza modificare il suo Rip, reindirizzerà semplicemente l'esecuzione all'istruzione successiva dopo la chiamata a RtlCaptureContext(), e con un Rsp corretto, quindi possiamo usarlo dopo le nostre chiamate a NtContinue() con contesti modificati, per poter terminare correttamente l'esecuzione dei nostri compiti. Per fare questo useremo una catena ROP, impostando l'Rsp del nostro primo contesto in modo che punti a uno stack costruito manualmente, che conterrà tutto ciò di cui abbiamo bisogno per reindirizzare l'esecuzione fino alla seconda chiamata a NtContinue() che imposterà il contesto corretto per terminare.

Ecco come dovrebbe apparire il nostro stack costruito:

Stiamo utilizzando 2 gadget ROP, uno per fissare o "saltare" sopra lo shadow space della nostra funzione, e il secondo è responsabile di posizionare l'argomento per NtContinue in rcx, e poi tornare ad essa.

Trovare questi 2 gadget ROP è abbastanza facile, quello per fissare lo shadow space è semplicemente l'epilogo di quasi qualsiasi funzione (ho trovato più di 500 hit solo in Ntdll), poiché come abbiamo visto prima, gli epiloghi sono progettati principalmente per ridurre l'Rsp, e il secondo è solo un pop rcx; ret; che è lungo 2 byte e ho trovato anche un paio tra le DLL Ntdll e Kernel32.

Un po' di reverse engineering sull'API Thread Pool

Come abbiamo visto, usare NtContinue richiede solo di avere il suo primo argomento riempito per funzionare, e questo è perfetto con la vecchia API Thread Pool, ma nella nuova API Thread Pool, gli argomenti vengono passati nella seconda posizione, quindi sì, da sola non funzionerà.

Dopo alcune ore senza sapere come risolvere quest'ultimo problema, mi è venuto in mente che entrambe le API utilizzavano le stesse funzioni in alcuni casi, e questo mi ha fatto pensare che potrebbero essere più simili di quanto sembrino, quindi ho deciso di investigare quale fosse la relazione tra loro.

Per la vecchia API stiamo usando CreateTimerQueueTimer() per accodare i nostri compiti, e nella nuova, abbiamo bisogno di due funzioni per fare lo stesso: CreateThreadpoolTimer(), che prenderà la funzione di callback e l'argomento da passare ad essa, e restituirà un puntatore a una struttura TP_TIMER che descrive il compito, e una seconda funzione per accodare il compito: SetThreadpoolTimer(), che prenderà il puntatore precedente e un puntatore a una struttura FILETIME che descrive quando il compito sarà eseguito.

Se facciamo reverse engineering a queste funzioni, troveremo questo:

Quindi, come possiamo vedere, CreateThreadpoolTimer() è solo un wrapper elaborato per TpAllocTimer(), e SetThreadpoolTimer() è solo un forwarder per TpSetTimer().

Ora controlliamo l'interno di CreateTimerQueueTimer(). All'inizio, è solo un altro wrapper elaborato per una funzione in Ntdll, RtlCreateTimer(), ed è qui che avviene la magia. Questa è una funzione più grande, ma ecco l'oro che stavamo cercando:

Come puoi vedere, all'interno di questa funzione c'è effettivamente una chiamata a TpAllocTimer() e a TpSetTimer(), il che è simile a dire che sta chiamando CreateThreadpoolTimer() e SetThreadpoolTimer() al suo interno. Come possiamo vedere, la funzione che stiamo accodando non è direttamente il callback che abbiamo dato alla funzione, ma sta impostando RtlpTpTimerCallback() come callback. Se non hai ancora capito cosa significa tutto questo, è che stiamo usando CreateThreadpoolTimer() per accodare una funzione che riceve i suoi argomenti nella seconda posizione, RtlpTpTimerCallback(), che eseguirà un'altra funzione con i suoi argomenti nella prima posizione.

Quindi l'unica cosa che dobbiamo ancora capire è come le informazioni di callback vengono passate a RtlpTpTimerCallback(), e dopo un po' di reverse engineering ho concluso con la seguente struttura, che sorpresa sorpresa, FUNZIONA!

Ora possiamo chiamare funzioni che ricevono i loro argomenti nella prima posizione e allo stesso tempo siamo in grado di chiudere i nostri pool, senza lasciare thread in esecuzione, vittoria vittoria. È importante notare che questa funzione non è esportata in Ntdll, quindi ho deciso di trovarla tramite la sua forma in byte all'interno della dll.

Quindi questa è la fine, e con tutto ciò che è stato rivisto, penso di aver dato le idee chiave che mi sono passate per la mente durante lo sviluppo di questo POC, e perché tutto è stato fatto nel modo in cui l'ho fatto.

Spero vi sia piaciuto :)


Considerazioni sui test

Questo codice è stato testato solo con il compilatore e linker MSCV, poiché questo POC dipende fortemente da come è stato compilato, consiglio di utilizzare questo stesso strumento, e non garantisco che funzionerà con altri compilatori senza modifiche.


Scarica lo strumento