Add memory obfuscation and ps-hidden process detection
This commit is contained in:
parent
cb334c0c94
commit
893409b549
17 changed files with 353 additions and 26 deletions
15
CLAUDE.md
15
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
|
||||
|
||||
|
|
|
|||
10
README.md
10
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<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
|
||||
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/<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)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
100
enodia_sentinel/detectors/memory_obfuscation.py
Normal file
100
enodia_sentinel/detectors/memory_obfuscation.py
Normal 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"),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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=[]))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue