qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga

Resuelve §NO-resuelve 2 del ADR 0015, que era el ticket que más desbloqueaba.
Tres síntomas que parecían distintos —apt sin poder bajar a `_apt`, pacman sin
poder chownear a `alpm`, pressure-vessel sin poder escribir su uid_map— eran la
misma causa: bwrap crea el userns con UN SOLO id.

La cura resultó tener TRES partes, y ninguna sobra:

1. `setcap cap_setuid+ep newuidmap` (+ cap_setgid en newgidmap). shadow.toml los
   instala pero no los provisiona; sin la capability no escriben el mapa.
2. Crear el userns nosotros, mapear el rango de /etc/subuid con newuidmap y
   pasárselo a bwrap con `--userns FD`. bwrap crea el suyo con un solo id A
   PROPÓSITO y nunca llama a newuidmap: el trabajo es de quien lo invoca. El fd
   lo abre la shell (`exec 3<…`), porque un fd sólo cruza el exec si no es
   CLOEXEC y no valía la pena una dep de C para un fcntl.
3. Devolver las capabilities DENTRO del namespace. Ésta no estaba en el plan y
   es la que costó: bwrap las tira todas, y en Linux ser root es tener
   CAP_SETUID, no tener uid 0. Sin ella apt seguía sin poder seteuid(42) — un
   síntoma que parecía de subuid y no lo era. Son seguras por construcción:
   dentro de un userns sólo alcanzan lo que ese namespace posee, o sea nuestros
   propios subuid. CAP_SYS_ADMIN queda fuera y sigue colgando de `nesting`.

MEDIDO después: uid_map de 65537 ids, setgroups: allow, apt instala SIN el
APT::Sandbox::User=root, pacman sincroniza con DownloadUser=alpm INTACTO, y el
userns anidado monta con root=true ⇒ el conflicto 2 de D9 se disuelve solo.

Dos cosas más que salieron por medir, no por pensar:

- El guardián MENTÍA. qorpa-preflight envolvía al hijo en `timeout`, que forkea,
  así que newuidmap apuntaba al PID equivocado y el kernel respondía "Operation
  not permitted" — un falso negativo idéntico a un fallo real. Decía que subuid
  no andaba cuando a mano andaba. Ahora sale exit 0.
- Quitar el impuesto MUEVE el problema: el upper pasa a contener ficheros de los
  subuid (apt deja los suyos con uid 165577) que nuestro uid no puede borrar ⇒
  recreate entra a un userns mapeado para limpiar. Y cuando no hay rango, se
  degrada diciendo la causa exacta en vez de quedar en misterio.

Y un detalle que no es cosmético: `--perms 1777` antes del `--tmpfs /tmp`, o el
_apt al que apt baja no puede escribir su fichero temporal. Un /tmp que no es
1777 no es /tmp.

