// Q1b de harkaq (SDD 16 §8): ¿el lector del host ve las denegaciones de ADENTRO de bwrap, // y se puede atribuir cada registro a SU build con la granja corriendo en paralelo? // // Q1 probó que la evidencia llega, pero con la víctima corriendo en el host. El caso real es // otro: `Sandbox::run` lanza bwrap con --unshare-all (user/pid/mount/net/ipc/uts ns) y el // lector (harkaq-audit) vive AFUERA, junto a hammerd. Dos preguntas: // // (a) ¿Cruzan los registros el userns/pidns? El audit no está namespaceado, así que // DEBERÍA — pero medirlo es el punto. // (b) ATRIBUCIÓN: con N builds en paralelo hay N dominios. ¿Cuál es de quién? // Hipótesis: el canario de D9 lo resuelve gratis. Si cada build usa un canario con // nonce ÚNICO, el primer registro logueado trae path="/harkaq-canary-" ⇒ // identifica el build sin depender del pid (que el pidns reescribe) ⇒ y de paso // revela su domain=, que ata todos los registros siguientes de ese build. // // Uso (como root): ./q1b-attrib → lanza 2 víctimas concurrentes y atribuye // ./q1b-attrib victim (uso interno, dentro de bwrap) #define _GNU_SOURCE #include #include #include "harkaq-uapi.h" #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); } #define ACCESS_READ \ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \ LANDLOCK_ACCESS_FS_EXECUTE) // ------------------------------------------------------------------ víctima (dentro de bwrap) // Política: todo lo que sh/cat necesitan, y NADA más. El canario queda deliberadamente fuera // ⇒ exactamente UNA denegación esperada, con un path que identifica a este build. static int victim(const char *canary) { struct landlock_ruleset_attr attr = {.handled_access_fs = ACCESS_READ}; int rs = ll_create(&attr, sizeof(attr), 0); if (rs < 0) { perror("[victima] create_ruleset"); return 2; } // /tmp NO va en la clausura: es donde vive el canario (único punto de montaje escribible // bajo `--ro-bind / /`, ver README). Todo lo demás es lo que sh/cat necesitan para correr. const char *allowed[] = {"/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", 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_READ, .parent_fd = dfd}; ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0); close(dfd); } if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("[victima] nnp"); return 2; } // LOG_NEW_EXEC_ON: sin esto no se loguea nada tras el execve (Q1/§3.4). Obligatorio. if (ll_restrict(rs, LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON) < 0) { perror("[victima] restrict_self"); return 2; } close(rs); // Post-exec, como el builder real (bwrap → sh -c → make). // Sin redirects: `>/dev/null` necesita /dev, que NO está en la clausura ⇒ sh fallaba en la // redirección y `cat` no llegaba a correr nunca. El rc=1 venía del redirect, no del canario: // el test habría medido la denegación equivocada y "pasado" igual. Mantener el comando // mínimo, y que el único acceso fuera de la clausura sea EL CANARIO. char cmd[512]; snprintf(cmd, sizeof(cmd), "cat %s; echo \"[victima] canario=%s cat rc=$?\" >&2", canary, canary); execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); perror("[victima] execl"); return 2; } // ------------------------------------------------------------------ netlink 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; 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 = {.nl_family = AF_NETLINK}; return sendto(fd, &req, req.h.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa)); } static int audit_status(int *enabled) { 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)); 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) { *enabled = ((struct audit_status *)NLMSG_DATA(h))->enabled; close(fd); return 0; } } 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 = {.mask = AUDIT_STATUS_ENABLED, .enabled = 1}; int r = nl_send(fd, AUDIT_SET, &st, sizeof(st)); close(fd); return r < 0 ? -1 : 0; } // ------------------------------------------------------------------ driver // Baja a un uid sin privilegio antes de lanzar bwrap. Dos razones: // 1. Corrección: bwrap con --unshare-all mapea SÓLO uid 0→0. Los ficheros de otro uid quedan // sin mapear (aparecen como `nobody`) y CAP_DAC_OVERRIDE dentro de un userns sólo vale // sobre uids MAPEADOS ⇒ root-en-userns no puede atravesar un home drwx------ y el exec // del binario falla con EACCES. Costó una corrida entera diagnosticarlo. // 2. Fidelidad: modela el despliegue real — harkaq-audit privilegiado (CAP_AUDIT_READ), // los builds sin privilegio. Si la evidencia cruzara sólo con builds root, no serviría. static void drop_privs(void) { const char *su = getenv("SUDO_UID"), *sg = getenv("SUDO_GID"); if (!su) return; // no venimos de sudo: nada que bajar uid_t uid = (uid_t)atoi(su); gid_t gid = sg ? (gid_t)atoi(sg) : uid; if (setgroups(0, NULL) < 0) perror("[q1b] setgroups"); if (setgid(gid) < 0) { perror("[q1b] setgid"); _exit(2); } if (setuid(uid) < 0) { perror("[q1b] setuid"); _exit(2); } } static void spawn_victim(const char *self, const char *canary_src, const char *canary_path) { // bwrap con los mismos ns que Sandbox::bwrap_args (--unshare-all). El canario se bindea // en una ruta fija que la política de landlock NO concede. execlp("bwrap", "bwrap", "--unshare-all", "--ro-bind", "/", "/", "--tmpfs", "/tmp", "--ro-bind", canary_src, canary_path, "--die-with-parent", self, "victim", canary_path, (char *)NULL); perror("execlp bwrap"); _exit(2); } int main(int argc, char **argv) { if (argc >= 3 && !strcmp(argv[1], "victim")) return victim(argv[2]); char self[4096]; ssize_t sl = readlink("/proc/self/exe", self, sizeof(self) - 1); if (sl < 0) { perror("readlink"); return 1; } self[sl] = 0; int enabled = -1; audit_status(&enabled); if (enabled != 1) { fprintf(stderr, "[q1b] audit enabled=%d → AUDIT_SET\n", enabled); audit_enable(); usleep(100000); audit_status(&enabled); } // Mismo gate que q1-audit: sin audit_enabled=1 un 0 no significa "sin denegaciones", // significa "el instrumento no mide". Abortar, nunca reportar. if (enabled != 1) { fprintf(stderr, "[q1b] ABORTO: no pude confirmar audit_enabled=1 (¿root?)\n"); return 4; } int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT); struct sockaddr_nl sa = {.nl_family = AF_NETLINK, .nl_groups = 1 << (AUDIT_NLGRP_READLOG - 1)}; if (nl < 0 || bind(nl, (struct sockaddr *)&sa, sizeof(sa)) < 0) { perror("[q1b] bind multicast (¿CAP_AUDIT_READ?)"); return 1; } fprintf(stderr, "[q1b] lector del HOST escuchando; audit enabled=%d\n", enabled); // DOS builds concurrentes, canarios con nonce distinto. Si la atribución por canario // funciona, cada registro cae inequívocamente en su build sin mirar el pid. const char *canaries[] = {"/tmp/harkaq-canary-AAAA", "/tmp/harkaq-canary-BBBB"}; pid_t kids[2]; for (int i = 0; i < 2; i++) { if ((kids[i] = fork()) == 0) { usleep(300000); drop_privs(); spawn_victim(self, "/etc/hostname", canaries[i]); } fprintf(stderr, "[q1b] build %d: bwrap pid=%d canario=%s\n", i, kids[i], canaries[i]); } struct timeval tv = {.tv_sec = 1}; setsockopt(nl, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); char buf[16384]; int seen[2] = {0, 0}, other = 0, total = 0; time_t deadline = time(NULL) + 8; while (time(NULL) < deadline) { ssize_t n = recv(nl, buf, sizeof(buf), 0); if (n <= 0) continue; for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n); h = NLMSG_NEXT(h, n)) { if (h->nlmsg_type != AUDIT_LANDLOCK_ACCESS && h->nlmsg_type != AUDIT_LANDLOCK_DOMAIN) continue; total++; const char *rec = (const char *)NLMSG_DATA(h); printf("[%s] %.*s\n", h->nlmsg_type == AUDIT_LANDLOCK_ACCESS ? "ACCESS" : "DOMAIN", (int)NLMSG_PAYLOAD(h, 0), rec); int matched = 0; for (int i = 0; i < 2; i++) if (strstr(rec, canaries[i])) { seen[i]++; matched = 1; } if (!matched && h->nlmsg_type == AUDIT_LANDLOCK_ACCESS) other++; } } // GATE DEL INSTRUMENTO: si la víctima no corrió, "0 registros" no significa "nada cruzó", // significa "no medí nada". La corrida anterior imprimió "NADA cruzó ⇒ replantear la // arquitectura" cuando bwrap ni siquiera había podido ejecutar el binario (EACCES). Un // veredicto que no verifica su propio estímulo es exactamente el falso 'hermético' de D9, // pero del lado del banco de pruebas. Tercera vez en esta sesión: por eso es un gate. int bad = 0; for (int i = 0; i < 2; i++) { int st = 0; waitpid(kids[i], &st, 0); if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) { fprintf(stderr, "[q1b] build %d NO corrió limpio (status=%d)\n", i, st); bad = 1; } } if (bad) { fprintf(stderr, "\n[q1b] ABORTO: alguna víctima no llegó a ejecutarse ⇒ el estímulo no existió.\n" " Cualquier conteo de registros sería del instrumento, no del kernel.\n"); return 4; } fprintf(stderr, "\n[q1b] ── VEREDICTO ──\n"); fprintf(stderr, "[q1b] (a) ¿cruzan el userns de bwrap? %s (%d registros landlock)\n", total > 0 ? "SÍ" : "NO", total); fprintf(stderr, "[q1b] (b) atribución por canario: AAAA=%d BBBB=%d (otros ACCESS=%d)\n", seen[0], seen[1], other); if (seen[0] >= 1 && seen[1] >= 1) fprintf(stderr, "[q1b] ⇒ el canario con nonce ATRIBUYE: 2 builds paralelos, sin ambigüedad.\n"); else if (total == 0) fprintf(stderr, "[q1b] ⇒ NADA cruzó. harkaq-audit no puede vivir en el host: replantear.\n"); else fprintf(stderr, "[q1b] ⇒ registros SÍ, atribución NO. Hace falta otra clave (¿domain=?).\n"); return 0; }