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.
|
- `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
|
||||||
|
|
||||||
|
|
|
||||||
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 |
|
| `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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
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_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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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=[]))}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue