diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index cbc7b266..a5546fcc 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -544,6 +544,19 @@ struct Instance { /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada. #[serde(default)] grants: Grants, + /// Correr el comando como este usuario de la imagen, en vez de como root. + /// + /// ── POR QUÉ ESTO Y NO `root = false` ─────────────────────────────────────────────────────── + /// MEDIDO con Steam: el cliente **se niega a correr como root** (`bin_steam.sh: Error: Cannot + /// run as root user`), así que la instancia de juegos necesita un usuario normal. La vía + /// tentadora —cambiar el mapa para caer en el uid 1000— **corrompe la instancia**: un fichero + /// creado bajo un mapa aparece con OTRO uid bajo el otro, y el `useradd` de la preparación deja + /// un `/home` que después su propio dueño no puede escribir. + /// + /// La vía correcta es la de cualquier runtime de contenedores: **un solo mapa, y se BAJA de + /// privilegio adentro**. Tenemos `CAP_SETUID` en el namespace, así que es un `setpriv` y ya. + #[serde(default, skip_serializing_if = "Option::is_none")] + run_as: Option, /// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo. /// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por /// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar. @@ -811,7 +824,7 @@ fn recreate(root: &Path, id: &str) -> Result<()> { /// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario /// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara. -fn grants_to_args(g: &Grants) -> (Vec, Vec) { +fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec, Vec) { let mut a: Vec = Vec::new(); let mut warn: Vec = Vec::new(); // Sin dep de libc: el dueño de /proc/self ES el uid efectivo. @@ -829,10 +842,10 @@ fn grants_to_args(g: &Grants) -> (Vec, Vec) { (ADR §NO-resuelve 1)".into(), ); let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); - (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string()) + (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0")) } - "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()), - "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()), + "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")), + "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")), otro => { warn.push(format!("socket desconocido {otro:?} — ignorado")); continue; @@ -908,7 +921,12 @@ impl Drop for UsernsMapeado { /// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de /// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude". -fn userns_con_rango() -> Result { +/// +/// `uid_dentro` decide quién sos adentro, y **eso lo hace el MAPA, no un flag de bwrap** — que es +/// justo lo que permite ser un usuario normal sin renunciar al rango. Hizo falta por Steam: el +/// cliente se niega a correr como root (`bin_steam.sh: Error: Cannot run as root user`), así que la +/// instancia de juegos NECESITA no ser root, y antes eso costaba el rango entero. +fn userns_con_rango(uid_dentro: u32) -> Result { let uid = std::fs::metadata("/proc/self") .map(|m| std::os::unix::fs::MetadataExt::uid(&m)) .unwrap_or(1000); @@ -956,10 +974,34 @@ fn userns_con_rango() -> Result { // el namespace del padre, que es el del host. std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?; + // Los tramos del mapa. Con `uid_dentro = 0` es el caso simple (somos root adentro y el resto + // del rango va detrás). Con cualquier otro, hay que partir el rango en tres para dejar hueco: + // los ids de abajo (incluido el 0, que tiene que EXISTIR para que la imagen tenga dueño de + // ficheros) salen del subuid, nosotros caemos en `uid_dentro`, y el resto sigue arriba. + let tramos = move |id_afuera: u32, r: &Rango| -> Vec { + if uid_dentro == 0 { + vec!["0".into(), id_afuera.to_string(), "1".into(), + "1".into(), r.inicio.to_string(), r.cantidad.to_string()] + } else { + let bajos = uid_dentro.min(r.cantidad); + let altos = r.cantidad.saturating_sub(bajos + 1); + let mut v = vec![ + "0".into(), r.inicio.to_string(), bajos.to_string(), + uid_dentro.to_string(), id_afuera.to_string(), "1".into(), + ]; + if altos > 0 { + v.extend([(uid_dentro + 1).to_string(), (r.inicio + bajos).to_string(), + altos.to_string()]); + } + v + } + }; + let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> { + let mut argv = vec![pid.to_string()]; + argv.extend(tramos(id, r)); let out = Command::new(bin) - .args([pid.to_string(), "0".into(), id.to_string(), "1".into(), - "1".into(), r.inicio.to_string(), r.cantidad.to_string()]) + .args(&argv) .output() .with_context(|| format!("no pude ejecutar {bin}"))?; if !out.status.success() { @@ -983,7 +1025,21 @@ fn run_instance( ) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; - let (grant_args, warns) = grants_to_args(&inst.grants); + + // Quién sos adentro. Con el mapeo por rango esto NO cuesta el rango: lo decide el propio mapa. + // ⚠ Cambiarlo en una instancia YA USADA la corrompe: los ficheros del `upper` quedaron con los + // uids del mapa anterior. Para correr como usuario normal está `run_as`, que no toca el mapa. + let uid_dentro: u32 = if inst.grants.root { 0 } else { 1000 }; + // El XDG_RUNTIME_DIR es del usuario que CORRE, no del uid del mapa: con `run_as`, apuntarlo al + // de root deja a las herramientas del Steam Runtime sin poder crear su temporal + // (`srt-logger: g_mkdtemp: Permission denied`), y eso se lee como un aviso menor hasta que algo + // deja de andar sin explicar por qué. + let uid_corre = match &inst.run_as { + Some(u) => usuario_de(&dir, &tree, u).map(|(x, _, _)| x).unwrap_or(uid_dentro), + None => uid_dentro, + }; + let runtime_dir = format!("/run/user/{uid_corre}"); + let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir); // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo @@ -994,8 +1050,8 @@ fn run_instance( // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio // haría que "no anda adentro" fuese un misterio en vez de una línea. let mut mapeado: Option = None; - if !single_id && inst.grants.root { - match userns_con_rango() { + if !single_id { + match userns_con_rango(uid_dentro) { Ok(m) => mapeado = Some(m), Err(e) => eprintln!( "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \ @@ -1041,20 +1097,48 @@ fn run_instance( // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal. // Un /tmp que no es 1777 no es /tmp. args.extend(["--proc", "/proc", "--dev", "/dev", - "--perms", "1777", "--tmpfs", "/tmp", - "--dir", "/run/user/0", "--chdir", "/"].map(String::from)); + "--perms", "1777", "--tmpfs", "/tmp"].map(String::from)); + args.extend(["--dir".to_string(), runtime_dir.clone(), "--chdir".into(), "/".into()]); + // El HOME sale del /etc/passwd de la IMAGEN, no de una constante: si la instancia creó un + // usuario, su home es el que ese fichero dice. Un HOME que no existe hace que media app se + // comporte raro sin decir por qué. + let home = home_de(&dir, &tree, uid_dentro); args.extend([ "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "--setenv", "HOME", "/root", + "--setenv", "HOME", &home, "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()), - "--setenv", "XDG_RUNTIME_DIR", "/run/user/0", ].map(String::from)); + args.extend(["--setenv".to_string(), "XDG_RUNTIME_DIR".into(), runtime_dir.clone()]); args.extend(grant_args); let mut cmd: Vec = if cmd.is_empty() { vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar. } else { cmd.to_vec() }; + // Bajar de privilegio ADENTRO, con el mapa intacto. `setpriv` viene en util-linux, que trae + // toda imagen de distro; si faltara, se dice en vez de correr como root en silencio — que sería + // exactamente lo que el manifiesto pidió no hacer. + if let Some(usuario) = &inst.run_as { + let (uid_u, gid_u, home_u) = usuario_de(&dir, &tree, usuario).with_context(|| { + format!("`run_as = {usuario:?}` pero no existe en el /etc/passwd de la instancia") + })?; + // Un prelude MÍNIMO como root para darle su runtime dir al usuario, y después `exec`: el + // `"$@"` evita entrecomillar el comando del usuario, que es donde estos wrappers se rompen. + let mut env = vec![ + "/bin/sh".to_string(), "-c".into(), + "chown \"$1\":\"$2\" \"$3\" 2>/dev/null; shift 3; exec \"$@\"".into(), + "qorpa".into(), uid_u.to_string(), gid_u.to_string(), runtime_dir.clone(), + "setpriv".into(), "--reuid".into(), uid_u.to_string(), + "--regid".into(), gid_u.to_string(), "--init-groups".into(), "--".into(), + ]; + env.extend(cmd); + cmd = env; + // El HOME tiene que ser el SUYO, no el de root: media app se comporta raro sin decir por qué. + if let Some(i) = args.iter().position(|a| a == "HOME") { + args[i + 1] = home_u; + } + } + // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host @@ -1366,6 +1450,40 @@ fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<() Ok(()) } +/// Busca el home del uid en el `/etc/passwd` de la instancia (vista merged). Cae a `/root` para el +/// 0 y a `/home/qorpa` si no hay entrada — nunca a nada, porque un HOME vacío rompe más que uno +/// inventado. +fn home_de(dir: &Path, tree: &Path, uid: u32) -> String { + if let Some(p) = resolver_en_instancia(dir, tree, "etc/passwd") { + if let Ok(txt) = std::fs::read_to_string(p) { + for l in txt.lines() { + let c: Vec<&str> = l.split(':').collect(); + if c.len() >= 6 && c[2].parse::() == Ok(uid) && !c[5].is_empty() { + return c[5].to_string(); + } + } + } + } + if uid == 0 { "/root".into() } else { "/home/qorpa".into() } +} + +/// (uid, gid, home) de un usuario del `/etc/passwd` de la instancia. Acepta nombre o uid. +fn usuario_de(dir: &Path, tree: &Path, quien: &str) -> Result<(u32, u32, String)> { + let p = resolver_en_instancia(dir, tree, "etc/passwd") + .context("la instancia no tiene /etc/passwd")?; + for l in std::fs::read_to_string(p)?.lines() { + let c: Vec<&str> = l.split(':').collect(); + if c.len() < 6 { + continue; + } + if c[0] == quien || c[2] == quien { + return Ok((c[2].parse()?, c[3].parse()?, + if c[5].is_empty() { "/".into() } else { c[5].to_string() })); + } + } + bail!("no encuentro al usuario {quien:?}") +} + fn src_label(dir: &Path) -> String { dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) } @@ -1664,12 +1782,12 @@ mod tests { #[test] fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { - let (a, w) = grants_to_args(&Grants::default()); + let (a, w) = grants_to_args(&Grants::default(), "/run/user/0"); assert!(a.contains(&"--unshare-net".to_string())); assert!(w.is_empty()); let g = Grants { network: true, ..Default::default() }; - let (a, _) = grants_to_args(&g); + let (a, _) = grants_to_args(&g, "/run/user/0"); assert!(!a.contains(&"--unshare-net".to_string())); // El segundo fallo clásico de la primera corrida, según el ADR. assert!(a.iter().any(|x| x == "/etc/resolv.conf")); @@ -1678,7 +1796,7 @@ mod tests { #[test] fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() { let g = Grants { sockets: vec!["wayland".into()], ..Default::default() }; - let (_, w) = grants_to_args(&g); + let (_, w) = grants_to_args(&g, "/run/user/0"); assert_eq!(w.len(), 1); assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); } @@ -1773,6 +1891,25 @@ mod tests { assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'"); } + #[test] + fn run_as_resuelve_uid_gid_y_home_del_passwd_de_la_imagen() { + let d = tempfile::tempdir().unwrap(); + let sha = "f".repeat(64); + imagen_falsa(d.path(), &sha); + let tree = d.path().join("images").join(&sha).join(TREE); + std::fs::write(tree.join("etc/passwd"), + "root:x:0:0::/root:/bin/bash\njugador:x:1000:1000::/home/jugador:/bin/bash\n").unwrap(); + let dir = d.path().join("instances/x"); + std::fs::create_dir_all(&dir).unwrap(); + + assert_eq!(usuario_de(&dir, &tree, "jugador").unwrap(), (1000, 1000, "/home/jugador".into())); + assert_eq!(usuario_de(&dir, &tree, "1000").unwrap().0, 1000); + assert!(usuario_de(&dir, &tree, "nadie").is_err(), "un run_as inexistente NO puede caer a root"); + // El HOME sale del passwd, no de una constante. + assert_eq!(home_de(&dir, &tree, 1000), "/home/jugador"); + assert_eq!(home_de(&dir, &tree, 0), "/root"); + } + #[test] fn sha_mal_formado_falla_antes_de_tocar_la_red() { assert!(normalize_sha256("abc").is_err()); diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 8a2e569b..939e0e12 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -332,6 +332,40 @@ adentro, no porque haya que elegir entre dos males. de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios, que es exactamente como lo hace pressure-vessel. +### D10 — Los tres muros que sólo aparecieron con Steam en la mano + +Ninguno estaba en el ADR, y los tres son de la clase «sella verde y falla en la mano del usuario» +que §D6 nombraba sin poder anticipar. + +**1. La jaula mataba TODOS los binarios de 32 bits.** El filtro seccomp de harkaq comprueba +`arch == AUDIT_ARCH_X86_64` y **mata** lo que no lo sea. Para un build es la defensa clásica y +correcta —los números de syscall son por arquitectura—; para el montón B es fatal, porque el cliente +de Steam es un ELF i386. El síntoma medido fue `ldd: exited with unknown exit code (159)` — **159 = +128+31 = SIGSYS** — que no se parece en nada a «tu jaula mata los 32 bits». La salida no es aflojar +el check (eso reabre el agujero) sino **darle a i386 su propia tabla y aplicarle la misma política**. +Los 25 números van literales y se verificaron **uno por uno contra `/usr/include/asm/unistd_32.h`**: +24 estaban bien y **uno mal** — `kexec_file_load` no existe en i386, y su número de x86_64 (320) es +ahí `utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que toque una marca de +tiempo. Es la diferencia entre una tabla escrita de memoria y una verificada. + +**2. Steam se niega a correr como root**, y cambiar el mapa para evitarlo **corrompe la instancia.** +`bin_steam.sh: Error: Cannot run as root user`. La vía tentadora es mapear el uid 1000 en vez del 0 +—el mapa decide quién sos—, pero **un fichero creado bajo un mapa aparece con OTRO uid bajo el +otro**: el `useradd` de la preparación deja un `/home` que después su propio dueño no puede escribir +(`mkdir: /home/jugador/.steam: Permission denied`). ⇒ el mapa es parte de la IDENTIDAD de la +instancia y cambiarlo pide `recreate`. La vía correcta es la de cualquier runtime de contenedores: +**un solo mapa, y se BAJA de privilegio adentro** (`run_as` en el manifiesto, `setpriv`, con el +`CAP_SETUID` que ya tenemos en el namespace). + +**3. El `XDG_RUNTIME_DIR` es del usuario que CORRE, no del uid del mapa.** Con `run_as`, apuntarlo +al de root deja a las herramientas del Steam Runtime sin poder crear su temporal +(`srt-logger: g_mkdtemp: Permission denied`). Se lee como un aviso menor hasta que algo deja de +andar sin explicar por qué. + +**Lo que sí quedó probado, y es el corazón del ADR:** un userland glibc ajeno con su cadena de 32 +bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un +contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton. + --- ## Lo que este ADR admite que NO resuelve @@ -438,7 +472,15 @@ Se escriben acá para que no se descubran en producción. host, porque un icono que el host no resuelve se ve como un cuadrito gris. Cada fichero escrito queda en un registro (`exported.json`) para que `--remove` borre **exactamente** lo generado y no por patrón sobre el `~/.local/bin` de alguien. -6. **Steam de punta a punta**, con verificación explícita del bwrap anidado. +6. ⚠️ **PARCIAL 2026-09-03** — hasta donde esta máquina permite, y destapó tres muros. Steam + 1.0.0.87 **instalado de verdad** desde el `multilib` de Arch (las libs de 32 bits que la F2 + daba por «una campaña entera»), su cliente i386 **bajado y desempacado** por el propio + bootstrap de Valve, y el **bwrap anidado verificado explícitamente** (`BWRAP-ANIDADO-OK`, 24 + montajes propios) — que era lo que este paso pedía. Ver D10. + + **Lo que NO se pudo, y por qué:** la máquina no tiene ninguna sesión gráfica (`/dev/dri` sí, + socket Wayland no), así que Steam no dibuja; y el runtime *sniper* sólo se baja al instalar un + juego, lo que exige credenciales. **pressure-vessel con un juego real sigue sin ejercitarse.** 7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias. 8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista. diff --git a/scripts/harkaq/harkaq-exec.c b/scripts/harkaq/harkaq-exec.c index 01b2577f..a114b900 100644 --- a/scripts/harkaq/harkaq-exec.c +++ b/scripts/harkaq/harkaq-exec.c @@ -153,56 +153,109 @@ static const int SYSCALLS_ANIDAMIENTO[] = { // (SDD 25 T17 midió además que la longitud de esta lista NO cuesta nada en ejecución — el bitmap // de acción constante del kernel la hace plana —, así que no hay ningún motivo de rendimiento // para recortarla ni para reordenarla.) -_Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) / - sizeof(SYSCALLS_DENEGADAS[0]) <= 250, +_Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) * 2 / + sizeof(SYSCALLS_DENEGADAS[0]) <= 240, "la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro"); +// ── i386: por qué hay una SEGUNDA tabla de números ────────────────────────────────────────────── +// +// El filtro de abajo comprueba la arquitectura y MATA lo que no sea x86_64. Para un build es +// correcto y es la defensa clásica: los números de syscall son POR ARCH, y sin el check un binario +// i386 pediría otra syscall con el mismo número. Para una instancia ajena es FATAL, y se midió: +// el cliente de Steam es un ELF i386, y moría con **SIGSYS** (`exit code 159`) antes de imprimir +// una línea. Un `ldd` que devuelve 159 no se parece en nada a «tu jaula mata los 32 bits». +// +// La salida NO es aflojar el check —eso reabre el agujero— sino darle a i386 su propia tabla y +// aplicarle la MISMA política. Los números van literales porque compilando para x86_64 no hay +// `__NR_*` de i386; son ABI estable, Linux no los renumera nunca. +#define I386_ARCH_TABLA_BASE \ + 26, /* ptrace */ \ + 347, 348, /* process_vm_readv, process_vm_writev */ \ + 357, /* bpf */ \ + 374, /* userfaultfd */ \ + 288, 286, 287, /* keyctl, add_key, request_key */ \ + 128, 350, 129, /* init_module, finit_module, delete_module */ \ + 283, /* kexec_load — `kexec_file_load` NO EXISTE en i386: poner su número de x86_64 (320) + habría denegado `utimensat`, que en i386 tiene ESE número y la usa cualquier cosa + que toque una marca de tiempo. Verificado contra + /usr/include/asm/unistd_32.h, los 25 números uno por uno; éste era el único mal. */ \ + 336, /* perf_event_open */ \ + 425, 426, 427 /* io_uring_{setup,enter,register} */ +#define I386_ARCH_TABLA_ANIDAMIENTO \ + 21, 52, /* mount, umount2 */ \ + 428, 429, 430, 431, 432, /* open_tree, move_mount, fsopen, fsconfig, fsmount */ \ + 346 /* setns */ +static const int SYSCALLS_DENEGADAS_I386[] = {I386_ARCH_TABLA_BASE}; +static const int SYSCALLS_ANIDAMIENTO_I386[] = {I386_ARCH_TABLA_ANIDAMIENTO}; + // Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó. static int poner_seccomp(int permitir_anidamiento) { - const int n_base = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0])); - const int n_anid = permitir_anidamiento - ? 0 - : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0])); - const int n = n_base + n_anid; - int *nums = calloc(n ? n : 1, sizeof(*nums)); - if (!nums) return -1; - for (int i = 0; i < n_base; i++) nums[i] = SYSCALLS_DENEGADAS[i]; - for (int i = 0; i < n_anid; i++) nums[n_base + i] = SYSCALLS_ANIDAMIENTO[i]; - // 2 (carga arch + salto) + 1 (carga nr) + n (comparaciones) + 3 (los tres RET). - struct sock_filter *f = calloc(n + 6, sizeof(*f)); - if (!f) { free(nums); return -1; } + const int n64b = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0])); + const int n64a = permitir_anidamiento + ? 0 + : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0])); + const int n32b = (int)(sizeof(SYSCALLS_DENEGADAS_I386) / sizeof(SYSCALLS_DENEGADAS_I386[0])); + const int n32a = + permitir_anidamiento + ? 0 + : (int)(sizeof(SYSCALLS_ANIDAMIENTO_I386) / sizeof(SYSCALLS_ANIDAMIENTO_I386[0])); + const int n64 = n64b + n64a, n32 = n32b + n32a; + + int *a64 = calloc(n64 ? n64 : 1, sizeof(int)); + int *a32 = calloc(n32 ? n32 : 1, sizeof(int)); + if (!a64 || !a32) { free(a64); free(a32); return -1; } + for (int i = 0; i < n64b; i++) a64[i] = SYSCALLS_DENEGADAS[i]; + for (int i = 0; i < n64a; i++) a64[n64b + i] = SYSCALLS_ANIDAMIENTO[i]; + for (int i = 0; i < n32b; i++) a32[i] = SYSCALLS_DENEGADAS_I386[i]; + for (int i = 0; i < n32a; i++) a32[n32b + i] = SYSCALLS_ANIDAMIENTO_I386[i]; + + // Disposición: [arch?x86_64] → tabla 64 → ALLOW ; si no, [arch?i386] → tabla 32 → ALLOW ; + // si no, KILL. El desplazamiento de un salto BPF clásico es __u8, así que todo esto tiene que + // caber en 255 instrucciones — de ahí el _Static_assert de arriba, que ahora suma las cuatro + // tablas. + const int len = 2 + 1 + n64 + 1 + 2 + 1 + n32 + 1 + 2; + struct sock_filter *f = calloc(len, sizeof(*f)); + if (!f) { free(a64); free(a32); return -1; } + + const int I_I386 = 2 + 1 + n64 + 1; // donde arranca la rama de 32 bits + const int I_ERRNO = len - 2; + const int I_KILL = len - 1; int k = 0; - const int I_ALLOW = 3 + n, I_ERRNO = 4 + n, I_KILL = 5 + n; - // Comprobar la arquitectura ANTES de mirar el número: los números de syscall son POR ARCH, y - // sin este check un binario i386 podría pedir otra syscall con el mismo número y colarse. Es - // el error clásico de los filtros seccomp escritos a mano. f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); - // El desplazamiento se cuenta desde ESTA instrucción, así que hace falta su índice. Fijarlo en - // una variable y no escribir `f[k++] = ... (I_KILL - k - 1 + 1)`: eso lee y modifica `k` en la - // misma expresión, sin punto de secuencia — UB en C11. Andaba (gcc y clang leen `k` después del - // incremento, y ese `+1` era justo la compensación), pero andaba apoyado en UB. Comprobado que - // el código generado no cambia, a `-O1` y `-O2`. Ver SDD 25 T17. - const int I_ARCH = k++; - f[I_ARCH] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, - I_KILL - I_ARCH - 1); + const int I_ARCH64 = k++; + f[I_ARCH64] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, + I_I386 - I_ARCH64 - 1); f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - for (int i = 0; i < n; i++, k++) - f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - (unsigned)nums[i], I_ERRNO - k - 1, 0); + for (int i = 0; i < n64; i++, k++) + f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a64[i], + I_ERRNO - k - 1, 0); + f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + + // Rama i386. Hay que RECARGAR `arch`: el acumulador quedó con el número de syscall. + f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, arch)); + const int I_ARCH32 = k++; + f[I_ARCH32] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_I386, 0, + I_KILL - I_ARCH32 - 1); + f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, nr)); + for (int i = 0; i < n32; i++, k++) + f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a32[i], + I_ERRNO - k - 1, 0); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); - (void)I_ALLOW; struct sock_fprog prog = {.len = (unsigned short)k, .filter = f}; // Requiere no_new_privs, que ya está puesto antes de llamar acá. int r = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog); free(f); - free(nums); + free(a64); + free(a32); return r; }