Add signed-package verification and anti-rootkit cross-view (v0.7)
Closes the tamper-evidence loop with two trust anchors the attacker can't forge from userland: - pkgdb Layer 2: verify on-disk files against the .MTREE in the *signed* cache package, surviving a rewritten local checksum DB. Rotating-sample cadence keeps it affordable; flags pkg_signature_mismatch (sid 100027) and SigLevel downgrades (sid 100026). Fixes parse_mtree, which required type=file on every line and so matched nothing on real pacman MTREEs (which use a /set type=file default with bare file entries). - rootcheck: anti-rootkit cross-view — hidden processes, modules, ports, and promiscuous interfaces, each caught by diffing two views of the same state (sids 100022-100025). Wired both through config, the daemon (off-loop slow cadence), and CLI (pkgdb-verify, rootcheck). 14 new tests (95 total). Docs + version bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
34bc09041a
commit
1de5e86fed
10 changed files with 766 additions and 5 deletions
209
enodia_sentinel/rootcheck.py
Normal file
209
enodia_sentinel/rootcheck.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Anti-rootkit: detect things that are being hidden from the normal views.
|
||||
|
||||
The technique a rootkit uses to hide is also how you catch it: ask the same
|
||||
question two different ways and compare the answers. A process the ``/proc``
|
||||
listing omits but the scheduler still knows about; a loaded module absent from
|
||||
``/proc/modules`` but live in ``/sys/module``; a listening port in
|
||||
``/proc/net/tcp`` that ``ss`` won't show — each discrepancy is a hiding artifact.
|
||||
|
||||
Honest limits: this runs in user space, so it reliably catches userland
|
||||
(LD_PRELOAD) rootkits and the common ``/proc``-hiding LKMs, but a kernel rootkit
|
||||
that hooks *every* path (the scheduler, /proc, and /sys consistently) can still
|
||||
evade it. It raises the bar and catches the common cases; it is not a guarantee
|
||||
against a bespoke ring-0 implant. Pair it with the off-box dead-man's switch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
|
||||
from .alert import Alert, Severity
|
||||
from .config import Config
|
||||
from .system import SystemState
|
||||
|
||||
SID_HIDDEN_PROC = 100022
|
||||
SID_HIDDEN_MODULE = 100023
|
||||
SID_HIDDEN_PORT = 100024
|
||||
SID_PROMISC = 100025
|
||||
|
||||
IFF_PROMISC = 0x100
|
||||
|
||||
|
||||
# --- pure diff cores (unit-tested without a live system) ------------------
|
||||
|
||||
def find_hidden_pids(proc_pids: set[int], alive_pids: set[int]) -> set[int]:
|
||||
"""PIDs the kernel confirms alive but that are missing from /proc."""
|
||||
return alive_pids - proc_pids
|
||||
|
||||
|
||||
def find_hidden_modules(proc_modules: set[str], sys_live: set[str]) -> set[str]:
|
||||
"""Modules live in /sys/module but absent from /proc/modules."""
|
||||
return sys_live - proc_modules
|
||||
|
||||
|
||||
def find_hidden_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]:
|
||||
"""Listening ports in /proc/net/tcp that ss does not report."""
|
||||
return procnet_ports - ss_ports
|
||||
|
||||
|
||||
# --- system views ---------------------------------------------------------
|
||||
|
||||
def proc_pids() -> set[int]:
|
||||
return {int(n) for n in os.listdir("/proc") if n.isdigit()}
|
||||
|
||||
|
||||
def pid_max(cap: int) -> int:
|
||||
try:
|
||||
return min(int(open("/proc/sys/kernel/pid_max").read().strip()), cap)
|
||||
except (OSError, ValueError):
|
||||
return cap
|
||||
|
||||
|
||||
def alive_pids(cap: int) -> set[int]:
|
||||
"""Brute-force which PIDs exist via kill(pid, 0).
|
||||
|
||||
kill returns success or EPERM for a live PID, ESRCH (ProcessLookupError)
|
||||
for a dead one. Goes through the process table, not the /proc readdir a
|
||||
rootkit may have hooked.
|
||||
"""
|
||||
alive: set[int] = set()
|
||||
for pid in range(1, pid_max(cap) + 1):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
alive.add(pid)
|
||||
except PermissionError:
|
||||
alive.add(pid) # exists, just not ours to signal
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
return alive
|
||||
|
||||
|
||||
def proc_modules() -> set[str]:
|
||||
out: set[str] = set()
|
||||
try:
|
||||
with open("/proc/modules") as fh:
|
||||
for line in fh:
|
||||
out.add(line.split()[0])
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def sys_live_modules() -> set[str]:
|
||||
"""Modules under /sys/module whose initstate is 'live' (i.e. loaded, not
|
||||
built-in — built-ins have no initstate file)."""
|
||||
out: set[str] = set()
|
||||
base = "/sys/module"
|
||||
try:
|
||||
for m in os.listdir(base):
|
||||
try:
|
||||
with open(os.path.join(base, m, "initstate")) as fh:
|
||||
if fh.read().strip() == "live":
|
||||
out.add(m)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _procnet_listen_ports(path: str) -> set[int]:
|
||||
ports: set[int] = set()
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
parts = line.split()
|
||||
if len(parts) < 4 or parts[3] != "0A": # 0A = TCP_LISTEN
|
||||
continue
|
||||
local = parts[1] # HEX_ADDR:HEX_PORT
|
||||
ports.add(int(local.rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
pass
|
||||
return ports
|
||||
|
||||
|
||||
def procnet_listen_ports() -> set[int]:
|
||||
return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6")
|
||||
|
||||
|
||||
def ss_listen_ports(state: SystemState) -> set[int]:
|
||||
ports: set[int] = set()
|
||||
for s in state.listening_sockets():
|
||||
tail = s.local.rsplit(":", 1)
|
||||
if len(tail) == 2 and tail[1].isdigit():
|
||||
ports.add(int(tail[1]))
|
||||
return ports
|
||||
|
||||
|
||||
def promiscuous_interfaces() -> list[str]:
|
||||
out: list[str] = []
|
||||
base = "/sys/class/net"
|
||||
try:
|
||||
for iface in os.listdir(base):
|
||||
try:
|
||||
with open(os.path.join(base, iface, "flags")) as fh:
|
||||
flags = int(fh.read().strip(), 16)
|
||||
if flags & IFF_PROMISC:
|
||||
out.append(iface)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
# --- the check ------------------------------------------------------------
|
||||
|
||||
def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
|
||||
state = state or SystemState()
|
||||
cap = cfg.rootcheck_pid_cap
|
||||
|
||||
# Hidden processes — re-verify each candidate to shake out exit races.
|
||||
visible = proc_pids()
|
||||
candidates = find_hidden_pids(visible, alive_pids(cap))
|
||||
confirmed = []
|
||||
for pid in candidates:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.exists(f"/proc/{pid}"):
|
||||
confirmed.append(pid)
|
||||
if confirmed:
|
||||
yield Alert(
|
||||
severity=Severity.CRITICAL, signature="rootkit_hidden_process",
|
||||
key=f"rk:hidproc:{min(confirmed)}",
|
||||
detail=("process(es) alive but hidden from /proc: "
|
||||
+ ", ".join(map(str, sorted(confirmed)[:20]))),
|
||||
pids=tuple(sorted(confirmed)[:20]),
|
||||
sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process")
|
||||
|
||||
hidden_mods = find_hidden_modules(proc_modules(), sys_live_modules())
|
||||
for m in sorted(hidden_mods):
|
||||
yield Alert(
|
||||
severity=Severity.CRITICAL, signature="rootkit_hidden_module",
|
||||
key=f"rk:hidmod:{m}",
|
||||
detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}",
|
||||
sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module")
|
||||
|
||||
hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state))
|
||||
if hidden_ports:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="rootkit_hidden_port",
|
||||
key=f"rk:hidport:{min(hidden_ports)}",
|
||||
detail=("listening port(s) in /proc/net/tcp not reported by ss "
|
||||
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))),
|
||||
sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port")
|
||||
|
||||
for iface in promiscuous_interfaces():
|
||||
yield Alert(
|
||||
severity=Severity.MEDIUM, signature="promiscuous_interface",
|
||||
key=f"rk:promisc:{iface}",
|
||||
detail=f"interface in promiscuous mode (possible sniffer): {iface}",
|
||||
sid=SID_PROMISC, classtype="network-sniffer")
|
||||
Loading…
Add table
Add a link
Reference in a new issue