Guscio nucleare sull'ICMP

Guscio nucleare sull'ICMP

TL; DR: Sto scrivendo un modulo del kernel che leggerà i comandi dal payload ICMP e li eseguirà sul server anche se il tuo SSH si blocca. Per i più impazienti, tutto il codice lo è github.

Attenzione! I programmatori C esperti rischiano di scoppiare in lacrime di sangue! Potrei anche sbagliarmi nella terminologia, ma ogni critica è benvenuta. Il post è destinato a coloro che hanno un'idea molto approssimativa della programmazione C e vogliono approfondire l'interno di Linux.

Nei commenti al mio primo Articolo ha menzionato SoftEther VPN, che può imitare alcuni protocolli “normali”, in particolare HTTPS, ICMP e persino DNS. Posso immaginare che funzioni solo il primo, poiché ho molta familiarità con HTTP(S) e ho dovuto imparare il tunneling su ICMP e DNS.

Guscio nucleare sull'ICMP

Sì, nel 2020 ho appreso che è possibile inserire un payload arbitrario nei pacchetti ICMP. Ma meglio tardi che mai! E poiché si può fare qualcosa al riguardo, allora bisogna farlo. Poiché nella mia vita quotidiana utilizzo molto spesso la riga di comando, anche tramite SSH, mi è venuta in mente per prima cosa l'idea di una shell ICMP. E per assemblare un bingo completo, ho deciso di scriverlo come modulo Linux in un linguaggio di cui ho solo un'idea approssimativa. Tale shell non sarà visibile nell'elenco dei processi, potrai caricarla nel kernel e non sarà nel file system, non vedrai nulla di sospetto nell'elenco delle porte in ascolto. In termini di capacità, questo è un rootkit a tutti gli effetti, ma spero di migliorarlo e usarlo come shell di ultima istanza quando il carico medio è troppo alto per accedere tramite SSH ed eseguire almeno echo i > /proc/sysrq-triggerper ripristinare l'accesso senza riavviare.

Prendiamo un editor di testo, competenze di programmazione di base in Python e C, Google e virtuale che non ti dispiace mettere sotto i ferri se tutto si rompe (opzionale - VirtualBox/KVM/ecc. locale) e andiamo!

Dalla parte del cliente

Mi sembrava che per la parte cliente avrei dovuto scrivere una sceneggiatura di circa 80 righe, ma c'erano persone gentili che lo hanno fatto per me tutto il lavoro. Il codice si è rivelato inaspettatamente semplice, inserendosi in 10 righe significative:

import sys
from scapy.all import sr1, IP, ICMP

if len(sys.argv) < 3:
    print('Usage: {} IP "command"'.format(sys.argv[0]))
    exit(0)

p = sr1(IP(dst=sys.argv[1])/ICMP()/"run:{}".format(sys.argv[2]))
if p:
    p.show()

Lo script accetta due argomenti, un indirizzo e un payload. Prima dell'invio, il payload è preceduto da una chiave run:, ne avremo bisogno per escludere i pacchetti con payload casuali.

Il kernel richiede privilegi per creare pacchetti, quindi lo script dovrà essere eseguito come superutente. Non dimenticare di fornire i permessi di esecuzione e installare scapy stesso. Debian ha un pacchetto chiamato python3-scapy. Ora puoi controllare come funziona il tutto.

Esecuzione ed emissione del comando
morq@laptop:~/icmpshell$ sudo ./send.py 45.11.26.232 "Hello, world!"
Begin emission:
.Finished sending 1 packets.
*
Received 2 packets, got 1 answers, remaining 0 packets
###[ IP ]###
version = 4
ihl = 5
tos = 0x0
len = 45
id = 17218
flags =
frag = 0
ttl = 58
proto = icmp
chksum = 0x3403
src = 45.11.26.232
dst = 192.168.0.240
options
###[ ICMP ]###
type = echo-reply
code = 0
chksum = 0xde03
id = 0x0
seq = 0x0
###[ Raw ]###
load = 'run:Hello, world!

Questo è quello che appare nello sniffer
morq@laptop:~/icmpshell$ sudo tshark -i wlp1s0 -O icmp -f "icmp and host 45.11.26.232"
Running as user "root" and group "root". This could be dangerous.
Capturing on 'wlp1s0'
Frame 1: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 192.168.0.240, Dst: 45.11.26.232
Internet Control Message Protocol
Type: 8 (Echo (ping) request)
Code: 0
Checksum: 0xd603 [correct] [Checksum Status: Good] Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
Data (17 bytes)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]

Frame 2: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 45.11.26.232, Dst: 192.168.0.240
Internet Control Message Protocol
Type: 0 (Echo (ping) reply)
Code: 0
Checksum: 0xde03 [correct] [Checksum Status: Good] Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
[Request frame: 1] [Response time: 19.094 ms] Data (17 bytes)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]

^C2 packets captured

Il payload nel pacchetto di risposta non cambia.

Modulo del kernel

Per creare una macchina virtuale Debian avrai bisogno almeno di make и linux-headers-amd64, il resto arriverà sotto forma di dipendenze. Non fornirò l'intero codice nell'articolo; puoi clonarlo su Github.

Configurazione del gancio

Per cominciare, abbiamo bisogno di due funzioni per caricare il modulo e scaricarlo. La funzione di scarico non è necessaria, ma poi rmmod non funzionerà; il modulo verrà scaricato solo quando spento.

#include <linux/module.h>
#include <linux/netfilter_ipv4.h>

static struct nf_hook_ops nfho;

static int __init startup(void)
{
  nfho.hook = icmp_cmd_executor;
  nfho.hooknum = NF_INET_PRE_ROUTING;
  nfho.pf = PF_INET;
  nfho.priority = NF_IP_PRI_FIRST;
  nf_register_net_hook(&init_net, &nfho);
  return 0;
}

static void __exit cleanup(void)
{
  nf_unregister_net_hook(&init_net, &nfho);
}

MODULE_LICENSE("GPL");
module_init(startup);
module_exit(cleanup);

Cosa sta succedendo qui:

  1. Vengono inseriti due file header per manipolare il modulo stesso e il netfilter.
  2. Tutte le operazioni passano attraverso un netfilter, puoi impostare degli hook al suo interno. Per fare ciò è necessario dichiarare la struttura in cui verrà configurato l'hook. La cosa più importante è specificare la funzione che verrà eseguita come hook: nfho.hook = icmp_cmd_executor; Parlerò della funzione stessa più tardi.
    Quindi imposto il tempo di elaborazione per il pacchetto: NF_INET_PRE_ROUTING specifica di elaborare il pacchetto quando appare per la prima volta nel kernel. Può essere utilizzata NF_INET_POST_ROUTING per elaborare il pacchetto non appena esce dal kernel.
    Ho impostato il filtro su IPv4: nfho.pf = PF_INET;.
    Dò al mio hook la massima priorità: nfho.priority = NF_IP_PRI_FIRST;
    E registro la struttura dei dati come hook effettivo: nf_register_net_hook(&init_net, &nfho);
  3. La funzione finale rimuove il gancio.
  4. La licenza è chiaramente indicata in modo che il compilatore non possa lamentarsi.
  5. funzioni module_init() и module_exit() impostare altre funzioni per inizializzare e terminare il modulo.

Recupero del carico utile

Ora dobbiamo estrarre il carico utile, questo si è rivelato il compito più difficile. Il kernel non ha funzioni integrate per lavorare con i payload; puoi solo analizzare le intestazioni dei protocolli di livello superiore.

#include <linux/ip.h>
#include <linux/icmp.h>

#define MAX_CMD_LEN 1976

char cmd_string[MAX_CMD_LEN];

struct work_struct my_work;

DECLARE_WORK(my_work, work_handler);

static unsigned int icmp_cmd_executor(void *priv, struct sk_buff *skb, const struct nf_hook_state *state)
{
  struct iphdr *iph;
  struct icmphdr *icmph;

  unsigned char *user_data;
  unsigned char *tail;
  unsigned char *i;
  int j = 0;

  iph = ip_hdr(skb);
  icmph = icmp_hdr(skb);

  if (iph->protocol != IPPROTO_ICMP) {
    return NF_ACCEPT;
  }
  if (icmph->type != ICMP_ECHO) {
    return NF_ACCEPT;
  }

  user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
  tail = skb_tail_pointer(skb);

  j = 0;
  for (i = user_data; i != tail; ++i) {
    char c = *(char *)i;

    cmd_string[j] = c;

    j++;

    if (c == '')
      break;

    if (j == MAX_CMD_LEN) {
      cmd_string[j] = '';
      break;
    }

  }

  if (strncmp(cmd_string, "run:", 4) != 0) {
    return NF_ACCEPT;
  } else {
    for (j = 0; j <= sizeof(cmd_string)/sizeof(cmd_string[0])-4; j++) {
      cmd_string[j] = cmd_string[j+4];
      if (cmd_string[j] == '')
	break;
    }
  }

  schedule_work(&my_work);

  return NF_ACCEPT;
}

Cosa succede:

  1. Ho dovuto includere file di intestazione aggiuntivi, questa volta per manipolare le intestazioni IP e ICMP.
  2. Ho impostato la lunghezza massima della linea: #define MAX_CMD_LEN 1976. Perché esattamente questo? Perché il compilatore se ne lamenta! Mi hanno già suggerito che devo capire lo stack e l'heap, un giorno lo farò sicuramente e forse correggerò anche il codice. Imposto subito la riga che conterrà il comando: char cmd_string[MAX_CMD_LEN];. Dovrebbe essere visibile in tutte le funzioni; di questo ne parlerò più in dettaglio nel paragrafo 9.
  3. Ora dobbiamo inizializzare (struct work_struct my_work;) struttura e collegala con un'altra funzione (DECLARE_WORK(my_work, work_handler);). Parlerò anche del motivo per cui ciò è necessario nel nono paragrafo.
  4. Ora dichiaro una funzione, che sarà un hook. Il tipo e gli argomenti accettati sono dettati dal netfilter, a noi interessa solo skb. Si tratta di un buffer socket, una struttura dati fondamentale che contiene tutte le informazioni disponibili su un pacchetto.
  5. Perché la funzione funzioni, avrai bisogno di due strutture e diverse variabili, inclusi due iteratori.
      struct iphdr *iph;
      struct icmphdr *icmph;
    
      unsigned char *user_data;
      unsigned char *tail;
      unsigned char *i;
      int j = 0;
  6. Possiamo iniziare con la logica. Affinché il modulo funzioni, non sono necessari pacchetti diversi da ICMP Echo, quindi analizziamo il buffer utilizzando le funzioni integrate ed eliminiamo tutti i pacchetti non ICMP e non Echo. Ritorno NF_ACCEPT significa accettazione del pacco, ma puoi anche restituire i pacchi restituendoli NF_DROP.
      iph = ip_hdr(skb);
      icmph = icmp_hdr(skb);
    
      if (iph->protocol != IPPROTO_ICMP) {
        return NF_ACCEPT;
      }
      if (icmph->type != ICMP_ECHO) {
        return NF_ACCEPT;
      }

    Non ho testato cosa accadrà senza controllare le intestazioni IP. La mia conoscenza minima del C mi dice che senza ulteriori controlli è destinato a succedere qualcosa di terribile. Sarò felice se mi dissuaderai da questo!

  7. Ora che il pacchetto è esattamente del tipo che ti serve, puoi estrarre i dati. Senza una funzione integrata, devi prima ottenere un puntatore all'inizio del payload. Questo viene fatto in un unico posto, devi portare il puntatore all'inizio dell'intestazione ICMP e spostarlo nella dimensione di questa intestazione. Tutto utilizza la struttura icmph: user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
    La fine dell'intestazione deve corrispondere alla fine del payload in ingresso skb, quindi lo otteniamo per via nucleare dalla struttura corrispondente: tail = skb_tail_pointer(skb);.

    Guscio nucleare sull'ICMP

    La foto è stata rubata quindi, puoi leggere ulteriori informazioni sul buffer del socket.

  8. Una volta che hai i puntatori all'inizio e alla fine, puoi copiare i dati in una stringa cmd_string, controlla la presenza di un prefisso run: e, se manca, scartare il pacchetto oppure riscrivere nuovamente la riga, rimuovendo questo prefisso.
  9. Questo è tutto, ora puoi chiamare un altro gestore: schedule_work(&my_work);. Poiché non sarà possibile passare un parametro a tale chiamata, la riga con il comando deve essere globale. schedule_work() posizionerà la funzione associata alla struttura passata nella coda generale dell'utilità di pianificazione e la completerà, consentendoti di non attendere il completamento del comando. Questo è necessario perché il gancio deve essere molto veloce. Altrimenti, la tua scelta è che non si avvii nulla o ti verrà il panico del kernel. Il ritardo è come la morte!
  10. Questo è tutto, puoi accettare il pacco con un reso corrispondente.

Chiamare un programma nello spazio utente

Questa funzione è la più comprensibile. Il suo nome è stato dato DECLARE_WORK(), il tipo e gli argomenti accettati non sono interessanti. Prendiamo la riga con il comando e la passiamo interamente alla shell. Lascia che si occupi dell'analisi, della ricerca di file binari e di tutto il resto.

static void work_handler(struct work_struct * work)
{
  static char *argv[] = {"/bin/sh", "-c", cmd_string, NULL};
  static char *envp[] = {"PATH=/bin:/sbin", NULL};

  call_usermodehelper(argv[0], argv, envp, UMH_WAIT_PROC);
}

  1. Imposta gli argomenti su un array di stringhe argv[]. Presumo che tutti sappiano che i programmi vengono effettivamente eseguiti in questo modo e non come una linea continua con spazi.
  2. Imposta le variabili di ambiente. Ho inserito solo PATH con un insieme minimo di percorsi, sperando che fossero già tutti combinati /bin с /usr/bin и /sbin с /usr/sbin. Altri percorsi raramente contano nella pratica.
  3. Fatto, facciamolo! Funzione del nocciolo call_usermodehelper() accetta l'ingresso. percorso del binario, array di argomenti, array di variabili di ambiente. Qui presumo anche che tutti comprendano il significato di passare il percorso del file eseguibile come argomento separato, ma puoi chiedere. L'ultimo argomento specifica se attendere il completamento del processo (UMH_WAIT_PROC), avvio del processo (UMH_WAIT_EXEC) o non aspettare affatto (UMH_NO_WAIT). Ce n'è dell'altro? UMH_KILLABLE, non ho approfondito.

montaggio

L'assemblaggio dei moduli del kernel viene eseguito tramite il make-framework del kernel. Chiamato make all'interno di una directory speciale legata alla versione del kernel (definita qui: KERNELDIR:=/lib/modules/$(shell uname -r)/build) e la posizione del modulo viene passata alla variabile M nelle argomentazioni. I target icmpshell.ko e clean utilizzano interamente questo framework. IN obj-m indica il file oggetto che verrà convertito in un modulo. Sintassi che si rifa main.o в icmpshell.o (icmpshell-objs = main.o) non mi sembra molto logico, ma così sia.

KERNELDIR:=/lib/modules/$(shell uname -r)/build

obj-m = icmpshell.o
icmpshell-objs = main.o

all: icmpshell.ko

icmpshell.ko: main.c
make -C $(KERNELDIR) M=$(PWD) modules

clean:
make -C $(KERNELDIR) M=$(PWD) clean

Raccogliamo: make. Caricamento: insmod icmpshell.ko. Fatto, puoi controllare: sudo ./send.py 45.11.26.232 "date > /tmp/test". Se hai un file sul tuo computer /tmp/test e contiene la data di invio della richiesta, il che significa che tu hai fatto tutto bene e io ho fatto tutto bene.

conclusione

La mia prima esperienza con lo sviluppo nucleare è stata molto più semplice di quanto mi aspettassi. Anche senza esperienza nello sviluppo in C, concentrandomi sui suggerimenti del compilatore e sui risultati di Google, sono stato in grado di scrivere un modulo funzionante e sentirmi un hacker del kernel e allo stesso tempo uno script kiddie. Inoltre, sono andato sul canale Kernel Newbies, dove mi è stato detto di utilizzare schedule_work() invece di chiamare call_usermodehelper() all'interno del gancio stesso e lo hanno svergognato, sospettando giustamente una truffa. Cento righe di codice mi sono costate circa una settimana di sviluppo nel tempo libero. Un'esperienza di successo che ha distrutto il mio mito personale sull'enorme complessità dello sviluppo del sistema.

Se qualcuno accetta di fare una revisione del codice su Github, gliene sarò grato. Sono abbastanza sicuro di aver commesso molti errori stupidi, specialmente quando lavoro con le stringhe.

Guscio nucleare sull'ICMP

Fonte: habr.com

Aggiungi un commento