// harkaq-exec — aplica la política y ejecuta el builder (SDD 16 §4). Corre DENTRO de bwrap, // como último eslabón antes del `sh -c` del build: bwrap ya puso los namespaces, el overlay y // la ausencia de red; esto pone el grano fino y —lo que importa— abre el canal de evidencia. // // harkaq-exec --policy [--abi-min 3] [--allow-nesting] [--no-landlock] -- [...] // // Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se // DERIVA, no se escribe): // ro /usr/include/zlib.h un fichero de la clausura: leer/ejecutar SÓLO él // ro /opt/zig un directorio: todo el subárbol // list /usr/include listar el directorio, SIN poder leer su contenido // rw /out // // El grano POR FICHERO no es capricho: el sandbox de hammer funde las deps y el rootfs Alpine // en el mismo `/usr` (`--overlay-src` × N + `--tmp-overlay /`), así que /usr/include/zlib.h // (dep declarada) y /usr/include/stdio.h (Alpine, no declarado) son el MISMO directorio. Una // regla sobre /usr concede los dos y la evidencia no vale nada. En el host sí sabemos qué // ficheros aporta cada dep ⇒ la clausura se enumera fichero a fichero. // // `list` existe por la misma razón: pkgconf/configure NECESITAN listar /usr/lib/pkgconfig, pero // listar no es leer. Landlock separa READ_DIR de READ_FILE, así que se concede el listado del // directorio y se deniega la lectura de los .pc que la receta no declaró. // // Estático a propósito: corre dentro del rootfs Alpine (musl) del sandbox. // // D7 (fallo cerrado, honesto sobre el ABI): // ABI < min → RECHAZA el build (sin REFER/TRUNCATE no compila software real) // min <= ABI < 7 → corre, pero avisa SIN EVIDENCIA (no hay audit de denegaciones) // ABI >= 7 → corre con LOG_NEW_EXEC_ON: la evidencia existe // Nunca se afirma evidencia que el kernel no respaldó. #define _GNU_SOURCE #include "harkaq-uapi.h" #include #include #include #include #include #include #include #include #include #include #include #include static int ll_create(const struct landlock_ruleset_attr *a, size_t n, __u32 f) { return syscall(SYS_landlock_create_ruleset, a, n, f); } static int ll_add(int fd, enum landlock_rule_type t, const void *a, __u32 f) { return syscall(SYS_landlock_add_rule, fd, t, a, f); } static int ll_restrict(int fd, __u32 f) { return syscall(SYS_landlock_restrict_self, fd, f); } // Conjuntos de derechos por ABI. Cada uno AÑADE a los anteriores; recortamos al ABI real del // kernel (D7 / §3.1: se consulta el ABI por syscall, JAMÁS por versión de kernel — hay // backports y el número miente). #define FS_ABI1 \ (LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | \ LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \ LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | \ LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | \ LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_SOCK | \ LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK | \ LANDLOCK_ACCESS_FS_MAKE_SYM) #define FS_ABI2 (FS_ABI1 | LANDLOCK_ACCESS_FS_REFER) #define FS_ABI3 (FS_ABI2 | LANDLOCK_ACCESS_FS_TRUNCATE) #define FS_ABI5 (FS_ABI3 | LANDLOCK_ACCESS_FS_IOCTL_DEV) #define RO_RIGHTS \ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE) // Derechos que Landlock SÓLO admite sobre directorios. Añadir una regla con cualquiera de // éstos sobre un fichero regular hace que el kernel rechace la regla entera con EINVAL — y // como la clausura es mayormente ficheros sueltos (§4), sin esta máscara no arranca nada. #define DIR_ONLY_RIGHTS \ (LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | \ LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | \ LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | \ LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM | \ LANDLOCK_ACCESS_FS_REFER) // ------------------------------------------------------------------ seccomp (D4) // // D4: "Landlock no basta". Landlock cubre fs, puertos TCP e IPC scoping — no cubre ptrace, bpf, // userfaultfd, keyctl, io_uring, montajes, ni la mitad de la superficie interesante. // // DENYLIST, no allowlist (corrige a D4, que pedía allowlist). Un allowlist por syscall para // builds ARBITRARIOS —compiladores, make, shells, linkers, perl— es un blanco móvil: cada // herramienta nueva lo rompe. Es el riesgo del §7 ("los falsos positivos matan proyectos de // sandboxing") aplicado a las syscalls, y con peor final: un build que muere por una syscall // legítima no da un diagnóstico útil, da un misterio. Lo *peligroso*, en cambio, es enumerable // y estable: no hay build honesto que cargue módulos, haga kexec o attachee un ptrace. // // EPERM y no KILL: matar el proceso deja un cadáver sin explicación; EPERM deja al build fallar // donde corresponde y al log decir por qué. Y lo que importa —la evidencia— la sigue dando // Landlock; esto sólo recorta superficie de escape. static const int SYSCALLS_DENEGADAS[] = { // io_uring: superficie de kernel enorme y bypassea buena parte del análisis por-syscall. // Denegado explícitamente en v1 (D4). Si un builder lo necesita, que lo justifique. #ifdef __NR_io_uring_setup __NR_io_uring_setup, __NR_io_uring_enter, __NR_io_uring_register, #endif // Inspección/inyección entre procesos: un build no necesita mirar dentro de otro. __NR_ptrace, __NR_process_vm_readv, __NR_process_vm_writev, #ifdef __NR_bpf __NR_bpf, #endif #ifdef __NR_userfaultfd __NR_userfaultfd, // usado para ganar carreras TOCTOU en el kernel #endif // Llavero del kernel: ni se lee ni se siembra desde un build. __NR_keyctl, __NR_add_key, __NR_request_key, // Módulos y kexec: un build que toca esto no es un build. __NR_init_module, __NR_finit_module, __NR_delete_module, __NR_kexec_load, #ifdef __NR_kexec_file_load __NR_kexec_file_load, #endif // perf: superficie histórica de escalada. __NR_perf_event_open, }; // La FAMILIA DEL ANIDAMIENTO, separada del resto a propósito. // // Para un BUILD estas syscalls no tienen uso legítimo: bwrap ya armó el árbol y re-montar sólo // sirve para escapar de él. Pero harkaq también enjaula instancias ajenas (qorpa, ADR 0015), y ahí // hay un caso legítimo y central: **Steam ya es un contenedor**. Su pressure-vessel monta y crea // namespaces DENTRO del nuestro, que es la configuración exacta que Valve prueba (ADR 0015 D6). // Denegarle `mount` no lo hace más seguro: lo hace no arrancar. // // Por eso se deniegan POR DEFECTO —el build no cambia ni un byte— y sólo se levantan con // `--allow-nesting`, que en qorpa cuelga de una concesión DECLARADA en el manifiesto. Abrir sólo // lo declarado, otra vez. // // `pivot_root` no está en ninguna de las dos listas: bwrap lo usa ANTES de llegar acá. static const int SYSCALLS_ANIDAMIENTO[] = { __NR_mount, __NR_umount2, #ifdef __NR_open_tree __NR_open_tree, __NR_move_mount, __NR_fsopen, __NR_fsconfig, __NR_fsmount, #endif // Salirse de los namespaces que bwrap puso. __NR_setns, }; // TECHO DE LA LISTA, y por qué no es un detalle: `jt`/`jf` de la BPF clásica son `__u8`. En la // forma de abajo el salto más largo mide n+3, así que a partir de ~252 entradas el desplazamiento // DA LA VUELTA y el filtro deja de decir lo que dice este fichero — sin error y sin aviso. Hoy son // 25 y sobra sitio; el riesgo es que agregar syscalls a una denylist es exactamente lo que uno // hace sin pensarlo. Esto no genera código: es un portón de compilación. // (SDD 25 T17 midió además que la longitud de esta lista NO cuesta nada en ejecución — el bitmap // de acción constante del kernel la hace plana —, así que no hay ningún motivo de rendimiento // para recortarla ni para reordenarla.) _Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) * 2 / sizeof(SYSCALLS_DENEGADAS[0]) <= 240, "la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro"); // ── i386: por qué hay una SEGUNDA tabla de números ────────────────────────────────────────────── // // El filtro de abajo comprueba la arquitectura y MATA lo que no sea x86_64. Para un build es // correcto y es la defensa clásica: los números de syscall son POR ARCH, y sin el check un binario // i386 pediría otra syscall con el mismo número. Para una instancia ajena es FATAL, y se midió: // el cliente de Steam es un ELF i386, y moría con **SIGSYS** (`exit code 159`) antes de imprimir // una línea. Un `ldd` que devuelve 159 no se parece en nada a «tu jaula mata los 32 bits». // // La salida NO es aflojar el check —eso reabre el agujero— sino darle a i386 su propia tabla y // aplicarle la MISMA política. Los números van literales porque compilando para x86_64 no hay // `__NR_*` de i386; son ABI estable, Linux no los renumera nunca. #define I386_ARCH_TABLA_BASE \ 26, /* ptrace */ \ 347, 348, /* process_vm_readv, process_vm_writev */ \ 357, /* bpf */ \ 374, /* userfaultfd */ \ 288, 286, 287, /* keyctl, add_key, request_key */ \ 128, 350, 129, /* init_module, finit_module, delete_module */ \ 283, /* kexec_load — `kexec_file_load` NO EXISTE en i386: poner su número de x86_64 (320) habría denegado `utimensat`, que en i386 tiene ESE número y la usa cualquier cosa que toque una marca de tiempo. Verificado contra /usr/include/asm/unistd_32.h, los 25 números uno por uno; éste era el único mal. */ \ 336, /* perf_event_open */ \ 425, 426, 427 /* io_uring_{setup,enter,register} */ #define I386_ARCH_TABLA_ANIDAMIENTO \ 21, 52, /* mount, umount2 */ \ 428, 429, 430, 431, 432, /* open_tree, move_mount, fsopen, fsconfig, fsmount */ \ 346 /* setns */ static const int SYSCALLS_DENEGADAS_I386[] = {I386_ARCH_TABLA_BASE}; static const int SYSCALLS_ANIDAMIENTO_I386[] = {I386_ARCH_TABLA_ANIDAMIENTO}; // Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó. static int poner_seccomp(int permitir_anidamiento) { const int n64b = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0])); const int n64a = permitir_anidamiento ? 0 : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0])); const int n32b = (int)(sizeof(SYSCALLS_DENEGADAS_I386) / sizeof(SYSCALLS_DENEGADAS_I386[0])); const int n32a = permitir_anidamiento ? 0 : (int)(sizeof(SYSCALLS_ANIDAMIENTO_I386) / sizeof(SYSCALLS_ANIDAMIENTO_I386[0])); const int n64 = n64b + n64a, n32 = n32b + n32a; int *a64 = calloc(n64 ? n64 : 1, sizeof(int)); int *a32 = calloc(n32 ? n32 : 1, sizeof(int)); if (!a64 || !a32) { free(a64); free(a32); return -1; } for (int i = 0; i < n64b; i++) a64[i] = SYSCALLS_DENEGADAS[i]; for (int i = 0; i < n64a; i++) a64[n64b + i] = SYSCALLS_ANIDAMIENTO[i]; for (int i = 0; i < n32b; i++) a32[i] = SYSCALLS_DENEGADAS_I386[i]; for (int i = 0; i < n32a; i++) a32[n32b + i] = SYSCALLS_ANIDAMIENTO_I386[i]; // Disposición: [arch?x86_64] → tabla 64 → ALLOW ; si no, [arch?i386] → tabla 32 → ALLOW ; // si no, KILL. El desplazamiento de un salto BPF clásico es __u8, así que todo esto tiene que // caber en 255 instrucciones — de ahí el _Static_assert de arriba, que ahora suma las cuatro // tablas. const int len = 2 + 1 + n64 + 1 + 2 + 1 + n32 + 1 + 2; struct sock_filter *f = calloc(len, sizeof(*f)); if (!f) { free(a64); free(a32); return -1; } const int I_I386 = 2 + 1 + n64 + 1; // donde arranca la rama de 32 bits const int I_ERRNO = len - 2; const int I_KILL = len - 1; int k = 0; f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); const int I_ARCH64 = k++; f[I_ARCH64] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, I_I386 - I_ARCH64 - 1); f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); for (int i = 0; i < n64; i++, k++) f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a64[i], I_ERRNO - k - 1, 0); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); // Rama i386. Hay que RECARGAR `arch`: el acumulador quedó con el número de syscall. f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); const int I_ARCH32 = k++; f[I_ARCH32] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_I386, 0, I_KILL - I_ARCH32 - 1); f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); for (int i = 0; i < n32; i++, k++) f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a32[i], I_ERRNO - k - 1, 0); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); struct sock_fprog prog = {.len = (unsigned short)k, .filter = f}; // Requiere no_new_privs, que ya está puesto antes de llamar acá. int r = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog); free(f); free(a64); free(a32); return r; } int main(int argc, char **argv) { const char *policy = NULL; int abi_min = 3, permitir_anidamiento = 0, sin_landlock = 0, i; for (i = 1; i < argc; i++) { if (!strcmp(argv[i], "--policy") && i + 1 < argc) policy = argv[++i]; else if (!strcmp(argv[i], "--abi-min") && i + 1 < argc) abi_min = atoi(argv[++i]); else if (!strcmp(argv[i], "--allow-nesting")) permitir_anidamiento = 1; // MEDIDO 2026-09-03: un dominio Landlock activo hace fallar `mount` con EACCES aunque // seccomp lo permita — el kernel no deja crear montajes nuevos bajo un dominio, porque un // montaje nuevo escaparía de sus reglas por-ruta. Es decir: **Landlock y un runtime de // contenedores anidado son incompatibles hoy**, y eso incluye a pressure-vessel de Valve // (ADR 0015 D6). Para ese caso se cambia política-de-fs por poder anidar; seccomp y // no_new_privs SIGUEN puestos, que es lo que más importa con un binario ajeno. Nunca por // defecto, y siempre dicho en la línea de arranque: la regla de harkaq es no afirmar una // jaula que no se puso. else if (!strcmp(argv[i], "--no-landlock")) sin_landlock = 1; else if (!strcmp(argv[i], "--")) { i++; break; } } if (!policy || i >= argc) { fprintf(stderr, "uso: harkaq-exec --policy [--abi-min N] -- [args...]\n"); return 2; } char **cmd = &argv[i]; int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); if (abi < abi_min) { // Fallo CERRADO: no correr un build que creemos enjaulado y no lo está. fprintf(stderr, "[harkaq] RECHAZO: ABI de Landlock = %d, se exige >= %d.\n" " Sin REFER/TRUNCATE no compila software real y la jaula sería ficción.\n", abi, abi_min); return 4; } int n_file = 0, n_dir = 0; if (sin_landlock) goto tras_landlock; __u64 handled = abi >= 5 ? FS_ABI5 : abi >= 3 ? FS_ABI3 : abi >= 2 ? FS_ABI2 : FS_ABI1; struct landlock_ruleset_attr attr = {.handled_access_fs = handled}; int rs = ll_create(&attr, sizeof(attr), 0); if (rs < 0) { perror("[harkaq] create_ruleset"); return 4; } FILE *f = fopen(policy, "r"); if (!f) { perror("[harkaq] abrir política"); return 4; } char line[4200]; while (fgets(line, sizeof(line), f)) { char *nl = strchr(line, '\n'); if (nl) *nl = 0; if (!line[0] || line[0] == '#') continue; __u64 grant; char *p; if (!strncmp(line, "ro ", 3)) { grant = RO_RIGHTS; p = line + 3; } else if (!strncmp(line, "rw ", 3)) { grant = handled; p = line + 3; } else if (!strncmp(line, "list ", 5)) { grant = LANDLOCK_ACCESS_FS_READ_DIR; p = line + 5; } else { fprintf(stderr, "[harkaq] política inválida: %s\n", line); return 4; } int dfd = open(p, O_PATH | O_CLOEXEC); if (dfd < 0) { // Una entrada de la clausura que no existe es un bug de harkaq-policy, no del // build. Fallar ruidoso: si la ignoráramos, la política sería MÁS chica que la // clausura y el build denegaría cosas legítimas — falsos positivos que matan // el proyecto (§7). fprintf(stderr, "[harkaq] la política nombra un path inexistente: %s (%m)\n", p); return 4; } // Recortar los derechos de-sólo-directorio cuando el destino NO lo es: el kernel // rechazaría la regla entera con EINVAL. Es el caso MAYORITARIO — la clausura es una // lista de ficheros. struct stat st; int is_dir = (fstat(dfd, &st) == 0) && S_ISDIR(st.st_mode); if (!is_dir) grant &= ~(__u64)DIR_ONLY_RIGHTS; grant &= handled; // nunca conceder más de lo que el ruleset declara manejar if (!grant) { fprintf(stderr, "[harkaq] regla vacía tras recortar por tipo: %s\n", p); close(dfd); return 4; } struct landlock_path_beneath_attr pb = {.allowed_access = grant, .parent_fd = dfd}; if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) { fprintf(stderr, "[harkaq] add_rule(%s): %m\n", p); close(dfd); return 4; } close(dfd); is_dir ? n_dir++ : n_file++; } fclose(f); if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("[harkaq] no_new_privs"); return 4; } // LOG_NEW_EXEC_ON: el flag del que cuelga TODO. harkaq restringe y DESPUÉS ejecuta el // builder; sin este flag el kernel no loguea ninguna denegación posterior al execve y // harkaq certificaría como herméticos todos los builds, para siempre, sin un solo error // visible (medido: §3.4). No es configuración, es la diferencia entre el sistema y un // sello de goma. __u32 flags = 0; if (abi >= 7) flags |= LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON; if (abi >= 8) flags |= LANDLOCK_RESTRICT_SELF_TSYNC; // D5: cubre todos los hilos if (ll_restrict(rs, flags) < 0) { perror("[harkaq] restrict_self"); return 4; } close(rs); tras_landlock:; // seccomp DESPUÉS de landlock y de no_new_privs, y justo antes del exec: el filtro se hereda // por fork/exec igual que el dominio de Landlock (D5), así que cubre todo el árbol del build. // Si el kernel lo rechaza NO se sigue: D7 es fallo cerrado — correr un build creyéndolo // enjaulado cuando le falta media jaula es peor que no correrlo. if (poner_seccomp(permitir_anidamiento) < 0) { fprintf(stderr, "[harkaq] RECHAZO: no pude instalar el filtro seccomp (%m).\n" " ¿CONFIG_SECCOMP_FILTER=y? D4: seccomp no es opcional.\n"); return 4; } if (sin_landlock) fprintf(stderr, "[harkaq] SIN LANDLOCK por pedido explícito: seccomp y no_new_privs SÍ están, pero " "NO hay política de ficheros ni evidencia. El confinamiento de fs queda en manos " "del namespace de montaje de quien invoca.\n"); else if (abi < 7) fprintf(stderr, "[harkaq] AVISO: ABI %d < 7 ⇒ el kernel no audita denegaciones. El build corre " "ENJAULADO pero SIN EVIDENCIA; el veredicto no podrá afirmar hermeticidad.\n", abi); else fprintf(stderr, "[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, " "logging post-exec ON%s\n", abi, n_file, n_dir, permitir_anidamiento ? ", ANIDAMIENTO PERMITIDO (mount/setns)" : ""); execvp(cmd[0], cmd); fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]); return 127; }