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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue