Shell nuclear sobre ICMP

Shell nuclear sobre ICMP

TL;DR: estoy escribiendo un módulo del núcleo que leerá comandos de la carga útil de ICMP y los ejecutará en el servidor incluso si tu SSH se ha caído. Para los más impacientes, todo el código está en github.

¡Cuidado! ¡Los programadores experimentados en C corren el riesgo de llorar lágrimas de sangre! Puedo estar equivocado incluso en la terminología, pero cualquier crítica es bien recibida. Este artículo está destinado a aquellos que tienen una idea aproximada de la programación en C y quieren echar un vistazo a las entrañas de Linux.

En los comentarios de mi primera el artículo mencionaron SoftEther VPN, que puede mimetizarse como algunos protocolos "comunes", en particular, HTTPS, ICMP e incluso DNS. Solo puedo imaginar el funcionamiento del primero, ya que estoy familiarizado con HTTP(S), y aprendí a hacer túneles sobre ICMP y DNS.

Shell nuclear sobre ICMP

Sí, en 2020 descubrí que se puede insertar una carga útil arbitraria en los paquetes ICMP. ¡Más vale tarde que nunca! Y dado que se puede hacer algo al respecto, hay que hacerlo. Como en mi día a día utilizo sobre todo la línea de comandos, incluso a través de SSH, la idea de un shell ICMP me vino a la cabeza primero. Y para reunir todo este bingo de tonterías, decidí escribirlo como un módulo de Linux en un lenguaje sobre el cual tengo solo una comprensión aproximada. Este shell no será visible en la lista de procesos, se puede cargar en el núcleo y no estará en el sistema de archivos, no verás nada sospechoso en la lista de puertos escuchando. En funciones, es un rootkit completo, pero espero mejorarlo y usarlo como un shell de último recurso, cuando la Carga Promedio sea demasiado alta para acceder por SSH y ejecutar al menos echo i > /proc/sysrq-trigger, para recuperar el acceso sin reiniciar.

Tomamos un editor de texto, habilidades básicas de programación en Python y C, Google y una máquina virtual que no me importe sacrificar si todo sale mal (opcional: local VirtualBox/KVM/etc.) y ¡vamos!

Parte del cliente

Pensé que tendría que escribir un script de unas 80 líneas para la parte del cliente, pero aparecieron personas amables que lo hicieron por mí todo el trabajo. El código resultó ser sorprendentemente simple, cabe en 10 líneas significativas:

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

if len(sys.argv) < 3:
    print('Uso: {} IP "comando"'.format(sys.argv[0]))
    exit(0)

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

El script acepta dos argumentos, la dirección y el payload. Antes de enviar el payload, se precede con una clave. run:, lo necesitaremos para excluir paquetes con payloads aleatorios.

El núcleo requiere privilegios para crear paquetes, por lo que el script debe ejecutarse con permisos de superusuario. No olvides otorgar permisos de ejecución e instalar scapy. En Debian hay un paquete llamado python3-scapy. Ahora podemos verificar cómo funciona todo esto.

Ejecutando y mostrando el comando
morq@laptop:~\/icmpshell$ sudo .\/send.py 45.11.26.232 "¡Hola, mundo!"
Comenzar emisión:
.Finalizado el envío de 1 paquete.
*
Recibidos 2 paquetes, se obtuvieron 1 respuestas, 0 paquetes restantes
###[ IP ]###
versión = 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:¡Hola, mundo!

Así es como se ve en el sniffer
morq@laptop:~\/icmpshell$ sudo tshark -i wlp1s0 -O icmp -f "icmp and host 45.11.26.232"
Ejecutándose como usuario "root" y grupo "root". Esto podría ser peligroso.
Capturando en 'wlp1s0'
Trama 1: 59 bytes en el cable (472 bits), 59 bytes capturados (472 bits) en la interfaz wlp1s0, id 0
Protocolo de Internet versión 4, Origen: 192.168.0.240, Destino: 45.11.26.232
Protocolo de control de mensajes de Internet
Tipo: 8 (Solicitud de Eco (ping))
Código: 0
Checksum: 0xd603 [correcto]
[Estado del Checksum: Bueno]
Identificador (BE): 0 (0x0000)
Identificador (LE): 0 (0x0000)
Número de secuencia (BE): 0 (0x0000)
Número de secuencia (LE): 0 (0x0000)
Datos (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
[Longitud: 17]

Frame 2: 59 bytes en la red (472 bits), 59 bytes capturados (472 bits) en la interfaz wlp1s0, id 0
Protocolo de Internet Versión 4, Src: 45.11.26.232, Dst: 192.168.0.240
Protocolo de control de mensajes de Internet
Tipo: 0 (Respuesta de eco (ping))
Código: 0
Checksum: 0xde03 [correcto]
[Estado del Checksum: Bueno]
Identificador (BE): 0 (0x0000)
Identificador (LE): 0 (0x0000)
Número de secuencia (BE): 0 (0x0000)
Número de secuencia (LE): 0 (0x0000)
[Marco de solicitud: 1]
[Tiempo de respuesta: 19.094 ms]
Datos (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
[Longitud: 17]

^C2 paquetes capturados

El payload en el paquete de respuesta no cambia.

Módulo del núcleo

Para compilar en una máquina virtual con Debian, se necesitarán como mínimo make y linux-headers-amd64, el resto se descargará como dependencias. No incluiré el código completo en el artículo, puedes clonarlo en GitHub.

Configuración del hook

Primero necesitaremos dos funciones para cargar y descargar el módulo. La función para descargar no es obligatoria, pero sin ella rmmod no se podrá ejecutar, el módulo solo se descargará al apagarse.

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

Lo que está sucediendo aquí:

  1. Se incluyen dos archivos de encabezado para manipular el módulo y el netfilter.
  2. Todas las operaciones pasan a través del netfilter, donde se pueden establecer hooks. Para esto, se debe declarar una estructura en la que se configurará el hook. Lo más importante es especificar la función que se ejecutará como hook: nfho.hook = icmp_cmd_executor; llegaré a la función más adelante.
    Luego definí el momento de procesamiento del paquete: NF_INET_PRE_ROUTING indica procesar el paquete en el momento en que acaba de aparecer en el núcleo. Se puede usar NF_INET_POST_ROUTING para procesar el paquete al salir del núcleo.
    Coloco un filtro en IPv4: nfho.pf = PF_INET;.
    Asigno a mi hook la máxima prioridad: nfho.priority = NF_IP_PRI_FIRST;
    Y registro la estructura de datos como un hook propiamente dicho: nf_register_net_hook(&init_net, &nfho);
  3. En la función final, el hook se elimina.
  4. La licencia está claramente indicada para que el compilador no se queje.
  5. Funciones module_init() y module_exit() definen otras funciones como inicializadora y de finalización del trabajo del módulo.

Extracción del payload

Ahora es necesario extraer el payload, que resultó ser la tarea más complicada. El núcleo no tiene funciones integradas para trabajar con el payload, solo se pueden analizar los encabezados de protocolos de nivel superior.

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

Qué está pasando:

  1. Tuve que incluir archivos de encabezado adicionales, esta vez para manipular los encabezados IP y ICMP.
  2. Establezco la longitud máxima de la cadena: #define MAX_CMD_LEN 1976. ¿Por qué exactamente así? ¡Porque el compilador se queja si es mayor! Ya me sugirieron que debería ocuparme de la pila y el montón, algún día definitivamente lo haré y tal vez incluso corregiré el código. De inmediato establezco la cadena donde se almacenará el comando: char cmd_string[MAX_CMD_LEN];. Debería ser visible en todas las funciones, de eso hablaré más detalladamente en el punto 9.
  3. Ahora necesito inicializar (struct work_struct my_work;) la estructura y vincularla a otra función (DECLARE_WORK(my_work, work_handler);). Sobre por qué es necesario, también lo explicaré en el noveno punto.
  4. Ahora declaro la función que será el hook. El tipo y los argumentos que recibe están dictados por el netfilter, solo nos interesa skb. Este es el buffer del socket, una estructura de datos fundamental que contiene toda la información disponible sobre el paquete.
  5. La función necesitará dos estructuras y varias variables, incluidos dos iteradores.
      struct iphdr *iph;
      struct icmphdr *icmph;
    
      unsigned char *user_data;
      unsigned char *tail;
      unsigned char *i;
      int j = 0;
  6. Se puede comenzar con la lógica. Para el funcionamiento del módulo no se necesitan otros paquetes que no sean ICMP Echo, así que analizamos el buffer con funciones integradas y descartamos todos los paquetes que no son ICMP o Echo. El retorno NF_ACCEPT significa aceptar el paquete, pero también puedes descartar los paquetes, devolviendo 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;
      }

    No he comprobado qué sucederá sin verificar los encabezados IP. Mi conocimiento mínimo de C me dice que sin verificaciones adicionales, algo horrible sucederá inevitablemente. Estaré encantado si me desmientes en esto.

  7. Ahora que el paquete es definitivamente del tipo correcto, se pueden extraer los datos. Sin una función integrada, primero hay que obtener un puntero al comienzo del payload. Esto se realiza desplazando el puntero desde el inicio del encabezado ICMP por el tamaño de este encabezado. Para todo se utiliza la estructura icmph: user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
    El final del encabezado debe coincidir con el final de la carga útil en skb, por lo que lo obtenemos a través de medios nucleares de la estructura correspondiente: tail = skb_tail_pointer(skb);.

    Shell nuclear sobre ICMP

    He tomado la imagen desde aquí, puedes leer más sobre el búfer de socket.

  8. Al obtener los punteros de inicio y fin, se pueden copiar los datos en la cadena cmd_string, verificar si tiene un prefijo run: y, o se descarta el paquete en caso de que no esté presente, o se vuelve a escribir la cadena, eliminando este prefijo.
  9. Bueno, ahora se puede llamar a otro controlador: schedule_work(&my_work);. Como en tal llamada no se puede pasar un parámetro, la cadena de comando debe ser global. schedule_work() colocará la función asociada con la estructura pasada en la cola general del planificador de tareas y terminará, permitiendo no esperar a que se complete el comando. Esto es necesario porque el gancho debe ser muy rápido. De lo contrario, o no se ejecutará nada o obtendrás un kernel panic. ¡La demora es fatal!
  10. Listo, se puede recibir el paquete con la devolución correspondiente.

Llamada al programa en espacio de usuario

Esta función es la más clara. Su nombre fue definido en DECLARE_WORK(), el tipo y los argumentos aceptados no son interesantes. Tomamos la cadena con el comando y la pasamos entera a la shell. Que se encargue de analizar, buscar binarios y todo lo demás.

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. Definimos los argumentos en un arreglo de cadenas argv[]. Supongo que todos saben que los programas en realidad se ejecutan de esta manera, y no como una cadena continua con espacios.
  2. Definimos las variables de entorno. Solo inserté PATH con un conjunto mínimo de rutas, suponiendo que ya todos tienen agrupadas /bin con /usr/bin y /sbin con /usr/sbin. Otras rutas son bastante raras en la práctica.
  3. ¡Listo, ejecutamos! La función del núcleo call_usermodehelper() acepta como entrada la ruta al binario, el arreglo de argumentos, el arreglo de variables de entorno. Aquí también supongo que todos entienden el significado de pasar la ruta al archivo ejecutable como un argumento separado, pero pueden preguntar. El último argumento indica si esperar a que el proceso termine (UMH_WAIT_PROC), iniciar el proceso (UMH_WAIT_EXEC) o no esperar en absoluto (UMH_NO_WAIT). También hay UMH_KILLABLE, no me molesté en profundizar en esto.

Compilación

La construcción de módulos del núcleo se realiza a través del mismo marco de make del núcleo. Se invoca make dentro de un directorio especial vinculado a la versión del núcleo (se determina aquí: KERNELDIR:=/lib/modules/$(shell uname -r)/build), y la ubicación del módulo se pasa a través de la variable M en los argumentos. En los objetivos icmpshell.ko y clean se utiliza todo este marco. En obj-m se indica el archivo objeto que se convertirá en módulo. La sintaxis que convierte main.o en icmpshell.o (icmpshell-objs = main.o) me parece no muy lógico, pero dejémoslo así.

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

Compilando: make. Cargando: insmod icmpshell.ko. Listo, puedes verificar: sudo ./send.py 45.11.26.232 "date > /tmp/test". Si en tu máquina ha aparecido un archivo /tmp/test y contiene la fecha del envío de la solicitud, significa que lo hiciste bien y yo también lo hice bien.

Conclusión

Mi primera experiencia en el desarrollo del núcleo resultó ser mucho más sencilla de lo que esperaba. Incluso sin tener experiencia en desarrollo en C, guiándome por las pistas del compilador y la salida de Google, pude escribir un módulo funcional y sentirme como un hacker del núcleo, y a la vez como un script-kiddy. Además, me uní al canal Kernel Newbies, donde me sugirieron usar schedule_work() en lugar de llamar call_usermodehelper() dentro del propio gancho, y me regañaron, sospechando justamente que era un fraude. Unas cien líneas de código me costaron alrededor de una semana de desarrollo en mi tiempo libre. Una experiencia exitosa que destruyó mi mito personal sobre la complicada naturaleza del desarrollo de sistemas.

Si alguien está dispuesto a hacer una revisión de código en GitHub, se lo agradecería. Estoy casi seguro de que cometí muchos errores estúpidos, especialmente en el manejo de cadenas.

Shell nuclear sobre ICMP

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster