FiND HiDDEN RESiDENT PR0CESSES -
Pubblicato da twiz <[email protected]> & sgrakkyu <[email protected]> il 04/02/2003
Livello avanzato
Introduzione
Questo articolo si divide fondamentalmente in due parti: una prima parte che
cercherà di analizzare le basi teoriche di FHRP, spaziando tra concetti,
implementazioni pratiche all'interno del kernel di linux e così via e una
seconda parte "pratica" che presenterà gli obiettivi e l'implementazione
effettiva del codice.
La prima parte non è strettamente necessaria per utilizzare FHRP, ma fornisce
le basi (e magari spunti) per capirlo a fondo e, magari, migliorarlo e/o
adattarlo alle proprie esigenze :)
Giusto per dare due coordinate, FHRP è un modulo scritto per il kernel 2.4 di
linux, implementato unicamente per sistemi uniprocessore, con l' obiettivo di
trovare eventuali processi nascosti su una macchina utilizzando il valore del
registro cr3 come signature.
Nonostante ciò una (direi buona :)) parte delle idee e del codice dovrebbe
essere applicabile anche a altri sistemi operativi, a sistemi SMP e,
eventualmente, a altre architetture.
Iniziamo
Al tutorial è allegato il codice sorgente dei programmi.
I processi e lo scheduler
Scovare processi nascosti, abbiamo detto. Do per scontato che tutti sappiate
cosa è un processo e che vi sia chiara l'"astrazione" che viene fatta nel
kernel di linux, dove sarebbe più corretto parlare di task, visto che linux
vede e gestisce kernel thread e processi in userland fondamentalmente allo
stesso modo, ovvero con una struct task_struct.
[nota: Questo non vuol dire che non ci sia differenza tra kthread e processi
in userland, infatti, ad esempio, la struct mm_struct di un kthread sarà
sempre uguale a NULL, in quanto la virtual memory che accede è quella
direttamente mappata in kernel space. Un altro esempio importante è che
attualmente nel thread "base" del kernel 2.4 la full preemption patch non è
applicata, dunque i kernel thread e, comunque, qualunque cosa giri in kernel
space non è pre-emptable]
Allo stesso modo non ci dilunghiamo su cos'è e come funziona uno scheduler,
è pieno di libri e testi per la rete che ne trattano l'argomento (per una
minima lista consultate la reference al fondo)... in una nutshell possiamo
definire lo scheduler come quella parte del sistema operativo che si occupa
di scegliere, tra più processi che competono per la CPU, quello da far
girare effettivamente, in base ad un determinato algoritmo (molti sono gli
algoritmi possibili e diversi possono essere gli obiettivi.. pensate solo alla
differenza tra un batch system e un real time system).
Diverse inoltre possono essere le situazioni in cui lo scheduler viene
richiamato, ad esempio quando un processo termina o blocca in attesa di una
determinata risorsa (per es. i/o su una porta) o quando questa diventa
disponibile o, ancora, nel caso forki o abbia esaurito il suo time quantum.
Un'ultima definizione che può essere utile dare è la differenza tra
*non-preemtive* e *preemptive* scheduling, nel primo caso lo scheduler sceglie
un processo e lo lascia in esecuzione finchè non ha terminato il suo lavoro,
blocchi o volontariamente rilasci la cpu, mentre nel secondo caso il processo
ha assegnato un determinato time quantum, esaurito il quale viene sospeso e un
altro processo viene scelto. Qualora venga implementata la "priorità" tra i
processi (Priority Scheduling Algorithm), un processo a più alta priorità
che diventa disponibile (ad esempio poichè s'è liberata una risorsa sulla
quale bloccava) viene schedulato e il processo precedente viene sospeso.
Conditio sine qua non del preemptive scheduling è ovviamente la presenza di
un timer interrupt.
Visto che, come detto, ci troveremo a lavorare col kernel di linux (thread di
sviluppo 2.4) e su un sistema x86 a 32bit uniprocessore vediamo di addentrarci
oltre e di vedere come tutto ciò venga implementato.
Ci sono almeno 3 testi reperibili online (Reference [2] [3] e [4]) che
analizzano, anche molto a fondo, lo scheduler nel kernel di linux, sia UP sia
SMP, quindi, anche in questo caso ci limiteremo ad analizzare i punti che più
ci interessano, cercando di andare più a fondo possibile su questi e
lasciando a chi fosse interessato ad approfondire oltre la lettura di questi
testi.
Il miglior modo per capire lo scheduler di linux rimane tuttavia leggersi
kernel/sched.c e alcune parti di kernel/timer.c (sys_alarm e sys_nanosleep
sono implementate in questo file) oltre a include/linux/sched.h e time[r].h .
Ad un dato momento un task può trovarsi in uno di questi 5 stati (membro task->state della struct task_struct ) :
<include/linux/sched.h>
[snip]
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 4
#define TASK_STOPPED 8
[snip]
TASK_RUNNING -> il task è sulla runqueue e compete dunque per la CPU. Tutti i
processi sulla runqueue sono in TASK_RUNNING state, mentre può non essere
vero il contrario, visto che l'azione di settare un processo in TASK_RUNNING e
metterlo nella runqueue non è atomica.
TASK_INTERRUPTIBLE -> Il task è in sleeping, ma può essere risvegliato da un signal oppure se finisce il timer per lo sleep settatogli con schedule_timeout() . Quando un processo va in sleep in TASK_INTERRUPTIBLE la sua task_struct viene inserita nella waitqueue legata alla risorsa su cui sta bloccando.
TASK_UNINTERRUPTIBLE -> Il task è in sleeping, ma viene garantito che ci
resterà fino all'expire del timer settatogli dalla schedule_timeout() .
Questa opzione viene raramente usata all'interno del kernel di linux, ma si
rivela utile nel caso di device driver che debbano aspettare che una
determinata operazione finisca e che, se interrotta, potrebbe ritornare un
valore errato o lasciare il device in uno stato impredicibile e/o corrotto.
TASK_STOPPED -> il task è stato stoppato o da un signal o poichè si sta cercando di tracciarlo con ptrace (ad un PTRACE_ATTACH viene inviato un SIGSTOP al child). Un task in TASK_STOPPED state non è ovviamente nella runqueue ed in nessuna waitqueue.
Del TASK_ZOMBIE state non ci interessa granchè, semplicemente il task ha terminato la sua esecuzione, ma il padre non ha eseguito una wait() sul suo status. Questi processi diventano figli "adottivi" di init, che, periodicamente, esegue delle wait(), eliminandoli de facto.
Cosa ci interessa notare è che *unicamente* i TASK_RUNNING competono per la
CPU, mentre a tutti gli altri non viene data CPU (a meno che, ovviamente, non
vengano risvegliati da un expire, per i processi in timeout, o, salvo i
TASK_UNINTERRUPTIBLE, da un signal).
L'importanza di ciò e del fatto che i TASK_STOPPED non finiscano in alcuna
waitqueue sarà più chiara quando analizzeremo le basi di FHRP e soprattuto
il sistema delle signature dei processi.
Case study n.1 -> schedule_timeout()
Già che abbiamo introdotto il concetto di timeout e di schedule_timeout() ,
vediamo come il kernel di linux lo gestisce. La funzione da cui partire è
senza dubbio schedule_timeout() , contenuta in kernel/sched.c .
signed long schedule_timeout(signed long timeout)
Questa funzione riceve come parametro il "tempo" in jiffies durante il quale
dovrà stare in sleep il processo. I jiffies altro non sono che il numero di
clock ticks dall'avvio della macchina, per questo il valore della variabile
jiffies viene incrementato ad ogni timer interrupt.
FHRP basa una buona parte del suo lavoro sul timer interrupt e questo
argomento verrà approfondito più avanti.
A questo punto, dopo aver dichiarato una struct timer_list (usata per settare il timeout) e un long expire viene eseguito uno switch sul valore di timeout (per comodità i commenti all'interno di sched.c sono stati rimossi):
switch (timeout)
{
case MAX_SCHEDULE_TIMEOUT:
schedule();
goto out;
default:
{
printk(KERN_ERR "schedule_timeout: wrong timeout "
"value %lx from %p\n", timeout,
__builtin_return_address(0));
current->state = TASK_RUNNING;
goto out;
}
}
Dei due casi ci interessa principalmente MAX_SCHEDULE_TIMEOUT : in questo caso
infatti non verrà settato alcun timer, ma verrà semplicemente invocato lo
scheduler, in modo che il processo, precedentemente messo in
TASK_INTERRUPTIBLE (generalmente ;)) o TASK_UNINTERRUPTIBLE esca dalla
runqueue e ne venga schedulato un altro. Il processo dunque *NON* si
sveglierà dopo un qualsivoglia periodo.
Per tutti gli altri casi (default :) viene semplicemente fatto un check (come scritto tra i commenti nel source *PARANOICO* :)) per controllare che non venga passato un valore negativo a schedule_timeout (cosa che non dovrebbe *mai* accadere comunque). In tal caso comunque schedule_timeout ritornerà 0 .
Qualora niente di tutto ciò si verifichi (ed è il caso più comune) la funzione si comporta così:
expire = timeout + jiffies;
init_timer(&timer);
timer.expires = expire;
timer.data = (unsigned long) current;
timer.function = process_timeout;
add_timer(&timer);
schedule();
del_timer_sync(&timer);
timeout = expire - jiffies;
Niente di strano, semplicemente viene calcolato il valore di expire (sommando
il valore attuale della variabile jiffies al valore di delay contenuto in
timeout) e vengono riempiti i campi della struct timer_list . Quello che ci
interessa notare è che la funzione che verrà richiamata per prima per
risvegliare il processo in sleep è process_timeout .
Prima di proseguire due parole su come un processo può essere messo in sleep, con o senza timeout. I casi sono fondamentalmente due: un'invocazione che definirei "manuale" di schedule_timeout() o l'uso delle varie interruptible_sleep_on_timeout / interruptible_sleep_on / sleep_on_timeout / sleep_on .
Un esempio di invocazione "manuale" lo abbiamo sia nella sys_nanosleep() , la
syscall che viene invocata quando nei codici in C scriviamo, ad esempio,
sleep(10) .
Tralasciando le parti relative ai processi in realtime ( task->policy settata
a SCHED_RR o SCHED_FIFO) le linee che ci interessano sono:
current->state = TASK_INTERRUPTIBLE;
expire = schedule_timeout(expire);
In questo caso non c'è nessuna necessità di settare una waitqueue, poichè
il processo non sta aspettando (ovvero bloccando per) una determinata risorsa,
ma semplicemente deve rimanere "in sleep" per un certo periodo.
Le varie *sleep_on* (asterischi usati come regexp ;)) fanno esattamente lo
stesso, semplicemente settano sempre una waitqueue aggiungendola alla
wait_queue_head_t struct passata come argomento e si occupano all'interno
della funzione di settare lo state del processo e di invocare, se necessario,
schedule_timeout() .
La differenza tra timeout o meno viene ottenuta, con schedule_timeout(), a
seconda che il valore di timeout sia diverso o uguale a MAX_SCHEDULE_TIMEOUT .
Case study n.2 -> Le tappe di un processo che si risveglia
Come abbiamo visto poco fa, all'expire del timer settato da
schedule_timeout() , la funzione richiamata è process_timeout() , che riceve
come parametro un unsigned long che altro non è che il puntatore alla
task_struct andata in sleep.
Da questo movimento in poi ci ritroveremo a saltellare tra varie funzioni,
ognuna che funge da "wrapper" alla successiva, fino ad arrivare alla
try_to_wake_up() , che è poi la funzione che effettivamente risveglierà il
nostro processo.
Quello che faremo in questo secondo case study sarà percorrere le tappe
cercando di mettere in evidenza le parti più interessanti per FHRP e i motivi
per cui si è scelto di hookare in determinati punti del codice.
La prima funzione che viene richiamata è appunto la process_timeout ed è anche la funzione che FHRP hooka per controllare questo tipo di processi. La funzione in sè, come tutti i wrapper, è molto semplice:
static void process_timeout(unsigned long __data)
{
struct task_struct * p = (struct task_struct *) __data;
wake_up_process(p);
}
Viene dichiarato un puntatore e con un cast lo si fa puntare al processo che
era andato in sleep e successivamente viene richiamata la wake_up_process .
La wake_up_process() è anch'essa una funzione wrapper, che richiama la
try_to_wake_up() .
Viene utilizzata, ad esempio, quando viene inviato un SIGCONT a un processo
( kernel/signal.c ).
inline int wake_up_process(struct task_struct * p)
{
return try_to_wake_up(p, 0);
}
Come detto poco fa altro non fa che richiamare la try_to_wake_up, passando
come secondo parametro (int synchronous , come vedremo tra pochissimo) "0",
ovvero la richiesta di richiamare reschedule_idle() , oltre a inserire il
modulo nella runqueue.
static inline int try_to_wake_up(struct task_struct * p, int synchronous)
{
unsigned long flags;
int success = 0;
spin_lock_irqsave(&runqueue_lock, flags);
p->state = TASK_RUNNING;
if (task_on_runqueue(p))
goto out;
add_to_runqueue(p);
if (!synchronous || !(p->cpus_allowed & (1 << smp_processor_id())))
reschedule_idle(p);
success = 1;
out:
spin_unlock_irqrestore(&runqueue_lock, flags);
return success;
}
Anche questa funzione è abbastanza semplice:
int this_cpu = smp_processor_id();
struct task_struct *tsk;
tsk = cpu_curr(this_cpu);
if (preemption_goodness(tsk, p, this_cpu) > 1)
tsk->need_resched = 1;
In tsk viene recuperato il processo corrente sulla CPU, mentre
preemption_goodness altro non fa che sottrarre la goodness del processo
risvegliato a quella del processo corrente. Se il valore è maggiore di uno
significa che la priorità del processo svegliato è maggiore e need_resched
viene settata a 1, forzando così una chiamata allo scheduler al primo
ret_from_intr o syscall.
Visto il buon numero di testi approfonditi reperibili online (Reference [2] [3] e [4]... oltre che sched.c ) non ci dilungheremo su altre parti dello scheduler di linux, quali ad esempio la goodness (che altro non è che il core dello scheduling algorithm) o schedule() in sè in quanto sono ampiamente trattate sia in Linux Kernel Internals 2.4 [2] sia nel capitolo di Understanding the linux kernel disponibile per il download [3].
Case study n.3 -> PIT e dintorni, alzare la frequenza del clock
Come abbiamo già avuto modo di dire il timer interrupt è la conditio sine
qua non di uno scheduling preemptive. E' infatti grazie al timer interrupt che
possiamo periodicamente diminuire il time quantum di un processo
( task->counter ) e settare, se uguale a 0, task->need_resched a 1 in modo da
invocare lo scheduler alla successiva ret_from_intr o ret_from_sys_call e
ottenere così un context switch.
Prendendo in esame il kernel di linux, la frequenza (modificabile a compile
time semplicemente cambiando il valore di HZ in asm/param.h ... di default
uguale a 100) è settata a 1 tick ogni 10ms.
Va da sè che se noi aumentiamo il valore di HZ otteniamo un sistema con un
maggiore response time, cioè il lasso di tempo che intercorre tra l'invio del
comando e l'esecuzione dello stesso, ma anche un maggior overhead, dovuto al
fatto che aumentano considerevolmente i context switch e ogni processo ha
globalmente ad ogni epoch "meno tempo a disposizione" per girare, in quanto il
suo counter si esaurirebbe in un tempo minore.
Allo stesso modo va da sè che se diminuiamo il valore di HZ otteniamo tempi
di risposta via via più lunghi.
Entrambe le azioni (aumentare e diminuire la frequenza di timer interrupt)
portano vantaggi a determinati processi, mentre ne penalizzano altri. Una
shell o una qualsiasi applicazione interattiva trae vantaggio dall'aumento,
mentre un'operazione come un find sull'intero disco fisso o un backup è
avvantaggiata da un maggiore delay tra i context switch.
FHRP, quando caricato, alza la frequenza del timer interrupt, portandola a 1 tick ogni millisecondo (valore che ovviamente è modificabile, come vedremo quando analizzeremo quella parte del codice), richiamando però la routine originale per l'handling del timer interrupt con la frequenza classica di 10ms. Tutto questo ci permette di controllare più volte tra un "effettivo" timer interrupt e l'altro cosa effettivamente stia girando sulla CPU.
Vediamo ora come tutto questo è possibile e soprattutto cosa permette il
raising del timer interrupt e come il kernel di linux gestisce tutto questo.
Al termine dell'analisi verrà anche proposto un semplice modulo che permette
di alzare e abbassare la frequenza a piacere.
[nota: questa parte non è strettamente necessaria per comprendere il
funzionamento di FHRP e, probabilmente, interesserà maggiormente gli
appassionati di architettura e devices a basso livello. Se non siete
interessati potete tranquillamente saltarla o leggerla per curiosità senza
soffermarvi troppo sui dettagli.
Inoltre, sebbene una parte della descrizione valga anche per sistemi SMP, le
parti analizzate e il codice proposto sono unicamente per UP.]
Partiamo dall'architettura. Analizzeremo l'8253/8254 (anche se de facto
analizzeremo solamente l'8253, in quanto non prenderemo in esame le estensioni
del 8254) Programmable Interrupt Timer chip.
Dei 3 canali disponibili sul PIT, l'unico che prenderemo in esame è il
canale/timer 0, ovvero il device che il kernel di linux utilizza per tener
conto del tempo (timer interrupt).
[nota: per un approfondimento su tutto ciò che non verrà trattato in questo spazio e, in questo caso, sugli altri due canali, ovvero il canale/timer 1 che controlla il refresh della DRAM e il canale/timer 2 che è legato allo speaker potete consultare le Reference [5] e [6]]
Tutti e tre i timer del chip 8253 sono controllati dallo stesso clock signal,
che deriva dall'oscillazione del quarzo sulla scheda madre. La frequenza di
questo clock signal è di circa 1.1931 MHz.
Ciascun timer ha un contatore, programmabile, che, in soldoni, calcola ogni
quanto tempo o dopo quanto tempo (a seconda dell'Operation Mode, descritto
più avanti) inviare il proprio "segnale".
Il timer 0 del chip 8253 è infatti collegato al PIC (Programmable Interrupt
Controller) 8259 che di base si occupa di ascoltare su 8 sources di interrupt
e di passare questi stessi, uno alla volta, alla CPU secondo un meccanismo di
priorità grazie al quale un interrupt più importante può interromperne un
altro e ricevere la CPU per sè.
L'8259 PIC permette di "maskare" determinati interrupt grazie agli 8 bit (uno
per interrupt) del IRM (Interrupt Mask Register), infatti un bit settato a 1
nell'IRM impedirà a quell'interrupt di "raggiungere" la CPU per essere
gestito.
[nota: effettivamente gli IRQ attualmente sono più di 8: sono il doppio, cioè 16, divisi tra 8 Master, direttamente collegati alla CPU, e 8 Slave, che passano attraverso l'IRQ2. Tuttavia non mi sembra il caso di approfondire oltre, chi fosse interessato può consultare la Reference [5]]
Il timer 0 del chip 8253 è legato all'IRQ0 dell'8259, ovvero l'Interrupt
Request (così viene definita una interrupt source che passa per l' 259) a
più alta priorità.
Come abbiamo detto più volte, la frequenza di default all'interno del kernel
linux di questo interrupt è di 100 Hz, ovvero 1 tick ogni 10ms.
Ciò si ottiene settando nel contatore del timer il valore di 11932 (dalla
semplice divisione otteniamo *circa* 100Hz o 10 ms), vediamo come nel kernel
di linux questo venga calcolato.
La macro che restituisce il valore è LATCH:
<include/linux/timex.h>
#define LATCH ((CLOCK_TICK_RATE + HZ/2) / HZ) /* For divider */
CLOCK_TICK_RATE è definita in include/asm/timex.h ed è uguale a 1193180. Il
motivo di questa divisione è facilmente intuibile. LATCH è una macro
generica, che si adatta a tutti i controller di tutte le architetture
supportate, mentre CLOCK_TICK_RATE , cioè la frequenza a cui gli interrupt
arrivano dal quarzo (ok... non è esattamente dal quarzo ;)) cambia da
architettura a architettura.
Prima di vedere come questo valore viene messo nel contatore relativo al
canale/timer 0 è necessario dire due parole relative alle porte a cui è
legato il PIT.
Il PIT è legato a 4 porte:
| 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | |___| |___| |_______| _
outb_p(0x34,0x43); /* binary, mode 2, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
Il tutto dovrebbe essere sufficientemente chiaro e non necessitare di
spiegazione.
<-| fhrp/citf.s |->
/*
* citf.s *
* Change the interrupt timer frequency via lkm *
* *
* This module gets the value to set in the counter of channel 0 via parameter 'freq' and *
* sets it. Default value is 0x4a9, the value you'll get setting HZ to 1000 inside kernel *
* I list there some value you could find useful to know : *
* for HZ == 100 (default in linux kernel) -> 0x2e9c *
* for HZ == 1000 (default in this lkm) -> 0x4a9 *
* for HZ == 50 -> 0x5d38 *
*
* Example : insmod citf.o freq=0x5d38
*/
.globl init_module
.globl cleanup_module
.globl freq
.data
.align 4
.size freq, 4
freq:
.long 0x4a9
.text
.align 4
start:
init_module:
pushl %ebp
movl %esp,%ebp
xorl %eax, %eax
pushfl
cli
movb $0x34, %al
outb %al, $0x43
movw freq, %ax
outb %al, $0x40
movb %ah, %al
outb %al, $0x40
popfl
xorl %eax, %eax
leave
ret
cleanup_module:
pushl %ebp
movl %esp,%ebp
xorl %eax, %eax
pushfl
cli
movb $0x34, %al
outb %al, $0x43
movw $0x2e9c, %ax
outb %al, $0x40
movb %ah, %al
outb %al, $0x40
popfl
leave
ret
.globl __module_parm_freq
.section .modinfo
__module_kernel_version:
.ascii "kernel_version=2.4.9\0"
__module_parm_freq:
.ascii "parm_freq=i\0"
Aggiungo di seguito un semplice codice C per calcolare i valori da passare
come parametro, più che altro per comodità, dal momento che spero vi sia
chiaro come questo venga calcolato :)
<-| fhrp/freq.c |->
#define MY_LATCH(x) ((1193180 + x/2) / x) /* For divider */
main(int argc, char **argv)
{
int i = atoi(argv[1]);
printf("%x\n", MY_LATCH(i) );
}
<-X->
FHRP: gli obiettivi, le idee e l'implementazione
Dopo questa parte "teorica" è il momento di presentare il tool in sè e di
analizzarne gli obiettivi e le idee che hanno portato alla sua stesura. Al
termine della presentazione verranno anche proposti eventuali spunti per
migliorare o estendere alcune funzioni.
Gli obiettivi
L'obiettivo di FHRP alla fin fine è uno solo, trovare processi che siano
stati nascosti dall'attacker. Si parte da un certo principio, i processi sono
stati nascosti venendo eliminati *almeno* dalla task_struct double chained
list (il motivo vi sarà chiaro a breve) e si vuole scovare anche un ipotetico
processo che, staccato da qualsiasi lista del kernel (vale a dire runqueue,
pidhash list e task list), riceva "quantum" di CPU ad esempio da un manual
switching a basso livello o da una modifica "pesante" allo scheduler in sè
(pur con tutte le limitazioni e le difficoltà che ci sono ad implementare uno
switch manuale o un hook consistente allo scheduler).
La prima cosa che ci serve per raggiungere il nostro scopo è avere un metodo
per riconoscere i processi, una sorta di signature che distingua i processi
'buoni' dai processi 'cattivi'.
La scelta è stato il valore di cr3, il quarto dei control register, perchè:
E' indispensabile per l'esecuzione del processo.
Il registro cr3 (anche detto PDBR - Page Directory Base Register) contiene
l'indirizzo fisico della base della page directory e due flag (PCD e PWT).
Solamente i 20 most-significative bits sono specificati, mentre per gli altri
12 si assume che il valore sia 0.
Proprio perchè indispensabile per l'esecuzione del processo non può essere
"fakato".
E' veloce da recuperare.
Il registro cr3 è contenuto in ogni task_struct in task->mm->pgd (per
confrontarlo dobbiamo ricordarci di "tradurlo" in indirizzo fisico con
__pa() ) ed è quindi veloce da recuperare per creare il database di cr3-noti.
Inoltre è molto veloce anche da recuperare mentre il processo è in
esecuzione sulla CPU (movl %cr3, %eax) e, questa, è una buona notizia, visto
che siamo in interrupt time e il clock è alzato a 1000Hz.
Il cr3 viene caricato dal kernel al momento del context switch da switch_mm() , con una semplice istruzione inline assembly:
asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));
Ora che avevamo trovato la nostra signature ci serviva un punto nel quale
andare a piazzarci per poter costantemente monitorare la CPU e il valore del
cr3 register.
[nota: per comodità abbiamo diviso il codice di FHRP in 4 file: 3 sorgenti .c e un include .h, dei quali andiamo ora a presentare le caratteristiche principali e i punti più interessanti, il resto leggetevelo nei file stessi ;)]
cr3-timer.c
All'interno di questo file sono presenti le funzioni raise_timer() e
restore_timer() che permettono di alzare all'insmod e riportare al valore
standard la frequenza dei timer interrupt. Se avete letto il case study n.3
dovreste capirle al volo (non sono che, in fondo, la trasposizione in C del
codice di citf.s); se l'avete saltato, l'unica cosa che vi interessa ora come
ora è che durante il periodo in cui il modulo sarà linkato al kernel si
verificherà un timer interrupt ogni ms.
La scelta di alzare a 1000Hz è stata presa per avere più possibilità di
scovare un processo sulla CPU e si è dimostrata ben bilanciata con il
possibile overhead che le nostre funzioni di check creano durante l'interrupt
time.
handler_new() è il nuovo handler che andiamo a settare per il timer
interrupt. Questo handler calcola grazie a HZ e MY_HZ la frequenza con cui
richiamare lo scheduler in modo che nulla cambi nel succedersi dei context
switch.
Sempre all'interno di handler_new viene eseguito il controllo tra il cr3
correntemente caricato e la lista di cr3 validi.
Come si scopre andando a vedere in timer.c e seguendo le varie funzioni
chiamate, sono molti i punti in cui potevamo hookare per ottenere pressapoco
lo stesso effetto. Si è scelto di andare a piazzarsi sulla cima della catena,
sostituendo all'indirizzo contenuto nella struct irqhandler irq0 (l'indirizzo
per l'handler del timer interrupt) l'indirizzo della nostra nuova funzione.
In questo modo finiremo col sovrascrivere anche altri eventuali hook con
l'obiettivo di *gestire* in qualche modo i processi nascosti.
La procedura che avviene per gestire un interrupt non è il target di questo
articolo e quindi non mi soffermo su come viene gestita la IDT e sul
meccanismo legato allo switching in kernel land, ma piuttosto è interessante,
per capire il funzionamento di FHRP, come vengono invocate le ISRs (Interrupt
Service Routine).
La struttura fondamentale che contiene la ISR è la struct irqaction
</include/linux/interrupt.h>
struct irqaction {
void (*handler)(int. void *, struct pt_regs *);
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id;
struct irqaction *next;
};
static struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL};
L'indirizzo della struct irq0, ricavato con nm o dalla System.map , è alla
base delle funzioni set_irq e restore_irq , che si occupano di settare la
nostra nuova struct irquaction (e quindi in pratica il nostro nuovo handler) e
di ripristinare lo stato originale.
cr3-func.c
In questo file si trovano alcune delle funzioni determinanti per il
funzionamento di FHRP e che aumentano sensibilmente la probabilità già cmq
abbastanza alta di trovare un eventuale processo nascosto.
Le prime due funzioni che andiamo a vedere sono la stop_all_process_safe() e
la resume_all_process() , relative, rispettivamente, allo "STOP" dei processi
e al "risveglio" di questi ultimi a lavoro finito.
Il vantaggio che bloccare tutti i processi 'buoni' ci dà è quello di
aumentare le probabilità di scheduling di un processo nascosto, permettendo
allo scheduler, de facto, di dare la CPU unicamente ai kthread, ai processi
'safè (che analizzeremo a breve) e, ovviamente, agli eventuali processi
occultati.
La funzione stop_all_process_safe() lista tutta la serie dei processi 'legali'
attivi sulla macchina al momento del caricamento del modulo (scorrendo la
task_struct double chained list con list_for_each ) e stoppa uno ad uno tutti
i processi inviandogli una force_sig(SIGSTOP, p) .
Tuttavia non *tutti* i processi vengono stoppati, come si vede dai controlli:
....
if(p->mm)
....
if((t != pid_bash_safe) && (t != SAFE_P_KLOGD) && (t != SAFE_P_SYSLOGD) && (t != SAFE_P_INIT))
....
if((p->state != TASK_UNINTERRUPTIBLE) && (p!=current))
....
Vengono infatti lasciati attivi:
resume_all_process() altro non è che l'opposto della stop_all_process_safe() e utilizza nuovamente force_sig per inviare un SIGCONT (in caso di qualche problema nel riacquistare la tty da parte di alcuni processi, qualche "fg" dovrebbe essere sufficiente).
L'ultima, ma cruciale, funzione che troviamo qui dentro (e che è già stata
"anticipata") è take_global_page_dir() , che si occupa di recuperare il cr3
del processo corrente.
unsigned long int take_global_page_dir()
{
__asm__ __volatile__ ("movl %cr3, %eax");
}
------] cr3-main.c
Questo è il cuore del modulo e, oltre alle init_module e cleanup_module (che
organizzano il funzionamento del modulo) ci sono alcune altre funzioni
interessanti.
La seconda funzione che analizziamo è la check_listening_socket() .
Questa funzione ci dà la possibilità di trovare eventuali processi nascosti
che siano in ascolto, dopo una accept() , su una determinata porta.
Innanzitutto vediamo di chiarirne la necessità. Prendiamo ad esempio un
"socket tcp" in ascolto su una porta (una normalissima backdoor ad esempio).
L'ultimo passo della accept sulla quale poi sleeperà è in net/ipv4/tcp.c la
wait_for_connect()
static int wait_for_connect(struct sock * sk, long timeo)
{
DECLARE_WAITQUEUE(wait, current);
int err;
add_wait_queue_exclusive(sk->sleep, &wait);
for (;;) {
current->state = TASK_INTERRUPTIBLE;
release_sock(sk);
if (sk->tp_pinfo.af_tcp.accept_queue == NULL)
timeo = schedule_timeout(timeo);
[...]
Questa è la parte che ci interessa. Viene dichiarata la waitqueue, si
controlla se c'è qualcuno in 'accept_queue' per il socket e, se così non è,
si invoca schedule_timeout .
A questo punto abbiamo bisogno di:
[nota: se date un veloce sguardo a net/netsyms.c vedrete che due "simboli" che
ci interessano, ovvero udp_hash e tcp_hashinfo , sono esportate solo se almeno
uno tra CONFIG_IPV6_MODULE , CONFIG_KHTTPD , CONFIG_KHTTPD_MODULE è settato.
Il problema si risolve velocemente hookando altre due funzioni, ma, visto che
si tratta di un tool lato admin, ciò non viene aggiunto nel codice (se volete
aggiungerlo un paio di #ifdef dovrebbero essere sufficienti ;)). La soluzione
più veloce rimane ricompilare con, ad esempio, CONFIG_IPV6_MODULE settato.]
config.h
L'header di fhrp contiene innanzitutto i #define per l'hook di alcune funzioni
(es. process_timeout() ) o per accedere a alcune struct a livello kernel (es.
irq0 o la listening raw socket hash table), che devono essere correttamente
settati usando nm o una System.map aggiornata.
Il nome della "stringa" da ricercare è messo tra i commenti di fianco a ogni
#define .
Sempre in questo file dovrete settare i pid di syslogd e klogd, essendo questi due demoni avviati allo startup della macchina dovrebbero mantenere sempre lo stesso pid anche dopo i reboot.
Le ultime due cose che potrebbero interessarvi da settare sono MY_HZ , che
decide a che frequenza portare il clock e MAX_RESULTS che decide qual è il
numero massimo di risultati da riportare. Entrambe sono settate a due valori
che di default dovrebbero andare bene. Tutt'al più qualora venissero
riportati 10 risultati potrebbe essere utile, per sicurezza, rifare la prova
con più risultati.
Volendo potete anche, con una piccola modifica, cambiare MAX_RESULTS e
rendere il totale dei results settabile a insmod-time con un MODULE_PARM .
L'ultima parte su cui ci soffermiamo in config.h è compare_cr3() . La
funzione è stata dichiarata static inline così da evitare una CALL a questa
(siamo in interrupt time e qualche ciclo in meno ci fa comodo... senza
dimenticare che comunque una CALL tende a flushare la pipeline).
La funzione è stata strutturata per essere il più "ottimizzata" possibile,
ad esempio con l'implementazione di una sorta di cache che tiene in memoria
l'ultimo cr3 trovato (infatti, avendo il clock alzato di dieci volte è molto
probabile che lo stesso cr3 maligno, nel momento in cui il programma runni
sulla cpu per un quantum o più, venga ripetitivamente trovato molte volte. La
cache ci permette di evitare di dover scorrere la lista dei cr3 trovati ogni
volta... non dimentichiamo che siamo in interrupt time e abbiamo il clock a
una frequenza più alta).
Conclusioni
Conclusioni, possibili modifiche e miglioramenti
Iniziamo col funzionamento... come vi sarà chiaro, questo non è un modulo
studiato per essere residente, anzi, dovrebbero essere sufficienti pochi
secondi (a meno che non vogliate essere sicuri contro sleep(100) o comunque
sleep lunghe... ma controlli incrociati le scoverebbero), il tempo di un paio
di epochs di finire, e dovreste avere una fotografia di ciò che gira sulla
vostra macchina.
Inolte il modulo restituisce sempre un FALSO POSITIVO. E' il valore del cr3 di
rmmod... abbiamo preferito farlo riportare, stampandone prima a video il
valore, per maggiore sicurezza. Va da sè che un semplice hidden_task - 1
rimuova questo falso positivo... ma conviene essere paranoici.
Vediamo comunque un esempio pratico del funzionamento del modulo:
root@twiz:/home/twiz/cr3/cr3-dev# insmod fhrp.o [snip] Pid: 79 Context: 2577000 Pid: 83 Context: 25da000 Pid: 85 Context: 2523000 Pid: 93 Context: 3c9c000 Pid: 94 Context: 3e1a000 Pid: 95 Context: 3cd1000 Pid: 96 Context: 3bd4000 Pid: 97 Context: 3b75000 Pid: 98 Context: 3ccd000 Pid: 99 Context: 24af000 Pid: 100 Context: 233d000 Pid: 101 Context: 23cc000 Pid: 194 Context: 3a36000 [snip] Setting up process_timeout hook.. root@twiz:/home/twiz/cr3/cr3-dev# rmmod fhrp Restoring process_timeout.. Ripristining all process... Leaving Module Hidden Processes Foud : 1 Cuurent-> deve essere rmmod: 2139000 cr3 malign : 2139000 pid : 672 got from Interrupt handler root@twiz:/home/twiz/cr3/cr3-dev#Il cr3 malign riportato altro non è che il falso positivo di cui parlavamo.
Continuiamo con una cosa che abbiamo già detto, ma che è particolarmente importante: questo modulo non è una panacea, se un ps troiano o un codice che modifica proc e alcune syscall sta nascondendo il processo questo modulo non può fare granchè, al massimo listarvi tutti i processi a insmod time. Ci sono altri modi per controllare, primo tra tutti listare la lista di task_struct (come fa il modulo a insmod time), utilizzare un ps safe o controllare i md5sum (qualora il redirect non sia a kernel level), utilizzare KSTAT per controllare le syscall.
Insomma non c'è *il* tool, ma un lavoro combinato di vari tool quando si tratta di controllare l'integrità di una macchina. Questo modulo viene in aiuto scovando gli hide più complessi, quelli che staccano il processo dalle liste conosciute, andando a agire molto a basso livello e appoggiandosi pochissimo a funzioni del kernel.
Niente è 100% valido quando sia l'attacker che il sysadmin possono lavorare a
kernel space. L' attacker potrebbe aver modificato la create_module() e
potrebbe fare pattern matching tra gli opcodes del modulo in caccia di un
movl %cr3, %eax o altri punti. A quel punto noi potremmo offuscare il codice,
renderlo automodificante, fare semplicemente pushl %cr3, popl %eax... inserire
random 'nop-like' all'interno del codice stesso (non necessariamente un NOP è
\x90 ;)).
Ancora, l'attacker potrebbe fare un'analisi statistica dell'accesso a
force_sig , controllare se è incrementale e frequente (il che significherebbe
che è in caricamento il nostro modulo) e invia solamente SIGSTOP e quindi
stoppare fino alla ricezione dei SIGCONT il processo nascosto.
Ma a quel punto noi potremmo *manualmente* stoppare i processi e riavviarli...
avremmo qualche piccolo problema in più (anche se è stato testato... ed è
abbastanza sicuro anche così ;)), ma aggireremmo anche questo controllo.
Insomma, sono scenari possibili una volta che si sa cosa 'potrebbe succedere',
ma ciò non toglie che questo modulo sia utile e valido in molte situazioni.
Inoltre modifiche pesanti come il check di opcode o della force_sig dovrebbero
essere veloci da vedere dumpando e disassemblando quelle funzioni e, magari,
notando jmp *%eax o pushl/ret o movl/ret "sospette" ;)
Il fatto stesso che il modulo sia stato scritto molto a basso livello e non sia residente ci dà già un buon grado di protezione (andiamo a sovrascrivere noi la struct irq0, modificando quindi anche un possibile hook dell'attacker, e così agiamo in molte situazioni), ma, ovviamente, non il 100% di sicurezza :)
Il modulo così com'è lascia aperte alcune migliorie che non vengono incluse nella versione di release. Tra queste:
Prima di passare alle References, un paio di ringraziamenti/shoutouts a vecna,
Dark-Angel, albe e rene @ irc.kernelnewbies.org per qualche discussione sullo
scheduler, i sistemi SMP e altro.
Un saluto va ai ragazzi del racl (la parte del PIT è su misura per voi :),
ndtwiz), a _oink (grazie per la cartolina ;) ndtwiz) e a "tutti quelli che ci
conoscono" (che fa molto telefonata a show televisivo). Direi che abbiamo
detto abbastanza cazzate :)
References
[1] - Modern Operating Systems - Second Edition - Andrew S. Tanenbaum [2] - Linux Kernel Internals 2.4 - Tigran Aivazian http://www.moses.uklinux.net/patches/lki.html [3] - Understanding the Linux Kernel - Bovet, Cesati - Ch10 "Scheduling" http://www.oreilly.com/catalog/linuxkernel/chapter/ch10.html [4] - For Kernel_Newbies By a Kernel_Newbie - A.R.Karthick http://www.freeos.com/articles/4536/ [5] - http://www.nondot.org/sabre/os/articles/MiscellaneousDevices/ [6] - Timer-related functionality in Linux kernels 2.x.x - Andre Derric Balsa http://www.cse.msu.edu/~zhengpei/tech/Linux/timerin2.2.htm [7] - Linux Kernel Sources 2.4.*