Expand monitoring for credential theft and covert protocols
This commit is contained in:
parent
3e5f8fc3f7
commit
cb334c0c94
17 changed files with 675 additions and 25 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -54,10 +54,24 @@ 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 ports, promiscuous
|
||||
interfaces, known LKM rootkit module names, and kernel/module taint.
|
||||
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.
|
||||
- `posture.py` performs advisory host hygiene checks.
|
||||
|
||||
## Current Threat Mapping
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
|
|
@ -100,4 +114,3 @@ When changing behavior, update the relevant set:
|
|||
- `docs/THREAT_MODEL.md` for trust-boundary/security-model changes.
|
||||
- `docs/ROADMAP.md` for planned or completed roadmap movement.
|
||||
- `config/enodia-sentinel.toml` for config knobs.
|
||||
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -56,6 +56,9 @@ EDRs are built on:
|
|||
| `reverse_shell` | An interpreter with a **network socket on fd 0/1/2** | Interactive shells get a pty and daemons get unix sockets — a *network* socket on stdio is `nc -e` / `bash -i >& /dev/tcp/...` |
|
||||
| `ld_preload` | Non-empty `/etc/ld.so.preload`, or `LD_PRELOAD` into a writable dir | Injecting into processes needs the library to exist somewhere |
|
||||
| `deleted_exe` | A process running from a **deleted / `memfd:`** binary | Fileless malware deletes its dropper; the kernel still names the inode `(deleted)` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -220,9 +223,14 @@ 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 7 | the enabled detector list |
|
||||
| `detectors` | all 10 | 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 |
|
||||
| `credential_access_allow_comms` | auth/keyring/browsers | comm names allowed to read credential stores |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -371,6 +379,9 @@ compare the answers.** A discrepancy is the hiding artifact.
|
|||
| `/sys/module/*/taint` | out-of-tree/proprietary/unsigned loaded modules needing review | 100029 |
|
||||
| `/proc/sys/kernel/tainted` | global kernel taint: forced loads/unloads, unsigned modules, warnings | 100030 |
|
||||
| `/proc/net/udp` vs `ss -u` | a UDP socket a hooked `ss` won't report | 100031 |
|
||||
| `/proc/net/raw` vs `ss -w` | a raw socket protocol a hooked `ss` won't report | 100034 |
|
||||
| `/proc/net/raw` ICMP protocols | persistent raw ICMP sockets used by knockers/sniffers | 100035 |
|
||||
| `/proc/net` special families vs `ss` | SCTP/DCCP/packet/TIPC/XDP sockets a hooked `ss` won't report | 100037 |
|
||||
|
||||
```bash
|
||||
enodia-sentinel rootcheck # one-shot cross-view scan
|
||||
|
|
@ -493,11 +504,18 @@ regression suite for both.
|
|||
|
||||
## Project status
|
||||
|
||||
v0.8-dev — expands security monitoring for Gonzalo/Peopleswar-style samples:
|
||||
`input_snooper` catches direct keyboard/HID event access, `credential_access`
|
||||
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.
|
||||
|
||||
v0.7 — closes the tamper-evidence loop with the **independent anchor**:
|
||||
signed-package verification (compares on-disk files to the `.MTREE` in the signed
|
||||
cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade
|
||||
check, and an **anti-rootkit cross-view** layer (hidden processes, modules,
|
||||
TCP/UDP ports, promiscuous interfaces, known LKM rootkit names, and
|
||||
TCP/UDP sockets, promiscuous interfaces, known LKM rootkit names, and
|
||||
kernel/module taint — each caught by asking independent views and diffing the
|
||||
answers).
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca
|
|||
# --- detectors (omit one to disable it) ----------------------------------
|
||||
detectors = [
|
||||
"reverse_shell", "ld_preload", "deleted_exe",
|
||||
"input_snooper", "credential_access", "stealth_network",
|
||||
"new_listener", "new_suid", "persistence", "egress",
|
||||
]
|
||||
|
||||
|
|
@ -55,6 +56,30 @@ watch_persistence = [
|
|||
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
|
||||
]
|
||||
|
||||
# Processes expected to keep keyboard/HID event devices open.
|
||||
input_snooper_allow_comms = [
|
||||
"Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway", "Hyprland",
|
||||
"systemd-logind", "input-remapper", "keyd", "kanata",
|
||||
]
|
||||
|
||||
# Processes expected to read credential stores during normal auth/user activity.
|
||||
credential_access_allow_comms = [
|
||||
"sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd",
|
||||
"gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox", "chromium",
|
||||
"chrome", "google-chrome", "brave", "brave-browser",
|
||||
]
|
||||
# Extra exact paths or directory prefixes treated as credential material.
|
||||
credential_access_extra_paths = []
|
||||
|
||||
# Expected owners of packet/raw/special protocol sockets.
|
||||
stealth_network_allow_comms = [
|
||||
"NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd",
|
||||
"tcpdump", "dumpcap", "wireshark", "suricata", "zeek",
|
||||
]
|
||||
# Socket family names to ignore entirely if they are normal in your environment:
|
||||
# raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock.
|
||||
stealth_network_allow_kinds = []
|
||||
|
||||
# --- 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
|
||||
|
|
@ -87,8 +112,9 @@ 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 listeners, interface flags for promiscuous sniffing,
|
||||
# known rootkit module names, per-module taint, and global kernel taint.
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -66,8 +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, sniffers, known rootkit
|
||||
modules, or unexplained kernel/module taint.
|
||||
- `rootcheck`: no hidden processes/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).
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ work that is bigger than single alerts.
|
|||
expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass.
|
||||
- ✅ Document incident response runbooks for reverse shells, persistence, trojaned
|
||||
binaries, hidden listeners, and sensor tampering ([RUNBOOKS.md](RUNBOOKS.md)).
|
||||
- ✅ 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.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,12 @@ Then pick the runbook below that matches the signature.
|
|||
|
||||
## Runbook 1 — Reverse shell / suspicious egress
|
||||
|
||||
**Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016).
|
||||
**Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016),
|
||||
`stealth_network` (sid 100036).
|
||||
|
||||
An interpreter wired to a socket, or an interpreter holding a connection to a
|
||||
public IP — the classic interactive-C2 and beaconing shapes.
|
||||
An interpreter wired to a socket, an interpreter holding a connection to a
|
||||
public IP, or activity over raw/SCTP/DCCP/packet/XDP/TIPC/vsock families - the
|
||||
classic interactive-C2, beaconing, and covert-channel shapes.
|
||||
|
||||
**Confirm**
|
||||
|
||||
|
|
@ -60,6 +62,11 @@ public IP — the classic interactive-C2 and beaconing shapes.
|
|||
|
||||
- Is the peer expected? Compare against `egress_allow_cidrs` in config. A package
|
||||
manager or browser to a public IP is normal; `bash`/`python`/`nc` is not.
|
||||
- For `stealth_network`, check the socket family and owner. Packet/raw sockets
|
||||
are normal for approved sniffers and some network managers; SCTP/DCCP/TIPC/XDP
|
||||
on a desktop or ordinary server should have a clear service owner. Tune with
|
||||
`stealth_network_allow_comms` or, for intentionally used families,
|
||||
`stealth_network_allow_kinds`.
|
||||
|
||||
**Contain**
|
||||
|
||||
|
|
@ -167,8 +174,10 @@ the transaction completes; a persistent mismatch is the real finding.
|
|||
**Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023),
|
||||
`rootkit_hidden_port` (100024), `promiscuous_interface` (100025),
|
||||
`rootkit_known_module` (100028), `rootkit_tainted_module` (100029),
|
||||
`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), and
|
||||
`new_listener` (100013) for an unexplained open port.
|
||||
`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.
|
||||
|
||||
These come from cross-view checks: the same question asked two ways, answered
|
||||
differently, because something is hiding.
|
||||
|
|
@ -187,6 +196,15 @@ differently, because something is 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.
|
||||
- `rootkit_hidden_raw_socket` or `raw_icmp_socket`: look for authorized ping,
|
||||
monitoring, traceroute, or packet tooling first. A persistent raw ICMP socket
|
||||
with no operator explanation is consistent with ICMP knockers/backdoors and
|
||||
should be investigated with process fd tables, package provenance, and memory
|
||||
capture before killing the process.
|
||||
- `rootkit_hidden_protocol_socket`: inspect SCTP, DCCP, packet, TIPC, or XDP
|
||||
use. If `/proc/net` sees the family but `ss` does not, treat normal socket
|
||||
tooling as suspect and pivot to offline evidence or the dashboard from another
|
||||
host.
|
||||
- A `rootkit_hidden_module` names a live kernel module absent from
|
||||
`/proc/modules` — strong evidence of an LKM rootkit.
|
||||
- A `rootkit_known_module` finding names a module associated with public LKM
|
||||
|
|
@ -214,7 +232,37 @@ modules you intentionally trust.
|
|||
|
||||
---
|
||||
|
||||
## Runbook 5 — Sensor tampering
|
||||
## Runbook 5 — Credential or input capture
|
||||
|
||||
**Triggers:** `credential_access` (100033), `input_snooper` (100032)
|
||||
|
||||
These alerts mean a non-allowlisted process has credential material or keyboard
|
||||
input devices open. They map to Gonzalo-style keylogging and local credential
|
||||
harvesting, but are intentionally behavior-based.
|
||||
|
||||
**Confirm**
|
||||
|
||||
- Open the snapshot for the alert and inspect the captured `cmdline`, `exe`,
|
||||
`cwd`, parent process, and fd target.
|
||||
- For `input_snooper`, confirm whether the process is an expected compositor,
|
||||
display server, input remapper, or accessibility tool. Add stable legitimate
|
||||
process names to `input_snooper_allow_comms`.
|
||||
- For `credential_access`, identify which secret was opened: shadow DB, private
|
||||
SSH key, browser credential store, or NetworkManager secret profile. Add
|
||||
only well-understood auth/keyring/browser readers to
|
||||
`credential_access_allow_comms`.
|
||||
|
||||
**Contain**
|
||||
|
||||
- Preserve evidence first for unknown processes: copy the executable path if it
|
||||
still exists, capture `/proc/<pid>/maps`, and export the incident.
|
||||
- Rotate exposed credentials and SSH keys after containment; assume browser
|
||||
session cookies and saved passwords may be compromised if browser stores were
|
||||
opened by an unknown process.
|
||||
|
||||
---
|
||||
|
||||
## Runbook 6 — 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
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ The long-term product should combine five functions:
|
|||
6. **Bash prototype remains the oracle.** The original shell implementation and
|
||||
red-team harness preserve behavioral compatibility for core signatures.
|
||||
|
||||
## Current Scope: v0.7
|
||||
## Current Scope: v0.8-dev
|
||||
|
||||
### Poll Detectors
|
||||
|
||||
|
|
@ -53,6 +53,9 @@ against cached process and socket data:
|
|||
| `reverse_shell` | Interpreter with a network socket on fd 0/1/2. |
|
||||
| `ld_preload` | Non-empty `/etc/ld.so.preload` or writable-path `LD_PRELOAD`. |
|
||||
| `deleted_exe` | Process executing from a deleted file or `memfd:` image. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -76,7 +79,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 ports, promiscuous interfaces, known rootkit modules, and kernel/module taint. |
|
||||
| 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. |
|
||||
|
||||
### Evidence Capture
|
||||
|
||||
|
|
|
|||
|
|
@ -24,10 +24,11 @@ Sentinel is designed to help against:
|
|||
| Adversary | Examples | Expected Sentinel value |
|
||||
|---|---|---|
|
||||
| Opportunistic remote shell | Webshell, exposed service RCE, stolen SSH key | Detect reverse shell, suspicious egress, new listeners, persistence writes. |
|
||||
| Credential harvesting | Shadow file reads, private SSH key theft, browser credential database access, keylogging | Detect credential file access and non-allowlisted input/HID event readers. |
|
||||
| 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 sockets, tainted modules | Detect LD_PRELOAD, cross-view inconsistencies, known LKM names, and kernel/module taint. |
|
||||
| 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. |
|
||||
| Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. |
|
||||
|
||||
## Trust Boundaries
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ _DEFAULT_WATCH = (
|
|||
|
||||
_ALL_DETECTORS = (
|
||||
"reverse_shell", "ld_preload", "deleted_exe",
|
||||
"input_snooper", "credential_access", "stealth_network",
|
||||
"new_listener", "new_suid", "persistence", "egress",
|
||||
)
|
||||
|
||||
|
|
@ -60,6 +61,31 @@ class Config:
|
|||
"/tmp", "/dev/shm", "/var/tmp", "/run/user",
|
||||
)
|
||||
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
|
||||
# Processes expected to hold keyboard/HID event devices open.
|
||||
input_snooper_allow_comms: frozenset[str] = frozenset((
|
||||
"Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway",
|
||||
"Hyprland", "systemd-logind", "input-remapper", "keyd", "kanata",
|
||||
))
|
||||
# Processes expected to read credential stores as part of normal auth or
|
||||
# user interaction. Malware harvesting those same files under another comm
|
||||
# still alerts.
|
||||
credential_access_allow_comms: frozenset[str] = frozenset((
|
||||
"sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd",
|
||||
"gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox",
|
||||
"chromium", "chrome", "google-chrome", "brave", "brave-browser",
|
||||
))
|
||||
# Extra exact paths or directory prefixes treated as credential material.
|
||||
credential_access_extra_paths: tuple[str, ...] = ()
|
||||
# Expected owners of packet/raw/special protocol sockets. Keep tight: these
|
||||
# families are common in sniffers, tunnels, and covert channels.
|
||||
stealth_network_allow_comms: frozenset[str] = frozenset((
|
||||
"NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd",
|
||||
"tcpdump", "dumpcap", "wireshark", "suricata", "zeek",
|
||||
))
|
||||
# Socket family names to ignore entirely, e.g. ["mptcp"] if your host uses
|
||||
# it legitimately. Valid observed kinds include raw, sctp, dccp, packet,
|
||||
# mptcp, tipc, xdp, and vsock.
|
||||
stealth_network_allow_kinds: frozenset[str] = frozenset()
|
||||
|
||||
# file integrity monitoring (FIM)
|
||||
fim_enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -15,13 +15,16 @@ from ..alert import Alert
|
|||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
from . import (
|
||||
credential_access,
|
||||
deleted_exe,
|
||||
egress,
|
||||
input_snooper,
|
||||
ld_preload,
|
||||
new_listener,
|
||||
new_suid,
|
||||
persistence,
|
||||
reverse_shell,
|
||||
stealth_network,
|
||||
)
|
||||
|
||||
DetectFn = Callable[[SystemState, Config], Iterable[Alert]]
|
||||
|
|
@ -40,6 +43,9 @@ REGISTRY: tuple[Detector, ...] = (
|
|||
Detector("reverse_shell", reverse_shell.detect),
|
||||
Detector("ld_preload", ld_preload.detect),
|
||||
Detector("deleted_exe", deleted_exe.detect),
|
||||
Detector("input_snooper", input_snooper.detect),
|
||||
Detector("credential_access", credential_access.detect),
|
||||
Detector("stealth_network", stealth_network.detect),
|
||||
Detector("egress", egress.detect),
|
||||
Detector("new_listener", new_listener.detect, needs_baseline=True),
|
||||
Detector("persistence", persistence.detect, needs_baseline=True),
|
||||
|
|
|
|||
99
enodia_sentinel/detectors/credential_access.py
Normal file
99
enodia_sentinel/detectors/credential_access.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""credential_access — live process has sensitive credential files open.
|
||||
|
||||
This catches credential harvesters that open shadow databases, private SSH
|
||||
keys, browser credential stores, or NetworkManager secret material during a
|
||||
poll sweep. Legitimate auth agents and browsers are configurable allow-list
|
||||
entries so the rule can stay enabled on desktops.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
|
||||
SID_CREDENTIAL_ACCESS = 100033
|
||||
|
||||
_EXACT_FILES = frozenset({
|
||||
"/etc/shadow",
|
||||
"/etc/gshadow",
|
||||
"/etc/security/opasswd",
|
||||
})
|
||||
|
||||
_BROWSER_SECRET_NAMES = frozenset({
|
||||
"logins.json",
|
||||
"key4.db",
|
||||
"Login Data",
|
||||
"Cookies",
|
||||
})
|
||||
|
||||
|
||||
def _fd_targets(proc) -> dict[str, str]:
|
||||
targets = getattr(proc, "fd_targets", {})
|
||||
return targets() if callable(targets) else targets
|
||||
|
||||
|
||||
def _clean_target(target: str) -> str:
|
||||
return target.removesuffix(" (deleted)")
|
||||
|
||||
|
||||
def _matches_extra(path: str, entries: tuple[str, ...]) -> bool:
|
||||
for entry in entries:
|
||||
if not entry:
|
||||
continue
|
||||
if entry.endswith("/") and path.startswith(entry):
|
||||
return True
|
||||
if path == entry or path.startswith(entry.rstrip("/") + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sensitive_kind(path: str, cfg: Config) -> str:
|
||||
if not path.startswith("/"):
|
||||
return ""
|
||||
if path in _EXACT_FILES:
|
||||
return "system credential database"
|
||||
if _matches_extra(path, cfg.credential_access_extra_paths):
|
||||
return "configured credential path"
|
||||
if "/.ssh/" in path:
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
if name.startswith("id_") or name.endswith(".pem") or name.endswith(".key"):
|
||||
return "private SSH key"
|
||||
if path.startswith("/etc/NetworkManager/system-connections/"):
|
||||
return "NetworkManager secret profile"
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
if name in _BROWSER_SECRET_NAMES:
|
||||
lowered = path.lower()
|
||||
if any(marker in lowered for marker in (
|
||||
"/.mozilla/", "/firefox/", "/chromium/", "/google-chrome/",
|
||||
"/chrome/", "/brave", "/vivaldi/", "/edge/",
|
||||
)):
|
||||
return "browser credential store"
|
||||
return ""
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
for proc in state.processes:
|
||||
comm = proc.comm or "?"
|
||||
if comm in cfg.credential_access_allow_comms:
|
||||
continue
|
||||
for fd, raw_target in _fd_targets(proc).items():
|
||||
target = _clean_target(raw_target)
|
||||
kind = _sensitive_kind(target, cfg)
|
||||
if not kind:
|
||||
continue
|
||||
yield Alert(
|
||||
severity=Severity.CRITICAL,
|
||||
signature="credential_access",
|
||||
key=f"cred:{proc.pid}:{target}",
|
||||
detail=(
|
||||
f"pid={proc.pid} comm={comm} fd={fd} has {kind} open: "
|
||||
f"{target}"
|
||||
),
|
||||
pids=(proc.pid,),
|
||||
sid=SID_CREDENTIAL_ACCESS,
|
||||
classtype="credential-theft",
|
||||
)
|
||||
break
|
||||
54
enodia_sentinel/detectors/input_snooper.py
Normal file
54
enodia_sentinel/detectors/input_snooper.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""input_snooper — processes reading keyboard/HID event devices.
|
||||
|
||||
Keyloggers commonly open ``/dev/input/event*`` or HID raw devices directly.
|
||||
Normal display servers, compositors, and input remappers can do this too, so
|
||||
known local input stack processes are configurable allow-list entries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..system import SystemState
|
||||
|
||||
SID_INPUT_SNOOPER = 100032
|
||||
|
||||
_INPUT_PREFIXES = (
|
||||
"/dev/input/event",
|
||||
"/dev/uinput",
|
||||
"/dev/hidraw",
|
||||
)
|
||||
|
||||
|
||||
def _fd_targets(proc) -> dict[str, str]:
|
||||
targets = getattr(proc, "fd_targets", {})
|
||||
return targets() if callable(targets) else targets
|
||||
|
||||
|
||||
def _is_input_device(target: str) -> bool:
|
||||
return target.startswith(_INPUT_PREFIXES)
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
for proc in state.processes:
|
||||
comm = proc.comm or "?"
|
||||
if comm in cfg.input_snooper_allow_comms:
|
||||
continue
|
||||
for fd, target in _fd_targets(proc).items():
|
||||
if not _is_input_device(target):
|
||||
continue
|
||||
yield Alert(
|
||||
severity=Severity.HIGH,
|
||||
signature="input_snooper",
|
||||
key=f"input:{proc.pid}:{target}",
|
||||
detail=(
|
||||
f"pid={proc.pid} comm={comm} fd={fd} has input device "
|
||||
f"open: {target}"
|
||||
),
|
||||
pids=(proc.pid,),
|
||||
sid=SID_INPUT_SNOOPER,
|
||||
classtype="credential-keylogging",
|
||||
)
|
||||
break
|
||||
66
enodia_sentinel/detectors/stealth_network.py
Normal file
66
enodia_sentinel/detectors/stealth_network.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""stealth_network — unusual socket families used for covert channels.
|
||||
|
||||
TCP and UDP are covered by reverse-shell, egress, and listener checks. This
|
||||
detector watches families attackers use to slide around those paths: raw IP,
|
||||
SCTP, DCCP, packet sockets, XDP, TIPC, vsock, and MPTCP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from ..alert import Alert, Severity
|
||||
from ..config import Config
|
||||
from ..netutil import ip_in_cidrs, is_public_ip, split_host_port
|
||||
from ..system import SystemState
|
||||
|
||||
SID_STEALTH_NETWORK = 100036
|
||||
|
||||
_WATCHED_KINDS = frozenset({
|
||||
"raw", "sctp", "dccp", "packet", "mptcp", "tipc", "xdp", "vsock",
|
||||
})
|
||||
_ALWAYS_HIGH = frozenset({"raw", "packet", "xdp"})
|
||||
_ACTIVE_STATES = frozenset({
|
||||
"ESTAB", "LISTEN", "UNCONN", "CONNECTED", "SYN-SENT", "SYN-RECV",
|
||||
})
|
||||
|
||||
|
||||
def _peer_is_public(peer: str, cfg: Config) -> bool:
|
||||
host, _port = split_host_port(peer)
|
||||
if not is_public_ip(host):
|
||||
return False
|
||||
return not ip_in_cidrs(host, cfg.egress_allow_cidrs)
|
||||
|
||||
|
||||
def _severity(kind: str, state: str, peer: str, cfg: Config) -> Severity:
|
||||
if kind in _ALWAYS_HIGH:
|
||||
return Severity.HIGH
|
||||
if state == "LISTEN" or _peer_is_public(peer, cfg):
|
||||
return Severity.HIGH
|
||||
return Severity.MEDIUM
|
||||
|
||||
|
||||
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
|
||||
allowed_kinds = set(cfg.stealth_network_allow_kinds)
|
||||
for sock in state.sockets:
|
||||
kind = sock.kind.lower()
|
||||
if kind not in _WATCHED_KINDS:
|
||||
continue
|
||||
if kind in allowed_kinds:
|
||||
continue
|
||||
if sock.comm and sock.comm in cfg.stealth_network_allow_comms:
|
||||
continue
|
||||
if sock.state and sock.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
yield Alert(
|
||||
severity=_severity(kind, sock.state, sock.peer, cfg),
|
||||
signature="stealth_network",
|
||||
key=f"stealthnet:{kind}:{sock.pid}:{sock.local}:{sock.peer}",
|
||||
detail=(
|
||||
f"{kind} socket state={sock.state} local={sock.local} "
|
||||
f"peer={sock.peer} comm={sock.comm or '?'} pid={sock.pid or '?'}"
|
||||
),
|
||||
pids=(sock.pid,) if sock.pid else (),
|
||||
sid=SID_STEALTH_NETWORK,
|
||||
classtype="covert-channel",
|
||||
)
|
||||
|
|
@ -32,6 +32,9 @@ SID_KNOWN_ROOTKIT_MODULE = 100028
|
|||
SID_TAINTED_MODULE = 100029
|
||||
SID_KERNEL_TAINTED = 100030
|
||||
SID_HIDDEN_UDP_PORT = 100031
|
||||
SID_HIDDEN_RAW_SOCKET = 100034
|
||||
SID_RAW_ICMP_SOCKET = 100035
|
||||
SID_HIDDEN_PROTOCOL_SOCKET = 100037
|
||||
|
||||
IFF_PROMISC = 0x100
|
||||
|
||||
|
|
@ -62,6 +65,13 @@ TAINT_FLAGS = {
|
|||
}
|
||||
|
||||
HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13})
|
||||
ICMP_RAW_PROTOCOLS = frozenset({1, 58}) # ICMP, IPv6-ICMP
|
||||
RAW_PROTOCOL_NAMES = {
|
||||
"icmp": 1,
|
||||
"ipv6-icmp": 58,
|
||||
"icmp6": 58,
|
||||
"sctp": 132,
|
||||
}
|
||||
|
||||
|
||||
# --- pure diff cores (unit-tested without a live system) ------------------
|
||||
|
|
@ -86,6 +96,18 @@ def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[in
|
|||
return procnet_ports - ss_ports
|
||||
|
||||
|
||||
def find_hidden_raw_protocols(procnet_protocols: set[int],
|
||||
ss_protocols: set[int]) -> set[int]:
|
||||
"""Raw socket protocols in /proc/net/raw that ss does not report."""
|
||||
return procnet_protocols - ss_protocols
|
||||
|
||||
|
||||
def find_hidden_protocol_kinds(procnet_kinds: set[str],
|
||||
ss_kinds: set[str]) -> set[str]:
|
||||
"""Special socket families visible in /proc/net but absent from ss."""
|
||||
return procnet_kinds - ss_kinds
|
||||
|
||||
|
||||
def _norm_module(name: str) -> str:
|
||||
return name.lower().replace("-", "_")
|
||||
|
||||
|
|
@ -238,6 +260,60 @@ def procnet_udp_ports() -> set[int]:
|
|||
return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6")
|
||||
|
||||
|
||||
def _procnet_raw_protocols(path: str) -> set[int]:
|
||||
protocols: set[int] = set()
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
local = parts[1] # HEX_ADDR:HEX_PROTO
|
||||
proto = int(local.rsplit(":", 1)[1], 16)
|
||||
if proto:
|
||||
protocols.add(proto)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return protocols
|
||||
|
||||
|
||||
def procnet_raw_protocols() -> set[int]:
|
||||
return (_procnet_raw_protocols("/proc/net/raw")
|
||||
| _procnet_raw_protocols("/proc/net/raw6"))
|
||||
|
||||
|
||||
def _procnet_has_rows(path: str) -> bool:
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
if line.strip():
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def procnet_protocol_kinds() -> set[str]:
|
||||
kinds: set[str] = set()
|
||||
checks = {
|
||||
"sctp": (
|
||||
"/proc/net/sctp/eps",
|
||||
"/proc/net/sctp/assocs",
|
||||
"/proc/net/sctp/remaddr",
|
||||
),
|
||||
"dccp": ("/proc/net/dccp", "/proc/net/dccp6"),
|
||||
"packet": ("/proc/net/packet",),
|
||||
"tipc": ("/proc/net/tipc/socket",),
|
||||
"xdp": ("/proc/net/xdp",),
|
||||
}
|
||||
for kind, paths in checks.items():
|
||||
if any(_procnet_has_rows(path) for path in paths):
|
||||
kinds.add(kind)
|
||||
return kinds
|
||||
|
||||
|
||||
def ss_listen_ports(state: SystemState) -> set[int]:
|
||||
ports: set[int] = set()
|
||||
for s in state.listening_sockets():
|
||||
|
|
@ -258,6 +334,31 @@ def ss_udp_ports(state: SystemState) -> set[int]:
|
|||
return ports
|
||||
|
||||
|
||||
def _protocol_from_endpoint(endpoint: str) -> int | None:
|
||||
tail = endpoint.rsplit(":", 1)
|
||||
if len(tail) != 2:
|
||||
return None
|
||||
label = tail[1].strip("[]").lower()
|
||||
if label.isdigit():
|
||||
return int(label)
|
||||
return RAW_PROTOCOL_NAMES.get(label)
|
||||
|
||||
|
||||
def ss_raw_protocols(state: SystemState) -> set[int]:
|
||||
protocols: set[int] = set()
|
||||
for s in state.sockets:
|
||||
if s.kind != "raw":
|
||||
continue
|
||||
proto = _protocol_from_endpoint(s.local)
|
||||
if proto is not None:
|
||||
protocols.add(proto)
|
||||
return protocols
|
||||
|
||||
|
||||
def ss_protocol_kinds(state: SystemState) -> set[str]:
|
||||
return {s.kind for s in state.sockets if s.kind}
|
||||
|
||||
|
||||
def promiscuous_interfaces() -> list[str]:
|
||||
out: list[str] = []
|
||||
base = "/sys/class/net"
|
||||
|
|
@ -361,6 +462,37 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
|
|||
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))),
|
||||
sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port")
|
||||
|
||||
raw_protocols = procnet_raw_protocols()
|
||||
hidden_raw = find_hidden_raw_protocols(raw_protocols, ss_raw_protocols(state))
|
||||
if hidden_raw:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="rootkit_hidden_raw_socket",
|
||||
key=f"rk:hidraw:{min(hidden_raw)}",
|
||||
detail=("raw socket protocol(s) in /proc/net/raw not reported by ss "
|
||||
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_raw)))),
|
||||
sid=SID_HIDDEN_RAW_SOCKET, classtype="rootkit-hidden-socket")
|
||||
|
||||
raw_icmp = raw_protocols & ICMP_RAW_PROTOCOLS
|
||||
if raw_icmp:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="raw_icmp_socket",
|
||||
key=f"rk:rawicmp:{min(raw_icmp)}",
|
||||
detail=("persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "
|
||||
+ ", ".join(map(str, sorted(raw_icmp)))
|
||||
+ " — validate authorized ping/monitoring tools or ICMP knockers"),
|
||||
sid=SID_RAW_ICMP_SOCKET, classtype="raw-socket-backdoor")
|
||||
|
||||
hidden_protocols = find_hidden_protocol_kinds(
|
||||
procnet_protocol_kinds(), ss_protocol_kinds(state))
|
||||
if hidden_protocols:
|
||||
yield Alert(
|
||||
severity=Severity.HIGH, signature="rootkit_hidden_protocol_socket",
|
||||
key=f"rk:hidproto:{sorted(hidden_protocols)[0]}",
|
||||
detail=("special protocol socket family present in /proc/net but "
|
||||
"absent from ss output (tool may be hooked): "
|
||||
+ ", ".join(sorted(hidden_protocols))),
|
||||
sid=SID_HIDDEN_PROTOCOL_SOCKET, classtype="rootkit-hidden-socket")
|
||||
|
||||
for iface in promiscuous_interfaces():
|
||||
yield Alert(
|
||||
severity=Severity.MEDIUM, signature="promiscuous_interface",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ from pathlib import Path
|
|||
|
||||
_INO_RE = re.compile(r"\bino:(\d+)")
|
||||
_USER_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+),fd=\d+')
|
||||
_SS_NETIDS = frozenset({
|
||||
"tcp", "udp", "raw", "sctp", "dccp", "mptcp",
|
||||
"p_raw", "p_dgr", "packet", "tipc", "xdp", "vsock",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -65,6 +69,23 @@ class Process:
|
|||
out[k] = v
|
||||
return out
|
||||
|
||||
@cached_property
|
||||
def fd_targets(self) -> dict[str, str]:
|
||||
"""Open file descriptor targets, keyed by fd number as a string."""
|
||||
out: dict[str, str] = {}
|
||||
fd_dir = f"/proc/{self.pid}/fd"
|
||||
try:
|
||||
names = sorted(os.listdir(fd_dir),
|
||||
key=lambda x: int(x) if x.isdigit() else 0)
|
||||
except OSError:
|
||||
return out
|
||||
for name in names:
|
||||
try:
|
||||
out[name] = os.readlink(os.path.join(fd_dir, name))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
@cached_property
|
||||
def status(self) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
|
|
@ -105,6 +126,7 @@ class Socket:
|
|||
inode: int | None
|
||||
comm: str
|
||||
pid: int | None
|
||||
kind: str = ""
|
||||
|
||||
|
||||
class SystemState:
|
||||
|
|
@ -166,8 +188,19 @@ class SystemState:
|
|||
if self._injected_socks is not None:
|
||||
return self._injected_socks
|
||||
out: list[Socket] = []
|
||||
for args in (["-tanep"], ["-uanep"]):
|
||||
out.extend(self._parse_ss(self._run_ss(args)))
|
||||
for args, kind in (
|
||||
(["-tanep"], "tcp"),
|
||||
(["-uanep"], "udp"),
|
||||
(["-wanep"], "raw"),
|
||||
(["-Sanep"], "sctp"),
|
||||
(["-danep"], "dccp"),
|
||||
(["-0anep"], "packet"),
|
||||
(["-Manep"], "mptcp"),
|
||||
(["--tipc", "-anep"], "tipc"),
|
||||
(["--xdp", "-anep"], "xdp"),
|
||||
(["--vsock", "-anep"], "vsock"),
|
||||
):
|
||||
out.extend(self._parse_ss(self._run_ss(args), kind=kind))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -182,13 +215,16 @@ class SystemState:
|
|||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_ss(text: str) -> list[Socket]:
|
||||
def _parse_ss(text: str, *, kind: str = "") -> list[Socket]:
|
||||
socks: list[Socket] = []
|
||||
for line in text.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
state, _rq, _sq, local, peer = parts[:5]
|
||||
if parts[0].lower() in _SS_NETIDS and len(parts) >= 6:
|
||||
state, local, peer = parts[1], parts[4], parts[5]
|
||||
else:
|
||||
state, local, peer = parts[0], parts[3], parts[4]
|
||||
rest = line
|
||||
ino_m = _INO_RE.search(rest)
|
||||
usr_m = _USER_RE.search(rest)
|
||||
|
|
@ -199,6 +235,7 @@ class SystemState:
|
|||
inode=int(ino_m.group(1)) if ino_m else None,
|
||||
comm=usr_m.group(1) if usr_m else "",
|
||||
pid=int(usr_m.group(2)) if usr_m else None,
|
||||
kind=kind,
|
||||
))
|
||||
return socks
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from dataclasses import dataclass, field
|
|||
from enodia_sentinel.alert import Severity
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.detectors import (
|
||||
deleted_exe, egress, ld_preload, new_listener, new_suid, reverse_shell,
|
||||
credential_access, deleted_exe, egress, input_snooper, ld_preload,
|
||||
new_listener, new_suid, reverse_shell,
|
||||
stealth_network,
|
||||
)
|
||||
from enodia_sentinel.system import Socket, SystemState
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ class FakeProc:
|
|||
ppid: int = 1
|
||||
uid: int = 0
|
||||
cwd: str = "/"
|
||||
fd_targets: dict = field(default_factory=dict)
|
||||
|
||||
def stdio_socket_inode(self):
|
||||
return self._stdio_inode
|
||||
|
|
@ -90,6 +93,71 @@ class TestDeletedExe(unittest.TestCase):
|
|||
self.assertEqual(list(deleted_exe.detect(state, cfg())), [])
|
||||
|
||||
|
||||
class TestInputSnooper(unittest.TestCase):
|
||||
def test_input_device_alerts(self):
|
||||
proc = FakeProc(pid=320, comm="hoxha",
|
||||
fd_targets={"7": "/dev/input/event3"})
|
||||
alerts = list(input_snooper.detect(SystemState(processes=[proc]), cfg()))
|
||||
self.assertEqual(len(alerts), 1)
|
||||
self.assertEqual(alerts[0].signature, "input_snooper")
|
||||
self.assertEqual(alerts[0].severity, Severity.HIGH)
|
||||
|
||||
def test_allowlisted_input_process_ignored(self):
|
||||
proc = FakeProc(pid=321, comm="Xorg",
|
||||
fd_targets={"7": "/dev/input/event3"})
|
||||
self.assertEqual(list(input_snooper.detect(SystemState(processes=[proc]), cfg())), [])
|
||||
|
||||
|
||||
class TestCredentialAccess(unittest.TestCase):
|
||||
def test_shadow_access_alerts(self):
|
||||
proc = FakeProc(pid=330, comm="hoxha",
|
||||
fd_targets={"4": "/etc/shadow"})
|
||||
alerts = list(credential_access.detect(SystemState(processes=[proc]), cfg()))
|
||||
self.assertEqual(len(alerts), 1)
|
||||
self.assertEqual(alerts[0].signature, "credential_access")
|
||||
self.assertEqual(alerts[0].severity, Severity.CRITICAL)
|
||||
|
||||
def test_private_key_access_alerts(self):
|
||||
proc = FakeProc(pid=331, comm="collector",
|
||||
fd_targets={"5": "/home/luna/.ssh/id_ed25519"})
|
||||
alerts = list(credential_access.detect(SystemState(processes=[proc]), cfg()))
|
||||
self.assertEqual(len(alerts), 1)
|
||||
self.assertIn("private SSH key", alerts[0].detail)
|
||||
|
||||
def test_browser_access_by_browser_ignored(self):
|
||||
proc = FakeProc(
|
||||
pid=332, comm="firefox",
|
||||
fd_targets={"8": "/home/luna/.mozilla/firefox/a/key4.db"},
|
||||
)
|
||||
self.assertEqual(list(credential_access.detect(SystemState(processes=[proc]), cfg())), [])
|
||||
|
||||
|
||||
class TestStealthNetwork(unittest.TestCase):
|
||||
def test_sctp_public_peer_alerts(self):
|
||||
sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443",
|
||||
1, "hoxha", 340, "sctp")
|
||||
alerts = list(stealth_network.detect(SystemState(sockets=[sock]), cfg()))
|
||||
self.assertEqual(len(alerts), 1)
|
||||
self.assertEqual(alerts[0].signature, "stealth_network")
|
||||
self.assertEqual(alerts[0].severity, Severity.HIGH)
|
||||
|
||||
def test_packet_socket_allowlisted_comm_ignored(self):
|
||||
sock = Socket("UNCONN", "*:eth0", "*", 1, "tcpdump", 341, "packet")
|
||||
self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), [])
|
||||
|
||||
def test_allowlisted_kind_ignored(self):
|
||||
c = Config()
|
||||
c.stealth_network_allow_kinds = frozenset({"mptcp"})
|
||||
sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443",
|
||||
1, "curl", 342, "mptcp")
|
||||
self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), c)), [])
|
||||
|
||||
def test_tcp_ignored(self):
|
||||
sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443",
|
||||
1, "python3", 343, "tcp")
|
||||
self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), [])
|
||||
|
||||
|
||||
class TestNewSuid(unittest.TestCase):
|
||||
def test_new_in_writable_is_critical(self):
|
||||
state = SystemState(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ class TestDiffCores(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
rootcheck.find_hidden_udp_ports({53, 5353, 4444}, {53, 5353}), {4444})
|
||||
|
||||
def test_hidden_raw_protocols_in_procnet_not_in_ss(self):
|
||||
self.assertEqual(
|
||||
rootcheck.find_hidden_raw_protocols({1, 58}, {58}), {1})
|
||||
|
||||
def test_hidden_protocol_kinds_in_procnet_not_in_ss(self):
|
||||
self.assertEqual(
|
||||
rootcheck.find_hidden_protocol_kinds({"sctp", "packet"}, {"packet"}),
|
||||
{"sctp"})
|
||||
|
||||
def test_known_rootkit_module_names_normalized(self):
|
||||
self.assertEqual(
|
||||
rootcheck.find_known_rootkit_modules({"ext4", "diamorphine", "adore-ng"}),
|
||||
|
|
@ -62,6 +71,33 @@ class TestSsPortsFromState(unittest.TestCase):
|
|||
state = SystemState(sockets=socks)
|
||||
self.assertEqual(rootcheck.ss_udp_ports(state), {53, 5353})
|
||||
|
||||
def test_extracts_raw_protocols_from_injected_state(self):
|
||||
socks = [
|
||||
Socket("UNCONN", "0.0.0.0:icmp", "0.0.0.0:*", 10, "hoxha", 1, "raw"),
|
||||
Socket("UNCONN", "[::]:ipv6-icmp", "[::]:*", 11, "monitor", 2, "raw"),
|
||||
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 12, "sshd", 3, "tcp"),
|
||||
]
|
||||
state = SystemState(sockets=socks)
|
||||
self.assertEqual(rootcheck.ss_raw_protocols(state), {1, 58})
|
||||
|
||||
def test_extracts_socket_kinds_from_injected_state(self):
|
||||
state = SystemState(sockets=[
|
||||
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443", 10, "h", 1, "sctp"),
|
||||
Socket("UNCONN", "*:eth0", "*", 11, "tcpdump", 2, "packet"),
|
||||
])
|
||||
self.assertEqual(rootcheck.ss_protocol_kinds(state), {"sctp", "packet"})
|
||||
|
||||
def test_ss_parser_handles_netid_column(self):
|
||||
text = 'p_raw UNCONN 0 0 *:eth0 * users:(("dhcpcd",pid=12,fd=7)) ino:123'
|
||||
sock = SystemState._parse_ss(text, kind="packet")[0]
|
||||
self.assertEqual(sock.state, "UNCONN")
|
||||
self.assertEqual(sock.local, "*:eth0")
|
||||
self.assertEqual(sock.peer, "*")
|
||||
self.assertEqual(sock.comm, "dhcpcd")
|
||||
self.assertEqual(sock.pid, 12)
|
||||
self.assertEqual(sock.inode, 123)
|
||||
self.assertEqual(sock.kind, "packet")
|
||||
|
||||
|
||||
class TestRunIntegration(unittest.TestCase):
|
||||
"""Drive run() with every system view monkeypatched to a known state."""
|
||||
|
|
@ -71,8 +107,9 @@ class TestRunIntegration(unittest.TestCase):
|
|||
self._saved = {}
|
||||
for name in ("proc_pids", "alive_pids", "proc_modules",
|
||||
"sys_live_modules", "procnet_listen_ports",
|
||||
"procnet_udp_ports", "promiscuous_interfaces", "module_taints",
|
||||
"kernel_taint"):
|
||||
"procnet_udp_ports", "procnet_raw_protocols",
|
||||
"procnet_protocol_kinds",
|
||||
"promiscuous_interfaces", "module_taints", "kernel_taint"):
|
||||
self._saved[name] = getattr(rootcheck, name)
|
||||
|
||||
def tearDown(self):
|
||||
|
|
@ -86,6 +123,8 @@ class TestRunIntegration(unittest.TestCase):
|
|||
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set())
|
||||
rootcheck.procnet_listen_ports = lambda: views.get("procnet_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_protocol_kinds = lambda: views.get("procnet_protocol_kinds", 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)
|
||||
|
|
@ -101,10 +140,14 @@ class TestRunIntegration(unittest.TestCase):
|
|||
proc_pids={1}, alive_pids={1},
|
||||
proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"},
|
||||
procnet_ports={22, 31337}, procnet_udp_ports={53, 4444},
|
||||
procnet_raw_protocols={1, 58},
|
||||
procnet_protocol_kinds={"sctp", "packet"},
|
||||
promisc=["eth0"])
|
||||
state = SystemState(sockets=[
|
||||
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1),
|
||||
Socket("UNCONN", "0.0.0.0:53", "0.0.0.0:0", 11, "dnsmasq", 2),
|
||||
Socket("UNCONN", "0.0.0.0:ipv6-icmp", "0.0.0.0:*", 12, "monitor", 3, "raw"),
|
||||
Socket("UNCONN", "*:eth0", "*", 13, "tcpdump", 4, "packet"),
|
||||
])
|
||||
alerts = {a.signature: a for a in rootcheck.run(self.cfg, state)}
|
||||
self.assertIn("rootkit_hidden_module", alerts)
|
||||
|
|
@ -115,6 +158,12 @@ class TestRunIntegration(unittest.TestCase):
|
|||
self.assertIn("31337", alerts["rootkit_hidden_port"].detail)
|
||||
self.assertIn("rootkit_hidden_udp_port", alerts)
|
||||
self.assertIn("4444", alerts["rootkit_hidden_udp_port"].detail)
|
||||
self.assertIn("rootkit_hidden_raw_socket", alerts)
|
||||
self.assertIn("1", alerts["rootkit_hidden_raw_socket"].detail)
|
||||
self.assertIn("raw_icmp_socket", alerts)
|
||||
self.assertEqual(alerts["raw_icmp_socket"].sid, rootcheck.SID_RAW_ICMP_SOCKET)
|
||||
self.assertIn("rootkit_hidden_protocol_socket", alerts)
|
||||
self.assertIn("sctp", alerts["rootkit_hidden_protocol_socket"].detail)
|
||||
self.assertIn("promiscuous_interface", alerts)
|
||||
self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue