#!/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())