From 893409b549761c7b165abbd4c9fcd441dbaf4f73 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 13 Jun 2026 03:55:09 -0700 Subject: [PATCH] Add memory obfuscation and ps-hidden process detection --- CLAUDE.md | 15 +-- README.md | 10 +- config/enodia-sentinel.toml | 17 ++- docs/COMMAND_REFERENCE.md | 2 + docs/OPERATIONS.md | 6 +- docs/ROADMAP.md | 3 + docs/RUNBOOKS.md | 42 +++++++- docs/SPECIFICATION.md | 3 +- docs/THREAT_MODEL.md | 3 +- enodia_sentinel/cli.py | 4 +- enodia_sentinel/config.py | 11 ++ enodia_sentinel/detectors/__init__.py | 2 + .../detectors/memory_obfuscation.py | 100 ++++++++++++++++++ enodia_sentinel/rootcheck.py | 47 +++++++- enodia_sentinel/system.py | 32 ++++++ tests/test_detectors.py | 60 ++++++++++- tests/test_rootcheck.py | 22 +++- 17 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 enodia_sentinel/detectors/memory_obfuscation.py diff --git a/CLAUDE.md b/CLAUDE.md index 9e9e68b..3c1ae27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,9 +54,10 @@ Start with: - `respond.py` builds read-only response plans from incident evidence. - `web.py` serves the HTTPS-only management console and JSON APIs. - `rootcheck.py` performs anti-rootkit cross-view checks: - hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, - raw ICMP/SCTP-style channels, promiscuous interfaces, known LKM rootkit module - names, and kernel/module taint. + hidden processes, processes hidden from `ps`, hidden modules, hidden + TCP/UDP/raw/special-protocol sockets, raw ICMP/SCTP-style channels, + promiscuous interfaces, known LKM rootkit module names, and kernel/module + taint. - `posture.py` performs advisory host hygiene checks. ## Current Threat Mapping @@ -66,11 +67,13 @@ Recent local sample families used for defensive coverage: - Gonzalo-style implant/rootkit behavior: direct input-event keylogging, credential harvesting, LD_PRELOAD/tool tampering, persistence writes, raw ICMP and SCTP-style knock/listener paths, deleted/fileless execution, and hidden - sockets/modules. + sockets/modules, plus heap/string obfuscation and mapped process-hiding + libraries. - Peopleswar-style C2 behavior: TLS C2 listener/check-in traffic, command queue responses, and API/listener ports. Sentinel should prefer behavior coverage - (`new_listener`, `egress`, `stealth_network`, rootcheck, credential/input - detectors) over brittle sample-name matching unless adding explicit IOCs. + (`new_listener`, `egress`, `stealth_network`, `memory_obfuscation`, rootcheck, + credential/input detectors) over brittle sample-name matching unless adding + explicit IOCs. ## Development Commands diff --git a/README.md b/README.md index 296a30b..77a2b5f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ EDRs are built on: | `input_snooper` | A non-allowlisted process holding `/dev/input`, `/dev/uinput`, or HID raw devices open | Keyloggers have to read keystroke/event devices somewhere; expected compositors/remappers are tunable | | `credential_access` | A non-allowlisted process with shadow files, private SSH keys, browser stores, or secret profiles open | Credential harvesters have to open the material they steal; legitimate auth/keyring/browser readers are tunable | | `stealth_network` | Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable | +| `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, or mapped process-hiding libraries | Encrypted/packed payloads still need executable memory after decrypting; hide libraries must be mapped to hook tools | | `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere | | `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | @@ -223,7 +224,7 @@ enodia-sentinel.service`. Every key is optional. Highlights: |---|---|---| | `sample_interval` | 4 | seconds between sweeps | | `cooldown` | 60 | min seconds before re-alerting a signature | -| `detectors` | all 10 | the enabled detector list | +| `detectors` | all 11 | the enabled detector list | | `interpreters` | bash sh … | process names treated as shells | | `egress_allow_cidrs` | [] | trusted public ranges (won't trip egress) | | `input_snooper_allow_comms` | desktop input stack | comm names allowed to hold input devices | @@ -231,6 +232,8 @@ enodia-sentinel.service`. Every key is optional. Highlights: | `credential_access_extra_paths` | [] | extra exact paths or directory prefixes treated as secrets | | `stealth_network_allow_comms` | network managers/sniffers | comm names allowed to own special protocol sockets | | `stealth_network_allow_kinds` | [] | socket families to ignore entirely | +| `memory_obfuscation_allow_comms` | JIT runtimes/browsers | comm names allowed to own JIT-like executable anonymous mappings | +| `memory_obfuscation_allow_paths` | [] | mapped path prefixes allowed for suspicious map shapes | | `suid_hot_dirs` | /tmp … | dirs where a SUID binary is CRITICAL | | `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) | | `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | @@ -372,6 +375,7 @@ compare the answers.** A discrepancy is the hiding artifact. | Cross-check | Hidden thing it surfaces | `sid` | |---|---|---| | `kill(pid, 0)` for every PID vs the `/proc` listing | a process the kernel schedules but `/proc` omits | 100022 | +| `/proc` process listing vs `ps -e` | a process visible to the kernel but hidden from normal process tools | 100038 | | `/sys/module` (initstate=live) vs `/proc/modules` | a loaded LKM hidden from the module list | 100023 | | `/proc/net/tcp` vs `ss` | a listening port a hooked `ss` won't report | 100024 | | `/sys/class/net/*/flags` | an interface in promiscuous mode (a sniffer) | 100025 | @@ -509,7 +513,9 @@ v0.8-dev — expands security monitoring for Gonzalo/Peopleswar-style samples: catches live credential harvesting against shadow files, SSH keys, browser stores, and secret profiles, `stealth_network` watches SCTP/DCCP/raw/packet and other special protocol families, and rootcheck now covers raw ICMP plus hidden -SCTP/DCCP/packet-family sockets. +SCTP/DCCP/packet-family sockets. Process-hiding coverage now includes `/proc` +vs `ps` cross-view checks and memory-map scanning for mapped hide libraries, +RWX/executable anonymous memory, and executable memfd/deleted mappings. v0.7 — closes the tamper-evidence loop with the **independent anchor**: signed-package verification (compares on-disk files to the `.MTREE` in the signed diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 8e425af..34d93c8 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -15,6 +15,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca detectors = [ "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", "credential_access", "stealth_network", + "memory_obfuscation", "new_listener", "new_suid", "persistence", "egress", ] @@ -80,6 +81,15 @@ stealth_network_allow_comms = [ # raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock. stealth_network_allow_kinds = [] +# Expected owners of JIT/executable anonymous mappings. +memory_obfuscation_allow_comms = [ + "java", "node", "firefox", "chromium", "chrome", "google-chrome", + "brave", "brave-browser", "WebKitWebProcess", "dotnet", "qemu-system-x86", + "qemu-system-x86_64", "wine", "wasmtime", +] +# Prefixes for mapped paths allowed to trip otherwise suspicious map shapes. +memory_obfuscation_allow_paths = [] + # --- file integrity monitoring (FIM) ------------------------------------ # SHA-256 baseline of security-critical files the package manager doesn't track # (configs, /usr/local, systemd units, keys). A pacman PostTransaction hook runs @@ -112,9 +122,10 @@ pkgdb_pkgverify_sample = 40 # packages verified per pass (rotates) # --- anti-rootkit cross-view -------------------------------------------- # Ask the same question two ways and diff the answers: kill(0) vs /proc for # hidden processes, /sys/module vs /proc/modules for hidden LKMs, /proc/net/tcp -# vs ss for hidden TCP/UDP/raw/special-protocol sockets, interface flags for -# promiscuous sniffing, known rootkit module names, raw ICMP/SCTP-style -# channels, per-module taint, and global kernel taint. +# vs ps for userland process hiding, /proc/net vs ss for hidden TCP/UDP/raw/ +# special-protocol sockets, interface flags for promiscuous sniffing, known +# rootkit module names, raw ICMP/SCTP-style channels, per-module taint, and +# global kernel taint. rootcheck_enabled = true rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 77bd489..7496013 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -74,9 +74,11 @@ enodia-sentinel rootcheck Runs anti-rootkit cross-view checks once: - PIDs alive via `kill(pid, 0)` but missing from `/proc`. +- PIDs visible in `/proc` but missing from `ps`. - Live modules in `/sys/module` but missing from `/proc/modules`. - Listening TCP ports in `/proc/net/tcp*` but missing from `ss`. - UDP ports in `/proc/net/udp*` but missing from `ss -u`. +- Raw and special-protocol sockets in `/proc/net` but missing from `ss`. - Network interfaces in promiscuous mode. - Known rootkit module names. - Tainted loaded modules (`/sys/module/*/taint`) and global kernel taint diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f5bc53d..c3b5ba6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -66,9 +66,9 @@ Expected healthy output: acknowledged. - `pkgdb-check`: package DB consistent with anchor. - `pkgdb-verify`: sampled files match signed cache packages. -- `rootcheck`: no hidden processes/modules/ports/raw/special-protocol sockets, - sniffers, known rootkit modules, raw ICMP/SCTP-style channels, or unexplained - kernel/module taint. +- `rootcheck`: no hidden processes, processes hidden from `ps`, hidden + modules/ports/raw/special-protocol sockets, sniffers, known rootkit modules, + raw ICMP/SCTP-style channels, or unexplained kernel/module taint. - `posture check`: no SSH/sudo/PATH/permission/signature hygiene findings, or only ones you have consciously accepted (e.g. password auth on a host that needs it). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3698b76..a3aa048 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -40,6 +40,9 @@ work that is bigger than single alerts. - ✅ Add Gonzalo/Peopleswar-style behavior coverage: input-device keylogging, credential-store/private-key access, SCTP/DCCP/raw/packet-family traffic, and raw ICMP/special-protocol rootcheck detection. +- ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps` + cross-view checks plus memory-map scans for hide libraries, RWX memory, and + executable anonymous/memfd/deleted mappings. Exit criteria: diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index e702aa6..a862a6d 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -176,8 +176,8 @@ the transaction completes; a persistent mismatch is the real finding. `rootkit_known_module` (100028), `rootkit_tainted_module` (100029), `kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), `rootkit_hidden_raw_socket` (100034), `raw_icmp_socket` (100035), -`rootkit_hidden_protocol_socket` (100037), and `new_listener` (100013) for an -unexplained open port. +`rootkit_hidden_protocol_socket` (100037), `rootkit_ps_hidden_process` (100038), +and `new_listener` (100013) for an unexplained open port. These come from cross-view checks: the same question asked two ways, answered differently, because something is hiding. @@ -193,6 +193,9 @@ differently, because something is hiding. - `new_listener`: identify the owning process and whether its binary is package-owned. A hidden port that `rootcheck` sees but `ss` does not is far more serious than a forgotten service. +- `rootkit_ps_hidden_process`: compare `/proc//cmdline`, `/proc//exe`, + and `/proc//maps` against `ps`/`pgrep` output. A process visible in + `/proc` but missing from `ps` strongly suggests userland process-tool hiding. - `rootkit_hidden_udp_port`: check whether a legitimate UDP service owns the port; a port present in `/proc/net/udp*` but absent from `ss -u` suggests tool output may be hooked. @@ -232,7 +235,38 @@ modules you intentionally trust. --- -## Runbook 5 — Credential or input capture +## Runbook 5 — Memory obfuscation or process-hiding library + +**Triggers:** `memory_obfuscation` (100039), `process_hiding_library` (100048) + +These alerts come from `/proc//maps`. They do not read process memory; they +flag map shapes associated with encrypted/packed payloads or userland hiding: +RWX pages, executable anonymous memory, executable `memfd`/deleted mappings, or +libraries with process-hiding names such as `libhide`. + +**Confirm** + +- Open the snapshot and inspect `cmdline`, `exe`, parent process, and captured + memory maps for the PID. +- For `process_hiding_library`, check whether the mapped library is package-owned + and whether it is loaded into tools like `ps`, `pgrep`, `ss`, shells, or admin + utilities. +- For `memory_obfuscation`, distinguish expected JIT runtimes from unknown + implants. Tune known runtimes with `memory_obfuscation_allow_comms`; use + `memory_obfuscation_allow_paths` only for well-understood local runtimes or + instrumentation. + +**Contain** + +- Preserve the executable and `/proc//maps` before killing. If the alert is + for executable `memfd` or deleted mappings, capture memory first if you have + trusted tooling; the on-disk payload may be gone. +- Re-run `rootcheck` after containment to confirm no process-tool hiding or + hidden sockets remain. + +--- + +## Runbook 6 — Credential or input capture **Triggers:** `credential_access` (100033), `input_snooper` (100032) @@ -262,7 +296,7 @@ harvesting, but are intentionally behavior-based. --- -## Runbook 6 — Sensor tampering +## Runbook 7 — Sensor tampering **Triggers:** `fim_modified` on a Sentinel-owned path (self-integrity), a stale or missing heartbeat surfaced by the off-box `watchdog`, or the dashboard going diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 241e542..25edb3f 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -56,6 +56,7 @@ against cached process and socket data: | `input_snooper` | Non-allowlisted process holding keyboard/input/HID devices open. | | `credential_access` | Non-allowlisted process reading credential databases, private keys, browser stores, or secret profiles. | | `stealth_network` | Non-allowlisted raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity. | +| `memory_obfuscation` | Non-allowlisted executable anonymous/memfd/deleted mappings, RWX memory, or mapped process-hiding libraries. | | `new_listener` | Listening socket absent from the startup baseline. | | `new_suid` | New SUID/SGID binary, critical in writable locations. | | `persistence` | Changes to cron, systemd units, SSH keys, shell rc files, and similar persistence locations. | @@ -79,7 +80,7 @@ Sentinel includes several integrity layers: | Package DB anchor | Detects out-of-band edits to `/var/lib/pacman/local`. | | Signed-package anchor | Compares on-disk files to `.MTREE` hashes from cached signed packages. | | Heartbeat | Writes daemon liveness for local dashboard and external watchdog use. | -| Rootcheck | Cross-view checks for hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, promiscuous interfaces, known rootkit modules, raw ICMP/SCTP-style channels, and kernel/module taint. | +| Rootcheck | Cross-view checks for hidden processes, processes hidden from `ps`, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, promiscuous interfaces, known rootkit modules, raw ICMP/SCTP-style channels, and kernel/module taint. | ### Evidence Capture diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index ca6ee05..5d2cd6a 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -28,7 +28,8 @@ Sentinel is designed to help against: | Local privilege escalation | New SUID helper, dropped setuid shell, writable path abuse | Detect new SUID/SGID and critical writable-directory placement. | | Fileless or short-lived execution | Deleted executable, memfd payload, fast `curl|sh` | Detect deleted executables and eBPF exec rules where available. | | Package/file tampering | Trojaned binary, rewritten package DB checksums | Detect FIM drift, package DB tamper, and signed-package mismatches. | -| Common rootkit hiding | LD_PRELOAD tricks, `/proc` hiding, module-list hiding, hidden TCP/UDP/raw/SCTP/packet sockets, tainted modules | Detect LD_PRELOAD, cross-view inconsistencies, known LKM names, raw ICMP/SCTP-style sockets, unusual protocol families, and kernel/module taint. | +| Common rootkit hiding | LD_PRELOAD tricks, process-tool hiding, `/proc` hiding, module-list hiding, hidden TCP/UDP/raw/SCTP/packet sockets, tainted modules | Detect LD_PRELOAD, `/proc` vs `ps` disagreement, mapped hide libraries, cross-view inconsistencies, known LKM names, raw ICMP/SCTP-style sockets, unusual protocol families, and kernel/module taint. | +| Memory-resident payloads | Encrypted heap/code, packed memfd stages, RWX shellcode, deleted mapped payloads | Detect executable anonymous/memfd/deleted mappings and writable+executable pages. | | Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. | ## Trust Boundaries diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 5b4c7f1..738aef4 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -197,8 +197,8 @@ def _cmd_rootcheck(cfg: Config) -> int: from . import rootcheck alerts = list(rootcheck.run(cfg)) if not alerts: - print("Rootcheck: no hidden processes/modules/ports, sniffers, " - "known rootkit modules, or kernel taint found.") + print("Rootcheck: no hidden processes/modules/sockets, process-tool " + "hiding, sniffers, known rootkit modules, or kernel taint found.") return 0 for a in sorted(alerts, key=lambda x: -x.severity): print(f"[{a.severity}] {a.signature:<22} {a.detail}") diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index ee5feac..c397945 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -27,6 +27,7 @@ _DEFAULT_WATCH = ( _ALL_DETECTORS = ( "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", "credential_access", "stealth_network", + "memory_obfuscation", "new_listener", "new_suid", "persistence", "egress", ) @@ -86,6 +87,16 @@ class Config: # it legitimately. Valid observed kinds include raw, sctp, dccp, packet, # mptcp, tipc, xdp, and vsock. stealth_network_allow_kinds: frozenset[str] = frozenset() + # Expected owners of JIT/executable anonymous mappings. Keep this to broad + # runtimes and browsers; process-hiding library names still alert elsewhere. + memory_obfuscation_allow_comms: frozenset[str] = frozenset(( + "java", "node", "firefox", "chromium", "chrome", "google-chrome", + "brave", "brave-browser", "WebKitWebProcess", "dotnet", "qemu-system-x86", + "qemu-system-x86_64", "wine", "wasmtime", + )) + # Prefixes for mapped paths that are allowed to trip otherwise suspicious + # memory-map shapes (for local JIT runtimes or known instrumentation). + memory_obfuscation_allow_paths: tuple[str, ...] = () # file integrity monitoring (FIM) fim_enabled: bool = True diff --git a/enodia_sentinel/detectors/__init__.py b/enodia_sentinel/detectors/__init__.py index 7af30c9..992b6b9 100644 --- a/enodia_sentinel/detectors/__init__.py +++ b/enodia_sentinel/detectors/__init__.py @@ -20,6 +20,7 @@ from . import ( egress, input_snooper, ld_preload, + memory_obfuscation, new_listener, new_suid, persistence, @@ -46,6 +47,7 @@ REGISTRY: tuple[Detector, ...] = ( Detector("input_snooper", input_snooper.detect), Detector("credential_access", credential_access.detect), Detector("stealth_network", stealth_network.detect), + Detector("memory_obfuscation", memory_obfuscation.detect), Detector("egress", egress.detect), Detector("new_listener", new_listener.detect, needs_baseline=True), Detector("persistence", persistence.detect, needs_baseline=True), diff --git a/enodia_sentinel/detectors/memory_obfuscation.py b/enodia_sentinel/detectors/memory_obfuscation.py new file mode 100644 index 0000000..3a43625 --- /dev/null +++ b/enodia_sentinel/detectors/memory_obfuscation.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""memory_obfuscation — memory-map indicators of hiding or encrypted payloads. + +Encrypted/packed implants still need executable memory after decrypting code. +This detector does not read process memory; it only inspects ``/proc//maps`` +for high-signal shapes: executable anonymous mappings, executable memfd/deleted +mappings, RWX pages, and mapped libraries associated with process hiding. +""" +from __future__ import annotations + +from collections.abc import Iterator + +from ..alert import Alert, Severity +from ..config import Config +from ..system import MemoryMap, SystemState + +SID_MEMORY_OBFUSCATION = 100039 +SID_PROCESS_HIDING_LIBRARY = 100048 + +_HIDE_LIBRARY_HINTS = frozenset({ + "libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide", +}) + + +def _maps(proc) -> list[MemoryMap]: + maps = getattr(proc, "memory_maps", []) + return maps() if callable(maps) else maps + + +def _is_anon_exec_path(path: str) -> bool: + if not path: + return True + if path in {"[heap]", "[stack]"}: + return True + return path.startswith("[anon") or path.startswith("[stack:") + + +def _hide_library(path: str) -> bool: + name = path.rsplit("/", 1)[-1].lower() + return any(hint in name for hint in _HIDE_LIBRARY_HINTS) + + +def _allowed_path(path: str, cfg: Config) -> bool: + for prefix in cfg.memory_obfuscation_allow_paths: + if prefix and path.startswith(prefix): + return True + return False + + +def _indicator(mm: MemoryMap, cfg: Config) -> tuple[str, Severity, str]: + path = mm.path + if path and _allowed_path(path, cfg): + return "", Severity.MEDIUM, "" + if path and _hide_library(path): + return "process_hiding_library", Severity.CRITICAL, ( + f"mapped library name suggests process hiding: {path}" + ) + executable = "x" in mm.perms + writable = "w" in mm.perms + if executable and ("memfd:" in path or "(deleted)" in path): + return "memory_obfuscation", Severity.CRITICAL, ( + f"executable transient mapping perms={mm.perms} path={path or ''}" + ) + if executable and writable: + return "memory_obfuscation", Severity.HIGH, ( + f"writable+executable memory mapping perms={mm.perms} path={path or ''}" + ) + if executable and _is_anon_exec_path(path): + return "memory_obfuscation", Severity.HIGH, ( + f"executable anonymous memory mapping perms={mm.perms} path={path or ''}" + ) + return "", Severity.MEDIUM, "" + + +def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: + for proc in state.processes: + comm = proc.comm or "?" + seen: set[str] = set() + for mm in _maps(proc): + signature, severity, detail = _indicator(mm, cfg) + if not signature or signature in seen: + continue + if signature == "memory_obfuscation" and comm in cfg.memory_obfuscation_allow_comms: + continue + seen.add(signature) + sid = (SID_PROCESS_HIDING_LIBRARY if signature == "process_hiding_library" + else SID_MEMORY_OBFUSCATION) + yield Alert( + severity=severity, + signature=signature, + key=f"mem:{signature}:{proc.pid}:{mm.start:x}-{mm.end:x}", + detail=( + f"pid={proc.pid} comm={comm} {detail} " + f"range={mm.start:x}-{mm.end:x}" + ), + pids=(proc.pid,), + sid=sid, + classtype=("process-hiding" if signature == "process_hiding_library" + else "memory-obfuscation"), + ) diff --git a/enodia_sentinel/rootcheck.py b/enodia_sentinel/rootcheck.py index 9c44e61..78e1c7c 100644 --- a/enodia_sentinel/rootcheck.py +++ b/enodia_sentinel/rootcheck.py @@ -35,6 +35,7 @@ SID_HIDDEN_UDP_PORT = 100031 SID_HIDDEN_RAW_SOCKET = 100034 SID_RAW_ICMP_SOCKET = 100035 SID_HIDDEN_PROTOCOL_SOCKET = 100037 +SID_PS_HIDDEN_PROCESS = 100038 IFF_PROMISC = 0x100 @@ -81,6 +82,11 @@ def find_hidden_pids(proc_pids: set[int], alive_pids: set[int]) -> set[int]: return alive_pids - proc_pids +def find_ps_hidden_pids(proc_pids: set[int], ps_seen: set[int]) -> set[int]: + """PIDs visible in /proc but missing from ps output.""" + return proc_pids - ps_seen + + 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 @@ -159,6 +165,29 @@ def alive_pids(cap: int) -> set[int]: return alive +def ps_pids() -> set[int] | None: + try: + res = subprocess.run( + ["ps", "-e", "-o", "pid="], + capture_output=True, text=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + out: set[int] = set() + for line in res.stdout.splitlines(): + try: + out.add(int(line.strip())) + except ValueError: + continue + return out + + +def proc_pid_exists(pid: int) -> bool: + return os.path.exists(f"/proc/{pid}") + + def proc_modules() -> set[str]: out: set[str] = set() try: @@ -393,7 +422,7 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: pass except OSError: continue - if not os.path.exists(f"/proc/{pid}"): + if not proc_pid_exists(pid): confirmed.append(pid) if confirmed: yield Alert( @@ -404,6 +433,22 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: pids=tuple(sorted(confirmed)[:20]), sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process") + tool_seen = ps_pids() + if tool_seen is not None: + missing_from_ps = [] + for pid in sorted(find_ps_hidden_pids(visible, tool_seen)): + if proc_pid_exists(pid): + missing_from_ps.append(pid) + if missing_from_ps: + yield Alert( + severity=Severity.HIGH, signature="rootkit_ps_hidden_process", + key=f"rk:pshidproc:{min(missing_from_ps)}", + detail=("process(es) visible in /proc but missing from ps output " + "(process tool may be hooked): " + + ", ".join(map(str, missing_from_ps[:20]))), + pids=tuple(missing_from_ps[:20]), + sid=SID_PS_HIDDEN_PROCESS, classtype="rootkit-hidden-process") + proc_mods = proc_modules() sys_mods = sys_live_modules() hidden_mods = find_hidden_modules(proc_mods, sys_mods) diff --git a/enodia_sentinel/system.py b/enodia_sentinel/system.py index 5740bb6..3c38a5a 100644 --- a/enodia_sentinel/system.py +++ b/enodia_sentinel/system.py @@ -86,6 +86,10 @@ class Process: continue return out + @cached_property + def memory_maps(self) -> list["MemoryMap"]: + return parse_memory_maps(self._read("maps")) + @cached_property def status(self) -> dict[str, str]: out: dict[str, str] = {} @@ -118,6 +122,34 @@ class Process: return None +@dataclass(frozen=True) +class MemoryMap: + start: int + end: int + perms: str + path: str = "" + + +def parse_memory_maps(text: str) -> list[MemoryMap]: + maps: list[MemoryMap] = [] + for line in text.splitlines(): + parts = line.split(maxsplit=5) + if len(parts) < 5: + continue + addr, perms = parts[0], parts[1] + start_s, sep, end_s = addr.partition("-") + if not sep: + continue + try: + start = int(start_s, 16) + end = int(end_s, 16) + except ValueError: + continue + path = parts[5] if len(parts) == 6 else "" + maps.append(MemoryMap(start=start, end=end, perms=perms, path=path)) + return maps + + @dataclass(frozen=True) class Socket: state: str diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 9be118e..9abce66 100755 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -7,10 +7,10 @@ from enodia_sentinel.alert import Severity from enodia_sentinel.config import Config from enodia_sentinel.detectors import ( credential_access, deleted_exe, egress, input_snooper, ld_preload, - new_listener, new_suid, reverse_shell, + memory_obfuscation, new_listener, new_suid, reverse_shell, stealth_network, ) -from enodia_sentinel.system import Socket, SystemState +from enodia_sentinel.system import MemoryMap, Socket, SystemState, parse_memory_maps @dataclass @@ -26,6 +26,7 @@ class FakeProc: uid: int = 0 cwd: str = "/" fd_targets: dict = field(default_factory=dict) + memory_maps: list = field(default_factory=list) def stdio_socket_inode(self): return self._stdio_inode @@ -158,6 +159,61 @@ class TestStealthNetwork(unittest.TestCase): self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), []) +class TestMemoryObfuscation(unittest.TestCase): + def test_maps_parser_extracts_path(self): + text = "7f00-8000 rwxp 00000000 00:00 0 /memfd:stage (deleted)\n" + maps = parse_memory_maps(text) + self.assertEqual(maps[0].start, 0x7f00) + self.assertEqual(maps[0].end, 0x8000) + self.assertEqual(maps[0].perms, "rwxp") + self.assertEqual(maps[0].path, "/memfd:stage (deleted)") + + def test_executable_memfd_alerts(self): + proc = FakeProc( + pid=350, comm="hoxha", + memory_maps=[MemoryMap(0x1000, 0x2000, "r-xp", "/memfd:stage (deleted)")], + ) + alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "memory_obfuscation") + self.assertEqual(alerts[0].severity, Severity.CRITICAL) + + def test_rwx_anonymous_alerts(self): + proc = FakeProc( + pid=351, comm="packer", + memory_maps=[MemoryMap(0x3000, 0x4000, "rwxp", "")], + ) + alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].severity, Severity.HIGH) + + def test_process_hiding_library_alerts(self): + proc = FakeProc( + pid=352, comm="bash", + memory_maps=[MemoryMap(0x5000, 0x6000, "r-xp", "/usr/lib/libhide.so")], + ) + alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "process_hiding_library") + self.assertEqual(alerts[0].severity, Severity.CRITICAL) + + def test_jit_allowlisted_comm_ignored(self): + proc = FakeProc( + pid=353, comm="node", + memory_maps=[MemoryMap(0x7000, 0x8000, "rwxp", "")], + ) + self.assertEqual(list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())), []) + + def test_hide_library_alerts_even_in_jit_allowlisted_comm(self): + proc = FakeProc( + pid=354, comm="node", + memory_maps=[MemoryMap(0x9000, 0xa000, "r-xp", "/tmp/libhide.so")], + ) + alerts = list(memory_obfuscation.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "process_hiding_library") + + class TestNewSuid(unittest.TestCase): def test_new_in_writable_is_critical(self): state = SystemState( diff --git a/tests/test_rootcheck.py b/tests/test_rootcheck.py index a5b2b68..d57cbc8 100644 --- a/tests/test_rootcheck.py +++ b/tests/test_rootcheck.py @@ -18,6 +18,9 @@ class TestDiffCores(unittest.TestCase): def test_no_hidden_pids_when_views_agree(self): self.assertEqual(rootcheck.find_hidden_pids({1, 2}, {1, 2}), set()) + def test_ps_hidden_pids_are_proc_minus_ps(self): + self.assertEqual(rootcheck.find_ps_hidden_pids({1, 2, 3}, {1, 3}), {2}) + def test_hidden_modules_live_in_sys_but_not_proc(self): self.assertEqual( rootcheck.find_hidden_modules({"ext4", "nf_tables"}, @@ -108,7 +111,7 @@ class TestRunIntegration(unittest.TestCase): for name in ("proc_pids", "alive_pids", "proc_modules", "sys_live_modules", "procnet_listen_ports", "procnet_udp_ports", "procnet_raw_protocols", - "procnet_protocol_kinds", + "procnet_protocol_kinds", "ps_pids", "proc_pid_exists", "promiscuous_interfaces", "module_taints", "kernel_taint"): self._saved[name] = getattr(rootcheck, name) @@ -125,12 +128,15 @@ class TestRunIntegration(unittest.TestCase): rootcheck.procnet_udp_ports = lambda: views.get("procnet_udp_ports", set()) rootcheck.procnet_raw_protocols = lambda: views.get("procnet_raw_protocols", set()) rootcheck.procnet_protocol_kinds = lambda: views.get("procnet_protocol_kinds", set()) + rootcheck.ps_pids = lambda: views.get("ps_pids", set()) + rootcheck.proc_pid_exists = lambda pid: pid in views.get("existing_pids", set()) rootcheck.promiscuous_interfaces = lambda: views.get("promisc", []) rootcheck.module_taints = lambda: views.get("module_taints", {}) rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0) def test_clean_system_yields_nothing(self): self._patch(proc_pids={1, 2}, alive_pids={1, 2}, + ps_pids={1, 2}, existing_pids={1, 2}, proc_modules={"ext4"}, sys_live_modules={"ext4"}) state = SystemState(sockets=[]) self.assertEqual(list(rootcheck.run(self.cfg, state)), []) @@ -138,6 +144,7 @@ class TestRunIntegration(unittest.TestCase): def test_hidden_module_and_port_and_promisc(self): self._patch( proc_pids={1}, alive_pids={1}, + ps_pids={1}, existing_pids={1}, proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}, procnet_ports={22, 31337}, procnet_udp_ports={53, 4444}, procnet_raw_protocols={1, 58}, @@ -167,9 +174,21 @@ class TestRunIntegration(unittest.TestCase): self.assertIn("promiscuous_interface", alerts) self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC) + def test_process_visible_in_proc_but_missing_from_ps_alerts(self): + self._patch( + proc_pids={1, 4242}, alive_pids={1, 4242}, + ps_pids={1}, existing_pids={1, 4242}, + proc_modules={"ext4"}, sys_live_modules={"ext4"}) + alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))} + self.assertIn("rootkit_ps_hidden_process", alerts) + self.assertEqual(alerts["rootkit_ps_hidden_process"].sid, + rootcheck.SID_PS_HIDDEN_PROCESS) + self.assertEqual(alerts["rootkit_ps_hidden_process"].pids, (4242,)) + def test_tainted_module_and_kernel_taint(self): self._patch( proc_pids={1}, alive_pids={1}, + ps_pids={1}, existing_pids={1}, proc_modules={"ext4", "vendor_gpu"}, sys_live_modules={"ext4", "vendor_gpu"}, module_taints={"vendor_gpu": "OE"}, @@ -185,6 +204,7 @@ class TestRunIntegration(unittest.TestCase): self.cfg.rootcheck_module_allow = ("vendor_gpu",) self._patch( proc_pids={1}, alive_pids={1}, + ps_pids={1}, existing_pids={1}, proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"}, module_taints={"vendor_gpu": "OE"}) alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))}