Add memory obfuscation and ps-hidden process detection

This commit is contained in:
Luna 2026-06-13 03:55:09 -07:00
parent cb334c0c94
commit 893409b549
17 changed files with 353 additions and 26 deletions

View file

@ -54,9 +54,10 @@ Start with:
- `respond.py` builds read-only response plans from incident evidence. - `respond.py` builds read-only response plans from incident evidence.
- `web.py` serves the HTTPS-only management console and JSON APIs. - `web.py` serves the HTTPS-only management console and JSON APIs.
- `rootcheck.py` performs anti-rootkit cross-view checks: - `rootcheck.py` performs anti-rootkit cross-view checks:
hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, hidden processes, processes hidden from `ps`, hidden modules, hidden
raw ICMP/SCTP-style channels, promiscuous interfaces, known LKM rootkit module TCP/UDP/raw/special-protocol sockets, raw ICMP/SCTP-style channels,
names, and kernel/module taint. promiscuous interfaces, known LKM rootkit module names, and kernel/module
taint.
- `posture.py` performs advisory host hygiene checks. - `posture.py` performs advisory host hygiene checks.
## Current Threat Mapping ## Current Threat Mapping
@ -66,11 +67,13 @@ Recent local sample families used for defensive coverage:
- Gonzalo-style implant/rootkit behavior: direct input-event keylogging, - Gonzalo-style implant/rootkit behavior: direct input-event keylogging,
credential harvesting, LD_PRELOAD/tool tampering, persistence writes, raw ICMP credential harvesting, LD_PRELOAD/tool tampering, persistence writes, raw ICMP
and SCTP-style knock/listener paths, deleted/fileless execution, and hidden 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 - Peopleswar-style C2 behavior: TLS C2 listener/check-in traffic, command queue
responses, and API/listener ports. Sentinel should prefer behavior coverage responses, and API/listener ports. Sentinel should prefer behavior coverage
(`new_listener`, `egress`, `stealth_network`, rootcheck, credential/input (`new_listener`, `egress`, `stealth_network`, `memory_obfuscation`, rootcheck,
detectors) over brittle sample-name matching unless adding explicit IOCs. credential/input detectors) over brittle sample-name matching unless adding
explicit IOCs.
## Development Commands ## Development Commands

View file

@ -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 | | `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 | | `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 | | `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_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 | | `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 | | `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 | | `sample_interval` | 4 | seconds between sweeps |
| `cooldown` | 60 | min seconds before re-alerting a signature | | `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 | | `interpreters` | bash sh … | process names treated as shells |
| `egress_allow_cidrs` | [] | trusted public ranges (won't trip egress) | | `egress_allow_cidrs` | [] | trusted public ranges (won't trip egress) |
| `input_snooper_allow_comms` | desktop input stack | comm names allowed to hold input devices | | `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 | | `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_comms` | network managers/sniffers | comm names allowed to own special protocol sockets |
| `stealth_network_allow_kinds` | [] | socket families to ignore entirely | | `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_hot_dirs` | /tmp … | dirs where a SUID binary is CRITICAL |
| `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) | | `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) |
| `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | | `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` | | 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 | | `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 | | `/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 | | `/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 | | `/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 catches live credential harvesting against shadow files, SSH keys, browser
stores, and secret profiles, `stealth_network` watches SCTP/DCCP/raw/packet and stores, and secret profiles, `stealth_network` watches SCTP/DCCP/raw/packet and
other special protocol families, and rootcheck now covers raw ICMP plus hidden 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**: v0.7 — closes the tamper-evidence loop with the **independent anchor**:
signed-package verification (compares on-disk files to the `.MTREE` in the signed signed-package verification (compares on-disk files to the `.MTREE` in the signed

View file

@ -15,6 +15,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca
detectors = [ detectors = [
"reverse_shell", "ld_preload", "deleted_exe", "reverse_shell", "ld_preload", "deleted_exe",
"input_snooper", "credential_access", "stealth_network", "input_snooper", "credential_access", "stealth_network",
"memory_obfuscation",
"new_listener", "new_suid", "persistence", "egress", "new_listener", "new_suid", "persistence", "egress",
] ]
@ -80,6 +81,15 @@ stealth_network_allow_comms = [
# raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock. # raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock.
stealth_network_allow_kinds = [] 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) ------------------------------------ # --- file integrity monitoring (FIM) ------------------------------------
# SHA-256 baseline of security-critical files the package manager doesn't track # SHA-256 baseline of security-critical files the package manager doesn't track
# (configs, /usr/local, systemd units, keys). A pacman PostTransaction hook runs # (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 -------------------------------------------- # --- anti-rootkit cross-view --------------------------------------------
# Ask the same question two ways and diff the answers: kill(0) vs /proc for # 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 # 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 # vs ps for userland process hiding, /proc/net vs ss for hidden TCP/UDP/raw/
# promiscuous sniffing, known rootkit module names, raw ICMP/SCTP-style # special-protocol sockets, interface flags for promiscuous sniffing, known
# channels, per-module taint, and global kernel taint. # rootkit module names, raw ICMP/SCTP-style channels, per-module taint, and
# global kernel taint.
rootcheck_enabled = true rootcheck_enabled = true
rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_interval = 300 # seconds between cross-view sweeps
rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0)

View file

@ -74,9 +74,11 @@ enodia-sentinel rootcheck
Runs anti-rootkit cross-view checks once: Runs anti-rootkit cross-view checks once:
- PIDs alive via `kill(pid, 0)` but missing from `/proc`. - 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`. - Live modules in `/sys/module` but missing from `/proc/modules`.
- Listening TCP ports in `/proc/net/tcp*` but missing from `ss`. - Listening TCP ports in `/proc/net/tcp*` but missing from `ss`.
- UDP ports in `/proc/net/udp*` but missing from `ss -u`. - 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. - Network interfaces in promiscuous mode.
- Known rootkit module names. - Known rootkit module names.
- Tainted loaded modules (`/sys/module/*/taint`) and global kernel taint - Tainted loaded modules (`/sys/module/*/taint`) and global kernel taint

View file

@ -66,9 +66,9 @@ Expected healthy output:
acknowledged. acknowledged.
- `pkgdb-check`: package DB consistent with anchor. - `pkgdb-check`: package DB consistent with anchor.
- `pkgdb-verify`: sampled files match signed cache packages. - `pkgdb-verify`: sampled files match signed cache packages.
- `rootcheck`: no hidden processes/modules/ports/raw/special-protocol sockets, - `rootcheck`: no hidden processes, processes hidden from `ps`, hidden
sniffers, known rootkit modules, raw ICMP/SCTP-style channels, or unexplained modules/ports/raw/special-protocol sockets, sniffers, known rootkit modules,
kernel/module taint. raw ICMP/SCTP-style channels, or unexplained kernel/module taint.
- `posture check`: no SSH/sudo/PATH/permission/signature hygiene findings, or - `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 only ones you have consciously accepted (e.g. password auth on a host that
needs it). needs it).

View file

@ -40,6 +40,9 @@ work that is bigger than single alerts.
- ✅ Add Gonzalo/Peopleswar-style behavior coverage: input-device keylogging, - ✅ Add Gonzalo/Peopleswar-style behavior coverage: input-device keylogging,
credential-store/private-key access, SCTP/DCCP/raw/packet-family traffic, and credential-store/private-key access, SCTP/DCCP/raw/packet-family traffic, and
raw ICMP/special-protocol rootcheck detection. 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: Exit criteria:

View file

@ -176,8 +176,8 @@ the transaction completes; a persistent mismatch is the real finding.
`rootkit_known_module` (100028), `rootkit_tainted_module` (100029), `rootkit_known_module` (100028), `rootkit_tainted_module` (100029),
`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), `kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031),
`rootkit_hidden_raw_socket` (100034), `raw_icmp_socket` (100035), `rootkit_hidden_raw_socket` (100034), `raw_icmp_socket` (100035),
`rootkit_hidden_protocol_socket` (100037), and `new_listener` (100013) for an `rootkit_hidden_protocol_socket` (100037), `rootkit_ps_hidden_process` (100038),
unexplained open port. and `new_listener` (100013) for an unexplained open port.
These come from cross-view checks: the same question asked two ways, answered These come from cross-view checks: the same question asked two ways, answered
differently, because something is hiding. 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 - `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 package-owned. A hidden port that `rootcheck` sees but `ss` does not is far more
serious than a forgotten service. serious than a forgotten service.
- `rootkit_ps_hidden_process`: compare `/proc/<pid>/cmdline`, `/proc/<pid>/exe`,
and `/proc/<pid>/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 - `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 port; a port present in `/proc/net/udp*` but absent from `ss -u` suggests tool
output may be hooked. 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/<pid>/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/<pid>/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) **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 **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 missing heartbeat surfaced by the off-box `watchdog`, or the dashboard going

View file

@ -56,6 +56,7 @@ against cached process and socket data:
| `input_snooper` | Non-allowlisted process holding keyboard/input/HID devices open. | | `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. | | `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. | | `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_listener` | Listening socket absent from the startup baseline. |
| `new_suid` | New SUID/SGID binary, critical in writable locations. | | `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. | | `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`. | | 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. | | 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. | | 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 ### Evidence Capture

View file

@ -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. | | 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. | | 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. | | 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. | | Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. |
## Trust Boundaries ## Trust Boundaries

View file

@ -197,8 +197,8 @@ def _cmd_rootcheck(cfg: Config) -> int:
from . import rootcheck from . import rootcheck
alerts = list(rootcheck.run(cfg)) alerts = list(rootcheck.run(cfg))
if not alerts: if not alerts:
print("Rootcheck: no hidden processes/modules/ports, sniffers, " print("Rootcheck: no hidden processes/modules/sockets, process-tool "
"known rootkit modules, or kernel taint found.") "hiding, sniffers, known rootkit modules, or kernel taint found.")
return 0 return 0
for a in sorted(alerts, key=lambda x: -x.severity): for a in sorted(alerts, key=lambda x: -x.severity):
print(f"[{a.severity}] {a.signature:<22} {a.detail}") print(f"[{a.severity}] {a.signature:<22} {a.detail}")

View file

@ -27,6 +27,7 @@ _DEFAULT_WATCH = (
_ALL_DETECTORS = ( _ALL_DETECTORS = (
"reverse_shell", "ld_preload", "deleted_exe", "reverse_shell", "ld_preload", "deleted_exe",
"input_snooper", "credential_access", "stealth_network", "input_snooper", "credential_access", "stealth_network",
"memory_obfuscation",
"new_listener", "new_suid", "persistence", "egress", "new_listener", "new_suid", "persistence", "egress",
) )
@ -86,6 +87,16 @@ class Config:
# it legitimately. Valid observed kinds include raw, sctp, dccp, packet, # it legitimately. Valid observed kinds include raw, sctp, dccp, packet,
# mptcp, tipc, xdp, and vsock. # mptcp, tipc, xdp, and vsock.
stealth_network_allow_kinds: frozenset[str] = frozenset() 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) # file integrity monitoring (FIM)
fim_enabled: bool = True fim_enabled: bool = True

View file

@ -20,6 +20,7 @@ from . import (
egress, egress,
input_snooper, input_snooper,
ld_preload, ld_preload,
memory_obfuscation,
new_listener, new_listener,
new_suid, new_suid,
persistence, persistence,
@ -46,6 +47,7 @@ REGISTRY: tuple[Detector, ...] = (
Detector("input_snooper", input_snooper.detect), Detector("input_snooper", input_snooper.detect),
Detector("credential_access", credential_access.detect), Detector("credential_access", credential_access.detect),
Detector("stealth_network", stealth_network.detect), Detector("stealth_network", stealth_network.detect),
Detector("memory_obfuscation", memory_obfuscation.detect),
Detector("egress", egress.detect), Detector("egress", egress.detect),
Detector("new_listener", new_listener.detect, needs_baseline=True), Detector("new_listener", new_listener.detect, needs_baseline=True),
Detector("persistence", persistence.detect, needs_baseline=True), Detector("persistence", persistence.detect, needs_baseline=True),

View file

@ -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/<pid>/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 '<anonymous>'}"
)
if executable and writable:
return "memory_obfuscation", Severity.HIGH, (
f"writable+executable memory mapping perms={mm.perms} path={path or '<anonymous>'}"
)
if executable and _is_anon_exec_path(path):
return "memory_obfuscation", Severity.HIGH, (
f"executable anonymous memory mapping perms={mm.perms} path={path or '<anonymous>'}"
)
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"),
)

