Jądrowy shell na ICMP

Jądrowy shell na ICMP

TL;DR: piszę moduł jądra, który będzie czytał polecenia z ładunku ICMP i wykonywał je na serwerze, nawet jeśli SSH padnie. Dla najbardziej niecierpliwych cały kod znajduje się na github.

Uważaj! Doświadczeni programiści w C ryzykują, że będą płakać krwawymi łzami! Mogę się mylić co do terminologii, ale każda krytyka jest mile widziana. Post jest skierowany do tych, którzy mają tylko ogólne pojęcie o programowaniu w C i chcą zajrzeć do wnętrza Linuxa.

W komentarzach do mojego pierwszego artykuł wspomniano SoftEther VPN, który potrafi udawać niektóre „zwykłe” protokoły, w szczególności HTTPS, ICMP, a nawet DNS. Znam tylko pierwszego z nich, ponieważ dobrze znam HTTP(S), a tunelowanie przez ICMP i DNS musiałem zbadać.

Jądrowy shell na ICMP

Tak, w 2020 roku dowiedziałem się, że do pakietów ICMP można wstawić dowolny ładunek. Lepiej późno niż wcale! A skoro można coś z tym zrobić, to trzeba to zrobić. Ponieważ na co dzień używam głównie wiersza poleceń, w tym przez SSH, pomysł ICMP-shela przyszedł mi do głowy w pierwszej kolejności. Aby stworzyć pełne bullsh*it bingo, postanowiłem pisać to jako moduł Linuxa w języku, o którym mam tylko ogólne pojęcie. Taki shell nie będzie widoczny na liście procesów, można go załadować do jądra i nie będzie leżał w systemie plików, nie zobaczysz nic podejrzanego na liście nasłuchujących portów. Pod względem możliwości jest to pełnoprawny rootkit, ale mam nadzieję, że dopracuję go i użyję jako shell ostatniej szansy, gdy Load Average będzie zbyt wysoki, aby połączyć się przez SSH i wykonać przynajmniej echo i > /proc/sysrq-trigger, aby przywrócić dostęp bez restartu.

Bierzemy edytor tekstu, podstawowe umiejętności programowania w Pythonie i C, Google oraz maszynę wirtualną , którą nie ma szkoda zniszczyć, jeśli wszystko się zepsuje (opcjonalnie — lokalny VirtualBox/KVM/etc) i jedziemy!

Część kliencka

Myślałem, że dla części klienckiej będę musiał napisać skrypt na około 80 linii, ale znalazły się dobre dusze, które zrobiły to za mnie całą pracę. Kod okazał się nieoczekiwanie prosty, mieści się w 10 znaczących liniach:

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

if len(sys.argv) < 3:
    print('Użycie: {} IP "komenda"'.format(sys.argv[0]))
    exit(0)

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

Skrypt przyjmuje dwa argumenty: adres oraz payload. Przed wysłaniem payload jest poprzedzany kluczem uruchom:, będzie nam potrzebny do wykluczenia pakietów z przypadkowym payloadem.

Jądro wymaga uprawnień do tworzenia pakietów, dlatego skrypt musi być uruchamiany z prawami superużytkownika. Nie zapomnij nadać uprawnień do wykonania i zainstalować scapy. W Debianie istnieje pakiet, który nazywa się python3-scapy. Teraz możemy sprawdzić, jak to wszystko działa.

Uruchomienie i wyjście z polecenia
morq@laptop:~\/icmpshell$ sudo .\/send.py 45.11.26.232 "Hello, world!"
Rozpocznij emisję:
.Zakończono wysyłanie 1 pakietu.
*
Otrzymano 2 pakiety, uzyskano 1 odpowiedź, pozostało 0 pakietów
###[ IP ]###
wersja = 4
ihl = 5
tos = 0x0
len = 45
id = 17218
flagi =
frag = 0
ttl = 58
proto = icmp
chksum = 0x3403
src = 45.11.26.232
dst = 192.168.0.240
options
###[ ICMP ]###
typ = echo-reply
kod = 0
chksum = 0xde03
id = 0x0
seq = 0x0
###[ Raw ]###
ładunek = 'run:Hello, world!

Tak to wygląda w snifferze
morq@laptop:~\/icmpshell$ sudo tshark -i wlp1s0 -O icmp -f "icmp and host 45.11.26.232"
Uruchamianie jako użytkownik "root" i grupa "root". To może być niebezpieczne.
Przechwytywanie na 'wlp1s0'
Ramka 1: 59 bajtów w sieci (472 bity), 59 bajtów przechwyconych (472 bity) na interfejsie wlp1s0, id 0
Protokół internetowy wersja 4, Źródło: 192.168.0.240, Cel: 45.11.26.232
Protokół sterowania komunikacją w internecie
Typ: 8 (Żądanie echo (ping))
Kod: 0
Suma kontrolna: 0xd603 [poprawna]
[Status sumy kontrolnej: Dobry]
Identyfikator (BE): 0 (0x0000)
Identyfikator (LE): 0 (0x0000)
Numer sekwencyjny (BE): 0 (0x0000)
Numer sekwencyjny (LE): 0 (0x0000)
Dane (17 bajtów)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 uruchom:Hello, world
0010 21 !
Dane: 72756e3a48656c6c6f2c20776f726c6421
[Długość: 17]

Ramka 2: 59 bajtów w sieci (472 bity), 59 bajtów przechwyconych (472 bity) na interfejsie wlp1s0, id 0
Protokół internetowy wersja 4, Źródło: 45.11.26.232, Dokładny: 192.168.0.240
Protokół sterowania komunikacją w internecie
Typ: 0 (Odpowiedź Echo (ping))
Kod: 0
Suma kontrolna: 0xde03 [poprawna]
[Status sumy kontrolnej: Dobry]
Identyfikator (BE): 0 (0x0000)
Identyfikator (LE): 0 (0x0000)
Numer sekwencyjny (BE): 0 (0x0000)
Numer sekwencyjny (LE): 0 (0x0000)
[Ramka żądania: 1]
[Czas odpowiedzi: 19.094 ms]
Dane (17 bajtów)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 uruchom:Hello, world
0010 21 !
Dane: 72756e3a48656c6c6f2c20776f726c6421
[Długość: 17]

^C2 pakiety przechwycone

Payload w pakiecie z odpowiedzią nie zmienia się.

Moduł jądra

Do zbudowania w wirtualce z Debianem potrzebne będą co najmniej make i linux-headers-amd64, reszta zostanie pobrana jako zależności. W artykule nie będę podawał całego kodu, możesz go sklonować na GitHubie.

Konfiguracja hooka

Na początek potrzebujemy dwóch funkcji do załadowania modułu i do jego wyładowania. Funkcja do wyładowania nie jest obowiązkowa, ale wtedy rmmod nie będzie można wykonać, moduł wyładowany zostanie tylko podczas wyłączenia.

#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);

Co się tutaj dzieje:

  1. Dwa pliki nagłówkowe są ładowane do manipulacji z modułem oraz z netfilterem.
  2. Wszystkie operacje odbywają się przez netfilter, w którym można ustawiać hooki. W tym celu należy zadeklarować strukturę, w której hook będzie konfigurowany. Najważniejsze jest wskazanie funkcji, która będzie wykonywana jako hook: nfho.hook = icmp_cmd_executor; do samej funkcji jeszcze dojdę.
    Następnie ustawiłem moment przetwarzania pakietu: NF_INET_PRE_ROUTING wskazuje, aby przetwarzać pakiet, gdy tylko pojawi się w jądrze. Można użyć NF_INET_POST_ROUTING do przetwarzania pakietu na wyjściu z jądra.
    Nakładam filtr na IPv4: nfho.pf = PF_INET;.
    Nadaję mojemu hookowi najwyższy priorytet: nfho.priority = NF_IP_PRI_FIRST;
    I rejestruję strukturę danych jako hook: nf_register_net_hook(&init_net, &nfho);
  3. W końcowej funkcji hook jest usuwany.
  4. Licencja jest wyraźnie oznaczona, aby kompilator nie zgłaszał błędów.
  5. Funkcje module_init() i module_exit() przypisują inne funkcje jako inicjującą i końcową pracę modułu.

Ekstrakcja payloadu

Teraz trzeba wyodrębnić payload, co okazało się najtrudniejszym zadaniem. W jądrze nie ma wbudowanych funkcji do pracy z payloadem, można tylko parsować nagłówki wyższych protokołów.

#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;
}

Co się dzieje:

  1. Musiałem dołączyć dodatkowe pliki nagłówkowe, tym razem do manipulacji nagłówkami IP i ICMP.
  2. Ustawiam maksymalną długość ciągu: #define MAX_CMD_LEN 1976. Dlaczego akurat taką? Ponieważ kompilator narzeka na dłuższe! Już mi zasugerowano, żebym zajął się stosem i stertą, kiedyś na pewno to zrobię i może nawet poprawię kod. Od razu ustalam ciąg, w którym będzie znajdować się komenda: char cmd_string[MAX_CMD_LEN];. Powinien być widoczny we wszystkich funkcjach, o tym dokładniej opowiem w punkcie 9.
  3. Teraz trzeba zainicjować (struct work_struct my_work;) strukturę i powiązać ją z jeszcze jedną funkcją (DECLARE_WORK(my_work, work_handler);). O tym, po co to potrzebne, również opowiem w dziewiątym punkcie.
  4. Teraz deklaruję funkcję, która będzie hookiem. Typ i przyjmowane argumenty są zdefiniowane przez netfilter, interesuje nas tylko skb. To bufor gniazda, fundamentalna struktura danych, która zawiera wszystkie dostępne informacje o pakiecie.
  5. Aby funkcja mogła działać, potrzeba dwóch struktur i kilku zmiennych, w tym dwóch iteratorów.
      struct iphdr *iph;
      struct icmphdr *icmph;
    
      unsigned char *user_data;
      unsigned char *tail;
      unsigned char *i;
      int j = 0;
  6. Można przystąpić do logiki. Do działania modułu nie są potrzebne żadne pakiety oprócz ICMP Echo, więc parsujemy bufor wbudowanymi funkcjami i odrzucamy wszystkie pakiety, które nie są ICMP ani Echo. Zwrócenie NF_ACCEPT oznacza akceptację pakietu, ale można również odrzucić pakiety, zwracając 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;
      }

    Nie sprawdzałem, co się stanie bez weryfikacji nagłówków IP. Moja minimalna wiedza o C podpowiada mi: bez dodatkowych weryfikacji z pewnością wydarzy się coś strasznego. Będę wdzięczny, jeśli mnie w tym rozwiążecie!

  7. Teraz, gdy pakiet jest na pewno odpowiedniego typu, można ekstraktować dane. Bez wbudowanej funkcji najpierw trzeba uzyskać wskaźnik na początek payloadu. Robi się to w dość nietypowy sposób, trzeba wziąć wskaźnik na początek nagłówka ICMP i przesunąć go o rozmiar tego nagłówka. Do wszystkiego używa się struktury icmph: user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
    Koniec nagłówka musi odpowiadać końcowi ładunku w skb, dlatego uzyskujemy go za pomocą odpowiedniej struktury: tail = skb_tail_pointer(skb);.

    Jądrowy shell na ICMP

    Obrazek został zabrany stąd, możesz poczytać więcej o buforze gniazda.

  8. Po uzyskaniu wskaźników na początek i koniec, można skopiować dane do napisu cmd_string, sprawdzić go pod kątem obecności prefiksu uruchom: i, albo wyrzucić pakiet w przypadku jego braku, albo ponownie zapisać napis, usuwając ten prefiks.
  9. No dobrze, teraz można wywołać jeszcze jeden handler: schedule_work(&my_work);. Ponieważ w takim wywołaniu nie można przekazać parametru, ciąg z komendą musi być globalny. schedule_work() umieści funkcję związaną z przekazaną strukturą w ogólnej kolejce planera zadań i zakończy się, pozwalając nie czekać na zakończenie komendy. Jest to konieczne, ponieważ hak musi być bardzo szybki. W przeciwnym razie nic się nie uruchomi lub dostaniesz kernel panic. Opóźnienie jest równoważne śmierci!
  10. Wszystko, można przyjąć pakiet odpowiednim zwrotem.

Wywołanie programu w przestrzeni użytkownika

Ta funkcja jest najprostsza. Jej nazwa została ustawiona w DECLARE_WORK(), typ i przyjmowane argumenty nie są interesujące. Bierzemy ciąg z komendą i przekazujemy go w całości shellowi. Niech sam zajmie się analizą, wyszukiwaniem binariów i wszystkim innym.

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. Ustalamy argumenty w tablicy napisów argv[]. Zakładam, że wszyscy wiedzą, że programy są w rzeczywistości wykonywane w ten sposób, a nie jako ciąg ze spacjami.
  2. Ustalamy zmienne środowiskowe. Wstawiłem tylko PATH z minimalnym zestawem ścieżek, zakładając, że wszyscy już je połączyli /bin z /usr/bin i /sbin z /usr/sbin. Pozostałe ścieżki rzadko mają znaczenie w praktyce.
  3. Gotowe, wykonujemy! Funkcja jądra call_usermodehelper() przyjmuje jako argument. ścieżkę do binarnych, tablicę argumentów, tablicę zmiennych środowiskowych. Tutaj również zakładam, że wszyscy rozumieją sens przekazywania ścieżki do pliku wykonywalnego jako osobnego argumentu, ale możecie pytać. Ostatni argument wskazuje, czy czekać na zakończenie procesu (UMH_WAIT_PROC), rozpocząć proces (UMH_WAIT_EXEC) czy nie czekać w ogóle (UMH_NO_WAIT). Jest jeszcze UMH_KILLABLE, nie zagłębiałem się w to.

Kompilacja

Kompilacja modułów jądrowych odbywa się za pomocą jądrowego frameworka make. Wywoływana jest make w specjalnym katalogu przypisanym do wersji jądra (określone tutaj: KERNELDIR:=/lib/modules/$(shell uname -r)/build), a lokalizacja modułu jest przekazywana jako zmienna M w argumentach. W celu icmpshell.ko i clean w całości wykorzystuje ten framework. W obj-m określamy plik obiektowy, który zostanie przekształcony w moduł. Składnia, która przekształca main.o do icmpshell.o (icmpshell-objs = main.o) wygląda dla mnie mało logicznie, ale niech tak będzie.

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

Budujemy: make. Ładujemy: insmod icmpshell.ko. Gotowe, można sprawdzić: sudo ./send.py 45.11.26.232 "date > /tmp/test". Jeśli w twoim komputerze pojawił się plik /tmp/test i znajduje się w nim data wysłania żądania, oznacza to, że wszystko zrobiłeś poprawnie, a ja również zrobiłem wszystko poprawnie.

Podsumowanie

Moje pierwsze doświadczenie z rozwojem jądra okazało się znacznie prostsze, niż się spodziewałem. Nawet nie mając doświadczenia w programowaniu w C, opierając się na wskazówkach kompilatora i wynikach Google, byłem w stanie napisać działający moduł i poczuć się jak haker jądra, a przy okazji jak skryptowy dzieciak. Ponadto wszedłem na kanał Kernel Newbies, gdzie zasugerowano mi użycie schedule_work() zamiast wywołania call_usermodehelper() wewnątrz samego hooka i właściwie mnie zawstydzono, słusznie podejrzewając oszustwo. Setki linii kodu zajęło mi około tygodnia pracy w wolnym czasie. Udane doświadczenie, które zburzyło mój osobisty mit o nieosiągalnej złożoności rozwoju systemowego.

Jeśli ktoś zgodzi się wykonać przegląd kodu na GitHubie, będę wdzięczny. Jestem niemal pewien, że popełniłem wiele głupich błędów, zwłaszcza przy pracy ze stringami.

Jądrowy shell na ICMP

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster