Computación Segura con filtros - SECCOMP BPF

Cada uno de los procesos que ejecutamos en el sistema tienen la opción de interaccionar con el kernel a través de las system calls (llamadas a sistema). Los procesos pueden pedir al kernel que realicen alguna tarea como modificar un fichero, crear un nuevo proceso, cambiar los permisos a un directorio, etc. utilizando un API (Application Programming Interface) por el que el kernel da acceso a sus servicios.

Muchas de las system calls están accesibles a cada proceso en el área de usuario, pero una gran parten no se utilizan durante toda la vida del proceso. El filtrado Seccomp ofrece un medio para limitar el número de system calls que expone el kernel a un determinado proceso.

Seccomp BPF (SECure COMputing with filters) son filtros expresados como un programa BPF (Berkeley Packet Filter). Proporcionan un medio de filtrar el número de system calls que un determinado proceso puede acceder. Los filtros operan como con los filtros de socket, excepto que los datos sobre los que se opera están relacionados con la llamada al sistema que se realizan, es decir, el número de llamada al sistema y los argumentos de la llamada. 

Podemos ver las syscalls que hace un proceso utilizando el comando strace. Para ver como funciona este comando vamos usar una aplicación muy sencilla que hace uso de dos llamadas de sistema: write() y openat():

holamundo.c
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/types.h> 
#include <sys/stat.h> 
#include <fcntl.h> 
 
int main (int argc, char *argv[]) 
{  
    int fd=0; 
 
    for(int i=0; i<3; i++)  
        printf("%d:Hola mundo\n", i); 
 
    fd = open("filexxx.txt", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR); 
    if(fd == -1) { 
        perror("Error al crear fichero: "); 
        exit(EXIT_FAILURE); 
    } 
 
    exit(EXIT_SUCCESS); 
} 

Construimos:

$ gcc -Wall holamundo.c -o holamundo

Y ejecutamos con strace:

$ strace -c -f ./holamundo 
0:Hola mundo
1:Hola mundo
2:Hola mundo
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         1           read
  0.00    0.000000           0         3           write
  0.00    0.000000           0         2           close
  0.00    0.000000           0         3           fstat
  0.00    0.000000           0         5           mmap
  0.00    0.000000           0         4           mprotect
  0.00    0.000000           0         1           munmap
  0.00    0.000000           0         3           brk
  0.00    0.000000           0         3         3 access
  0.00    0.000000           0         1           execve
  0.00    0.000000           0         1           arch_prctl
  0.00    0.000000           0         3           openat
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    30         3 total

Podemos ver que tenemos alguna syscalls más de las esperadas 3 write()y 1 openat(). El motivo es que el shell debe realizar un proceso para cargar nuestra aplicación en memoria y ejecutarla.

Shell Syscall

Lo primero que hace el shell que corre en el terminal es hacer un fork() para crear una copia de si mismo y en esa copia ejecuta una llamada a una de las funciones de la familia exec() para ejecutar nuestra aplicación. El comando strace comienza a trabajar justo después del fork(). Lo primero que veremos entonces sera una llamada a exec() seguida a una inicialización de memoria con la llamada a brk():

$ strace ./holamundo 
execve("./holamundo", ["./holamundo"], 0x7fff07ea94b0 /* 52 vars */) = 0
brk(NULL)
.
.

Después tendremos la carga de librerías compartidas:

.
.
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=87283, ...}) = 0
mmap(NULL, 87283, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f84939f0000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0
.
.

Finalmente encontramos nuestras syscalls:

.
.
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)                               = 0x55d7995ea000
brk(0x55d79960b000)                     = 0x55d79960b000
write(1, "0:Hola mundo\n", 130:Hola mundo
)          = 13
write(1, "1:Hola mundo\n", 131:Hola mundo
)          = 13
write(1, "2:Hola mundo\n", 132:Hola mundo
)          = 13
openat(AT_FDCWD, "filexxx.txt", O_WRONLY|O_CREAT, 000) = 3
exit_group(0)                           = ?
+++ exited with 0 +++

La ejecución del programa imprime tres veces Hola Mundo por pantalla y crea un fichero llamado filexxx.txt.

Filtros con Seccomp BPF

Vamos a ver como limitar el acceso de esta aplicación al API del Kernel que da acceso a las syscall. Para ello vamos a crear un filtro Seccomp BPF.

Filtro Seccomp

Filtros directos BPF