View file

@ -35,6 +35,7 @@ SID_HIDDEN_UDP_PORT = 100031
SID_HIDDEN_RAW_SOCKET = 100034 SID_HIDDEN_RAW_SOCKET = 100034
SID_RAW_ICMP_SOCKET = 100035 SID_RAW_ICMP_SOCKET = 100035
SID_HIDDEN_PROTOCOL_SOCKET = 100037 SID_HIDDEN_PROTOCOL_SOCKET = 100037
SID_PS_HIDDEN_PROCESS = 100038
IFF_PROMISC = 0x100 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 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]: def find_hidden_modules(proc_modules: set[str], sys_live: set[str]) -> set[str]:
"""Modules live in /sys/module but absent from /proc/modules.""" """Modules live in /sys/module but absent from /proc/modules."""
return sys_live - proc_modules return sys_live - proc_modules
@ -159,6 +165,29 @@ def alive_pids(cap: int) -> set[int]:
return alive 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]: def proc_modules() -> set[str]:
out: set[str] = set() out: set[str] = set()
try: try:
@ -393,7 +422,7 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
pass pass
except OSError: except OSError:
continue continue
if not os.path.exists(f"/proc/{pid}"): if not proc_pid_exists(pid):
confirmed.append(pid) confirmed.append(pid)
if confirmed: if confirmed:
yield Alert( yield Alert(
@ -404,6 +433,22 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
pids=tuple(sorted(confirmed)[:20]), pids=tuple(sorted(confirmed)[:20]),
sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process") 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() proc_mods = proc_modules()
sys_mods = sys_live_modules() sys_mods = sys_live_modules()
hidden_mods = find_hidden_modules(proc_mods, sys_mods) hidden_mods = find_hidden_modules(proc_mods, sys_mods)

View file

@ -86,6 +86,10 @@ class Process:
continue continue
return out return out
@cached_property
def memory_maps(self) -> list["MemoryMap"]:
return parse_memory_maps(self._read("maps"))
@cached_property @cached_property
def status(self) -> dict[str, str]: def status(self) -> dict[str, str]:
out: dict[str, str] = {} out: dict[str, str] = {}
@ -118,6 +122,34 @@ class Process:
return None 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) @dataclass(frozen=True)
class Socket: class Socket:
state: str state: str

View file

@ -7,10 +7,10 @@ from enodia_sentinel.alert import Severity
from enodia_sentinel.config import Config from enodia_sentinel.config import Config
from enodia_sentinel.detectors import ( from enodia_sentinel.detectors import (
credential_access, deleted_exe, egress, input_snooper, ld_preload, 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, stealth_network,
) )
from enodia_sentinel.system import Socket, SystemState from enodia_sentinel.system import MemoryMap, Socket, SystemState, parse_memory_maps
@dataclass @dataclass
@ -26,6 +26,7 @@ class FakeProc:
uid: int = 0 uid: int = 0
cwd: str = "/" cwd: str = "/"
fd_targets: dict = field(default_factory=dict) fd_targets: dict = field(default_factory=dict)
memory_maps: list = field(default_factory=list)
def stdio_socket_inode(self): def stdio_socket_inode(self):
return self._stdio_inode return self._stdio_inode
@ -158,6 +159,61 @@ class TestStealthNetwork(unittest.TestCase):
self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), []) 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): class TestNewSuid(unittest.TestCase):
def test_new_in_writable_is_critical(self): def test_new_in_writable_is_critical(self):
state = SystemState( state = SystemState(

View file

@ -18,6 +18,9 @@ class TestDiffCores(unittest.TestCase):
def test_no_hidden_pids_when_views_agree(self): def test_no_hidden_pids_when_views_agree(self):
self.assertEqual(rootcheck.find_hidden_pids({1, 2}, {1, 2}), set()) 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): def test_hidden_modules_live_in_sys_but_not_proc(self):
self.assertEqual( self.assertEqual(
rootcheck.find_hidden_modules({"ext4", "nf_tables"}, rootcheck.find_hidden_modules({"ext4", "nf_tables"},
@ -108,7 +111,7 @@ class TestRunIntegration(unittest.TestCase):
for name in ("proc_pids", "alive_pids", "proc_modules", for name in ("proc_pids", "alive_pids", "proc_modules",
"sys_live_modules", "procnet_listen_ports", "sys_live_modules", "procnet_listen_ports",
"procnet_udp_ports", "procnet_raw_protocols", "procnet_udp_ports", "procnet_raw_protocols",
"procnet_protocol_kinds", "procnet_protocol_kinds", "ps_pids", "proc_pid_exists",
"promiscuous_interfaces", "module_taints", "kernel_taint"): "promiscuous_interfaces", "module_taints", "kernel_taint"):
self._saved[name] = getattr(rootcheck, name) 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_udp_ports = lambda: views.get("procnet_udp_ports", set())
rootcheck.procnet_raw_protocols = lambda: views.get("procnet_raw_protocols", set()) rootcheck.procnet_raw_protocols = lambda: views.get("procnet_raw_protocols", set())
rootcheck.procnet_protocol_kinds = lambda: views.get("procnet_protocol_kinds", 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.promiscuous_interfaces = lambda: views.get("promisc", [])
rootcheck.module_taints = lambda: views.get("module_taints", {}) rootcheck.module_taints = lambda: views.get("module_taints", {})
rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0) rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0)
def test_clean_system_yields_nothing(self): def test_clean_system_yields_nothing(self):
self._patch(proc_pids={1, 2}, alive_pids={1, 2}, 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"}) proc_modules={"ext4"}, sys_live_modules={"ext4"})
state = SystemState(sockets=[]) state = SystemState(sockets=[])
self.assertEqual(list(rootcheck.run(self.cfg, state)), []) 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): def test_hidden_module_and_port_and_promisc(self):
self._patch( self._patch(
proc_pids={1}, alive_pids={1}, proc_pids={1}, alive_pids={1},
ps_pids={1}, existing_pids={1},
proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}, proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"},
procnet_ports={22, 31337}, procnet_udp_ports={53, 4444}, procnet_ports={22, 31337}, procnet_udp_ports={53, 4444},
procnet_raw_protocols={1, 58}, procnet_raw_protocols={1, 58},
@ -167,9 +174,21 @@ class TestRunIntegration(unittest.TestCase):
self.assertIn("promiscuous_interface", alerts) self.assertIn("promiscuous_interface", alerts)
self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC) 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): def test_tainted_module_and_kernel_taint(self):
self._patch( self._patch(
proc_pids={1}, alive_pids={1}, proc_pids={1}, alive_pids={1},
ps_pids={1}, existing_pids={1},
proc_modules={"ext4", "vendor_gpu"}, proc_modules={"ext4", "vendor_gpu"},
sys_live_modules={"ext4", "vendor_gpu"}, sys_live_modules={"ext4", "vendor_gpu"},
module_taints={"vendor_gpu": "OE"}, module_taints={"vendor_gpu": "OE"},
@ -185,6 +204,7 @@ class TestRunIntegration(unittest.TestCase):
self.cfg.rootcheck_module_allow = ("vendor_gpu",) self.cfg.rootcheck_module_allow = ("vendor_gpu",)
self._patch( self._patch(
proc_pids={1}, alive_pids={1}, proc_pids={1}, alive_pids={1},
ps_pids={1}, existing_pids={1},
proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"}, proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"},
module_taints={"vendor_gpu": "OE"}) module_taints={"vendor_gpu": "OE"})
alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))} alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))}