
Rilevatore in modalità utente che intercetta syscall indirette. Intrappola Hell's Hall, Tartarus' Gate, RecycledGate e syscall VEH e molte altre.
Copyright (C) 2026 Adam Zypherion <[email protected]> — con licenza GPL-3.0.
Le syscall indirette sono semplici una volta che ne hai vista una. Il loader attraversa ntdll, legge l'SSN dal prologo di Nt*, trova i due byte 0F 05 poco dopo, imposta di persona r10/rax/rdx/r8/r9 e fa jmp direttamente a quei due byte. La syscall avviene dall'interno di ntdll. La tua hook sull'export di kernel32 non viene mai toccata. La tua hook sull'export di ntdll non viene mai toccata. [RSP] al momento della SYSCALL punta alla pagina RWX del loader, ma nulla nella chiamata appare insolito dall'esterno.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
Le varianti hanno nomi. Tartarus' Gate e RecycledGate usano l'istruzione syscall appartenente a uno stub ma l'SSN di un altro, quindi anche se registri il nome dello stub che la tua hook riporta, è una menzogna. VEH syscall innesca intenzionalmente una violazione di accesso e usa la propria VEH per riscrivere il contesto in modo che RIP cada sull'istruzione syscall di ntdll con i registri già pronti. Hell's Gate non usa affatto ntdll. Il loader scrive 0F 05 nella propria pagina RWX e viene eseguito da lì.
La risposta in modalità kernel (KM) è un driver; potrei effettivamente occuparmene col tempo e rilasciare un progetto, ma non accadrà presto :D
PAGE_GUARD sembrava dovesse funzionare. Marchi la pagina che contiene i byte della syscall con PAGE_GUARD, intercetti la STATUS_GUARD_PAGE_VIOLATION in una VEH, ispezioni, reindirizzi a un trampolino privato, imposti il flag trap, esci a passo singolo, riarmi la pagina. Un trap per chiamata Nt, indipendentemente da come il loader ci è arrivato. Funziona contro tutto tranne Hell's Gate.
Il problema è che il sistema operativo non collabora. PAGE_GUARD è monouso – cosa significa? Ogni volta che scatta il bit viene cancellato e devi rimetterlo. Il tuo handler esegue syscall (NtProtect per rimettere la guardia, NtContinue per riprendere) e quelle syscall hanno stub e quegli stub sono sulla pagina che hai appena protetto. Ho aggirato gran parte di questo problema con stub di syscall privati costruiti su una pagina separata di nostra proprietà (allocare RWX, scrivere mov r10,rcx; mov eax,SSN; syscall; ret, bloccare RX, non toccare mai ntdll), ma era fragile. Ogni versione minore di Windows cambiava i tempi. Ogni thread generato dal campione era un'altra corsa contro il worker di integrità che ricostruiva la guardia. Il successo demo era circa dal 30 al 50 percento su dieci esecuzioni sulla stessa macchina.
Alla fine ho smesso di cercare di convincere Windows che PAGE_GUARD dovesse comportarsi come volevo io e ho provato invece a sovrascrivere il byte.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
Il byte all'istruzione syscall è 0F 05. Solo il primo byte (0F) è il prefisso per una famiglia di opcode a due byte che include SYSCALL, CPUID e RDTSC. Da solo non è eseguibile; la CPU ha bisogno del secondo byte per decodificare. Se sostituisci il primo byte con 0xCC (INT3), la coppia diventa CC 05, che la CPU decodifica come INT3 seguito da un byte randagio che non raggiunge mai. Qualsiasi percorso di codice che atterra su quell'indirizzo solleva EXCEPTION_BREAKPOINT.
Questo è l'intero meccanismo. La nostra VEH intercetta il breakpoint, cerca a quale stub appartiene l'indirizzo (costruiamo la mappa all'inizializzazione enumerando gli export di ntdll), esegue tre controlli su chi si è presentato e imposta Context->Rip su un trampolino privato che esegue la vera syscall e ritorna. Il byte rimane CC. Il chiamante successivo lo colpisce allo stesso modo, quindi nessun capovolgimento di protezione di pagina avanti e indietro.
Per essere chiari: sì, questo è un hook tramite sovrascrittura di byte. Non è però il byte che di solito si intende con "hook ntdll". Il classico hook EDR sovrascrive i primi byte dello stub con un JMP <my_func>:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; sovrascrive "mov r10, rcx"
...
0F 05 syscall
C3 ret
Questo è esattamente ciò che le syscall indirette aggirano. Il loader legge l'SSN da solo e salta direttamente al 0F 05; il prologo (e il tuo jmp) non vengono mai eseguiti, l'hook non scatta mai. Quello che facciamo noi è sovrascrivere il byte della syscall stesso:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; non toccato
B8 18 00 00 00 mov eax, 18h ; non toccato
CC 05 int3 / 05 ; era 0F 05, abbiamo scritto CC sopra 0F
C3 ret
Ora non importa come sei arrivato a quell'indirizzo. Attraverso il prologo, attraverso un salto indiretto che salta il prologo, attraverso una riscrittura del contesto VEH-syscall che ci fa cadere RIP, qualsiasi cosa. Se la CPU esegue quel byte, si blocca. Stessa famiglia di tecnica dell'hook classico, posizione diversa, copertura completamente diversa.
Il trampolino si presenta così:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
I tre controlli sono invariati rispetto alla versione PAGE_GUARD perché erano i tre controlli giusti; avevano solo bisogno di un posto affidabile dove vivere.
Indirizzo di ritorno. [RSP] è dove la syscall sarebbe tornata. Per una chiamata reale è all'interno di ntdll, kernel32, kernelbase o una delle DLL runtime correlate. Per Hell's Hall è all'interno della pagina RWX da cui il loader sta eseguendo. Abbiamo una lista breve di target di ritorno affidabili costruita all'inizializzazione chiamando GetModuleHandle per quei nomi e leggendo gli intervalli .text dai loro header PE.
SSN. Il prologo dello stub è già stato eseguito prima che raggiunga il byte di syscall, quindi eax contiene il valore che vi è stato caricato. Se il loader ha fatto uno scambio Tartarus, quel valore non corrisponderà all'SSN che abbiamo letto da questo stesso stub durante l'enumerazione. Registriamo la discrepanza e il trampolino scrive l'SSN corretto prima della propria syscall, quindi la funzione kernel che viene eseguita è quella che appartiene al byte invece di quella voluta dal loader. La tecnica viene registrata e neutralizzata nello stesso passaggio.
Stack walk. RtlVirtualUnwind dal contesto corrente, cinque frame su. Ogni frame dovrebbe avere RIP all'interno di un modulo noto e dovrebbe avere una voce RUNTIME_FUNCTION. Lo shellcode e i gadget ROP falliscono questo controllo anche quando [RSP] stesso sembra affidabile, cosa che un loader può falsificare (può prevedere approssimativamente dove sarà il suo chiamante in memoria e forgiare un indirizzo di ritorno credibile lì).
Se un controllo fallisce, inseriamo una piccola struct in un ring lock free e il thread di drenaggio la stampa la prossima volta che si sveglia:
[!! hallwatch !!] syscall indiretta (chiamante non fidato, ssn sbagliato per questo stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (lo stub codifica 0x00000018)
thread : 26388
Hell's Gate è dove INT3 smette di aiutare. Non abbiamo mai patchato la pagina RWX del loader perché non sapevamo che esistesse.
Quello che facciamo invece è lo scanner più semplice che potrebbe funzionare. Ogni 250 ms (fondamentalmente troppo frequente, spreca cicli CPU ma è per un PoC) il worker di integrità esamina lo spazio degli indirizzi con VirtualQuery, cercando qualsiasi regione MEM_COMMIT con una protezione di pagina eseguibile. Se la regione è all'interno di un modulo caricato viene saltata. Se è all'interno del nostro pool di trampolini viene saltata. Ciò che rimane è memoria eseguibile estranea. Scansioniamo fino a 64 KB di essa per coppie di byte 0F 05, deduplicamo per indirizzo, registriamo ogni hit unico una volta. So che è stupido e sprechi anche cicli CPU, ma è un PoC che non dovrebbe sminuire la situazione, ma attualmente lo fa.
Questo intercetta Hell's Gate in una nuova allocazione RWX e intercetta shadow ntdll (un loader che fa NtMapViewOfSection su ntdll.dll per ottenere una copia privata a un nuovo indirizzo). Il caso shadow è divertente. Il mapping non è nel nostro snapshot dei moduli, quindi le sue istruzioni syscall appaiono come byte eseguibili estranei, anche se provengono da una DLL firmata perfettamente legittima sul disco.
Altra variante: l'attaccante incorpora 0F 05 nel .text del proprio binario, quindi la syscall vive all'interno di un modulo caricato invece che in una pagina RWX. Foreign-RWX salta i moduli caricati, quindi questo un tempo sfuggiva. Quindi abbiamo un altro scanner che esamina i byte eseguibili di ogni modulo, salta ntdll, win32u e wow64cpu (i tre moduli che hanno legittimamente istruzioni syscall) e segnala 0F 05 solo se c'è un C3 entro i tre byte successivi. Il controllo del ret è ciò che lo rende utilizzabile. 0F 05 da solo appare nell'output del compilatore come parte di istruzioni più grandi tutto il tempo, ma non è mai adiacente a un C3. Gli stub di syscall reali lo sono sempre. Log:
[hallwatch] scan: syscall stub nel testo del modulo a 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate all'interno del .text del modulo caricato
Il worker di integrità ha altri due piccoli compiti. Legge il byte in ogni stub patchato ad ogni tick e scrive CC se non è CC. Questo è il più semplice possibile unhook: il campione legge lo 0F originale da qualche parte (un fresh mapping di ntdll, il PE su disco, una tabella di byte noti) e lo riscrive sopra la nostra patch. Ce ne accorgiamo al tick successivo e riconfezioniamo sopra.
Ogni cinquantesimo tick (circa 12,5 secondi) il worker re-installa la nostra VEH in testa alla catena e rimuove l'handle precedente. Un campione può camminare su LdrpVectorHandlerList e disiscriverci; il worker limita la durata di quel gap. Dodici secondi e mezzo sono una finestra lunga se vieni attaccato e corta se esegui malware in una sandbox. Probabilmente la accorcerò una volta misurato il costo di farlo più spesso.
Il thread worker viene eseguito con un flag TLS impostato che la VEH controlla. Le syscall del worker (NtProtect per il patching, NtQuery per la scansione) innescano i nostri breakpoint come qualsiasi altra cosa, ma il flag dice alla VEH di saltare la logica di rilevamento e reindirizzare silenziosamente attraverso il trampolino.
La sezione critica si inizializza da sola la prima volta che qualcuno chiama, tramite un compare-exchange a tre stati (0 = non inizializzato, 2 = occupato, 1 = pronto). È brutto ma evita la necessità di un costruttore statico all'interno di una DLL, che su Windows è un insieme separato di problemi che coinvolgono il loader lock, ecc.
Una cosa ABI degna di nota. INT3 è un trap, il che significa che Context->Rip punta all'istruzione successiva quando la nostra VEH viene chiamata, non al trap stesso. Se abbiamo patchato il byte a 0x7FF827660372, Context->Rip arriva alla VEH come 0x7FF827660373. Record->ExceptionAddress punta al trap, ma dobbiamo reimpostare Context->Rip = ExceptionAddress prima di reindirizzarlo al trampolino, altrimenti il trampolino inizia un byte dopo e la syscall non fa nulla di utile. Te lo dico perché è stato un bug che mi ha richiesto un sacco di tempo per trovarlo.
Ci sono quattro export. IscInitialize arma il rilevatore; DllMain lo chiama automaticamente ma puoi chiamarlo da un processo host se vuoi un valore di ritorno. IscGetDetectionCount restituisce un contatore monotonico crescente. IscShutdown attende gli handler in corso e ripristina i byte 0F. IscFlush svuota in modo sincrono il ring, utile se stai incorporando il rilevatore in una sandbox che ha bisogno degli eventi prima che il campione esca.
L'integrazione minima è LoadLibrary. DllMain gestisce l'inizializzazione e avvia entrambi i thread in background da lì.
Isc = Syscall Indirette
Cose che ancora non intercettiamo.
Un campione che fa controlli di integrità.
Un campione che utilizza stub che non abbiamo patchato. L'allowlist attuale è di circa 40 nomi che coprono primitive offensive di memoria, processo, thread, sezione, token e file. Aggiungerne altri è incrementale purché DllMain termini rapidamente. Patchare tutti i 488 stub dall'interno del loader lock è... ehm...
Un campione eseguito con privilegi kernel. Non è un problema in spazio utente.
INT3 è ciò che abbiamo scelto alla fine, ma il meccanismo di trap non è la parte interessante. Il motivo per cui funziona meglio di PAGE_GUARD è che il trap non richiede una negoziazione continua con il sistema operativo. Il byte è CC. Rimane CC. Il sistema operativo non ha un'opinione su quali byte risiedano in ntdll.