2 tests nuevos (el rango se lee por usuario; CAP_SYS_ADMIN NO está en las caps
de root, o `nesting` dejaría de ser una decisión). 45/45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
Sergio
2026-09-03 05:27:03 +00:00
co-authored by Claude Opus 5
parent f19b8b3e4a
commit 89da9c822f
7 changed files with 280 additions and 36 deletions
+246 -14
View File
@@ -106,6 +106,10 @@ pub enum QorpaCmd {
/// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
#[arg(long)]
no_jail: bool,
/// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
/// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
#[arg(long)]
single_id: bool,
/// Comando dentro (default: el shell de la imagen). Va después de `--`.
#[arg(last = true)]
cmd: Vec<String>,
@@ -126,8 +130,8 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
create(&resolve_root(root), &id, &base, distro.as_deref())
}
QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
QorpaCmd::Run { id, root, dry_run, no_jail, cmd } => {
run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail)
QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
}
QorpaCmd::Export { .. } => bail!(
"sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
@@ -439,7 +443,27 @@ fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
}
}
}
std::fs::remove_dir_all(p)
match std::fs::remove_dir_all(p) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
// contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
// nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
// creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
// así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
// salir tal cual, que es lo correcto.
let st = Command::new("unshare")
.args(["-U", "--map-auto", "-r", "rm", "-rf"])
.arg(p)
.status()?;
if st.success() && !p.exists() {
Ok(())
} else {
Err(e)
}
}
Err(e) => Err(e),
}
}
enum TreeState { Missing, Empty, Populated }
@@ -558,6 +582,24 @@ fn harkaq_exec() -> PathBuf {
.join("harkaq-exec")
}
/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
///
/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
/// que es exactamente el síntoma que parecía de subuid y no lo era.
///
/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
///
/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
/// `nesting` declarada. Abrir sólo lo declarado, también acá.
const CAPS_ROOT: &[&str] = &[
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
"CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
"CAP_KILL", "CAP_AUDIT_WRITE",
];
/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
@@ -791,7 +833,126 @@ fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
(a, warn)
}
fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool) -> Result<()> {
// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
//
// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
// propio mapa. Tres síntomas distintos, una sola causa.
//
// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
//
// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
struct Rango { inicio: u32, cantidad: u32 }
fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
let txt = std::fs::read_to_string(fichero).ok()?;
txt.lines().find_map(|l| {
let mut c = l.split(':');
let (u, i, n) = (c.next()?, c.next()?, c.next()?);
if u != usuario {
return None;
}
Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
})
}
/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
struct UsernsMapeado {
tenedor: std::process::Child,
ns_path: String,
ids: u32,
}
impl Drop for UsernsMapeado {
fn drop(&mut self) {
// El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
// un proceso colgado por cada instancia arrancada.
let _ = self.tenedor.kill();
let _ = self.tenedor.wait();
}
}
/// 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<UsernsMapeado> {
let uid = std::fs::metadata("/proc/self")
.map(|m| std::os::unix::fs::MetadataExt::uid(&m))
.unwrap_or(1000);
let gid = std::fs::metadata("/proc/self")
.map(|m| std::os::unix::fs::MetadataExt::gid(&m))
.unwrap_or(1000);
let usuario = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_default();
let ru = leer_subid("/etc/subuid", &usuario)
.with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
let rg = leer_subid("/etc/subgid", &usuario)
.with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
std::fs::create_dir_all(&tmp)?;
let listo = tmp.join("listo");
let adelante = tmp.join("adelante");
for f in [&listo, &adelante] {
let _ = std::fs::remove_file(f);
let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
if !st.success() {
bail!("no pude crear el fifo {}", f.display());
}
}
let tenedor = Command::new("unshare")
.arg("-U")
.arg("sh")
.arg("-c")
.arg(format!(
"echo r > {l}; read _ < {a}",
l = listo.display(), a = adelante.display()
))
.spawn()
.context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
let pid = tenedor.id();
let mut guard = UsernsMapeado {
tenedor,
ns_path: format!("/proc/{pid}/ns/user"),
ids: 1 + ru.cantidad,
};
// Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
// el namespace del padre, que es el del host.
std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
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()])
.output()
.with_context(|| format!("no pude ejecutar {bin}"))?;
if !out.status.success() {
bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(())
};
mapear("newuidmap", uid, &ru)?;
mapear("newgidmap", gid, &rg)?;
// Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
// matemos. Lo que importa es que el proceso VIVA, no lo que haga.
let _ = std::fs::remove_file(&listo);
let _ = std::fs::remove_file(&adelante);
guard.ids = 1 + ru.cantidad;
Ok(guard)
}
fn run_instance(
root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool,
) -> 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);
@@ -799,20 +960,45 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
// 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
// que no se declara, no entra (D2, "por defecto NADA").
// El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
// `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
// adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
// `--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<UsernsMapeado> = None;
if !single_id && inst.grants.root {
match userns_con_rango() {
Ok(m) => mapeado = Some(m),
Err(e) => eprintln!(
"⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
),
}
}
let mut args: Vec<String> = vec![
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
"--hostname", "qorpa",
"--clearenv",
"--die-with-parent",
].into_iter().map(String::from).collect();
if mapeado.is_none() {
// bwrap crea el suyo, con un solo id.
args.insert(0, "--unshare-user".into());
if inst.grants.root {
args.extend(["--uid", "0", "--gid", "0"].map(String::from));
}
}
if inst.grants.root {
args.extend(["--uid", "0", "--gid", "0"].map(String::from));
for c in CAPS_ROOT {
args.push("--cap-add".into());
args.push((*c).to_string());
}
if inst.grants.nesting {
eprintln!(
"⚠ `nesting` y `root` juntos NO funcionan hoy: con el uid remapeado a 0, un userns \
anidado no puede escribir su uid_map ⇒ pressure-vessel no arranca. Poné \
`root = false` para juegos, o esperá al mapeo por rango (subuid + --userns FD)."
);
// La que permite montar. Sólo con la concesión declarada.
args.push("--cap-add".into());
args.push("CAP_SYS_ADMIN".into());
}
}
@@ -823,7 +1009,11 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
args.push("/".into());
// /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
// `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
// `_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));
args.extend([
"--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@@ -884,6 +1074,10 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
eprintln!("{w}");
}
if dry_run {
if let Some(m) = &mapeado {
println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
println!("# exec 3<{} ⇒ bwrap --userns 3 …", m.ns_path);
}
// Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
let mut linea = String::from("bwrap");
for a in &args {
@@ -899,9 +1093,25 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
inst.grants.devices.len(), inst.grants.dirs.len(),
if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "" },
if inst.grants.seal_image { "sellada" } else { "escribible" });
eprintln!(" ids mapeados: {}",
match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
let st = Command::new("bwrap").args(&args).status()
.context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
// Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
// `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
// sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
// exactamente lo que pasa.
let st = match &mapeado {
Some(m) => Command::new("sh")
.arg("-c")
.arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
.arg("qorpa")
.arg(&m.ns_path)
.args(&args)
.status()
.context("no pude ejecutar sh/bwrap")?,
None => Command::new("bwrap").args(&args).status()
.context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?,
};
match st.code() {
Some(0) => Ok(()),
Some(c) => std::process::exit(c),
@@ -1225,6 +1435,28 @@ mod tests {
assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
}
#[test]
fn el_rango_de_subuid_se_lee_por_usuario() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("subuid");
std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
assert_eq!((r.inicio, r.cantidad), (165536, 65536));
assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
}
#[test]
fn las_caps_de_root_no_incluyen_la_de_montar() {
// CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
// declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
// comportamiento por defecto — que es justo lo que D7 prohíbe.
assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
// Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
assert!(CAPS_ROOT.contains(&c), "falta {c}");
}
}
#[test]
fn sha_mal_formado_falla_antes_de_tocar_la_red() {
assert!(normalize_sha256("abc").is_err());
+28 -21
View File
@@ -310,12 +310,12 @@ arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de fi
pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no
puso.
**Conflicto 2 — `root` adentro y anidar se pelean.** Medido: con el uid remapeado a 0 (`--uid 0`), un
userns anidado **no puede escribir su `uid_map`**. Sin remapear, anida bien pero el gestor de
paquetes se queja. ⇒ la instancia de juegos y la de gestor de paquetes quieren mapeos **opuestos**,
y el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que
el impuesto de §NO-resuelve 2: **un rango real de subuid** mapeado con `newuidmap` y pasado a bwrap
con `--userns FD`. Ése es hoy el ticket que más desbloquea del ADR entero.
**Conflicto 2 — ~~`root` adentro y anidar se pelean~~, RESUELTO el mismo día.** Con el userns de un
solo id que crea bwrap, `--uid 0` impedía que un userns anidado escribiera su `uid_map`: la
instancia de juegos y la de gestor de paquetes querían mapeos **opuestos**. Con el mapeo por rango
de §NO-resuelve 2 el conflicto desaparece —anida **con `root = true`**, verificado— y `root` deja de
ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos
adentro, no porque haya que elegir entre dos males.
**Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro
de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios,
@@ -334,23 +334,30 @@ Se escriben acá para que no se descubran en producción.
capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario.
Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR.
2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.**
Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y
`setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas:
2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda
por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt`
(no podía bajar a `_apt`), `pacman` (no podía chownear su descarga a `alpm`) y el userns anidado
de pressure-vessel. **Tres síntomas, una causa.**
| gestor | qué pasa | severidad |
|---|---|---|
| `apt` (Ubuntu 24.04) | el método http hace `setgroups` para bajar a `_apt``Operation not permitted` | **para**, pero se saltea con `-o APT::Sandbox::User=root`, y entonces baja 34 MB, instala y corre los triggers de dpkg enteros. Único residuo: `W: chown to root:adm … failed`, un **aviso** |
| `pacman` (Arch) | chownea el directorio de descarga al usuario `alpm``Invalid argument` | **para en duro**; con `DownloadUser` comentado sincroniza, y tras `pacman-key --init && --populate` instala y el binario corre |
La cura entera —`hammer qorpa run` la aplica sola cuando puede— tiene **tres partes, y ninguna
sobra**:
**subuid no es un muro, es un impuesto.** Un solo id alcanza para instalar paquetes reales en
los dos gestores; lo que rompe es el `chown`/`setgroups` a *otro* id, y cada gestor lo hace en un
sitio distinto. Lo que sigue faltando para quitar el impuesto: `newuidmap`/`newgidmap` con
`cap_setuid`/`cap_setgid` (`recipes/shadow.toml` los INSTALA pero no los provisiona — el mismo
hueco espera en la distro propia) **y** una vía para que bwrap use ese mapa: bwrap crea el userns
con un solo id a propósito y no llama a `newuidmap`, así que hay que crear el namespace aparte,
mapear el rango y pasárselo con `--userns FD`. `scripts/qorpa/qorpa-preflight.sh` ya mide las dos
mitades.
| pieza | por qué |
|---|---|
| `setcap cap_setuid+ep newuidmap` (y `cap_setgid` en `newgidmap`) | el binario está en el corpus (`recipes/shadow.toml`) pero **sin provisionar**; sin la capability no puede escribir el mapa |
| crear el userns **nosotros**, mapear el rango de `/etc/subuid` y pasárselo a bwrap con `--userns FD` | bwrap crea el suyo con **un solo id a propósito** y nunca llama a `newuidmap`: el trabajo es de quien lo invoca |
| devolver las **capabilities** dentro del namespace (`--cap-add`) | **bwrap las tira todas**, y en Linux ser root es tener `CAP_SETUID`, no tener uid 0. Sin esto `apt` sigue sin poder `seteuid(42)` — un síntoma que *parecía* de subuid y no lo era |
Con las tres: `uid_map` de **65537 ids**, `setgroups: allow`, `apt` instala **sin** el
`APT::Sandbox::User=root`, `pacman` sincroniza con `DownloadUser = alpm` **intacto**, y el
userns anidado monta **con `root = true`** ⇒ el conflicto 2 de D9 se disuelve. Las capabilities
concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese
namespace posee, o sea nuestros propios subuid. `CAP_SYS_ADMIN` queda fuera y cuelga de `nesting`.
**Y una consecuencia que sólo aparece después:** el `upper/` pasa a contener ficheros de los
subuid (apt deja los suyos con uid 165577) que **nuestro uid normal no puede borrar**
`recreate` y la poda tienen que entrar a un userns mapeado para limpiar
(`unshare -U --map-auto -r rm -rf`). Quitar el impuesto mueve el problema, no lo evapora.
3. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado,
sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3
+6 -1
View File
@@ -149,7 +149,12 @@ if [ -n "$RU" ] && [ -n "$RG" ] && [ "$herramienta_ok" = 1 ]; then
d="$(mktemp -d)"; mkfifo "$d/listo" "$d/adelante"
set -- $RU; ui=$1; uc=$2
set -- $RG; gi=$1; gc=$2
timeout 20 unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" &
# SIN `timeout` acá, y no es un descuido: `timeout` FORKEA, así que `$!` sería SU pid y no el
# del proceso que entró al namespace ⇒ newuidmap apuntaría al proceso equivocado y el kernel
# respondería "Operation not permitted" — un falso negativo que se lee igual que un fallo real.
# (Costó una medición: el guardián decía que subuid no andaba cuando a mano andaba.)
# El corte por tiempo lo ponen los `timeout` de los fifos de abajo.
unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" &
hijo=$!
if timeout 10 head -c 1 "$d/listo" >/dev/null 2>&1; then
err="$( { newuidmap "$hijo" 0 "$(id -u)" 1 1 "$ui" "$uc" && \