diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index f927c717..00e0cedb 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -461,8 +461,45 @@ no 5. Pero las tres cosas que importan salieron bien: plausible. 3. **El runtime base no es una lista de binarios: es su CIERRE de `.so`.** Conceder `/bin/bash` arrastra `libreadline`+`libncursesw`; conceder `/bin/coreutils` arrastra `libacl`+`libattr`. - Eso es *computable* (`ldd`), no adivinable ⇒ `harkaq-policy` debe derivar también el cierre - dinámico del runtime base, igual que deriva la clausura de las deps. Misma idea, otro origen. + Eso es *computable*, no adivinable ⇒ **ya está derivado** (`harkaq-base-closure.py`). Es D1 + aplicado al runtime base: mantenerlo a mano sería exactamente el "alguien mantiene un perfil + de permisos" que D1 dice que mata a todos los sandboxes. + + No usa `ldd` (resolvería contra el host, no contra el rootfs Alpine): lee los `DT_NEEDED` del + ELF y resuelve dentro del rootfs por las rutas de musl. **Validación:** el cierre derivado de + `{sh, busybox, bash, coreutils, env}` reproduce **exactamente** las 7 librerías que las rondas + 2 y 3 habían descubierto a mano, en una sola pasada, y además caza los symlinks y + `libc.musl-x86_64.so.1` que se habían escapado. Emite symlink **y** destino: el kernel denuncia + el fichero real (`libz.so.1.3.2`, no `libz.so.1`), pero el build abre por el nombre corto. + +### 4.4 El diagnóstico sobre un `configure` real, con el entorno fiel + +Con el entorno del sandbox real replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`, +`LC_ALL=C`…), `configure` llega mucho más lejos y el diagnóstico se vuelve el que el proyecto +prometía. **205 denegaciones del kernel**, y tras clasificar: + +``` +esperadas (170): /usr/bin/gcc, /usr/bin/ldd ← sondas de compilador +DEUDA (34) en 8 paths: + /usr/bin/ld /usr/bin/nm /usr/bin/objdump /usr/bin/strip ← binutils de ALPINE + /usr/bin/make /usr/bin/getconf + /usr/lib/gcc/x86_64-alpine-linux-musl ← libdir del gcc de ALPINE + /opt ← (bug de política, ver abajo) +``` + +**Esto es la tesis del §0 hecha dato.** Un `configure` que se creía hermético está yendo a buscar +los binutils y el libdir de gcc de Alpine. Y la clasificación es lo que lo hace legible: sin ella +son 205 denegaciones planas y el hallazgo queda enterrado; con ella son **8 paths accionables**. + +Conecta con dos frentes abiertos, y en ambos la lista coincide: los swaps del `selfhost-verify` +(`SWAP_COREUTILS`, las herramientas en el path) y la campaña *matar gcc* (`recipes/binutils.toml` +existe justamente porque zig provee `as/ld/ar` pero el resto se toma de Alpine). + +**Bug de política encontrado por el propio experimento:** `/opt` aparece como deuda porque la +política concede `ro /opt/zig` pero no deja **listar** el padre. Regla general: **todo directorio +concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso positivo. +`harkaq-policy.sh` ya lo hace para la clausura de las deps; falta hacerlo para las superficies de +contrato. **Y el residuo es todo significativo, ninguno ruido:** diff --git a/scripts/harkaq/README.md b/scripts/harkaq/README.md index 02bf652d..09c6c3ab 100644 --- a/scripts/harkaq/README.md +++ b/scripts/harkaq/README.md @@ -233,3 +233,36 @@ Las expectativas viajan en la política como `# expect ` — una sola fuen `harkaq-exec` las ignora como comentario. `SinEvidencia` manda sobre todo: si el lector no es confiable no se clasifica nada, porque reinterpretarlo sería el falso `Hermetico` que el canario existe para impedir. + +### `harkaq-base-closure.py` — el runtime base, derivado + +```sh +scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine /bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env +``` + +Emite `ro ` para cada binario **y todo su cierre transitivo de `.so`**, en rutas del +sandbox. No usa `ldd` (resolvería contra el host): lee los `DT_NEEDED` del ELF y resuelve dentro +del rootfs por las rutas de musl. Emite symlink **y** destino — el kernel denuncia el fichero real +(`libz.so.1.3.2`) pero el build abre por el nombre corto (`libz.so.1`). + +**Validación:** reproduce exactamente las 7 librerías que las rondas 2 y 3 de §4.3 habían +descubierto a mano, en una pasada, y además caza los symlinks y `libc.musl-x86_64.so.1` que se +habían escapado. + +### El diagnóstico sobre un `configure` real (§4.4) + +Con el entorno del sandbox replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`, +`LC_ALL=C`), 205 denegaciones del kernel se clasifican en: + +``` +esperadas (170): /usr/bin/gcc, /usr/bin/ldd +DEUDA (34) en 8 paths: + /usr/bin/{ld,nm,objdump,strip} ← binutils de ALPINE + /usr/bin/{make,getconf} + /usr/lib/gcc/x86_64-alpine-linux-musl + /opt ← bug de política: falta `list` en los ancestros +``` + +Sin clasificar son 205 líneas planas; clasificado, 8 paths accionables. **Regla que salió de acá: +todo directorio concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso +positivo. diff --git a/scripts/harkaq/harkaq-base-closure.py b/scripts/harkaq/harkaq-base-closure.py new file mode 100755 index 00000000..d4a3c75f --- /dev/null +++ b/scripts/harkaq/harkaq-base-closure.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""harkaq-base-closure — deriva el cierre dinámico del runtime base (SDD 16 §4.3). + + harkaq-base-closure.py [...] + +Emite reglas `ro ` para cada binario y **todo su cierre transitivo de `.so`**, en las rutas +tal como se ven DENTRO del sandbox. + +Por qué existe: §4.3 midió que el runtime base no es una lista de binarios sino su cierre. +Conceder /bin/bash arrastra libreadline+libncursesw; /bin/coreutils arrastra libacl+libattr. Se +descubrió a mano, en rondas, mirando denegaciones — pero es **computable**, así que mantenerlo a +mano sería exactamente el "alguien mantiene un perfil de permisos" que D1 dice que mata a todos +los sandboxes. Es D1 aplicado al runtime base: se deriva, no se escribe. + +No se usa `ldd`: resolvería contra el host, no contra el rootfs Alpine. Se leen los DT_NEEDED del +ELF (readelf funciona sobre cualquier ELF, sea cual sea su libc) y se resuelve dentro del rootfs. +""" +import os +import subprocess +import sys + +# Rutas de búsqueda de ld-musl. El default de musl es /lib:/usr/local/lib:/usr/lib; Alpine no +# suele traer ld-musl-*.path, pero si estuviera, mandaría — se lee si existe. +SEARCH_POR_DEFECTO = ["/lib", "/usr/local/lib", "/usr/lib"] + + +def rutas_de_busqueda(rootfs): + conf = os.path.join(rootfs, "etc/ld-musl-x86_64.path") + if os.path.exists(conf): + with open(conf) as f: + rutas = [l.strip() for l in f if l.strip()] + if rutas: + return rutas + return SEARCH_POR_DEFECTO + + +def needed(ruta_host): + """DT_NEEDED + el intérprete. Devuelve ([sonames], interp|None).""" + try: + out = subprocess.run(["readelf", "-dl", ruta_host], capture_output=True, text=True).stdout + except FileNotFoundError: + print("hace falta readelf (binutils)", file=sys.stderr) + sys.exit(2) + sonames, interp = [], None + for linea in out.splitlines(): + if "(NEEDED)" in linea and "Shared library:" in linea: + sonames.append(linea.split("[")[1].split("]")[0]) + elif "program interpreter:" in linea: + interp = linea.split("program interpreter:")[1].strip().rstrip("]") + return sonames, interp + + +def resolver(rootfs, soname, search): + for d in search: + cand = os.path.join(rootfs, d.lstrip("/"), soname) + if os.path.exists(cand): + return cand + return None + + +def dentro(rootfs, ruta_host): + """Path del sandbox = path del host sin el prefijo del rootfs.""" + return "/" + os.path.relpath(ruta_host, rootfs) + + +def main(): + if len(sys.argv) < 3: + print(__doc__.split("\n")[2].strip(), file=sys.stderr) + return 2 + rootfs = os.path.realpath(sys.argv[1]) + search = rutas_de_busqueda(rootfs) + + pendientes = list(sys.argv[2:]) + vistos, salida = set(), set() + + while pendientes: + p = pendientes.pop() + host = os.path.join(rootfs, p.lstrip("/")) + if not os.path.exists(host): + print(f"# AVISO: no existe en el rootfs: {p}", file=sys.stderr) + continue + # Resolver symlinks: /bin/sh → /bin/busybox, /usr/lib/libz.so.1 → libz.so.1.3.2. El + # kernel denuncia el fichero REAL (medido: `libz.so.1.3.2`, no el symlink), así que la + # regla tiene que caer sobre el destino. Emitimos AMBOS: el symlink porque es el nombre + # por el que se abre, y el destino porque es el que Landlock evalúa. + real = os.path.realpath(host) + for h in {host, real}: + if h.startswith(rootfs) and os.path.exists(h): + salida.add(dentro(rootfs, h)) + if real in vistos: + continue + vistos.add(real) + + sonames, interp = needed(real) + if interp: + pendientes.append(interp) + for so in sonames: + hallado = resolver(rootfs, so, search) + if hallado: + pendientes.append(dentro(rootfs, hallado)) + else: + # Un soname irresoluble NO se calla: o el rootfs está roto, o la búsqueda está + # mal, y en cualquier caso la política saldría más chica que el runtime real ⇒ + # falsos positivos, que es lo que mata a estos proyectos (§7). + print(f"# AVISO: soname sin resolver: {so} (de {dentro(rootfs, real)})", + file=sys.stderr) + + print(f"# runtime base: cierre dinámico de {len(sys.argv) - 2} binario(s) → {len(salida)} rutas") + for r in sorted(salida): + print(f"ro {r}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/harkaq/q2-runtime-base.sh b/scripts/harkaq/q2-runtime-base.sh index 963e6203..f42f426b 100755 --- a/scripts/harkaq/q2-runtime-base.sh +++ b/scripts/harkaq/q2-runtime-base.sh @@ -118,6 +118,10 @@ bwrap --overlay-src "$ROOTFS" $(for d in $DEPS; do printf -- "--overlay-src %s " --chdir /src --unshare-all --die-with-parent \ --setenv PATH /opt/zig:/usr/bin:/bin \ --setenv HOME /tmp \ + --setenv CC "zig cc -mcpu=baseline" \ + --setenv CXX "zig c++ -mcpu=baseline" \ + --setenv AR "zig ar" \ + --setenv SOURCE_DATE_EPOCH 1 --setenv LC_ALL C --setenv LANG C --setenv TZ UTC \ /tmp/harkaq-exec --policy /tmp/harkaq.policy -- /bin/sh -c "$CMD" RC=$? set -e