BPF para los más pequeños, parte cero: BPF clásico

Berkeley Packet Filters (BPF) es una tecnología del kernel de Linux que ha estado en las portadas de publicaciones tecnológicas en inglés durante varios años. Las conferencias están llenas de informes sobre el uso y desarrollo de BPF. David Miller, mantenedor del subsistema de red Linux, da su charla en Linux Plumbers 2018 “Esta charla no es sobre XDP” (XDP es un caso de uso para BPF). Brendan Gregg da charlas tituladas Superpoderes de BPF de Linux. Toke Høiland-Jørgensen risasque el núcleo es ahora un micronúcleo. Thomas Graf promueve la idea de que BPF es javascript para el kernel.

Todavía no existe una descripción sistemática de BPF en Habré y, por lo tanto, en una serie de artículos intentaré hablar sobre la historia de la tecnología, describir la arquitectura y las herramientas de desarrollo y describir las áreas de aplicación y práctica del uso de BPF. Este artículo, cero de la serie, cuenta la historia y la arquitectura del BPF clásico y también revela los secretos de sus principios operativos. tcpdump, seccomp, strace, y mucho más.

El desarrollo de BPF está controlado por la comunidad de redes Linux, las principales aplicaciones existentes de BPF están relacionadas con redes y por lo tanto, con permiso. @eucariote, Llamé a la serie “BPF para los más pequeños”, en honor a la gran serie. "Redes para los más pequeños".

Un breve curso sobre la historia de BPF(c)

La tecnología BPF moderna es una versión mejorada y ampliada de la antigua tecnología del mismo nombre, ahora llamada BPF clásica para evitar confusiones. Se creó una conocida utilidad basada en el clásico BPF. tcpdump, mecanismo seccomp, así como módulos menos conocidos xt_bpf para iptables y clasificador cls_bpf. En Linux moderno, los programas BPF clásicos se traducen automáticamente al nuevo formato; sin embargo, desde el punto de vista del usuario, la API se ha mantenido y todavía se están encontrando nuevos usos para el BPF clásico, como veremos en este artículo. Por esta razón, y también porque siguiendo la historia del desarrollo del BPF clásico en Linux quedará más claro cómo y por qué evolucionó a su forma moderna, decidí comenzar con un artículo sobre el BPF clásico.

A finales de los años ochenta del siglo pasado, los ingenieros del famoso laboratorio Lawrence Berkeley se interesaron por la cuestión de cómo filtrar correctamente los paquetes de red en el hardware moderno de finales de los años ochenta del siglo pasado. La idea básica del filtrado, implementada originalmente en la tecnología CSPF (CMU/Stanford Packet Filter), era filtrar paquetes innecesarios lo antes posible, es decir. en el espacio del kernel, ya que esto evita copiar datos innecesarios en el espacio del usuario. Para proporcionar seguridad en tiempo de ejecución para ejecutar código de usuario en el espacio del kernel, se utilizó una máquina virtual protegida.

Sin embargo, las máquinas virtuales para los filtros existentes fueron diseñadas para ejecutarse en máquinas basadas en pilas y no funcionaron tan eficientemente en máquinas RISC más nuevas. Como resultado, gracias a los esfuerzos de los ingenieros de Berkeley Labs, se desarrolló una nueva tecnología BPF (Berkeley Packet Filters), cuya arquitectura de máquina virtual se diseñó sobre la base del procesador Motorola 6502, el caballo de batalla de productos tan conocidos como Apple II o NES. La nueva máquina virtual aumentó el rendimiento del filtro decenas de veces en comparación con las soluciones existentes.

Arquitectura de la máquina BPF

Nos familiarizaremos con la arquitectura de forma práctica, analizando ejemplos. Sin embargo, para empezar, digamos que la máquina tenía dos registros de 32 bits accesibles al usuario, un acumulador A y registro de índice X, 64 bytes de memoria (16 palabras), disponibles para escritura y posterior lectura, y un pequeño sistema de comandos para trabajar con estos objetos. Las instrucciones de salto para implementar expresiones condicionales también estaban disponibles en los programas, pero para garantizar la finalización oportuna del programa, los saltos sólo podían realizarse hacia adelante, es decir, en particular, estaba prohibido crear bucles.

El esquema general para arrancar la máquina es el siguiente. El usuario crea un programa para la arquitectura BPF y, utilizando algunos mecanismo del kernel (como una llamada al sistema), carga y conecta el programa a Para algo al generador de eventos en el kernel (por ejemplo, un evento es la llegada del siguiente paquete a la tarjeta de red). Cuando ocurre un evento, el kernel ejecuta el programa (por ejemplo, en un intérprete) y la memoria de la máquina corresponde a Para algo región de memoria del kernel (por ejemplo, datos de un paquete entrante).

Lo anterior nos bastará para empezar a ver ejemplos: nos familiarizaremos con el sistema y el formato de los comandos según sea necesario. Si desea estudiar de inmediato el sistema de comando de una máquina virtual y conocer todas sus capacidades, puede leer el artículo original. El filtro de paquetes BSD y/o la primera mitad del archivo Documentación/redes/filtro.txt de la documentación del núcleo. Además, podrás estudiar la presentación. libpcap: Una arquitectura y metodología de optimización para la captura de paquetes, en el que McCanne, uno de los autores de BPF, habla de la historia de la creación libpcap.

