harkaq: seccomp implementado (D4) — denylist, no allowlist; el hash no se mueve

D4 declaraba seccomp "obligatorio, no opcional" y harkaq-exec tenía CERO
seccomp: el documento afirmaba algo que el código no hacía. Cerrado.

CORRECCIÓN DELIBERADA A D4: pedía ALLOWLIST por syscall, se implementó DENYLIST.
Un allowlist para builds ARBITRARIOS (compiladores, make, shells, linkers, perl)
es un blanco móvil que cada herramienta nueva rompe — 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 sí es enumerable y estable: no hay build
honesto que cargue módulos, haga kexec o attachee un ptrace.

Denegadas: io_uring_*, ptrace, process_vm_{readv,writev}, bpf, userfaultfd,
keyctl/add_key/request_key, {init,finit,delete}_module, kexec_*,
perf_event_open, mount/umount2, open_tree/move_mount/fs*, setns.
pivot_root NO: bwrap lo usa ANTES de llegar a harkaq-exec.

EPERM y no KILL: matar deja un cadáver sin explicación; EPERM deja al build
fallar donde corresponde y al log decir por qué. Se instala DESPUÉS de
no_new_privs y de Landlock, justo antes del exec, y se hereda por fork/exec como
el dominio (D5). Si el kernel lo rechaza NO se corre el build (D7: media jaula
creyéndose entera es peor que ninguna).

Chequeo de arquitectura antes del número de syscall: los nros son POR ARCH y sin
el check un binario i386 podría colar otra syscall con el mismo número — el error
clásico de los filtros seccomp a mano.

COMPROBADO: ptrace → EPERM bajo la jaula (control positivo), y un build REAL de
zlib sale Hermetico ×3 con el hash del artefacto IDÉNTICO al de antes de seccomp
(b3:adc5c251…). Añadir media jaula no movió un byte — como debe ser: esto recorta
superficie de escape, no cambia el build.

El --seccomp <fd> de bwrap quedó SIN USAR: harkaq-exec ya corre dentro y con
no_new_privs puesto, así que instala el filtro él mismo — una pieza menos de
plumbing y el filtro queda al lado de la política que lo justifica.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:31:58 -04:00
co-authored by Claude Opus 4.8
parent 8f7109f4e9
commit caa23f9ab7
2 changed files with 123 additions and 0 deletions
+27
View File
@@ -560,6 +560,33 @@ usarla.
### D4 — Landlock no basta; seccomp es obligatorio, no opcional
> **Corregida al implementarla (2026-07-15).** El texto de abajo pedía **allowlist** por syscall.
> Lo implementado es una **denylist**, y el cambio es deliberado: un allowlist para builds
> *arbitrarios* —compiladores, make, shells, linkers, perl— es un blanco móvil que cada
> herramienta nueva 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.
>
> Denegadas (en `harkaq-exec.c`): `io_uring_*`, `ptrace`, `process_vm_{readv,writev}`, `bpf`,
> `userfaultfd`, `keyctl`/`add_key`/`request_key`, `{init,finit,delete}_module`, `kexec_*`,
> `perf_event_open`, `mount`/`umount2`, la familia `open_tree`/`move_mount`/`fs*`, `setns`.
> **`pivot_root` NO se deniega**: bwrap lo usa *antes* de llegar a harkaq-exec.
>
> **EPERM, 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é. Se instala **después** de `no_new_privs` y de
> Landlock, justo antes del `exec`, y se hereda por fork/exec igual que el dominio (D5). Si el
> kernel lo rechaza, **no se corre el build** (D7: fallo cerrado — media jaula creyéndose entera
> es peor que ninguna).
>
> **Comprobado:** `ptrace` → `EPERM` bajo la jaula; y un build real de zlib sale `Hermetico` ×3
> con **el hash del artefacto idéntico** al de antes de seccomp (`b3:adc5c251…`). Añadir media
> jaula no movió un byte — como debe ser: esto recorta superficie de escape, no cambia el build.
>
> Nota: el `--seccomp <fd>` de bwrap que se menciona abajo terminó **sin usarse**. `harkaq-exec`
> ya corre dentro y con `no_new_privs` puesto, así que instala el filtro él mismo — una pieza
> menos de plumbing y el filtro queda al lado de la política que lo justifica.
Landlock cubre fs, puertos TCP e IPC scoping. No cubre `ptrace`, `bpf`, `userfaultfd`, `keyctl`,
`io_uring`, montajes, ni la mitad de la superficie interesante. seccomp-bpf con allowlist por
syscall es la otra mitad de la jaula.
+96
View File
@@ -31,7 +31,11 @@
#define _GNU_SOURCE
#include "harkaq-uapi.h"
#include <errno.h>
#include <fcntl.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -79,6 +83,88 @@ static int ll_restrict(int fd, __u32 f) {
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,
// Montajes: bwrap ya armó el árbol; re-montar sólo sirve para escapar de él. `pivot_root`
// NO se deniega: bwrap lo usa ANTES de llegar acá (esto corre después del pivot).
__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,
};
// Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó.
static int poner_seccomp(void) {
const int n = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]));
// 2 (carga arch + salto) + 1 (carga nr) + n (comparaciones) + 3 (los tres RET).
struct sock_filter *f = calloc(n + 6, sizeof(*f));
if (!f) return -1;
int k = 0;
const int I_ALLOW = 3 + n, I_ERRNO = 4 + n, I_KILL = 5 + n;
// Comprobar la arquitectura ANTES de mirar el número: los números de syscall son POR ARCH, y
// sin este check un binario i386 podría pedir otra syscall con el mismo número y colarse. Es
// el error clásico de los filtros seccomp escritos a mano.
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, arch));
f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0,
I_KILL - k - 1 + 1);
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr));
for (int i = 0; i < n; i++, k++)
f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
(unsigned)SYSCALLS_DENEGADAS[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);
(void)I_ALLOW;
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);
return r;
}
int main(int argc, char **argv) {
const char *policy = NULL;
int abi_min = 3, i;
@@ -170,6 +256,16 @@ int main(int argc, char **argv) {
if (ll_restrict(rs, flags) < 0) { perror("[harkaq] restrict_self"); return 4; }
close(rs);
// 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() < 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 (abi < 7)
fprintf(stderr,
"[harkaq] AVISO: ABI %d < 7 ⇒ el kernel no audita denegaciones. El build corre "