harkaq: Q1b CERRADA — la evidencia cruza el userns y el canario es la clave primaria
Banco: scripts/harkaq/q1b-attrib.c. Dos builds CONCURRENTES en bwrap --unshare-all (los ns de Sandbox::bwrap_args), sin privilegio (uid 1000), canarios con nonce distinto, lector en el host como root. (a) ✅ Los registros CRUZAN el userns/pidns/mountns (6 registros llegaron al lector del host) y cruzan con builds SIN privilegio, que es como corren de verdad. ⇒ harkaq-audit vive en el host junto a hammerd; la arquitectura del §4 se sostiene. (b) ✅ El canario con nonce ATRIBUYE: AAAA=1 BBBB=1, cero cruces con 2 builds en paralelo. ⇒ la granja puede construir en paralelo sin contaminarse la evidencia entre builds. Tres hallazgos colaterales que valen más que el veredicto: - El contador quedó VALIDADO: dos `deallocated denials=1`, uno por dominio, cuadrando con 1 ACCESS cada uno ⇒ el chequeo anti-pérdida (nº ACCESS == denials) está medido, no supuesto. - La identidad sobrevive a los ns: pid=8498 uid=1000 son del HOST (adentro del pidns la víctima es pid 1). - dev+ino identifican el OBJETO, no el BUILD: los 2 canarios dan el mismo ino=4457713 (ambos bindean /etc/hostname). Atribuir por dev/ino habría fundido dos builds leyendo el mismo fichero no declarado — el caso más común que harkaq va a ver. La clave es domain=, y el canario la revela. ⇒ D9 hace TRES trabajos con un mecanismo: honestidad (§3.4), clave primaria (§3.5) y, vía el contador, detección de pérdida. Nota de método: esta corrida también falló 2 veces más, y las 2 el banco afirmó algo falso con total confianza. (1) `cat $canario >/dev/null` — /dev no estaba en la clausura ⇒ sh fallaba en el REDIRECT y cat no corría; el rc=1 no era del canario. (2) Como root, bwrap no pudo ni ejecutar el binario y el veredicto imprimió "NADA cruzó ⇒ replantear la arquitectura" a partir de cero estímulo (causa: --unshare-all mapea sólo uid 0→0; CAP_DAC_OVERRIDE en userns sólo vale sobre uids MAPEADOS ⇒ root-en-userns no atraviesa un home drwx------). Fix: drop_privs() + gate de estímulo (exit 4 si una víctima no sale con status 0). Van 3 veces en una sesión que un cero fue el instrumento y no el kernel, con quien escribía el banco mirando de frente ese riesgo. harkaq sin canario no es un riesgo de configuración: es el comportamiento por defecto. Quedan Q1c (privilegio de harkaq-audit: mejor un helper acotado que hammerd entero) y Q1d (qué ruta reporta exe= con --bind /src). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+86
-11
@@ -231,6 +231,66 @@ chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen f
|
||||
> experimento ahora aborta con código 4 si no puede **confirmar** `audit_enabled=1`, en vez de
|
||||
> reportar cero. La misma disciplina es la que D9 le impone a harkaq.
|
||||
|
||||
### 3.5 Q1b resuelta: la evidencia cruza el userns, y el canario es la clave primaria
|
||||
|
||||
Experimento `scripts/harkaq/q1b-attrib.c` (2026-07-15). Dos builds **concurrentes**, cada uno
|
||||
dentro de bwrap con `--unshare-all` (los mismos ns que `Sandbox::bwrap_args`), sin privilegio
|
||||
(uid 1000), con un canario de nonce distinto. El lector corre en el **host**, como root.
|
||||
|
||||
```
|
||||
[ACCESS] domain=146973f1c blockers=fs.read_file path="/tmp/harkaq-canary-BBBB" dev="nvme1n1p6" ino=4457713
|
||||
[DOMAIN] domain=146973f1c status=allocated mode=enforcing pid=8498 uid=1000 exe="…/q1b" comm="q1b"
|
||||
[ACCESS] domain=146973f1f blockers=fs.read_file path="/tmp/harkaq-canary-AAAA" dev="nvme1n1p6" ino=4457713
|
||||
[DOMAIN] domain=146973f1f status=allocated mode=enforcing pid=8499 uid=1000 exe="…/q1b" comm="q1b"
|
||||
[DOMAIN] domain=146973f1f status=deallocated denials=1
|
||||
[DOMAIN] domain=146973f1c status=deallocated denials=1
|
||||
```
|
||||
|
||||
**(a) Los registros cruzan.** 6 registros landlock de adentro de bwrap llegaron al lector del
|
||||
host. El audit no está namespaceado y se comporta como tal. ⇒ **`harkaq-audit` vive en el host,
|
||||
junto a hammerd; la arquitectura de §4 se sostiene.** Y cruzan con builds **sin privilegio**, que
|
||||
es como corren de verdad (si sólo cruzaran con builds root, no servirían).
|
||||
|
||||
**(b) El canario con nonce atribuye.** `AAAA=1`, `BBBB=1`, cero registros ajenos, con dos builds
|
||||
en paralelo. ⇒ **Q1b cerrada: la granja puede construir en paralelo sin contaminarse la
|
||||
evidencia.**
|
||||
|
||||
Tres hallazgos colaterales que valen más que el veredicto:
|
||||
|
||||
1. **El contador quedó validado.** Los dos `deallocated denials=1` llegaron en ventana, uno por
|
||||
dominio, y cuadran con 1 `ACCESS` cada uno. El chequeo anti-pérdida de §3.4
|
||||
(`nº ACCESS == denials`) está medido, no supuesto.
|
||||
2. **La identidad sobrevive a los namespaces:** `pid=8498 uid=1000` son del **host** (adentro del
|
||||
pidns la víctima se ve como pid 1). El audit reporta identidad del lado donde está el lector.
|
||||
3. **`dev`+`ino` identifican el OBJETO, no el BUILD.** Los dos canarios reportan el mismo
|
||||
`ino=4457713` (ambos bindean `/etc/hostname`). Atribuir por dev/ino habría fundido dos builds
|
||||
que leen el mismo fichero no declarado — que es *el caso más común* que harkaq va a ver. **La
|
||||
clave es `domain=`**; el canario es lo que la revela.
|
||||
|
||||
⇒ El flujo de atribución queda cerrado: **canario ⇒ aprendo el `domain=` ⇒ atribuyo por `domain=`
|
||||
todo lo demás del build.** D9 hace tres trabajos con un mecanismo: honestidad (§3.4), clave
|
||||
primaria (acá) y — vía el contador — detección de pérdida.
|
||||
|
||||
> **Nota de método (la tercera, y ya no es anécdota).** Esta corrida también falló primero, dos
|
||||
> veces más, y las dos el banco reportó un resultado inventado con total confianza:
|
||||
> 1. El comando de la víctima era `cat $canario >/dev/null`. `/dev` no estaba en la clausura ⇒ sh
|
||||
> fallaba **en la redirección** y `cat` no llegaba a correr. El `rc=1` venía del redirect: el
|
||||
> test medía una denegación que no era la que creía medir.
|
||||
> 2. Corriendo como root, bwrap no pudo ni ejecutar el binario (`execvp: Permission denied`) y el
|
||||
> veredicto imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en el host: replantear"* — una
|
||||
> conclusión arquitectónica falsa, a partir de cero estímulo. (Causa: `--unshare-all` mapea
|
||||
> sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` **en un userns
|
||||
> sólo vale sobre uids mapeados** ⇒ root-en-userns no atraviesa un home `drwx------`.)
|
||||
>
|
||||
> El banco ahora tiene un **gate de estímulo**: si alguna víctima no sale con status 0, aborta
|
||||
> (exit 4) en vez de contar registros.
|
||||
>
|
||||
> Van **tres** veces en una sesión —`NLM_F_ACK`, el redirect, el exec— que un cero resultó ser el
|
||||
> instrumento y no el kernel, y las tres el programa afirmó con confianza algo falso. Es la
|
||||
> evidencia empírica más fuerte a favor de D9, y viene del lado incómodo: *el banco lo escribió
|
||||
> alguien que sabía exactamente cuál era el riesgo, mirándolo de frente, y cayó igual, tres veces.*
|
||||
> harkaq sin canario no es un riesgo de configuración: es el comportamiento por defecto.
|
||||
|
||||
---
|
||||
|
||||
## 4. Arquitectura
|
||||
@@ -261,10 +321,15 @@ chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen f
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ harkaq-audit (lector de AUDIT_LANDLOCK_*) │
|
||||
│ EN EL HOST, junto a hammerd — FUERA de bwrap (§3.5): │
|
||||
│ el audit no está namespaceado y los registros cruzan. │
|
||||
│ Requiere CAP_AUDIT_READ (§8 Q1c). │
|
||||
│ canario ⇒ domain= ⇒ atribuye el resto (D9) │
|
||||
│ denegaciones → Denial { blockers, path, dev, ino, ... }│
|
||||
└───────────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
Verdict { PolicyId, recipe_hash, artifact_hash, denials[] }
|
||||
Verdict { PolicyId, recipe_hash, artifact_hash,
|
||||
estado: Hermetico | Impuro{denials[]} | SinEvidencia }
|
||||
│
|
||||
blake3 ──► CAS ──► willay ──► iniy
|
||||
```
|
||||
@@ -406,6 +471,14 @@ El canario cubre la mala configuración (flag ausente, audit apagado, lector ca
|
||||
`deallocated denials=N` de §3.4 cubre la otra falla, la pérdida de registros por backlog: se exige
|
||||
`nº de ACCESS == N`. Dos chequeos, dos fallas, ninguno redundante.
|
||||
|
||||
**Y el canario es además la clave primaria de la evidencia** (medido en §3.5). Con un nonce único
|
||||
por build, su registro revela el `domain=` de ese build, y `domain=` ata todos los registros
|
||||
restantes del mismo. Sin él no hay atribución fiable con la granja construyendo en paralelo: el
|
||||
`pid=` viaja en el `allocated`, que es **perezoso**, y `dev`+`ino` identifican el **objeto**, no el
|
||||
build — dos builds leyendo el mismo fichero no declarado dan el mismo `ino`, que es justo el caso
|
||||
más común que harkaq va a ver. Un mecanismo, tres trabajos: honestidad, atribución y detección de
|
||||
pérdida.
|
||||
|
||||
**Por qué es un no-negociable.** Es la misma disciplina de D7 (fallo cerrado) y de D3 (cero
|
||||
quieting), aplicada al único punto donde el sistema podría mentirse a sí mismo en silencio. Y no
|
||||
es hipotético: el banco de pruebas de Q1 ya cayó en esa trampa una vez (§3.4, nota de método).
|
||||
@@ -483,16 +556,18 @@ vibra.
|
||||
el multicast `AUDIT_NLGRP_READLOG`. Precondiciones medidas: `audit_enabled=1` (no viene de
|
||||
fábrica) y `LOG_NEW_EXEC_ON` (sin él, cero registros tras el `exec`). No hay canario gratis ⇒
|
||||
D9. Detalle y mediciones en §3.4. **El gate del proyecto está pasado: harkaq es viable.**
|
||||
- **Q1b (Fase 1, lo que Q1 destapó):** ¿el lector funciona igual con el build **dentro del userns
|
||||
de bwrap**? El audit no está namespaceado, así que `harkaq-audit` lee desde el host (junto a
|
||||
hammerd) y debería ver los denials de adentro — pero hay que medirlo, no suponerlo. Y de paso:
|
||||
¿cómo se atribuye un registro a *su* build cuando la granja corre varios en paralelo? El campo
|
||||
`domain=` es la clave candidata; el `pid=` del `allocated` es el puente, pero §3.4 midió que ese
|
||||
registro es perezoso. **Riesgo concreto:** sin atribución fiable, los builds paralelos se
|
||||
contaminan la evidencia entre sí.
|
||||
- **Q1c:** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ` (leer) y
|
||||
`CAP_AUDIT_CONTROL` (encender el audit, si no se hornea `audit=1` en el cmdline). En el laptop
|
||||
hizo falta root. ¿hammerd ya lo tiene, o hace falta un helper con capabilities acotadas?
|
||||
- **Q1b — ✅ CERRADA (2026-07-15).** Los registros cruzan el userns/pidns/mountns de bwrap hasta un
|
||||
lector en el host, con builds **sin privilegio**. La atribución con la granja en paralelo se
|
||||
resuelve con el canario de nonce único (D9): revela el `domain=`, que ata el resto. Medido con 2
|
||||
builds concurrentes, cero cruces. Detalle en §3.5.
|
||||
- **Q1c (Fase 1):** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ`
|
||||
(leer el multicast) y, salvo que se hornee `audit=1` en el cmdline, `CAP_AUDIT_CONTROL` (para el
|
||||
`AUDIT_SET`). En el laptop hizo falta root. Medido en §3.5: el lector privilegiado y los builds
|
||||
sin privilegio conviven bien. Falta decidir si es hammerd o un helper con capabilities acotadas
|
||||
— preferible lo segundo: `CAP_AUDIT_*` es superficie de más para el daemon entero.
|
||||
- **Q1d (Fase 1, menor):** el `exe=` del registro trae la ruta **del host**. Con el sandbox real
|
||||
(`--bind <src> /src`) hay que confirmar qué ruta reporta para un builder que vive en `/src`, y si
|
||||
sirve para algo o basta con `domain=`.
|
||||
- **Q2 (Fase 2):** ¿qué es exactamente el "runtime base" que toda receta necesita y ninguna
|
||||
declara (`/bin/sh`, la libc del rootfs, `/opt/zig`)? ¿Va en la clausura implícitamente, o se
|
||||
declara y se vuelve parte del hash de la receta? *Esta es la decisión de diseño de verdad*, y
|
||||
|
||||
@@ -40,3 +40,41 @@ Es el modo de falla de harkaq sobre su propio banco de pruebas: un sistema cuyo
|
||||
*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.
|
||||
|
||||
## `q1b-attrib.c` — userns y atribución (Q1b, ✅ cerrado 2026-07-15)
|
||||
|
||||
Responde: *¿el lector del host ve las denegaciones de adentro de bwrap, y se puede atribuir cada
|
||||
registro a SU build con la granja en paralelo?*
|
||||
|
||||
```sh
|
||||
gcc -O1 -Wall -o ~/.cache/harkaq/q1b q1b-attrib.c # NO compilar bajo /tmp: --tmpfs /tmp lo tapa
|
||||
sudo ~/.cache/harkaq/q1b
|
||||
```
|
||||
|
||||
Lanza 2 víctimas concurrentes en bwrap `--unshare-all` (los ns de `Sandbox::bwrap_args`), cada una
|
||||
con un canario de nonce distinto, y escucha desde el host. Las víctimas bajan a `$SUDO_UID`: el
|
||||
despliegue real es lector privilegiado + builds sin privilegio.
|
||||
|
||||
Resultado: los registros **cruzan** el userns/pidns/mountns, y el canario con nonce **atribuye**
|
||||
sin ambigüedad (`AAAA=1 BBBB=1`, cero cruces). Análisis en `docs/16-harkaq-jaula.md` §3.5.
|
||||
|
||||
### Los tres rastrillos de este banco, para el próximo que lo toque
|
||||
|
||||
1. **No compilar bajo `/tmp`**: el sandbox hace `--tmpfs /tmp` y tapa el binario.
|
||||
2. **El canario necesita un punto de montaje escribible.** Con `--ro-bind / /`, bwrap no puede
|
||||
crear `/harkaq-canary-X` en la raíz de sólo lectura (el sandbox real usa `--tmp-overlay /` y no
|
||||
tendría el problema). Va bajo `/tmp`, y por eso `/tmp` **no** entra en la clausura de la víctima.
|
||||
3. **Root + `--unshare-all` no atraviesa un home `drwx------`.** bwrap mapea sólo uid 0→0; los
|
||||
ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` en un userns sólo vale sobre uids
|
||||
**mapeados**. De ahí `drop_privs()`.
|
||||
|
||||
### Por qué también aborta con código 4
|
||||
|
||||
Mismo gate que `q1-audit`, más uno: **si alguna víctima no sale con status 0, aborta** en vez de
|
||||
contar registros. La corrida que lo motivó imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en
|
||||
el host: replantear"* cuando bwrap ni siquiera había podido ejecutar el binario. Una conclusión
|
||||
arquitectónica falsa, a partir de cero estímulo.
|
||||
|
||||
Van tres veces en una sola sesión (`NLM_F_ACK`, el redirect a `/dev/null`, el exec) que un cero
|
||||
resultó ser el instrumento y no el kernel. Un veredicto que no verifica su propio estímulo es el
|
||||
falso `Hermetico` de D9, pero del lado del banco.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// 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-<nonce>" ⇒
|
||||
// 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 <canario> (uso interno, dentro de bwrap)
|
||||
|
||||
#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 <grp.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_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;
|
||||
}
|
||||
Reference in New Issue
Block a user