harkaq: Q1 CERRADA — la evidencia llega; + D9 (canario deliberado) y el banco de pruebas
Q1 era el gate del proyecto (¿llegan los registros AUDIT_LANDLOCK_* a un lector?). Medido en el laptop (ABI 10) con scripts/harkaq/q1-audit.c: ✅ VIABLE. El multicast AUDIT_NLGRP_READLOG entrega blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369 + exe=/comm= del que lo intentó. Es el diagnóstico de de-Alpinización que el SDD promete, saliendo del kernel sin instrumentar nada. Dos precondiciones que el Draft 2 no tenía, ambas medidas: - audit_enabled=1 NO viene de fábrica (sin audit=1 en cmdline, sin auditd) ⇒ cero registros de cualquier escenario. AUDIT_SET o audit=1 horneado. - LOG_NEW_EXEC_ON es OBLIGATORIO: same-exec=2 registros, new-exec=0, new-exec-logon=4. harkaq restringe y DESPUÉS execea el builder ⇒ sin el flag certificaría herméticos TODOS los builds, denials=[], sin un error. D9 (nueva, no negociable): el canario lo fabrica harkaq. Se investigó si el registro `deallocated denials=N` servía de verificación cruzada gratis: NO. Medido con ventana de 6s, un dominio con flags default y denegación post-exec no emite NADA (ni allocated, ni access, ni el contador); y el `allocated` es perezoso (llega pegado al primer denial logueado) ⇒ su ausencia tampoco prueba nada. Un build hermético y un lector ciego son bit-idénticos: denials=[]. El Verdict pasa a 3 estados (Hermetico / Impuro / SinEvidencia). El contador SÍ sirve para lo otro: nº ACCESS == denials caza registros perdidos por backlog (riesgo real con la granja en paralelo). Nota de método (§3.4): la 1ª corrida dio 0 en los 3 escenarios y "confirmaba" la hipótesis. Era el instrumento roto (AUDIT_GET con NLM_F_ACK: el ACK llegaba antes que la respuesta → enabled=-1 → nunca se encendía el audit). Se cazó sólo porque el CONTROL (same-exec) también dio 0, y eso era imposible. Es el modo de falla de harkaq en vivo sobre su propio banco. El test ahora aborta (exit 4) si no confirma audit_enabled=1. Nuevas Q1b (¿el lector ve a través del userns de bwrap? ¿cómo se atribuye un registro a SU build con la granja en paralelo?) y Q1c (privilegio de harkaq-audit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# harkaq — banco de pruebas
|
||||
|
||||
Ver `docs/16-harkaq-jaula.md`. Acá viven los experimentos que sostienen las afirmaciones del SDD:
|
||||
si el doc dice "medido", el que lo midió está en este directorio.
|
||||
|
||||
## `q1-audit.c` — el gate del proyecto (Q1, ✅ cerrado 2026-07-15)
|
||||
|
||||
Responde: *¿llegan los registros `AUDIT_LANDLOCK_*` a un lector?* Todo harkaq cuelga de eso — su
|
||||
producto es la evidencia, no la jaula.
|
||||
|
||||
```sh
|
||||
gcc -O1 -Wall -o q1-audit q1-audit.c
|
||||
for s in same-exec new-exec new-exec-logon; do sudo ./q1-audit $s; done
|
||||
```
|
||||
|
||||
Necesita root (`CAP_AUDIT_READ` para leer el multicast, `CAP_AUDIT_CONTROL` para encender el
|
||||
audit). **Enciende `audit_enabled=1` globalmente y no lo restaura** — reversible, no persiste al
|
||||
reboot (no toca el cmdline), pero deja `dmesg` más ruidoso mientras tanto.
|
||||
|
||||
### Resultados en el laptop (ABI 10, kernel 7.2.0-rc3)
|
||||
|
||||
| Escenario | Registros `ACCESS` | Qué prueba |
|
||||
|---|---|---|
|
||||
| `same-exec` | 2 | el kernel loguea por defecto sin `execve` |
|
||||
| `new-exec` | **0** | **flags por defecto ⇒ ciego tras `execve`** — el caso real de harkaq |
|
||||
| `new-exec-logon` | 4 | `LOG_NEW_EXEC_ON` lo arregla |
|
||||
|
||||
Los registros traen `blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369` y el
|
||||
`exe=` que lo intentó. Análisis completo en `docs/16-harkaq-jaula.md` §3.4.
|
||||
|
||||
### Por qué aborta con código 4
|
||||
|
||||
Si no puede **confirmar** `audit_enabled=1`, aborta en vez de reportar 0 registros. No es
|
||||
paranoia: la primera corrida dio 0 en los tres escenarios y parecía confirmar la hipótesis. Era un
|
||||
bug del instrumento (`AUDIT_GET` con `NLM_F_ACK` → el ACK llegaba antes que la respuesta → nunca
|
||||
se encendía el audit). Se detectó sólo porque el **control** (`same-exec`) también dio 0, y eso era
|
||||
imposible.
|
||||
|
||||
Es el modo de falla de harkaq sobre su propio banco de pruebas: un sistema cuyo producto es la
|
||||
*ausencia* de algo no falla ruidosamente — dice que todo está bien. De ahí sale D9 (el canario
|
||||
deliberado), y de ahí sale la regla de este directorio: **todo experimento lleva un control que
|
||||
debe dar positivo.** Un experimento sin control no mide, tranquiliza.
|
||||
@@ -0,0 +1,248 @@
|
||||
// Q1 de harkaq (SDD 16 §8): ¿llegan los registros AUDIT_LANDLOCK_ACCESS a un lector?
|
||||
//
|
||||
// Prueba las tres cosas de las que cuelga el proyecto:
|
||||
// 1. ¿El audit del kernel emite el registro? (audit_enabled, kauditd)
|
||||
// 2. ¿Un lector multicast (AUDIT_NLGRP_READLOG) los recibe? (CAP_AUDIT_READ)
|
||||
// 3. ¿SOBREVIVEN AL execve? — harkaq restringe y DESPUÉS ejecuta el builder.
|
||||
// El default del kernel NO loguea tras exec (linux/landlock.h:80-83).
|
||||
// Sin LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON, harkaq vería denials=[] SIEMPRE.
|
||||
//
|
||||
// Uso (como root): ./q1-audit same-exec | new-exec | new-exec-logon
|
||||
//
|
||||
// El "víctima" se autoencierra con una política que sólo permite leer /tmp, y
|
||||
// después intenta leer /etc/passwd (fuera de la clausura) → EACCES → debe generar
|
||||
// un AUDIT_LANDLOCK_ACCESS. El padre escucha el netlink y lo imprime.
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/audit.h>
|
||||
#include <linux/landlock.h>
|
||||
#include <linux/netlink.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.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);
|
||||
}
|
||||
|
||||
#define ACCESS_FS_ROUGHLY_READ \
|
||||
(LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \
|
||||
LANDLOCK_ACCESS_FS_EXECUTE)
|
||||
|
||||
// ---------------------------------------------------------------- netlink audit
|
||||
|
||||
static int nl_open(void) {
|
||||
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT);
|
||||
if (fd < 0) { perror("socket(NETLINK_AUDIT)"); return -1; }
|
||||
struct sockaddr_nl sa = {0};
|
||||
sa.nl_family = AF_NETLINK;
|
||||
sa.nl_pid = 0;
|
||||
// Grupo multicast de sólo-lectura: exactamente para lectores como harkaq-audit.
|
||||
sa.nl_groups = 1 << (AUDIT_NLGRP_READLOG - 1);
|
||||
if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
|
||||
perror("bind(AUDIT_NLGRP_READLOG) <-- ¿falta CAP_AUDIT_READ?");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
// Manda un AUDIT_GET/AUDIT_SET. `payload` NULL ⇒ GET.
|
||||
static int nl_send(int fd, int type, const void *payload, size_t len) {
|
||||
struct {
|
||||
struct nlmsghdr h;
|
||||
char data[256];
|
||||
} req = {0};
|
||||
req.h.nlmsg_len = NLMSG_LENGTH(len);
|
||||
req.h.nlmsg_type = type;
|
||||
// SIN NLM_F_ACK: la respuesta de AUDIT_GET la manda el kernel de forma ASÍNCRONA
|
||||
// (audit_send_reply usa un kthread), así que el ACK llegaba ANTES que el AUDIT_GET
|
||||
// y el lector lo tomaba por "fallo". Pedir sólo REQUEST y filtrar por tipo al leer.
|
||||
req.h.nlmsg_flags = NLM_F_REQUEST;
|
||||
req.h.nlmsg_seq = 1;
|
||||
req.h.nlmsg_pid = getpid();
|
||||
if (payload) memcpy(req.data, payload, len);
|
||||
struct sockaddr_nl sa = {0};
|
||||
sa.nl_family = AF_NETLINK;
|
||||
return sendto(fd, &req, req.h.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa));
|
||||
}
|
||||
|
||||
// Lee el estado del audit por un socket dedicado (unicast). Devuelve enabled o -1.
|
||||
static int audit_status(int *enabled, int *pid) {
|
||||
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT);
|
||||
if (fd < 0) return -1;
|
||||
if (nl_send(fd, AUDIT_GET, NULL, 0) < 0) { close(fd); return -1; }
|
||||
struct timeval tv = {.tv_sec = 1};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
// Drenamos hasta encontrar el AUDIT_GET: puede venir detrás de otros mensajes.
|
||||
char buf[8192];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
ssize_t n = recv(fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) break;
|
||||
for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n);
|
||||
h = NLMSG_NEXT(h, n)) {
|
||||
if (h->nlmsg_type == AUDIT_GET) {
|
||||
struct audit_status *st = (struct audit_status *)NLMSG_DATA(h);
|
||||
*enabled = st->enabled;
|
||||
*pid = st->pid;
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
if (h->nlmsg_type == NLMSG_ERROR) {
|
||||
struct nlmsgerr *e = (struct nlmsgerr *)NLMSG_DATA(h);
|
||||
if (e->error)
|
||||
fprintf(stderr, "[q1] AUDIT_GET rechazado: %s\n", strerror(-e->error));
|
||||
}
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int audit_enable(void) {
|
||||
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT);
|
||||
if (fd < 0) return -1;
|
||||
struct audit_status st = {0};
|
||||
st.mask = AUDIT_STATUS_ENABLED;
|
||||
st.enabled = 1;
|
||||
int r = nl_send(fd, AUDIT_SET, &st, sizeof(st));
|
||||
close(fd);
|
||||
return r < 0 ? -1 : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- la víctima
|
||||
|
||||
// Se autoencierra: sólo /tmp legible. Todo lo demás → EACCES + registro de audit.
|
||||
static void jail(__u32 restrict_flags) {
|
||||
struct landlock_ruleset_attr attr = {.handled_access_fs = ACCESS_FS_ROUGHLY_READ};
|
||||
int rs = ll_create(&attr, sizeof(attr), 0);
|
||||
if (rs < 0) { perror("landlock_create_ruleset"); exit(2); }
|
||||
|
||||
// La "clausura declarada": /tmp y el intérprete. Nada más.
|
||||
const char *allowed[] = {"/tmp", "/usr/lib", "/lib", "/bin", "/usr/bin", NULL};
|
||||
for (int i = 0; allowed[i]; i++) {
|
||||
int dfd = open(allowed[i], O_PATH | O_CLOEXEC);
|
||||
if (dfd < 0) continue;
|
||||
struct landlock_path_beneath_attr pb = {.allowed_access = ACCESS_FS_ROUGHLY_READ,
|
||||
.parent_fd = dfd};
|
||||
if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) perror("add_rule");
|
||||
close(dfd);
|
||||
}
|
||||
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("no_new_privs"); exit(2); }
|
||||
if (ll_restrict(rs, restrict_flags) < 0) { perror("landlock_restrict_self"); exit(2); }
|
||||
close(rs);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *scenario = argc > 1 ? argv[1] : "new-exec";
|
||||
|
||||
int enabled = -1, apid = -1;
|
||||
if (audit_status(&enabled, &apid) == 0)
|
||||
fprintf(stderr, "[q1] audit: enabled=%d auditd_pid=%d\n", enabled, apid);
|
||||
else
|
||||
fprintf(stderr, "[q1] audit: no pude leer el estado (¿sin CAP_AUDIT_CONTROL?)\n");
|
||||
|
||||
// `!= 1` y no `== 0`: si el GET falló (enabled=-1) igual hay que intentar encenderlo.
|
||||
// El bug de la primera corrida fue justo este: GET falló → enabled=-1 → no entró acá →
|
||||
// audit_enabled siguió en 0 → CERO registros en los 3 escenarios → un "hallazgo" que
|
||||
// era el instrumento roto.
|
||||
if (enabled != 1) {
|
||||
fprintf(stderr, "[q1] audit no habilitado (enabled=%d) → AUDIT_SET\n", enabled);
|
||||
if (audit_enable() < 0) perror("[q1] AUDIT_SET");
|
||||
usleep(100000);
|
||||
if (audit_status(&enabled, &apid) == 0)
|
||||
fprintf(stderr, "[q1] audit: enabled=%d (tras AUDIT_SET)\n", enabled);
|
||||
}
|
||||
// GATE: sin audit_enabled=1 el kernel no emite NADA y el test no mide lo que cree medir.
|
||||
// Abortar es obligatorio: un 0 acá significaría "instrumento roto", no "sin denegaciones".
|
||||
if (enabled != 1) {
|
||||
fprintf(stderr,
|
||||
"[q1] ABORTO: no pude confirmar audit_enabled=1. Cualquier resultado sería\n"
|
||||
" un falso negativo del test, no una medición. (¿root? ¿audit=1?)\n");
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Sin CAP_AUDIT_READ no hay lector, pero la víctima corre igual: así una corrida
|
||||
// sin privilegio valida al menos que la JAULA deniega (la mitad barata del test).
|
||||
int nl = nl_open();
|
||||
if (nl < 0)
|
||||
fprintf(stderr, "[q1] sin lector (correr como root para la prueba completa)\n");
|
||||
else
|
||||
fprintf(stderr, "[q1] lector multicast OK. escenario=%s\n", scenario);
|
||||
|
||||
__u32 flags = 0;
|
||||
if (!strcmp(scenario, "new-exec-logon")) flags = LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON;
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
usleep(200000); // dejamos que el padre esté escuchando
|
||||
jail(flags);
|
||||
if (!strcmp(scenario, "same-exec")) {
|
||||
// Denegación SIN execve: el default del kernel SÍ la loguea.
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
fprintf(stderr, "[victima] open(/etc/passwd) = %d (%s)\n", fd, strerror(errno));
|
||||
} else {
|
||||
// Denegación TRAS execve: el caso REAL de harkaq (bwrap → sh -c → make).
|
||||
fprintf(stderr, "[victima] execve(/bin/cat /etc/passwd)\n");
|
||||
execl("/bin/cat", "cat", "/etc/passwd", (char *)NULL);
|
||||
perror("execl");
|
||||
}
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
// El pid del hijo permite atribuir los registros DOMAIN (traen pid=) a NUESTRO dominio y
|
||||
// no al de una corrida anterior que se libera tarde — el error que casi me como.
|
||||
fprintf(stderr, "[q1] hijo pid=%d (atribuir los DOMAIN por este pid)\n", pid);
|
||||
char buf[16384];
|
||||
int landlock_records = 0, total = 0;
|
||||
if (nl < 0) {
|
||||
waitpid(pid, NULL, 0);
|
||||
fprintf(stderr, "\n[q1] jaula validada; evidencia NO probada (hace falta root).\n");
|
||||
return 3;
|
||||
}
|
||||
// Deadline de pared, NO "cortar al primer timeout": el registro `status=deallocated
|
||||
// denials=N` se emite cuando el dominio se libera, DESPUÉS de que el hijo muere, y con
|
||||
// retraso (RCU/workqueue). Con la ventana de 2s se perdía y aparecía en la corrida
|
||||
// siguiente, haciéndolo pasar por el dominio equivocado. Escuchamos 6s pasado el exit.
|
||||
struct timeval tv = {.tv_sec = 1};
|
||||
setsockopt(nl, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
time_t deadline = time(NULL) + 6;
|
||||
while (time(NULL) < deadline) {
|
||||
ssize_t n = recv(nl, buf, sizeof(buf), 0);
|
||||
if (n <= 0) continue; // timeout de 1s: seguimos hasta el deadline
|
||||
for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n);
|
||||
h = NLMSG_NEXT(h, n)) {
|
||||
total++;
|
||||
if (h->nlmsg_type == AUDIT_LANDLOCK_ACCESS ||
|
||||
h->nlmsg_type == AUDIT_LANDLOCK_DOMAIN) {
|
||||
landlock_records++;
|
||||
printf("[REGISTRO %s] %.*s\n",
|
||||
h->nlmsg_type == AUDIT_LANDLOCK_ACCESS ? "ACCESS" : "DOMAIN",
|
||||
(int)(NLMSG_PAYLOAD(h, 0)), (char *)NLMSG_DATA(h));
|
||||
}
|
||||
}
|
||||
}
|
||||
waitpid(pid, NULL, 0);
|
||||
|
||||
fprintf(stderr, "\n[q1] VEREDICTO escenario=%s: %d registros landlock (%d msgs audit total)\n",
|
||||
scenario, landlock_records, total);
|
||||
if (landlock_records == 0)
|
||||
fprintf(stderr, "[q1] ⇒ SIN EVIDENCIA. harkaq reportaría denials=[] (falso 'hermético').\n");
|
||||
else
|
||||
fprintf(stderr, "[q1] ⇒ EVIDENCIA ENTREGADA. Q1 viable por esta vía.\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user