From 2fa89ce3fa233fdcc4be348d011986f78b03b5de Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 22:56:47 +0000 Subject: [PATCH] =?UTF-8?q?censar:=20tres=20nombres=20equivocados=20que=20?= =?UTF-8?q?llegaban=20al=20plan,=20y=20los=207=20=C2=ABbinario=20desconoci?= =?UTF-8?q?do=C2=BB=20eran=20permisos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/` y el que va de `label` en la tarjeta de arje. · `comm` está capado a 15 caracteres: `willay-crosscheck` llegaba como `willay-crossche` y con ese nombre no casaba contra su declaración ⇒ figuraba como `no-declarado` TENIENDO su tarjeta en la semilla de arje. La línea de comando trae el nombre entero. · `supervise-daemon` no es un servicio, es un ENVOLTORIO. Los cinco de gioser se fundían en una entrada `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon` salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar, entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token antes del `--` suelto (comprobado contra los cinco). · `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura. **Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** Muchos demonios reescriben su `argv[0]` (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de comando no dice cuál es el binario — `/proc//exe` sí, y necesita ser dueño o root. Medido: uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13 root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14 Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso. Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era `/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a la vista, un servicio que acá corre sin privilegios termina de root en el destino. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 36 +++++++++ scripts/mudanza/censar.py | 148 +++++++++++++++++++++++++++++++++---- scripts/mudanza/planear.py | 9 ++- 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 3ef26526..2e9186a7 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -313,6 +313,42 @@ porque `hostname` dice `momento`: declarado-muerto 85 fosil-sin-dns 13 ``` +#### Tres nombres equivocados, y los tres llegaban al plan + +El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de +clasificación —no cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/` y el +que va de `label` en la tarjeta de arje. + +- **`comm` está capado a 15 caracteres.** `willay-crosscheck` llegaba como `willay-crossche`, y con + ese nombre no casaba contra su declaración ⇒ figuraba como **`no-declarado`** teniendo su tarjeta + en la semilla de arje. La línea de comando trae el nombre entero. +- **`supervise-daemon` no es un servicio, es un envoltorio.** Los cinco de gioser se fundían en UNA + entrada llamada `supervise-daemo`; y del otro lado, `dbus`, `metalog`, `dhcpcd`, `squid` y + `shuma-daemon` salían como **`declarado-muerto` estando vivos** — el mismo agujero que el censo + existe para tapar, entrando por la otra puerta. El nombre real es su `argv[1]`; el **comando** real + es el último token antes del `--` suelto. +- **`head -15` con `ppid==1`** era un resto de tubería reparentado, contado como servicio + `no-declarado`. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura. + +#### «No se pudo leer su binario» eran dos cosas distintas + +7 de 38 servicios salían `desconocido`. La causa no era una: muchos demonios **reescriben su +`argv[0]`** (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de +comando no dice cuál es el binario — pero `/proc//exe` sí, y necesita ser dueño o root. Con el +censo corriendo como root, los 7 se resuelven: + +``` + uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13 + root : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14 +``` + +Así que el censo ahora **dice cuál de las dos cosas pasó**: «repetilo con sudo» y «averigualo a mano» +son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso. + +Y desenvolver al supervisor destapó un dato que se estaba perdiendo: `shuma-daemon` corre con +`--user sergio`. La tarjeta de arje **no tiene campo de usuario**, así que sin eso a la vista un +servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión. + #### El HTTP toca al origen; el DNS no Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py index 1ced5c69..b9f7eb7e 100755 --- a/scripts/mudanza/censar.py +++ b/scripts/mudanza/censar.py @@ -94,13 +94,24 @@ def invocaciones(t, pids): # Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron. cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null") cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null") - cmd, cwd = cmd.strip(), cwd.strip() - if cmd.startswith("(sonda falló"): - cmd = "" - if cwd.startswith("(sonda falló"): - cwd = "" - if cmd or cwd: - out[nombre] = {"cmdline": cmd, "cwd": cwd} + # ⚠ Tercera sonda, y también separada. Muchos demonios REESCRIBEN su `argv[0]` para decir en + # qué andan (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la + # línea de comando NO dice cuál es el binario. `/proc//exe` sí — pero necesita permiso + # de ptrace, o sea ser el dueño o root. Medido en gioser con uid 1001: 7 de 38 servicios + # quedaban en «no se pudo leer su binario» por esto, y no por ser realmente desconocidos. + exe, _ = t.run(f"readlink /proc/{pid}/exe 2>/dev/null") + cmd, cwd, exe = cmd.strip(), cwd.strip(), exe.strip() + for v in ("cmd", "cwd", "exe"): + if locals()[v].startswith("(sonda falló"): + cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd), + ("" if v == "exe" else exe)) + sup = SUPERVISADOS.get(pid) + if sup: + cmd, exe = sup["cmdline"], sup["exec"] + if cmd or cwd or exe: + out[nombre] = {"cmdline": cmd, "cwd": cwd, + "exe": exe if exe.startswith("/") else "", + "usuario": (sup or {}).get("usuario", "")} return out @@ -136,14 +147,98 @@ def duenos(t, ejecutables): return res +# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos, +# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco +# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del +# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca. +SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"} +_COMM_SUP = {s[:15] for s in SUPERVISORES} + +# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer +# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al +# envoltorio, no al servicio. +SUPERVISADOS = {} + + +def _mandado_por_supervisor(args): + """El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre. + + El formato es `supervise-daemon --start [opciones] -- [argumentos]`, así + que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser + (dbus, dhcpcd, metalog, squid, shuma-daemon). + + ⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje + hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía + `/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`. + + ⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver + sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el + origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría.""" + p = args.split() + if "--start" not in p: + return "", "", "" + resto = p[p.index("--start") + 1:] + usuario = "" + for i, x in enumerate(resto): + if x in ("--user", "-u") and i + 1 < len(resto): + usuario = resto[i + 1] + break + corte = resto.index("--") if "--" in resto else len(resto) + izq, der = resto[:corte], resto[corte + 1:] + if not izq or not izq[-1].startswith("/"): + return "", "", usuario + return izq[-1], " ".join([izq[-1]] + der), usuario + + +def _desenvolver(comm, args): + """(nombre_real, args_reales) para un proceso que puede ser un envoltorio.""" + p = args.split() + if not p: + return comm, args + base = os.path.basename(p[0]) + if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}: + return comm, args + if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1: + # `supervise-daemon --start …` · `runsv /etc/service/` + return os.path.basename(p[1].rstrip("/")), args + for i, x in enumerate(p): # start-stop-daemon + if x in ("--name", "-n") and i + 1 < len(p): + return p[i + 1], args + if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p): + return os.path.basename(p[i + 1]), args + return comm, args + + +def _destruncar(comm, args): + """`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como + `willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en + `--in /etc/init.d/` (ruta que no existe) y el que va de `label` en la tarjeta de arje. + La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado.""" + if len(comm) < 15: + return comm + p = args.split() + if p: + base = os.path.basename(p[0]) + if base.startswith(comm): + return base + return comm + + def procesos_vivos(t): """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración.""" - out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'") + out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'") vivos = {} for l in out.splitlines(): - parts = l.split() - if len(parts) == 2 and parts[0].isdigit(): - vivos.setdefault(parts[1], []).append(int(parts[0])) + parts = l.split(None, 3) + if len(parts) < 3 or not parts[0].isdigit(): + continue + pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "") + nombre, args = _desenvolver(comm, args) + nombre = _destruncar(nombre, args) + exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "") + if exe: + SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario} + vivos.setdefault(nombre, []).append(pid) # los kernel threads no son servicios return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq", "migration", "rcu_", "irq/", "idle_"))} @@ -320,6 +415,11 @@ def fuera_de_git(t): NO_SERVICIOS = { "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo", "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl", + # Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como + # servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano + # ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo. + "head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup", + "timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more", } @@ -432,6 +532,10 @@ def emitir_toml(c): L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}") L.append(f"cwd = {toml_escape(s.get('cwd',''))}") L.append(f"exec = {toml_escape(s.get('exec',''))}") + if s.get('usuario'): + L.append(f"usuario = {toml_escape(s['usuario'])}") + if s.get('exec_motivo'): + L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}") L.append(f"paquete = {toml_escape(s.get('paquete',''))}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") @@ -521,12 +625,28 @@ def main(): i = inv.get(e['name'], {}) e['cmdline'] = i.get('cmdline', '') e['cwd'] = i.get('cwd', '') - ejes = [ (e['cmdline'] or '').split(' ')[0] for e in svc if (e.get('cmdline') or '').startswith('/') ] + e['exe_real'] = i.get('exe', '') + if i.get('usuario'): + e['usuario'] = i['usuario'] + # `/proc//exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el + # proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso. + def _exe_de(e): + return e.get('exe_real') or ( + (e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '') + ejes = [x for x in (_exe_de(e) for e in svc) if x] dn = duenos(t, ejes) + raiz = os.geteuid() == 0 if a.local else None for e in svc: - exe = (e.get('cmdline') or '').split(' ')[0] - e['exec'] = exe if exe.startswith('/') else '' + exe = _exe_de(e) + e['exec'] = exe e['paquete'] = dn.get(exe, '') + if not exe and e.get('pids'): + # Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son + # dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso. + e['exec_motivo'] = ("el censo no corrió como root: `/proc//exe` de un proceso de " + "otro usuario no se puede leer. Repetilo con sudo" + if raiz is False else + "el proceso reescribió su argv[0] y no se pudo leer /proc//exe") pts = puertos(t) nombres = dominios(t) sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index 093ea722..856e7f65 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -301,7 +301,8 @@ def origen_binario(e, repo=None): f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que " f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar " f"el origen") - return ("desconocido", "no se pudo leer su binario: hace falta averiguarlo a mano") + motivo = e.get("exec_motivo") if isinstance(e, dict) else "" + return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano") def recomendar(tipo, e, datos_que_mueren=()): @@ -427,6 +428,12 @@ def revisar(censo, interactivo=True): if t == "servicio" and e.get("decision") != "muere" and g != "muere": oc, od = origen_binario(e) print(f" └─ binario: [{oc}] {od[:96]}") + if e.get("usuario") and e["usuario"] != "root": + # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el + # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá + # corre sin privilegios termina de root en el destino y nadie se entera. + print(f" └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino " + f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando") lista, rec, motivo = alternativas(n, _REPO) if lista: op = " ".join(