TL, д-р: пишу модуль ядра, який читатиме команди з пейлоаду ICMP і виконувати їх на сервері навіть у тому випадку, якщо у вас впав SSH. Для найбільш нетерплячих весь код на .
Обережно! Досвідчені програмісти на C ризикують розплакатися кривавими сльозами! Я можу помилятися навіть у термінології, але будь-яка критика схвалюється. Пост розрахований на тих, хто має приблизне уявлення про програмування на C і хоче заглянути у нутрощі Linux.
У коментарях до моєї першої згадали SoftEther VPN, який вміє мімікрувати під деякі «звичайні» протоколи, зокрема HTTPS, ICMP і навіть DNS. Я уявляю роботу тільки першого з них, тому що добре знайомий з HTTP(S), а тунелювання поверх ICMP і DNS довелося вивчати.

Так, я в 2020 році дізнався, що до ICMP-пакетів можна вставити довільний пейлоад. Але краще запізно, ніж ніколи! І якщо вже з цим можна щось зробити, значить треба робити. Так як у своїй повсякденності найчастіше я користуюся командним рядком, у тому числі через SSH, ідея ICMP-шелла спала мені на думку насамперед. А щоб зібрати повне буллщит-бінго, вирішив писати у вигляді модуля Linux мовою, про яку я маю лише приблизне уявлення. Такий шелл не буде видно в списку процесів, можна завантажити його в ядро і він не лежатиме на файловій системі, ви не побачите нічого підозрілого в списку портів, що прослуховуються. За своїми можливостями це повноцінний руткіт, але я сподіваюся допрацювати і використовувати його як шелл останньої надії, коли Load Average занадто високий для того, щоб зайти по SSH і виконати хоча б echo i > /proc/sysrq-trigger, щоб відновити доступ без перезавантаження.
Беремо текстовий редактор, базові скіли програмування на Python та C, гугл та яку не шкода пустити під ніж якщо все поламається (опціонально – локальний VirtualBox/KVM/etc) та погнали!
Клієнтська частина
Мені здавалося, що для клієнтської частини доведеться писати скрипт рядків на 80, але знайшлися добрі люди, які зробили за мене . Код виявився несподівано простим, вміщується у 10 значних рядків:
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() Скрипт приймає два аргументи, адресою та пейлоад. Перед відправкою пейлоад передує ключу run:, він нам знадобиться щоб виключити пакети з довільним пейлоадом.
Ядро вимагає привілеїв для того, щоб крафтити пакети, тому скрипт доведеться запускати з правами суперкористувача. Не забудьте дати права на виконання та встановити сам scapy. У Debian є пакет, називається python3-scapy. Тепер можна перевіряти, як усе це працює.
Запуск та виведення команди
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!
Так це виглядає у сніффері
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
Протокол керуючих повідомлень Інтернету
Тип: 0 (Echo (ping) reply)
Код: 0
Checksum: 0xde03 [correct]
[Checksum Status: Good]
ID (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 пакети captured
Пейлоад у пакеті з відповіддю не змінюється.
Модуль ядра
Для складання у віртуалці з Debian знадобляться як мінімум make и linux-headers-amd64решта підтягнеться у вигляді залежностей. У статті код повністю наводити не буду, ви його можете схилювати на гітхабі.
Налаштування хука
Спочатку нам знадобляться дві функції для того, щоб завантажити модуль і щоб його вивантажити. Функція для вивантаження не є обов'язковою, але тоді і rmmod виконати не вийде, модуль вивантажиться лише при вимкненні.
#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);Що тут відбувається:
- Підтягуються два заголовні файли для маніпуляцій власне з модулем і з нетфільтром.
- Усі операції проходять через нетфільтр, у ньому можна ставити хуки. Для цього потрібно заявити структуру, в якій хук налаштовуватиметься. Найважливіше — вказати функцію, яка виконуватиметься як хук:
nfho.hook = icmp_cmd_executor;до самої функції я ще дістануся.
Потім я поставив момент обробки пакета:NF_INET_PRE_ROUTINGвказує на обробку пакета, коли він тільки з'явився в ядрі. Можна використовуватиNF_INET_POST_ROUTINGдля обробки пакета на виході із ядра.
Вішаю фільтр на IPv4:nfho.pf = PF_INET;.
Призначаю своєму хуку найвищий пріоритет:nfho.priority = NF_IP_PRI_FIRST;
І реєструю структуру даних як власне хук:nf_register_net_hook(&init_net, &nfho); - У завершальній функції хук видаляється.
- Ліцензія позначена явно, щоб компілятор не лаявся.
- Функції
module_init()иmodule_exit()задають інші функції як ініціалізуючу та завершальну роботу модуля.
Вилучення пейлоаду
Тепер потрібно витягти пейлоад, це виявилося найскладнішим завданням. У ядрі немає вбудованих функцій для роботи з пейлоадом, можна лише ширяти заголовки більш високорівневих протоколів.
#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;
}Що відбувається:
- Довелося підключити додаткові заголовні файли, цього разу для маніпуляції з IP- та ICMP-хедерами.
- Задаю максимальну довжину рядка:
#define MAX_CMD_LEN 1976. Чому саме таку? Тому що на велику компілятор лається! Мені вже підказали, що треба розбиратися зі стеком і купою, коли я обов'язково це зроблю і може навіть поправлю код. Відразу задаю рядок, в якому лежатиме команда:char cmd_string[MAX_CMD_LEN];. Вона повинна бути видима у всіх функціях, про це докладніше розповім у пункті 9. - Тепер треба ініціалізувати (
struct work_struct my_work;) структуру та пов'язати її з ще однією функцією (DECLARE_WORK(my_work, work_handler);). Про те, навіщо це потрібно, я також розповім у дев'ятому пункті. - Тепер оголошую функцію, яка буде хуком. Тип і аргументи, що приймаються, диктуються нетфільтром, нас цікавить тільки
skb. Це буфер сокету, фундаментальна структура даних, яка містить усі доступні відомості про пакет. - Для роботи функції знадобиться дві структури, і кілька змінних, у тому числі два ітератори.
struct iphdr *iph; struct icmphdr *icmph; unsigned char *user_data; unsigned char *tail; unsigned char *i; int j = 0; - Можна розпочати логіку. Для роботи модуля не потрібні ніякі пакети, крім ICMP Echo, тому паримо буфер вбудованими функціями і викидаємо все не ICMP-і не Echo-пакети. Повернення
NF_ACCEPTозначає прийняття пакета, але можете й дропнути пакети, повернувши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; }Я не перевіряв, що відбудеться без перевірки заголовків IP. Моє мінімальне знання C підказує: без додаткових перевірок обов'язково станеться щось страшне. Я буду радий, якщо ви мене в цьому переконаєте!
- Тепер, коли пакет точно потрібного типу, можна отримувати дані. Без вбудованої функції доводиться спочатку отримувати покажчик початку пейлода. Робиться це через одне місце, потрібно взяти покажчик на початок заголовка ICMP і пересунути його розмір цього заголовка. Для всього використовується структура
icmph:user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
Кінець заголовка повинен збігатися з кінцем корисного навантаженняskbтому отримуємо його ядерними засобами з відповідної структури:tail = skb_tail_pointer(skb);.
Картинку поцупив , можете прочитати детальніше про буфер сокету. - Отримавши покажчики на початок і на кінець, можна скопіювати дані в рядок
cmd_string, перевірити її на наявність префіксаrun:або викинути пакет у разі його відсутності, або знову перезаписати рядок, видаливши цей префікс. - Ну все, тепер можна викликати ще один хендлер:
schedule_work(&my_work);. Так як у такий виклик передати параметр не вийде, рядок з командою має бути глобальним.schedule_work()помістить функцію асоційовану з переданою структурою в загальну чергу планувальника завдань і завершиться, дозволивши не чекати на завершення команди. Це потрібно тому, що хук має бути дуже швидким. Інакше у вас на вибір нічого не запуститься або ви отримаєте kernel panic. Зволікання смерті подібне! - Все, можна приймати пакет відповідним поверненням.
Виклик програми в юзерспейсі
Ця функція найзрозуміліша. Назва її було задано в DECLARE_WORK(), тип та прийняті аргументи не цікаві. Беремо рядок з командою та передаємо її повністю шеллу. Нехай він сам розбирається з парсингом, пошуком бінарів та з усім іншим.
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);
}- Задаємо аргументи в масив рядків
argv[]. Припустимо, що всі знають, що програми насправді виконуються саме так, а не суцільним рядком із пробілами. - Задаємо змінні оточення. Я вставив тільки PATH з мінімальним набором шляхів, розраховуючи що всі вже об'єднані
/binс/usr/binи/sbinс/usr/sbin. Інші шляхи досить рідко мають значення практично. - Готово, виконуємо! Функція ядра
call_usermodehelper()приймає на вхід. шлях до бінара, масив аргументів, масив змінних оточення. Тут я теж припускаю, що всі розуміють сенс передачі шляху до файлу, що виконується окремим аргументом, але можете запитати. Останній аргумент вказує, чи чекати завершення процесу (UMH_WAIT_PROC), запуску процесу (UMH_WAIT_EXEC) або не чекати взагалі (UMH_NO_WAIT). Є щеUMH_KILLABLEя не став розбиратися в цьому.
збірка
Складання ядерних модулів виконується через ядерний make-фреймворк. Викликається make усередині спеціальної директорії прив'язаної до версії ядра (визначається тут: KERNELDIR:=/lib/modules/$(shell uname -r)/build), а місцезнаходження модуля передається змінною M у аргументах. У таргетах icmpshell.ko та clean цілком використовується цей фреймворк. У obj-m вказується об'єктний файл, який буде перероблено в модуль. Синтаксис, який переробляє main.o в icmpshell.o (icmpshell-objs = main.o) для мене виглядає не дуже логічним, але нехай так і буде.
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
чисто:
make -C $(KERNELDIR) M=$(PWD) clean
Збираємо: make. Завантажуємо: insmod icmpshell.ko. Готово, можна перевіряти: sudo ./send.py 45.11.26.232 "date > /tmp/test". Якщо у вас на машині з'явився файл /tmp/test і в ньому лежить дата відправки запиту, отже, ви зробили все правильно і я зробив все правильно.
Висновок
Мій перший досвід ядерної розробки виявився набагато простішим, ніж я очікував. Навіть не маючи досвіду розробки на C, орієнтуючись на підказки компілятора та видачу гугла, я зміг написати робочий модуль і відчути себе кернел хакером, а заразом і скрипт-кідді. Окрім цього, я зайшов на канал Kernel Newbies, де мені підказали використовувати schedule_work() замість виклику call_usermodehelper() усередині самого хука і присоромили, справедливо запідозривши скамі. Сотня рядків коду мені коштувала десь тижні розробки у вільний час. Вдалий досвід, що зруйнував мій особистий міф про непосильну складність системної розробки.
Якщо хтось погодиться виконати код-рев'ю на гітхабі, я буду вдячний. Я майже впевнений, що припустився багато дурних помилок, особливо в роботі з рядками.
Джерело: habr.com

