fix(privilegio): el bit de los reemplazos de busybox, y el symlink del piso que los tapaba
El operador preguntó: «está actualizado takana? porque allá yo había eliminado busybox, y en la instalación está así». Lo esta —el hub a 1 commit— y el agujero era otro, en dos mitades. 1. NINGUNA de las recetas que REEMPLAZAN a busybox declaraba su bit. Medido sobre los artefactos del store: los 16 binarios que en cualquier distro son setuid salen todos `-rwxr-xr-x`, y de shadow, util-linux, iputils, linux-pam y uutils ninguna declaraba nada. El encabezado de takana-core/src/setuid.rs los lista uno por uno desde el 22-sep y decia que el hueco era LATENTE «porque el instalador solo pone contrasena de root»; desde que lib/cuenta.sh crea una cuenta de persona dejo de serlo. 8 declaraciones nuevas, y la mas grave es `unix_chkpwd`: es con lo que PAM verifica una contrasena cuando el que pregunta no es root, asi que sin su bit EL GREETER NO PUEDE AUTENTICAR A NADIE — con la contrasena correcta, y diciendo que esta mal. Metadato: los cuatro hashes siguen SELLADOS, cero reforja. 2. Y aunque se declare, EL SYMLINK DEL PISO LO TAPABA. Ningun perfil declara busybox, pero el product-rootfs (artefacto del 22 de agosto) lo trae horneado con ~331 symlinks. El piso entra primero (`cp -al`) y la fusion usa `-n`, asi que `bin/su -> busybox` gana sobre el `bin/su` de shadow. El bit se aplica sobre el arbol del PERFIL —donde ese `bin/su` es el binario real y queda 4755— y la fusion lo descartaba: `su`, `passwd`, `mount` y `umount` terminaban apuntando a un busybox sin bit. El aviso ya existia («418 fichero(s) ya estaban y NO se pisaron») y nadie lo leyo como lo que era. El arreglo libera SOLO las rutas con [[setuid]] declarado, y el criterio es duro: a un symlink no se le puede poner el bit, asi que un symlink ahi es una capacidad muerta por construccion. Los otros ~325 no se tocan — reemplazar un applet por un binario con otras banderas puede romper un script del arranque, y eso es el trabajo de docs/plan-botar-busybox.md, con su propia medicion. Certificado: scripts/probar-fusion-privilegio.sh extrae el bloque REAL del armador (no una copia) y lo corre contra un arbol de juguete: `bin/su` y `usr/bin/passwd` llegan como binarios de shadow 4755, y el brazo de control es `bin/ls` —un applet SIN declaracion— que sigue siendo el symlink. Si se tocara, el arreglo habria dejado de estar acotado. Falta metal: instalar y correr `su`, `passwd` y `ping` como usuario normal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
6 qillqakuna · +159 −0
- recipes/iputils.toml +14 −0
- recipes/linux-pam.toml +14 −0
- recipes/shadow.toml +29 −0
- recipes/util-linux.toml +19 −0
- scripts/instalador-variantes.sh +35 −0
- scripts/probar-fusion-privilegio.sh +48 −0
diff --git a/recipes/iputils.toml b/recipes/iputils.toml
index 9d28db1c..85c6ba87 100644
--- a/recipes/iputils.toml
+++ b/recipes/iputils.toml
@@ -31,3 +31,17 @@ install = "DESTDIR=/out ninja -C output install"
[deps]
build = ["binutils", "libcap", "linux-headers", "meson", "samurai", "python3", "pkgconf"]
+
+# ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, `[[setuid]]`) ─────────────────────────────────────────────
+# Declarado el 2026-10-15, al medir los artefactos del store: los binarios de abajo salen todos
+# `-rwxr-xr-x` y en cualquier otra distro son setuid. El hueco estaba ANOTADO —el encabezado de
+# `crates/takana-core/src/setuid.rs` los lista uno por uno— y era LATENTE mientras el instalador
+# solo pusiera contrasena de root. Desde que `lib/cuenta.sh` crea una cuenta de persona, dejo de
+# ser latente: hay un humano no-root que los choca a todos.
+#
+# Es metadato (fuera de `hash_inputs`), asi que entra en una receta YA SELLADA sin mover un solo
+# `ArtifactHash` — comprobado con `takana hash --check` antes y despues.
+[[setuid]]
+path = "usr/bin/ping"
+mode = "4755"
+why = "abrir el socket ICMP. Sin el bit sale 1 al instante y el mensaje se lee como «no hay red»: el operador probo `ping 8.8.8.8` el 2026-10-14 y concluyo que la maquina estaba sin red cuando el problema era el permiso. ⚠ La alternativa moderna es el sysctl `net.ipv4.ping_group_range`, que no necesita setuid y seria mejor; mientras nadie lo ponga en el arranque, el bit es lo que hace que ping diga la verdad."
diff --git a/recipes/linux-pam.toml b/recipes/linux-pam.toml
index 720dcc54..84095099 100644
--- a/recipes/linux-pam.toml
+++ b/recipes/linux-pam.toml
@@ -23,3 +23,17 @@ install = 'make DESTDIR=/out install'
[deps]
build = ["flex", "pkgconf", "make"]
+
+# ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, `[[setuid]]`) ─────────────────────────────────────────────
+# Declarado el 2026-10-15, al medir los artefactos del store: los binarios de abajo salen todos
+# `-rwxr-xr-x` y en cualquier otra distro son setuid. El hueco estaba ANOTADO —el encabezado de
+# `crates/takana-core/src/setuid.rs` los lista uno por uno— y era LATENTE mientras el instalador
+# solo pusiera contrasena de root. Desde que `lib/cuenta.sh` crea una cuenta de persona, dejo de
+# ser latente: hay un humano no-root que los choca a todos.
+#
+# Es metadato (fuera de `hash_inputs`), asi que entra en una receta YA SELLADA sin mover un solo
+# `ArtifactHash` — comprobado con `takana hash --check` antes y despues.
+[[setuid]]
+path = "sbin/unix_chkpwd"
+mode = "4755"
+why = "es con lo que PAM verifica una contrasena cuando el que pregunta NO es root, y por eso es el mas grave de la lista: sin el bit, `pam_unix` no puede leer /etc/shadow y el greeter de mirada no puede autenticar a NADIE — con la contrasena correcta. El sintoma no menciona el privilegio, dice que la contrasena esta mal. Ver SDD 28 §5.11, que lo anticipo: «fallan a la vez el cambio de contrasena, el mount de usuario, FUSE, ping y —peor— unix_chkpwd»."
diff --git a/recipes/shadow.toml b/recipes/shadow.toml
index db8987dd..a66bb984 100644
--- a/recipes/shadow.toml
+++ b/recipes/shadow.toml
@@ -92,3 +92,32 @@ install -m644 /dev/null etc/subgid
# linux-pam, libbsd, setcap. Sin fcaps (setcap falla en el sandbox) ⇒ sin libcap.
[deps]
build = ["linux-headers", "make"]
+
+# ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, `[[setuid]]`) ─────────────────────────────────────────────
+# Declarado el 2026-10-15, al medir los artefactos del store: los binarios de abajo salen todos
+# `-rwxr-xr-x` y en cualquier otra distro son setuid. El hueco estaba ANOTADO —el encabezado de
+# `crates/takana-core/src/setuid.rs` los lista uno por uno— y era LATENTE mientras el instalador
+# solo pusiera contrasena de root. Desde que `lib/cuenta.sh` crea una cuenta de persona, dejo de
+# ser latente: hay un humano no-root que los choca a todos.
+#
+# Es metadato (fuera de `hash_inputs`), asi que entra en una receta YA SELLADA sin mover un solo
+# `ArtifactHash` — comprobado con `takana hash --check` antes y despues.
+[[setuid]]
+path = "bin/su"
+mode = "4755"
+why = "cambiar de usuario desde una sesion que no es root. Sin el bit contesta «su: must be suid to work properly» (busybox) o «Authentication failure» (shadow), y en los dos casos el mensaje manda a buscar la contrasena y no el permiso. Medido en metal el 2026-10-15, cuando era la unica escalada que le quedaba al operador."
+
+[[setuid]]
+path = "usr/bin/passwd"
+mode = "4755"
+why = "que el dueno de la maquina pueda cambiar SU PROPIA contrasena. Importa mas de lo que parece porque root queda cerrado por diseno cuando hay quien escale con doas: sin este bit, la contrasena de la persona solo se puede cambiar con `doas passwd`, y el que la olvida no tiene camino."
+
+[[setuid]]
+path = "usr/bin/newuidmap"
+mode = "4755"
+why = "escribe el uid_map de un user namespace sin privilegio. Lo pide la jaula de compilacion: `scripts/qorpa/qorpa-preflight.sh` ya lo comprueba y avisa «newuidmap SIN setuid y SIN capability — no puede escribir el uid_map», o sea que el hueco estaba detectado y sin cerrar."
+
+[[setuid]]
+path = "usr/bin/newgidmap"
+mode = "4755"
+why = "el par del anterior para el gid_map: los dos o ninguno, porque un namespace con uid mapeado y gid sin mapear falla al primer acceso a un fichero de grupo."
diff --git a/recipes/util-linux.toml b/recipes/util-linux.toml
index b4bbd2fd..7f017f9f 100644
--- a/recipes/util-linux.toml
+++ b/recipes/util-linux.toml
@@ -75,3 +75,22 @@ make DESTDIR="/out" install LDFLAGS="-all-static -no-pie"
# autoconf/automake/po4a/libxcrypt/sqlite/linux-pam/libcap-ng/systemd-minimal-libs.
[deps]
build = ["linux-headers", "make"]
+
+# ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, `[[setuid]]`) ─────────────────────────────────────────────
+# Declarado el 2026-10-15, al medir los artefactos del store: los binarios de abajo salen todos
+# `-rwxr-xr-x` y en cualquier otra distro son setuid. El hueco estaba ANOTADO —el encabezado de
+# `crates/takana-core/src/setuid.rs` los lista uno por uno— y era LATENTE mientras el instalador
+# solo pusiera contrasena de root. Desde que `lib/cuenta.sh` crea una cuenta de persona, dejo de
+# ser latente: hay un humano no-root que los choca a todos.
+#
+# Es metadato (fuera de `hash_inputs`), asi que entra en una receta YA SELLADA sin mover un solo
+# `ArtifactHash` — comprobado con `takana hash --check` antes y despues.
+[[setuid]]
+path = "bin/mount"
+mode = "4755"
+why = "montar sin ser root lo que /etc/fstab marque `user`, que es como una persona usa un pendrive en una imagen que NO trae udisks. El bit no da mas que eso: mount rechaza cualquier punto que fstab no haya autorizado, y esa es la costura que lo hace defendible."
+
+[[setuid]]
+path = "bin/umount"
+mode = "4755"
+why = "el par del anterior: poder montar y no poder desmontar deja el dispositivo tomado hasta reiniciar, y la persona tira del cable — que es la forma de perder datos."
diff --git a/scripts/instalador-variantes.sh b/scripts/instalador-variantes.sh
index 865b5817..9ab885c3 100755
--- a/scripts/instalador-variantes.sh
+++ b/scripts/instalador-variantes.sh
@@ -138,6 +138,41 @@ compone() { # <variante> <medio> → deja el árbol local en $LOCAL/<variante>
_antes=$(find "$C" ! -type d 2>/dev/null | wc -l)
_trae=$(find "$H" ! -type d 2>/dev/null | wc -l)
( cd "$H" && find . -type d -exec mkdir -p "$C/{}" \; ) 2>/dev/null || true
+ # ── UN SYMLINK A BUSYBOX no tapa el binario REAL que necesita el bit ──────────────────────
+ # Ningún perfil declara `busybox`, pero el PISO lo trae horneado con sus ~331 symlinks, y el
+ # piso entra primero: con `-n`, `bin/su → busybox` gana sobre el `bin/su` de shadow. El bit
+ # de privilegio se aplicó sobre el árbol del perfil —donde ESE `bin/su` es el binario real y
+ # quedó 4755— y la fusión lo descartaba, dejando la capacidad muerta. Medido el 2026-10-15:
+ # `su`, `passwd`, `mount` y `umount` eran symlinks a un busybox sin bit.
+ #
+ # Se liberan SÓLO las rutas con `[[setuid]]` declarado, y el criterio es duro: **a un symlink
+ # no se le puede poner el bit**, así que un symlink ahí es una capacidad muerta por
+ # construcción. Los otros ~325 no se tocan — reemplazar un applet por un binario con otras
+ # banderas puede romper un script del arranque, y eso es el trabajo de
+ # `docs/plan-botar-busybox.md`, con su propia medición.
+ _recetas_p=""
+ for _pk in $(python3 scripts/targets.py "$p" 2>/dev/null); do
+ [ -f "recipes/$_pk.toml" ] && _recetas_p="$_recetas_p recipes/$_pk.toml"
+ done
+ _liberados=0
+ if [ -n "$_recetas_p" ] && [ -x ./target/release/takana ]; then
+ for _sp in $(./target/release/takana setuid $_recetas_p --json 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ print("\n".join(x["path"] for x in json.load(sys.stdin)))
+except Exception:
+ pass' 2>/dev/null); do
+ [ -L "$C/$_sp" ] || continue
+ [ -f "$H/$_sp" ] && [ ! -L "$H/$_sp" ] || continue
+ case "$(readlink "$C/$_sp")" in
+ *busybox*)
+ rm -f "$C/$_sp"
+ echo " · $_sp: el symlink a busybox del piso NO tapa al de $p (el bit no va en un symlink)"
+ _liberados=$((_liberados + 1)) ;;
+ esac
+ done
+ fi
+ [ "$_liberados" -gt 0 ] && echo " ✓ $_liberados binario(s) privilegiado(s) liberado(s) del symlink heredado"
( cd "$H" && find . ! -type d -exec cp -a -l -n {} "$C/{}" \; ) 2>/dev/null || true
_despues=$(find "$C" ! -type d 2>/dev/null | wc -l)
_choques=$(( _antes + _trae - _despues ))
diff --git a/scripts/probar-fusion-privilegio.sh b/scripts/probar-fusion-privilegio.sh
new file mode 100755
index 00000000..cfd81bae
--- /dev/null
+++ b/scripts/probar-fusion-privilegio.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+# Prueba del bloque REAL de liberación de symlinks: se extrae del propio instalador-variantes.sh
+# (no una copia) y se corre contra un árbol de juguete. Lo que se mide es si `bin/su` de shadow
+# llega entero al árbol compuesto en vez de quedar tapado por el symlink a busybox del piso.
+set -eu
+cd "$(cd "$(dirname "$0")/.." && pwd)"
+W=/tmp/probar-fusion.$$
+trap 'rm -rf "$W"' EXIT
+C="$W/C"; H="$W/H"
+mkdir -p "$C/bin" "$C/usr/bin" "$H/bin" "$H/usr/bin"
+
+# El piso: busybox y sus symlinks (lo que trae product-rootfs).
+printf 'soy busybox\n' > "$C/bin/busybox"; chmod 755 "$C/bin/busybox"
+ln -s busybox "$C/bin/su"
+ln -s ../../bin/busybox "$C/usr/bin/passwd"
+ln -s busybox "$C/bin/ls" # un applet SIN [[setuid]]: NO debe tocarse
+# El perfil: los binarios de verdad, ya con su bit (como los deja `setuid --apply`).
+printf 'soy el su de shadow\n' > "$H/bin/su"; chmod 4755 "$H/bin/su"
+printf 'soy el passwd de shadow\n' > "$H/usr/bin/passwd"; chmod 4755 "$H/usr/bin/passwd"
+printf 'soy el ls de uutils\n' > "$H/bin/ls"; chmod 755 "$H/bin/ls"
+
+p=base
+# ── el bloque REAL, extraído del script que va a correr ────────────────────────────────────────
+sed -n '/^ _recetas_p=""/,/^ \[ "\$_liberados" -gt 0 \]/p' scripts/instalador-variantes.sh > "$W/bloque.sh"
+[ -s "$W/bloque.sh" ] || { echo "✗ no pude extraer el bloque del armador"; exit 1; }
+echo " (bloque extraído: $(wc -l < "$W/bloque.sh") líneas)"
+. "$W/bloque.sh"
+# ── y la copia que viene después, igual que en el armador ──────────────────────────────────────
+( cd "$H" && find . -type d -exec mkdir -p "$C/{}" \; ) 2>/dev/null || true
+( cd "$H" && find . ! -type d -exec cp -a -l -n {} "$C/{}" \; ) 2>/dev/null || true
+
+echo "══ cómo quedó el árbol compuesto ══"
+fallo=0
+for b in bin/su usr/bin/passwd; do
+ if [ -L "$C/$b" ]; then echo " ✗ $b sigue siendo un symlink a $(readlink "$C/$b")"; fallo=1
+ elif [ "$(stat -c%a "$C/$b")" = 4755 ] && grep -q shadow "$C/$b"; then
+ echo " ✓ $b es el binario de shadow, 4755"
+ else echo " ✗ $b quedó raro: $(stat -c%A "$C/$b")"; fallo=1; fi
+done
+# El brazo de control: un applet sin declaración NO se toca. Si se tocara, este arreglo habría
+# dejado de estar acotado y estaría reemplazando los 331 symlinks sin medición.
+if [ -L "$C/bin/ls" ] && [ "$(readlink "$C/bin/ls")" = busybox ]; then
+ echo " ✓ bin/ls (sin [[setuid]]) sigue siendo el applet: el arreglo está acotado"
+else
+ echo " ✗ bin/ls se tocó, y no tiene declaración de privilegio"; fallo=1
+fi
+[ -f "$C/bin/busybox" ] || { echo " ✗ se perdió el busybox del piso"; fallo=1; }
+[ "$fallo" = 0 ] && echo "✓✓ la fusión deja pasar el binario privilegiado y no toca lo demás" || exit 1