harkaq-base-closure.py: deriva el cierre dinámico del runtime base. Es D1
aplicado a la base — mantenerla a mano sería 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. 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: el cierre derivado de {sh,busybox,bash,coreutils,env} reproduce
EXACTAMENTE las 7 librerías que las rondas 2-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. El método es computable, no adivinado.
§4.4 — 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: 205
denegaciones del kernel, y clasificadas:
esperadas (170): /usr/bin/gcc, /usr/bin/ldd ← sondas de compilador
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 ← libdir del gcc de ALPINE
/opt ← bug de política
Es la tesis del §0 hecha dato: un configure que se creía hermético va 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. Coincide con los swaps del selfhost-verify y
con la campaña "matar gcc" (recipes/binutils.toml existe justo porque zig provee
as/ld/ar pero el resto se toma de Alpine).
Bug de política encontrado por el propio experimento: /opt sale 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 para las superficies de contrato.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
4.5 KiB
Python
Executable File
116 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""harkaq-base-closure — deriva el cierre dinámico del runtime base (SDD 16 §4.3).
|
|
|
|
harkaq-base-closure.py <rootfs> <binario-dentro> [<binario-dentro>...]
|
|
|
|
Emite reglas `ro <path>` 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())
|