Pasamos ahora a considerar todos los ejemplos importantes del uso de BPF clásico en Linux: tcpdump (libpcap), segundo, xt_bpf, cls_bpf.

tcpdump

El desarrollo de BPF se llevó a cabo en paralelo con el desarrollo de la interfaz para el filtrado de paquetes, una conocida utilidad. tcpdump. Y, dado que este es el ejemplo más antiguo y famoso del uso de BPF clásico, disponible en muchos sistemas operativos, comenzaremos nuestro estudio de la tecnología con él.

(Ejecuté todos los ejemplos de este artículo en Linux 5.6.0-rc6. La salida de algunos comandos se ha editado para mejorar la legibilidad).

Ejemplo: observar paquetes IPv6

Imaginemos que queremos ver todos los paquetes IPv6 en una interfaz. eth0. Para ello podemos ejecutar el programa. tcpdump con un filtro sencillo ip6:

$ sudo tcpdump -i eth0 ip6

En este caso, tcpdump compila el filtro ip6 en el código de bytes de la arquitectura BPF y enviarlo al kernel (ver detalles en la sección Tcpdump: cargando). El filtro cargado se ejecutará para cada paquete que pase por la interfaz. eth0. Si el filtro devuelve un valor distinto de cero n, luego hasta n Los bytes del paquete se copiarán al espacio del usuario y lo veremos en la salida. tcpdump.

BPF para los más pequeños, parte cero: BPF clásico

Resulta que podemos averiguar fácilmente qué código de bytes se envió al kernel. tcpdump con la ayuda del tcpdump, si lo ejecutamos con la opción -d:

$ sudo tcpdump -i eth0 -d ip6
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 3
(002) ret      #262144
(003) ret      #0

En la línea cero ejecutamos el comando ldh [12], que significa "cargar en el registro A media palabra (16 bits) ubicada en la dirección 12” y la única pregunta es ¿a qué tipo de memoria nos dirigimos? La respuesta es que en x начинается (x+1)º byte del paquete de red analizado. Leemos paquetes desde la interfaz Ethernet. eth0y esto medioque el paquete se vea así (para simplificar, asumimos que no hay etiquetas VLAN en el paquete):

       6              6          2
|Destination MAC|Source MAC|Ether Type|...|

Entonces después de ejecutar el comando ldh [12] en el registro A habrá un campo Ether Type — el tipo de paquete transmitido en esta trama Ethernet. En la línea 1 comparamos el contenido del registro. A (tipo de paquete)c 0x86ddy esto y hay El tipo que nos interesa es IPv6. En la línea 1, además del comando de comparación, hay dos columnas más: jt 2 и jf 3 — marcas a las que debe acudir si la comparación tiene éxito (A == 0x86dd) y sin éxito. Entonces, en un caso exitoso (IPv6) vamos a la línea 2, y en un caso fallido, a la línea 3. En la línea 3 el programa termina con el código 0 (no copie el paquete), en la línea 2 el programa termina con el código 262144 (cópieme un paquete de un máximo de 256 kilobytes).

Un ejemplo más complicado: miramos los paquetes TCP por puerto de destino.

Veamos cómo se ve un filtro que copia todos los paquetes TCP con el puerto de destino 666. Consideraremos el caso de IPv4, ya que el caso de IPv6 es más sencillo. Después de estudiar este ejemplo, puedes explorar el filtro IPv6 tú mismo como ejercicio (ip6 and tcp dst port 666) y un filtro para el caso general (tcp dst port 666). Entonces, el filtro que nos interesa se ve así:

$ sudo tcpdump -i eth0 -d ip and tcp dst port 666
(000) ldh      [12]
(001) jeq      #0x800           jt 2    jf 10
(002) ldb      [23]
(003) jeq      #0x6             jt 4    jf 10
(004) ldh      [20]
(005) jset     #0x1fff          jt 10   jf 6
(006) ldxb     4*([14]&0xf)
(007) ldh      [x + 16]
(008) jeq      #0x29a           jt 9    jf 10
(009) ret      #262144
(010) ret      #0

Ya sabemos qué hacen las líneas 0 y 1. En la línea 2 ya hemos comprobado que se trata de un paquete IPv4 (Tipo Ether = 0x800) y cargarlo en el registro A 24º byte del paquete. Nuestro paquete se parece

       14            8      1     1
|ethernet header|ip fields|ttl|protocol|...|

lo que significa que cargamos en el registro A el campo Protocolo del encabezado IP, lo cual es lógico, porque queremos copiar solo paquetes TCP. Comparamos el protocolo con 0x6 (IPPROTO_TCP) en la línea 3.

En las líneas 4 y 5 cargamos las medias palabras ubicadas en la dirección 20 y usamos el comando jset comprobar si uno de los tres está configurado banderas - llevar la máscara proporcionada jset Se borran los tres bits más significativos. Dos de los tres bits nos dicen si el paquete es parte de un paquete IP fragmentado y, de ser así, si es el último fragmento. El tercer bit está reservado y debe ser cero. No queremos verificar paquetes enteros ni rotos, por lo que verificamos los tres bits.

La línea 6 es la más interesante de este listado. Expresión ldxb 4*([14]&0xf) significa que cargamos en el registro X los cuatro bits menos significativos del decimoquinto byte del paquete multiplicados por 4. Los cuatro bits menos significativos del decimoquinto byte es el campo Longitud del encabezado de Internet Encabezado IPv4, que almacena la longitud del encabezado en palabras, por lo que luego debes multiplicarlo por 4. Curiosamente, la expresión 4*([14]&0xf) es una designación para un esquema de direccionamiento especial que sólo se puede utilizar en esta forma y sólo para un registro X, es decir. tampoco podemos decir ldb 4*([14]&0xf) ni ldxb 5*([14]&0xf) (solo podemos especificar un desplazamiento diferente, por ejemplo, ldxb 4*([16]&0xf)). Está claro que este esquema de direccionamiento se agregó a BPF precisamente para recibir X (registro de índice) Longitud del encabezado IPv4.

Entonces, en la línea 7 intentamos cargar media palabra en (X+16). Recordando que 14 bytes están ocupados por el encabezado de Ethernet, y X contiene la longitud del encabezado IPv4, entendemos que en A El puerto de destino TCP está cargado:

       14           X           2             2
|ethernet header|ip header|source port|destination port|

Finalmente, en la línea 8 comparamos el puerto de destino con el valor deseado y en las líneas 9 o 10 devolvemos el resultado: si se copia el paquete o no.

Tcpdump: cargando

En los ejemplos anteriores, específicamente no detallamos exactamente cómo cargamos el código de bytes BPF en el kernel para el filtrado de paquetes. Generalmente hablando, tcpdump Portado a muchos sistemas y para trabajar con filtros. tcpdump usa la biblioteca libpcap. Brevemente, para colocar un filtro en una interfaz usando libpcap, necesitas hacer lo siguiente:

Para ver cómo funciona pcap_setfilter implementado en Linux, utilizamos strace (se han eliminado algunas líneas):

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

En las dos primeras líneas de salida creamos enchufe crudo para leer todas las tramas de Ethernet y vincularlas a la interfaz eth0. Desde El nuestro primer ejemplo sabemos que el filtro ip constará de cuatro instrucciones BPF, y en la tercera línea vemos cómo usar la opción SO_ATTACH_FILTER llamada al sistema setsockopt Cargamos y conectamos un filtro de longitud 4. Este es nuestro filtro.

Vale la pena señalar que en BPF clásico, cargar y conectar un filtro siempre ocurre como una operación atómica, y en la nueva versión de BPF, cargar el programa y vincularlo al generador de eventos están separados en el tiempo.

Verdad oculta

Una versión un poco más completa del resultado se ve así:

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0xbeefbeefbeef}, 16) = 0
recvfrom(3, 0x7ffcad394257, 1, MSG_TRUNC, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

Como se mencionó anteriormente, cargamos y conectamos nuestro filtro al conector de la línea 5, pero ¿qué sucede en las líneas 3 y 4? Resulta que esto libpcap se encarga de nosotros - para que la salida de nuestro filtro no incluya paquetes que no lo cumplan, la biblioteca conecta filtro ficticio ret #0 (eliminar todos los paquetes), cambia el socket al modo sin bloqueo e intenta restar todos los paquetes que podrían quedar de los filtros anteriores.

En total, para filtrar paquetes en Linux usando BPF clásico, necesita tener un filtro en forma de estructura como struct sock_fprog y un zócalo abierto, después de lo cual el filtro se puede conectar al zócalo mediante una llamada al sistema setsockopt.

Curiosamente, el filtro se puede conectar a cualquier enchufe, no solo en bruto. Aquí ejemplo un programa que corta todos menos los dos primeros bytes de todos los datagramas UDP entrantes. (Agregué comentarios en el código para no saturar el artículo).

Más detalles sobre el uso setsockopt para conectar filtros, consulte enchufe (7), sino sobre escribir tus propios filtros como struct sock_fprog sin ayuda tcpdump hablaremos en la sección Programando BPF con nuestras propias manos..

BPF clásico y el siglo XXI

BPF se incluyó en Linux en 1997 y ha seguido siendo un caballo de batalla durante mucho tiempo. libpcap sin ningún cambio especial (cambios específicos de Linux, por supuesto, eran, pero no cambiaron el panorama global). Los primeros signos serios de que BPF evolucionaría se produjeron en 2011, cuando Eric Dumazet propuso parche, que agrega Just In Time Compiler al kernel: un traductor para convertir el código de bytes BPF a nativo x86_64 código.

El compilador JIT fue el primero en la cadena de cambios: en 2012 apareció capacidad de escribir filtros para segundo, utilizando BPF, en enero de 2013 hubo adicional módulo xt_bpf, que le permite escribir reglas para iptables con la ayuda de BPF, y en octubre de 2013 fue adicional también un módulo cls_bpf, que le permite escribir clasificadores de tráfico utilizando BPF.

Pronto veremos todos estos ejemplos con más detalle, pero primero nos será útil aprender a escribir y compilar programas arbitrarios para BPF, ya que las capacidades proporcionadas por la biblioteca libpcap limitado (ejemplo simple: filtro generado libpcap puede devolver solo dos valores: 0 o 0x40000) o, en general, como en el caso de seccomp, no son aplicables.

Programando BPF con nuestras propias manos.

Conozcamos el formato binario de las instrucciones BPF, es muy simple:

   16    8    8     32
| code | jt | jf |  k  |

Cada instrucción ocupa 64 bits, en los cuales los primeros 16 bits son el código de instrucción, luego hay dos sangrías de ocho bits, jt и jf, y 32 bits para el argumento K, cuyo propósito varía de un comando a otro. Por ejemplo, el comando ret, que finaliza el programa tiene el código 6, y el valor de retorno se toma de la constante K. En C, una única instrucción BPF se representa como una estructura

struct sock_filter {
        __u16   code;
        __u8    jt;
        __u8    jf;
        __u32   k;
}

y todo el programa tiene la forma de una estructura.

struct sock_fprog {
        unsigned short len;
        struct sock_filter *filter;
}

Por lo tanto, ya podemos escribir programas (por ejemplo, conocemos los códigos de instrucción de [ 1 ]). Así se verá el filtro ip6 de nuestro primer ejemplo:

struct sock_filter code[] = {
        { 0x28, 0, 0, 0x0000000c },
        { 0x15, 0, 1, 0x000086dd },
        { 0x06, 0, 0, 0x00040000 },
        { 0x06, 0, 0, 0x00000000 },
};
struct sock_fprog prog = {
        .len = ARRAY_SIZE(code),
        .filter = code,
};

El programa prog podemos usarlo legalmente en una llamada

setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))

Escribir programas en forma de códigos de máquina no es muy conveniente, pero a veces es necesario (por ejemplo, para depurar, crear pruebas unitarias, escribir artículos sobre Habré, etc.). Para mayor comodidad, en el archivo. <linux/filter.h> Se definen macros auxiliares: el mismo ejemplo anterior podría reescribirse como

struct sock_filter code[] = {
        BPF_STMT(BPF_LD|BPF_H|BPF_ABS, 12),
        BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ETH_P_IPV6, 0, 1),
        BPF_STMT(BPF_RET|BPF_K, 0x00040000),
        BPF_STMT(BPF_RET|BPF_K, 0),
}

Sin embargo, esta opción no es muy conveniente. Esto es lo que razonaron los programadores del kernel de Linux y, por lo tanto, en el directorio tools/bpf kernels puede encontrar un ensamblador y un depurador para trabajar con BPF clásico.

El lenguaje ensamblador es muy similar a la salida de depuración. tcpdump, pero además podemos especificar etiquetas simbólicas. Por ejemplo, aquí hay un programa que descarta todos los paquetes excepto TCP/IPv4:

$ cat /tmp/tcp-over-ipv4.bpf
ldh [12]
jne #0x800, drop
ldb [23]
jneq #6, drop
ret #-1
drop: ret #0

De forma predeterminada, el ensamblador genera código en el formato <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., para nuestro ejemplo con TCP será

$ tools/bpf/bpf_asm /tmp/tcp-over-ipv4.bpf
6,40 0 0 12,21 0 3 2048,48 0 0 23,21 0 1 6,6 0 0 4294967295,6 0 0 0,

Para comodidad de los programadores de C, se puede utilizar un formato de salida diferente:

$ tools/bpf/bpf_asm -c /tmp/tcp-over-ipv4.bpf
{ 0x28,  0,  0, 0x0000000c },
{ 0x15,  0,  3, 0x00000800 },
{ 0x30,  0,  0, 0x00000017 },
{ 0x15,  0,  1, 0x00000006 },
{ 0x06,  0,  0, 0xffffffff },
{ 0x06,  0,  0, 0000000000 },

Este texto se puede copiar en la definición de estructura de tipo. struct sock_filter, como hicimos al principio de esta sección.

Extensiones de Linux y netsniff-ng

Además de BPF estándar, Linux y tools/bpf/bpf_asm apoyo y conjunto no estándar. Básicamente, las instrucciones se utilizan para acceder a los campos de una estructura. struct sk_buff, que describe un paquete de red en el kernel. Sin embargo, también existen otros tipos de instrucciones auxiliares, por ejemplo ldw cpu se cargará en el registro A resultado de ejecutar una función del kernel raw_smp_processor_id(). (En la nueva versión de BPF, estas extensiones no estándar se han ampliado para proporcionar a los programas un conjunto de ayudas del kernel para acceder a la memoria, estructuras y generar eventos). Aquí hay un ejemplo interesante de un filtro en el que copiamos solo el encabezados de paquetes en el espacio del usuario usando la extensión poff, compensación de carga útil:

ld poff
ret a

Las extensiones BPF no se pueden utilizar en tcpdump, pero esta es una buena razón para familiarizarse con el paquete de utilidades. netsniff-ng, que, entre otras cosas, contiene un programa avanzado netsniff-ng, que, además de filtrar mediante BPF, también contiene un generador de tráfico eficaz y más avanzado que tools/bpf/bpf_asm, un ensamblador BPF llamado bpfc. El paquete contiene documentación bastante detallada; consulte también los enlaces al final del artículo.

segundo

