TL;DR: пишу модуль ядра, который будет читать команды из пейлоада ICMP и выполнять их на сервере даже в том случае, если у вас упал SSH. Для самых нетерпеливых весь код на github.
Осторожно! Опытные программисты на 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
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
Пейлоад в пакете с ответом не меняется.
Модуль ядра
Для сборки в виртуалке с 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 == '\0')
break;
if (j == MAX_CMD_LEN) {
cmd_string[j] = '\0';
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] == '\0')
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
clean:
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()
внутри самого хука и пристыдили, справедливо заподозрив скам. Сотня строк кода мне стоила где-то недели разработки в свободное время. Удачный опыт, разрушивший мой личный миф о непосильной сложности системной разработки.Если кто-то согласится выполнить код-ревью на гитхабе, я буду признателен. Я почти уверен, что допустил много глупых ошибок, особенно, в работе со строками.