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

@ -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}")

View file

@ -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

View file

@ -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),

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_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)

View file

@ -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