Entonces, ya sabemos cómo escribir programas BPF de complejidad arbitraria y estamos listos para ver nuevos ejemplos, el primero de los cuales es la tecnología seccomp, que permite, utilizando filtros BPF, administrar el conjunto y el conjunto de argumentos de llamadas al sistema disponibles para un proceso dado y sus descendientes.

La primera versión de seccomp se agregó al kernel en 2005 y no fue muy popular, ya que proporcionaba solo una opción: limitar el conjunto de llamadas al sistema disponibles para un proceso a lo siguiente: read, write, exit и sigreturn, y el proceso que violó las reglas fue eliminado usando SIGKILL. Sin embargo, en 2012, seccomp agregó la capacidad de usar filtros BPF, lo que le permite definir un conjunto de llamadas al sistema permitidas e incluso realizar comprobaciones de sus argumentos. (Curiosamente, Chrome fue uno de los primeros usuarios de esta funcionalidad, y la gente de Chrome actualmente está desarrollando un mecanismo KRSI basado en una nueva versión de BPF y que permite la personalización de los módulos de seguridad de Linux). Al final se pueden encontrar enlaces a documentación adicional. del artículo.

Tenga en cuenta que ya ha habido artículos en el centro sobre el uso de seccomp, tal vez alguien quiera leerlos antes (o en lugar de) leer las siguientes subsecciones. En el artículo Contenedores y seguridad: seccomp proporciona ejemplos del uso de seccomp, tanto la versión 2007 como la versión que usa BPF (los filtros se generan usando libseccomp), habla sobre la conexión de seccomp con Docker y también proporciona muchos enlaces útiles. En el artículo Aislar demonios con systemd o "¡no necesitas Docker para esto!" Cubre, en particular, cómo agregar listas negras o listas blancas de llamadas al sistema para demonios que ejecutan systemd.

A continuación veremos cómo escribir y cargar filtros para seccomp en C desnudo y usando la biblioteca libseccomp y cuáles son los pros y los contras de cada opción, y finalmente, veamos cómo utiliza seccomp el programa strace.

Escribir y cargar filtros para seccomp

Ya sabemos cómo escribir programas BPF, así que primero veamos la interfaz de programación de seccomp. Puede establecer un filtro a nivel de proceso y todos los procesos secundarios heredarán las restricciones. Esto se hace mediante una llamada al sistema. seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

donde &filter - esto es un indicador de una estructura que ya conocemos struct sock_fprog, es decir. Programa BPF.

¿En qué se diferencian los programas para seccomp de los programas para sockets? Contexto transmitido. En el caso de los sockets, se nos dio un área de memoria que contiene el paquete, y en el caso de seccomp se nos dio una estructura como

struct seccomp_data {
    int   nr;
    __u32 arch;
    __u64 instruction_pointer;
    __u64 args[6];
};

es nr es el número de la llamada al sistema que se lanzará, arch - arquitectura actual (más sobre esto a continuación), args - hasta seis argumentos de llamada al sistema, y instruction_pointer es un puntero a la instrucción del espacio de usuario que realizó la llamada al sistema. Así, por ejemplo, para cargar la signatura del sistema en el registro A tenemos que decir

ldw [0]

Hay otras características para los programas seccomp, por ejemplo, solo se puede acceder al contexto mediante alineación de 32 bits y no se puede cargar media palabra o un byte, al intentar cargar un filtro. ldh [0] llamada al sistema seccomp volverá EINVAL. La función comprueba los filtros cargados. seccomp_check_filter() granos. (Lo curioso es que en la confirmación original que agregó la funcionalidad seccomp, olvidaron agregar permiso para usar la instrucción para esta función mod (resto de la división) y ahora no está disponible para los programas seccomp BPF, desde su incorporación romperá ABI.)

Básicamente, ya sabemos todo lo necesario para escribir y leer programas seccomp. Por lo general, la lógica del programa se organiza como una lista blanca o negra de llamadas al sistema, por ejemplo el programa

ld [0]
jeq #304, bad
jeq #176, bad
jeq #239, bad
jeq #279, bad
good: ret #0x7fff0000 /* SECCOMP_RET_ALLOW */
bad: ret #0

comprueba una lista negra de cuatro llamadas al sistema numeradas 304, 176, 239, 279. ¿Cuáles son estas llamadas al sistema? No podemos decirlo con certeza, ya que no sabemos para qué arquitectura se escribió el programa. Por lo tanto, los autores de seccomp oferta inicie todos los programas con una verificación de arquitectura (la arquitectura actual se indica en el contexto como un campo arch la estructura struct seccomp_data). Con la arquitectura comprobada, el comienzo del ejemplo quedaría así:

ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64

y luego los números de llamada de nuestro sistema obtendrían ciertos valores.

Escribimos y cargamos filtros para seccomp usando libseccomp

Escribir filtros en código nativo o en ensamblador BPF le permite tener control total sobre el resultado, pero al mismo tiempo, a veces es preferible tener código portátil y/o legible. La biblioteca nos ayudará con esto. libseccomp, que proporciona una interfaz estándar para escribir filtros en blanco o negro.

Por ejemplo, escribamos un programa que ejecute un archivo binario elegido por el usuario, habiendo instalado previamente una lista negra de llamadas al sistema desde el artículo anterior (el programa ha sido simplificado para una mayor legibilidad, la versión completa se puede encontrar aquí):

#include <seccomp.h>
#include <unistd.h>
#include <err.h>

static int sys_numbers[] = {
        __NR_mount,
        __NR_umount2,
       // ... еще 40 системных вызовов ...
        __NR_vmsplice,
        __NR_perf_event_open,
};

int main(int argc, char **argv)
{
        scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);

        for (size_t i = 0; i < sizeof(sys_numbers)/sizeof(sys_numbers[0]); i++)
                seccomp_rule_add(ctx, SCMP_ACT_TRAP, sys_numbers[i], 0);

        seccomp_load(ctx);

        execvp(argv[1], &argv[1]);
        err(1, "execlp: %s", argv[1]);
}

Primero definimos una matriz. sys_numbers de más de 40 números de llamada del sistema para bloquear. Luego, inicializa el contexto. ctx y decirle a la biblioteca lo que queremos permitir (SCMP_ACT_ALLOW) todas las llamadas al sistema de forma predeterminada (es más fácil crear listas negras). Luego, una a una, agregamos todas las llamadas al sistema de la lista negra. En respuesta a una llamada al sistema de la lista, solicitamos SCMP_ACT_TRAP, en este caso seccomp enviará una señal al proceso SIGSYS con una descripción de qué llamada al sistema violó las reglas. Finalmente, cargamos el programa en el kernel usando seccomp_load, que compilará el programa y lo adjuntará al proceso mediante una llamada al sistema seccomp(2).

Para una compilación exitosa, el programa debe estar vinculado con la biblioteca. libseccomp, Por ejemplo:

cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccomp

Ejemplo de un lanzamiento exitoso:

$ ./seccomp_lib echo ok
ok

Ejemplo de una llamada al sistema bloqueada:

$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Bad system call

Uso stracepara detalles:

$ sudo strace -e seccomp ./seccomp_lib mount -t bpf bpf /tmp
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=50, filter=0x55d8e78428e0}) = 0
--- SIGSYS {si_signo=SIGSYS, si_code=SYS_SECCOMP, si_call_addr=0xboobdeadbeef, si_syscall=__NR_mount, si_arch=AUDIT_ARCH_X86_64} ---
+++ killed by SIGSYS (core dumped) +++
Bad system call

¿Cómo podemos saber que el programa finalizó debido al uso de una llamada al sistema ilegal? mount(2).

Entonces, escribimos un filtro usando la biblioteca. libseccomp, ajustando código no trivial en cuatro líneas. En el ejemplo anterior, si hay una gran cantidad de llamadas al sistema, el tiempo de ejecución se puede reducir notablemente, ya que la verificación es solo una lista de comparaciones. Para la optimización, libseccomp recientemente tuvo parche incluido, que agrega soporte para el atributo de filtro SCMP_FLTATR_CTL_OPTIMIZE. Establecer este atributo en 2 convertirá el filtro en un programa de búsqueda binaria.

Si quieres ver cómo funcionan los filtros de búsqueda binarios, echa un vistazo a guión sencillo, que genera dichos programas en el ensamblador BPF marcando números de llamada del sistema, por ejemplo:

$ echo 1 3 6 8 13 | ./generate_bin_search_bpf.py
ld [0]
jeq #6, bad
jgt #6, check8
jeq #1, bad
jeq #3, bad
ret #0x7fff0000
check8:
jeq #8, bad
jeq #13, bad
ret #0x7fff0000
bad: ret #0

Es imposible escribir algo significativamente más rápido, ya que los programas BPF no pueden realizar saltos de sangría (no podemos hacer, por ejemplo, jmp A o jmp [label+X]) y por lo tanto todas las transiciones son estáticas.

seccomp y strace

Todo el mundo conoce la utilidad. strace es una herramienta indispensable para estudiar el comportamiento de los procesos en Linux. Sin embargo, muchos también han oído hablar de problemas de desempeño al utilizar esta utilidad. El hecho es que strace implementado usando ptrace(2), y en este mecanismo no podemos especificar en qué conjunto de llamadas al sistema necesitamos detener el proceso, es decir, por ejemplo, comandos

$ time strace du /usr/share/ >/dev/null 2>&1

real    0m3.081s
user    0m0.531s
sys     0m2.073s

и

$ time strace -e open du /usr/share/ >/dev/null 2>&1

real    0m2.404s
user    0m0.193s
sys     0m1.800s

se procesan aproximadamente al mismo tiempo, aunque en el segundo caso queremos rastrear solo una llamada al sistema.

Nueva opción --seccomp-bpfañadido a strace La versión 5.3 le permite acelerar el proceso muchas veces y el tiempo de inicio bajo el seguimiento de una llamada al sistema ya es comparable al tiempo de un inicio normal:

$ time strace --seccomp-bpf -e open du /usr/share/ >/dev/null 2>&1

real    0m0.148s
user    0m0.017s
sys     0m0.131s

$ time du /usr/share/ >/dev/null 2>&1

real    0m0.140s
user    0m0.024s
sys     0m0.116s

(Aquí, por supuesto, hay un ligero engaño en el sentido de que no estamos rastreando la llamada principal al sistema de este comando. Si estuviéramos rastreando, por ejemplo, newfsstatentonces strace frenaría tan fuerte como sin --seccomp-bpf.)

¿Cómo funciona esta opción? Sin ella strace se conecta al proceso y lo inicia usando PTRACE_SYSCALL. Cuando un proceso gestionado emite una (cualquier) llamada al sistema, el control se transfiere a strace, que analiza los argumentos de la llamada al sistema y la ejecuta usando PTRACE_SYSCALL. Después de un tiempo, el proceso completa la llamada al sistema y al salir, el control se transfiere nuevamente. strace, que mira los valores de retorno e inicia el proceso usando PTRACE_SYSCALL, etcétera.

BPF para los más pequeños, parte cero: BPF clásico

Sin embargo, con seccomp este proceso se puede optimizar exactamente como nos gustaría. Es decir, si queremos mirar solo la llamada al sistema X, entonces podemos escribir un filtro BPF que para X devuelve un valor SECCOMP_RET_TRACE, y para llamadas que no son de nuestro interés - SECCOMP_RET_ALLOW:

ld [0]
jneq #X, ignore
trace: ret #0x7ff00000
ignore: ret #0x7fff0000

En este caso strace Inicialmente inicia el proceso como PTRACE_CONT, nuestro filtro se procesa para cada llamada al sistema, si la llamada al sistema no es X, entonces el proceso continúa ejecutándose, pero si esto X, entonces seccomp transferirá el control straceque analizará los argumentos e iniciará el proceso como PTRACE_SYSCALL (ya que seccomp no tiene la capacidad de ejecutar un programa al salir de una llamada al sistema). Cuando regresa la llamada al sistema, strace reiniciará el proceso usando PTRACE_CONT y esperará nuevos mensajes de seccomp.

BPF para los más pequeños, parte cero: BPF clásico

Al usar la opción --seccomp-bpf hay dos restricciones. En primer lugar, no será posible unirse a un proceso ya existente (opción -p programa strace), ya que esto no es compatible con seccomp. En segundo lugar, no hay posibilidad no Mire los procesos secundarios, ya que todos los procesos secundarios heredan los filtros seccomp sin la capacidad de deshabilitarlos.

Un poco más de detalle sobre cómo exactamente strace trabaja con seccomp se puede encontrar desde informe reciente. Para nosotros, lo más interesante es que el BPF clásico representado por seccomp todavía se utiliza hoy en día.

xt_bpf

Volvamos ahora al mundo de las redes.

Antecedentes: hace mucho tiempo, en 2007, el núcleo era adicional módulo xt_u32 para filtro de red. Fue escrito por analogía con un clasificador de tráfico aún más antiguo. cls_u32 y le permitió escribir reglas binarias arbitrarias para iptables usando las siguientes operaciones simples: cargar 32 bits de un paquete y realizar un conjunto de operaciones aritméticas en ellos. Por ejemplo,

sudo iptables -A INPUT -m u32 --u32 "6&0xFF=1" -j LOG --log-prefix "seen-by-xt_u32"

Carga los 32 bits del encabezado IP, comenzando en el relleno 6, y les aplica una máscara. 0xFF (tome el byte bajo). Este campo protocol Cabecera IP y la comparamos con 1 (ICMP). Puede combinar muchas comprobaciones en una regla y también puede ejecutar el operador @ — mueve X bytes a la derecha. Por ejemplo, la regla

iptables -m u32 --u32 "6&0xFF=0x6 && 0>>22&0x3C@4=0x29"

comprueba si el número de secuencia TCP no es igual 0x29. No entraré en más detalles, ya que ya está claro que escribir tales reglas a mano no es muy conveniente. En el artículo BPF: el código de bytes olvidado, hay varios enlaces con ejemplos de uso y generación de reglas para xt_u32. Consulte también los enlaces al final de este artículo.

Desde 2013 módulo en lugar de módulo xt_u32 puedes usar un módulo basado en BPF xt_bpf. Cualquiera que haya leído hasta aquí ya debería tener claro el principio de su funcionamiento: ejecutar el código de bytes BPF como reglas de iptables. Puede crear una nueva regla, por ejemplo, como esta:

iptables -A INPUT -m bpf --bytecode <байткод> -j LOG

aquí <байткод> - este es el código en formato de salida ensamblador bpf_asm por defecto, por ejemplo,

$ cat /tmp/test.bpf
ldb [9]
jneq #17, ignore
ret #1
ignore: ret #0

$ bpf_asm /tmp/test.bpf
4,48 0 0 9,21 0 1 17,6 0 0 1,6 0 0 0,

# iptables -A INPUT -m bpf --bytecode "$(bpf_asm /tmp/test.bpf)" -j LOG

En este ejemplo estamos filtrando todos los paquetes UDP. Contexto para un programa BPF en un módulo xt_bpf, por supuesto, apunta a los datos del paquete, en el caso de iptables, al comienzo del encabezado IPv4. Valor de retorno del programa BPF booleanoDonde false significa que el paquete no coincide.

Está claro que el módulo xt_bpf admite filtros más complejos que el ejemplo anterior. Veamos ejemplos reales de Cloudfare. Hasta hace poco usaban el módulo xt_bpf para protegerse contra ataques DDoS. En el artículo Presentamos las herramientas BPF explican cómo (y por qué) generan filtros BPF y publican enlaces a un conjunto de utilidades para crear dichos filtros. Por ejemplo, usando la utilidad bpfgen puede crear un programa BPF que coincida con una consulta DNS para un nombre habr.com:

$ ./bpfgen --assembly dns -- habr.com
ldx 4*([0]&0xf)
ld #20
add x
tax

lb_0:
    ld [x + 0]
    jneq #0x04686162, lb_1
    ld [x + 4]
    jneq #0x7203636f, lb_1
    ldh [x + 8]
    jneq #0x6d00, lb_1
    ret #65535

lb_1:
    ret #0

En el programa primero cargamos en el registro. X dirección de inicio de línea x04habrx03comx00 dentro de un datagrama UDP y luego verifique la solicitud: 0x04686162 <-> "x04hab" etcétera

Un poco más tarde, Cloudfare publicó el código compilador p0f -> BPF. En el artículo Presentamos el compilador p0f BPF hablan sobre qué es p0f y cómo convertir firmas p0f a BPF:

$ ./bpfgen p0f -- 4:64:0:0:*,0::ack+:0
39,0 0 0 0,48 0 0 8,37 35 0 64,37 0 34 29,48 0 0 0,
84 0 0 15,21 0 31 5,48 0 0 9,21 0 29 6,40 0 0 6,
...

Actualmente ya no uso Cloudfare xt_bpf, desde que se mudaron a XDP, una de las opciones para usar la nueva versión de BPF, consulte. L4Drop: Mitigaciones de DDoS XDP.

cls_bpf

El último ejemplo del uso de BPF clásico en el kernel es el clasificador. cls_bpf para el subsistema de control de tráfico en Linux, agregado a Linux a finales de 2013 y reemplazando conceptualmente al antiguo cls_u32.

Sin embargo, ahora no describiremos el trabajo. cls_bpf, ya que desde el punto de vista del conocimiento del BPF clásico esto no nos aportará nada: ya nos hemos familiarizado con todas las funciones. Además, en artículos posteriores que hablen sobre BPF extendido, nos encontraremos con este clasificador más de una vez.

Otra razón para no hablar del uso del BPF c clásico cls_bpf El problema es que, en comparación con BPF extendido, el alcance de aplicabilidad en este caso se reduce radicalmente: los programas clásicos no pueden cambiar el contenido de los paquetes y no pueden guardar el estado entre llamadas.

Así que es hora de decir adiós al BPF clásico y mirar hacia el futuro.

Adiós al BPF clásico

Observamos cómo la tecnología BPF, desarrollada a principios de los años noventa, vivió con éxito durante un cuarto de siglo y hasta el final encontró nuevas aplicaciones. Sin embargo, de manera similar a la transición de las máquinas apiladas a RISC, que sirvió de impulso para el desarrollo del BPF clásico, en la década de 32 hubo una transición de las máquinas de 64 bits a XNUMX bits y el BPF clásico comenzó a quedar obsoleto. Además, las capacidades del BPF clásico son muy limitadas y, además de la arquitectura obsoleta, no tenemos la capacidad de guardar el estado entre llamadas a programas BPF, no hay posibilidad de interacción directa del usuario, no hay posibilidad de interactuar. con el kernel, excepto para leer un número limitado de campos de estructura sk_buff y al iniciar las funciones auxiliares más simples, no puede cambiar el contenido de los paquetes y redirigirlos.

De hecho, actualmente todo lo que queda del BPF clásico en Linux es la interfaz API, y dentro del kernel todos los programas clásicos, ya sean filtros de socket o filtros seccomp, se traducen automáticamente a un nuevo formato, BPF extendido. (Hablaremos exactamente sobre cómo sucede esto en el próximo artículo).

La transición a una nueva arquitectura comenzó en 2013, cuando Alexey Starovoitov propuso un esquema de actualización de BPF. En 2014 los parches correspondientes comenzó a aparecer en el núcleo. Hasta donde tengo entendido, el plan inicial era solo optimizar la arquitectura y el compilador JIT para que se ejecutara de manera más eficiente en máquinas de 64 bits, pero estas optimizaciones marcaron el comienzo de un nuevo capítulo en el desarrollo de Linux.

Otros artículos de esta serie cubrirán la arquitectura y las aplicaciones de la nueva tecnología, inicialmente conocida como BPF interna, luego BPF extendida y ahora simplemente BPF.

referencias

  1. Steven McCanne y Van Jacobson, "El filtro de paquetes BSD: una nueva arquitectura para la captura de paquetes a nivel de usuario", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: una arquitectura y metodología de optimización para la captura de paquetes", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. Tutorial de coincidencia de IPtable U32.
  5. BPF - el código de bytes olvidado: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Presentamos la herramienta BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Una descripción general de segundo: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Contenedores y seguridad: seccomp
  11. habr: Aislar demonios con systemd o "¡no necesitas Docker para esto!"
  12. Paul Chaignon, "strace --seccomp-bpf: una mirada bajo el capó", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Fuente: habr.com

Añadir un comentario