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 .
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 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ć.

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 , 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 . 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:
- Dwa pliki nagłówkowe są ładowane do manipulacji z modułem oraz z netfilterem.
- 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_ROUTINGwskazuje, aby przetwarzać pakiet, gdy tylko pojawi się w jądrze. Można użyćNF_INET_POST_ROUTINGdo 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); - W końcowej funkcji hook jest usuwany.
- Licencja jest wyraźnie oznaczona, aby kompilator nie zgłaszał błędów.
- Funkcje
module_init()imodule_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:
- Musiałem dołączyć dodatkowe pliki nagłówkowe, tym razem do manipulacji nagłówkami IP i ICMP.
- 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. - 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. - 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. - 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; - 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_ACCEPToznacza akceptację pakietu, ale można również odrzucić pakiety, zwracającNF_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!
- 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 wskb, dlatego uzyskujemy go za pomocą odpowiedniej struktury:tail = skb_tail_pointer(skb);.
Obrazek został zabrany , możesz poczytać więcej o buforze gniazda. - 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 prefiksuuruchom:i, albo wyrzucić pakiet w przypadku jego braku, albo ponownie zapisać napis, usuwając ten prefiks. - 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! - 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);
}- 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. - Ustalamy zmienne środowiskowe. Wstawiłem tylko PATH z minimalnym zestawem ścieżek, zakładając, że wszyscy już je połączyli
/binz/usr/bini/sbinz/usr/sbin. Pozostałe ścieżki rzadko mają znaczenie w praktyce. - 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 jeszczeUMH_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.
Źródło: habr.com

