Files
takana/scripts/harkaq/harkaq-exec.c
T
sergioandClaude Opus 4.8 caa23f9ab7 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>
2026-07-15 19:31:58 -04:00

284 lines
14 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <fichero> [--abi-min 3] -- <cmd> [args...]
//
// 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 <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>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
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,
// 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;
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], "--")) { i++; break; }
}
if (!policy || i >= argc) {
fprintf(stderr, "uso: harkaq-exec --policy <f> [--abi-min N] -- <cmd> [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;
}
__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];
int n_file = 0, n_dir = 0;
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);
// 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 "
"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\n",
abi, n_file, n_dir);
execvp(cmd[0], cmd);
fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]);
return 127;
}