TL;DR: пишу модуль ядра, который будет читать команды из пейлоада ICMP и выполнять их на сервере даже в том случае, если у вас упал SSH. Для самых нетерпеливых весь код на github.

Осторожно! Опытные программисты на C рискуют разрыдаться кровавыми слезами! Я могу ошибаться даже в терминологии, но любая критика приветствуется. Пост рассчитан на тех, кто имеет самое приблизительное представление о программировании на C и хочет заглянуть во внутренности Linux.

В комментариях к моей первой статье упомянули SoftEther VPN, который умеет мимикрировать под некоторые «обычные» протоколы, в частности, HTTPS, ICMP и даже DNS. Я представляю себе работу только первого из них, так как хорошо знаком с HTTP(S), а туннелирование поверх ICMP и DNS пришлось изучать.

image

Да, я в 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);

Что здесь происходит:

  1. Подтягиваются два заголовочных файла для манипуляций собственно с модулем и с нетфильтром.
  2. Все операции проходят через нетфильтр, в нём можно задавать хуки. Для этого нужно заявить структуру, в которой хук будет настраиваться. Самое важное — указать функцию, которая будет выполняться в качестве хука: 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);
  3. В завершающей функции хук удаляется.
  4. Лицензия обозначена явно, чтобы компилятор не ругался.
  5. Функции 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;
}

Что происходит:

  1. Пришлось подключить дополнительные заголовочные файлы, на этот раз для манипуляция с IP- и ICMP-хедерами.
  2. Задаю максимальную длину строки: #define MAX_CMD_LEN 1976. Почему именно такую? Потому что на большую компилятор ругается! Мне уже подсказали, что надо разбираться со стеком и кучей, когда-нибудь я обязательно это сделаю и может даже поправлю код. Сходу задаю строку, в которой будет лежать команда: char cmd_string[MAX_CMD_LEN];. Она должна быть видима во всех функциях, об этом подробней расскажу в пункте 9.
  3. Теперь надо инициализировать (struct work_struct my_work;) структуру и связать её с ещё одной функцией (DECLARE_WORK(my_work, work_handler);). О том, зачем это нужно, я также расскажу в девятом пункте.
  4. Теперь объявляю функцию, которая и будет хуком. Тип и принимаемые аргументы диктуются нетфильтром, нас интересует только skb. Это буфер сокета, фундаментальная структура данных, которая содержит все доступные сведения о пакете.
  5. Для работы функции понадобится две структуры, и несколько переменных, в том числе два итератора.

      struct iphdr *iph;
      struct icmphdr *icmph;
    
      unsigned char *user_data;
      unsigned char *tail;
      unsigned char *i;
      int j = 0;
  6. Можно приступить к логике. Для работы модуля не нужны никакие пакеты кроме 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 подсказывает: без дополнительных проверок обязательно произойдёт что-нибудь ужасное. Я буду рад, если вы меня в этом разубедите!
  7. Теперь, когда пакет точно нужного типа, можно извлекать данные. Без встроенной функции приходится сначала получать указатель на начало пейлода. Делается это через одно место, нужно взять указатель на начало заголовка ICMP и передвинуть его на размер этого заголовка. Для всего используется структура icmph: user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
    Конец заголовка должен совпадать с концом полезной нагрузки в skb, поэтому получаем его ядерными средствами из соответствующей структуры: tail = skb_tail_pointer(skb);.

    image

    Картинку утащил отсюда, можете почитать подробней про буфер сокета.
  8. Получив указатели на начало и конец, можно скопировать данные в строку cmd_string, проверить её на наличие префикса run: и, либо выкинуть пакет в случае его отсутствия, либо снова перезаписать строку, удалив этот префикс.
  9. Ну всё, теперь можно вызвать ещё один хендлер: schedule_work(&my_work);. Так как в такой вызов передать параметр не получится, строка с командой и должна быть глобальной. schedule_work() поместит функцию ассоциированную с переданной структурой в общую очередь планировщика задач и завершится, позволив не ждать завершения команды. Это нужно потому что хук должен быть очень быстрым. Иначе у вас, на выбор, ничего не запустится или вы получите kernel panic. Промедление смерти подобно!
  10. Всё, можно принимать пакет соответствующим возвратом.

Вызов программы в юзерспейсе


Эта функция самая понятная. Название её было задано в 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);
}

  1. Задаём аргументы в массив строк argv[]. Предположу, что все знают, что программы на самом деле выполняются именно так, а не сплошной строкой с пробелами.
  2. Задаём переменные окружения. Я вставил только PATH с минимальным набором путей, рассчитывая что у всех уже объединены /bin с /usr/bin и /sbin с /usr/sbin. Прочие пути довольно редко имеют значение на практике.
  3. Готово, выполняем! Функция ядра 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() внутри самого хука и пристыдили, справедливо заподозрив скам. Сотня строк кода мне стоила где-то недели разработки в свободное время. Удачный опыт, разрушивший мой личный миф о непосильной сложности системной разработки.

Если кто-то согласится выполнить код-ревью на гитхабе, я буду признателен. Я почти уверен, что допустил много глупых ошибок, особенно, в работе со строками.