Primero vamos a usar la forma directa de crear este tipo de filtros que es usar un subconjunto de C compilado con LLVM. LLVM es un compilador de propósito general que puede emitir diferentes tipos de bytecode. En este caso LLVM generará un código ensamblador BPF que posteriormente cargaremos en el Kernel. Actualmente también podemos hacer lo mismo con GCC.

Vamos a crear un filtro para nuestra aplicación holamundo, que impedirá las llamadas de sistema write() o openat():

filtro_seccomp.c
/*  
 * (c) Author: David Quiroga  
 * e-mail: david [at] clibre [dot] io  
 *  
 * SPDX-License-Identifier: GPL-3.0  
 */  
 
#define _GNU_SOURCE 
#include <errno.h> 
#include <linux/audit.h> 
#include <linux/bpf.h> 
#include <linux/filter.h> 
#include <linux/seccomp.h> 
#include <linux/unistd.h> 
#include <stddef.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <sys/prctl.h> 
#include <unistd.h> 
#include <string.h> 
 
static int install_filter(int nr, int arch, int error) { 
     
    struct sock_filter filter[] = { 
        /* Cargamos la arquitectura */ 
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 
                (offsetof(struct seccomp_data, arch))), 
 
        /* Si la arquitectura no es la esperada matamos el proceso */ 
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, arch, 1, 0), 
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), 
 
        /* Cargamos el número de syscall */ 
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 
                (offsetof(struct seccomp_data, nr))), 
 
        /* Se permiten todas las syscall menos la siguiente */ 
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, 1, 0), 
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), 
        BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA)) 
    }; 
     
    struct sock_fprog prog = { 
        .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])), 
        .filter = filter, 
    }; 
     
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) { 
        perror("Fallo en prctl(PR_SET_SECCOMP)"); 
        return 1; 
    }  
     
    return 0; 
} 
 
int main(int argc, char const *argv[]) { 
    if(argc < 2) { 
        printf("Uso: %s <programa> --open\n", argv[0]); 
        return 1; 
    } 
 
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { 
        perror("Fallo en prctl(NO_NEW_PRIVS)"); 
        return 1; 
    } 
  
    if (argc == 3 && (strcmp(argv[2], "--open") == 0)) 
        install_filter(__NR_openat, AUDIT_ARCH_X86_64, EPERM);  
 
    install_filter(__NR_write, AUDIT_ARCH_X86_64, EPERM); 
  
    execlp(argv[1], argv[1], "", argv[0], (char *) NULL); 
    perror("Fallo en execlp\n"); 
 
    return 255; 
} 

En la función encargada de instalar el filtro install_filter() utilizamos las macros que facilita el interfaz de BPF para inicializar el array con:

  • BPF_STMT (opcode, operand)
  • BPF_JUMP (opcode, operand, true_offset, false_offset)

En los comentarios del código indico que es lo que estan haciendo estas macros.

Creamos el ejecutable:

❯ clang filtro_seccomp.c -o filtro_seccomp

Cuando ejecutamos el filtro por defecto prohíbe la syscall write(), no obtenemos salida en pantalla pero el programa continúa y escribe el fichero filexxx.txt (porque le indicamos SECCOMP_RET_ERRNO en el filtro):

❯ ./filtro_seccomp ./holamundo

Pero si limitamos la syscall openat() tenemos el siguiente error:

❯ ./filtro_seccomp ./holamundo --open
./holamundo: error while loading shared libraries: libc.so.6: cannot open shared object file: Operation not permitted

Esto lo que está impidiendo no es la llamada open de nuestro código para crear el fichero, sino la carga de la librería libc que vimos al hacer strace de holamundo:

.
.
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
.
.

Librería libseccomp

Tenemos que afinar un poco más nuestro filtro para limitar solo las escrituras. En este caso vamos a usar la librería libseccomp. Esta librería nos permite trabajar con los filtros de forma más sencilla, evitando escribir directamente las reglas BPF. Vamos a ver el código:

filtro_libseccomp.c
/*  
 * (c) Author: David Quiroga  
 * e-mail: david [at] clibre [dot] io  
 *  
 * SPDX-License-Identifier: GPL-3.0  
 */  
 
#define _GNU_SOURCE 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <sys/utsname.h> 
#include <seccomp.h> 
#include <err.h> 
#include <unistd.h> 
#include <errno.h> 
#include <fcntl.h> /* O_WRONLY|O_CREAT */ 
 
static void install_filter(const char *syscall, struct scmp_arg_cmp *arg_cmp) 
{ 
    /* Por defecto permitir syscalls */ 
    scmp_filter_ctx seccomp_ctx = seccomp_init(SCMP_ACT_ALLOW); 
    if (!seccomp_ctx) 
        err(1, "Fallo en seccomp_init"); 
 
    /* syscall que no permitimos */ 
    /* Para matar el proceso usariamos SCMP_ACT_KILL */  
    if(seccomp_rule_add_array(seccomp_ctx, SCMP_ACT_ERRNO(EPERM),  
         seccomp_syscall_resolve_name(syscall), 1, arg_cmp)) { 
        perror("Fallo en seccomp_rule_add_array"); 
        exit(1);  
    } 
  
    /* Aplicar el filtro */  
    if (seccomp_load(seccomp_ctx)) { 
        perror("Fallo en seccomp_load"); 
        exit(1); 
    }  
 
    /* liberar el contexto */ 
    seccomp_release(seccomp_ctx); 
} 
 
int main(int argc, char *argv[]) 
{ 
    struct scmp_arg_cmp arg_openat[] = { SCMP_A2(SCMP_CMP_EQ, O_WRONLY|O_CREAT) }; 
    struct scmp_arg_cmp arg_write[] = { SCMP_A0(SCMP_CMP_EQ, 1) }; 
 
    if(argc < 2) { 
        printf("Uso: %s <programa> --open\n", argv[0]); 
        return 1; 
    } 
 
    if (argc == 3 && (strcmp(argv[2], "--open") == 0)) 
        install_filter("openat", arg_openat); 
    else  
        install_filter("write", arg_write);  
  
    execlp(argv[1], argv[1], "", argv[0], (char *) NULL); 
    perror("Fallo en execlp"); 
 
    return 255; 
} 

La estructura del programa es similar pero la llamada a las funciones de libseccomp facilitan mucho la labor. Lo más importante, y el cambio de funcionalidad con respecto a la anterior versión, lo tenemos en estas instrucciones:

struct scmp_arg_cmp arg_openat[] = { SCMP_A2(SCMP_CMP_EQ, O_WRONLY|O_CREAT) }; 
struct scmp_arg_cmp arg_write[] = { SCMP_A0(SCMP_CMP_EQ, 1) };  
. 
. 
seccomp_rule_add_array(seccomp_ctx, SCMP_ACT_ERRNO(EPERM),  
         seccomp_syscall_resolve_name(syscall), 1, arg_cmp) 

Lo que le indicamós aquí, es que no solo compruebe el número de syscall, sino que también mire en los parametros que coincida con los que les pasamos en el argumento arg_cmp. Es decir 1 (STDOUT) en caso de write()O_WRONLY|O_CREAT para openat(). Solo en estos caso limitamos su uso.

A la hora de filtrar, como siempre que se aplican filtros en seguridad, se debe aplicar el principio de prohibir por defecto y permitir solo lo que necesitamos, y no como en este ejemplo que permitimos todo y limitamos una llamada. Esto sería una mala política de seguridad y un agujero de seguridad tarde o temprano

Construimos y ejecutamos la aplicación:

❯ gcc -Wall filtro_libseccomp.c -o filtro_libseccomp -lseccomp
❯ ./filtro_libseccomp ./holamundo
❯ ./filtro_libseccomp ./holamundo --open
0:Hola mundo
1:Hola mundo
2:Hola mundo
Error al crear fichero: : Operation not permitted

Al limitar syscall write() no muestra nada en pantalla, pero podemos comprobar que nos crea el fichero filexxx.txt. En cambio al limitar syscall openat() esta vez si que nos ejecuta el programa, muestra en pantalla las salidas de write() (printf()) pero falla al crear el fichero con el error que indicamos EPERM (Operation not permited).

Conclusión

Seccomp es una poderosa herramienta para limitar la exposición del kernel a las llamadas a sistema por parte de una aplicación. Complementada con otras herramientas que nos ofrece el sistema, como las capabilities y los namespaces entre otras, tenemos un conjunto diseñado para blindar aplicaciones. Aunque el uso directo en código puede resultar algo elaborado, estas herramientas se están empleando en diferentes aplicaciones actualmente que actúan de sandboxes en el equipo, limitando y encorsetando lo que puede hacer una aplicación. En próximas entradas iremos viendo algunos de estos sandboxes.

Modificado por última vez enSábado, 26 Septiembre 2020 15:09
(2 votos)
Etiquetado como :

Deja un comentario

Asegúrese de introducir toda la información requerida, indicada por un asterisco (*). No se permite código HTML.