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.
|
- `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 ports, promiscuous
|
hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets,
|
||||||
interfaces, known LKM rootkit module names, and kernel/module taint.
|
raw ICMP/SCTP-style channels, 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
|
||||||
|
|
||||||
|
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
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -100,4 +114,3 @@ When changing behavior, update the relevant set:
|
||||||
- `docs/THREAT_MODEL.md` for trust-boundary/security-model changes.
|
- `docs/THREAT_MODEL.md` for trust-boundary/security-model changes.
|
||||||
- `docs/ROADMAP.md` for planned or completed roadmap movement.
|
- `docs/ROADMAP.md` for planned or completed roadmap movement.
|
||||||
- `config/enodia-sentinel.toml` for config knobs.
|
- `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/...` |
|
| `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 |
|
| `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)` |
|
| `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_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 |
|
||||||
|
|
@ -220,9 +223,14 @@ 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 7 | the enabled detector list |
|
| `detectors` | all 10 | 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 |
|
||||||
|
| `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_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 |
|
||||||
|
|
@ -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 |
|
| `/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/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/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
|
```bash
|
||||||
enodia-sentinel rootcheck # one-shot cross-view scan
|
enodia-sentinel rootcheck # one-shot cross-view scan
|
||||||
|
|
@ -493,11 +504,18 @@ regression suite for both.
|
||||||
|
|
||||||
## Project status
|
## 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**:
|
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
|
||||||
cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade
|
cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade
|
||||||
check, and an **anti-rootkit cross-view** layer (hidden processes, modules,
|
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
|
kernel/module taint — each caught by asking independent views and diffing the
|
||||||
answers).
|
answers).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca
|
||||||
# --- detectors (omit one to disable it) ----------------------------------
|
# --- detectors (omit one to disable it) ----------------------------------
|
||||||
detectors = [
|
detectors = [
|
||||||
"reverse_shell", "ld_preload", "deleted_exe",
|
"reverse_shell", "ld_preload", "deleted_exe",
|
||||||
|
"input_snooper", "credential_access", "stealth_network",
|
||||||
"new_listener", "new_suid", "persistence", "egress",
|
"new_listener", "new_suid", "persistence", "egress",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -55,6 +56,30 @@ watch_persistence = [
|
||||||
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
|
"/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) ------------------------------------
|
# --- 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
|
||||||
|
|
@ -87,8 +112,9 @@ 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 listeners, interface flags for promiscuous sniffing,
|
# vs ss for hidden TCP/UDP/raw/special-protocol sockets, interface flags for
|
||||||
# known rootkit module names, per-module taint, and global kernel taint.
|
# promiscuous sniffing, known 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)
|
||||||
|
|
|
||||||
|
|
@ -66,8 +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, sniffers, known rootkit
|
- `rootcheck`: no hidden processes/modules/ports/raw/special-protocol sockets,
|
||||||
modules, or unexplained kernel/module taint.
|
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
|
- `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).
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ work that is bigger than single alerts.
|
||||||
expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass.
|
expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass.
|
||||||
- ✅ Document incident response runbooks for reverse shells, persistence, trojaned
|
- ✅ Document incident response runbooks for reverse shells, persistence, trojaned
|
||||||
binaries, hidden listeners, and sensor tampering ([RUNBOOKS.md](RUNBOOKS.md)).
|
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:
|
Exit criteria:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,12 @@ Then pick the runbook below that matches the signature.
|
||||||
|
|
||||||
## Runbook 1 — Reverse shell / suspicious egress
|
## 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
|
An interpreter wired to a socket, an interpreter holding a connection to a
|
||||||
public IP — the classic interactive-C2 and beaconing shapes.
|
public IP, or activity over raw/SCTP/DCCP/packet/XDP/TIPC/vsock families - the
|
||||||
|
classic interactive-C2, beaconing, and covert-channel shapes.
|
||||||
|
|
||||||
**Confirm**
|
**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
|
- 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.
|
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**
|
**Contain**
|
||||||
|
|
||||||
|
|
@ -167,8 +174,10 @@ the transaction completes; a persistent mismatch is the real finding.
|
||||||
**Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023),
|
**Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023),
|
||||||
`rootkit_hidden_port` (100024), `promiscuous_interface` (100025),
|
`rootkit_hidden_port` (100024), `promiscuous_interface` (100025),
|
||||||
`rootkit_known_module` (100028), `rootkit_tainted_module` (100029),
|
`rootkit_known_module` (100028), `rootkit_tainted_module` (100029),
|
||||||
`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), and
|
`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031),
|
||||||
`new_listener` (100013) for an unexplained open port.
|
`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
|
These come from cross-view checks: the same question asked two ways, answered
|
||||||
differently, because something is hiding.
|
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
|
- `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.
|
||||||
|
- `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
|
- A `rootkit_hidden_module` names a live kernel module absent from
|
||||||
`/proc/modules` — strong evidence of an LKM rootkit.
|
`/proc/modules` — strong evidence of an LKM rootkit.
|
||||||
- A `rootkit_known_module` finding names a module associated with public LKM
|
- 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
|
**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
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ The long-term product should combine five functions:
|
||||||
6. **Bash prototype remains the oracle.** The original shell implementation and
|
6. **Bash prototype remains the oracle.** The original shell implementation and
|
||||||
red-team harness preserve behavioral compatibility for core signatures.
|
red-team harness preserve behavioral compatibility for core signatures.
|
||||||
|
|
||||||
## Current Scope: v0.7
|
## Current Scope: v0.8-dev
|
||||||
|
|
||||||
### Poll Detectors
|
### Poll Detectors
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ against cached process and socket data:
|
||||||
| `reverse_shell` | Interpreter with a network socket on fd 0/1/2. |
|
| `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`. |
|
| `ld_preload` | Non-empty `/etc/ld.so.preload` or writable-path `LD_PRELOAD`. |
|
||||||
| `deleted_exe` | Process executing from a deleted file or `memfd:` image. |
|
| `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_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. |
|
||||||
|
|
@ -76,7 +79,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 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
|
### Evidence Capture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,11 @@ Sentinel is designed to help against:
|
||||||
| Adversary | Examples | Expected Sentinel value |
|
| Adversary | Examples | Expected Sentinel value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Opportunistic remote shell | Webshell, exposed service RCE, stolen SSH key | Detect reverse shell, suspicious egress, new listeners, persistence writes. |
|
| 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. |
|
| 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 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. |
|
| Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. |
|
||||||
|
|
||||||
## Trust Boundaries
|
## Trust Boundaries
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,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",
|
||||||
"new_listener", "new_suid", "persistence", "egress",
|
"new_listener", "new_suid", "persistence", "egress",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -60,6 +61,31 @@ class Config:
|
||||||
"/tmp", "/dev/shm", "/var/tmp", "/run/user",
|
"/tmp", "/dev/shm", "/var/tmp", "/run/user",
|
||||||
)
|
)
|
||||||
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
|
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)
|
# file integrity monitoring (FIM)
|
||||||
fim_enabled: bool = True
|
fim_enabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ from ..alert import Alert
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..system import SystemState
|
from ..system import SystemState
|
||||||
from . import (
|
from . import (
|
||||||
|
credential_access,
|
||||||
deleted_exe,
|
deleted_exe,
|
||||||
egress,
|
egress,
|
||||||
|
input_snooper,
|
||||||
ld_preload,
|
ld_preload,
|
||||||
new_listener,
|
new_listener,
|
||||||
new_suid,
|
new_suid,
|
||||||
persistence,
|
persistence,
|
||||||
reverse_shell,
|
reverse_shell,
|
||||||
|
stealth_network,
|
||||||
)
|
)
|
||||||
|
|
||||||
DetectFn = Callable[[SystemState, Config], Iterable[Alert]]
|
DetectFn = Callable[[SystemState, Config], Iterable[Alert]]
|
||||||
|
|
@ -40,6 +43,9 @@ REGISTRY: tuple[Detector, ...] = (
|
||||||
Detector("reverse_shell", reverse_shell.detect),
|
Detector("reverse_shell", reverse_shell.detect),
|
||||||
Detector("ld_preload", ld_preload.detect),
|
Detector("ld_preload", ld_preload.detect),
|
||||||
Detector("deleted_exe", deleted_exe.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("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),
|
||||||
|
|
|
||||||
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_TAINTED_MODULE = 100029
|
||||||
SID_KERNEL_TAINTED = 100030
|
SID_KERNEL_TAINTED = 100030
|
||||||
SID_HIDDEN_UDP_PORT = 100031
|
SID_HIDDEN_UDP_PORT = 100031
|
||||||
|
SID_HIDDEN_RAW_SOCKET = 100034
|
||||||
|
SID_RAW_ICMP_SOCKET = 100035
|
||||||
|
SID_HIDDEN_PROTOCOL_SOCKET = 100037
|
||||||
|
|
||||||
IFF_PROMISC = 0x100
|
IFF_PROMISC = 0x100
|
||||||
|
|
||||||
|
|
@ -62,6 +65,13 @@ TAINT_FLAGS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13})
|
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) ------------------
|
# --- 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
|
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:
|
def _norm_module(name: str) -> str:
|
||||||
return name.lower().replace("-", "_")
|
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")
|
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]:
|
def ss_listen_ports(state: SystemState) -> set[int]:
|
||||||
ports: set[int] = set()
|
ports: set[int] = set()
|
||||||
for s in state.listening_sockets():
|
for s in state.listening_sockets():
|
||||||
|
|
@ -258,6 +334,31 @@ def ss_udp_ports(state: SystemState) -> set[int]:
|
||||||
return ports
|
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]:
|
def promiscuous_interfaces() -> list[str]:
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
base = "/sys/class/net"
|
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)))),
|
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))),
|
||||||
sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port")
|
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():
|
for iface in promiscuous_interfaces():
|
||||||
yield Alert(
|
yield Alert(
|
||||||
severity=Severity.MEDIUM, signature="promiscuous_interface",
|
severity=Severity.MEDIUM, signature="promiscuous_interface",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ from pathlib import Path
|
||||||
|
|
||||||
_INO_RE = re.compile(r"\bino:(\d+)")
|
_INO_RE = re.compile(r"\bino:(\d+)")
|
||||||
_USER_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+),fd=\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
|
@dataclass
|
||||||
|
|
@ -65,6 +69,23 @@ class Process:
|
||||||
out[k] = v
|
out[k] = v
|
||||||
return out
|
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
|
@cached_property
|
||||||
def status(self) -> dict[str, str]:
|
def status(self) -> dict[str, str]:
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
|
|
@ -105,6 +126,7 @@ class Socket:
|
||||||
inode: int | None
|
inode: int | None
|
||||||
comm: str
|
comm: str
|
||||||
pid: int | None
|
pid: int | None
|
||||||
|
kind: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SystemState:
|
class SystemState:
|
||||||
|
|
@ -166,8 +188,19 @@ class SystemState:
|
||||||
if self._injected_socks is not None:
|
if self._injected_socks is not None:
|
||||||
return self._injected_socks
|
return self._injected_socks
|
||||||
out: list[Socket] = []
|
out: list[Socket] = []
|
||||||
for args in (["-tanep"], ["-uanep"]):
|
for args, kind in (
|
||||||
out.extend(self._parse_ss(self._run_ss(args)))
|
(["-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
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -182,13 +215,16 @@ class SystemState:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_ss(text: str) -> list[Socket]:
|
def _parse_ss(text: str, *, kind: str = "") -> list[Socket]:
|
||||||
socks: list[Socket] = []
|
socks: list[Socket] = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) < 5:
|
if len(parts) < 5:
|
||||||
continue
|
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
|
rest = line
|
||||||
ino_m = _INO_RE.search(rest)
|
ino_m = _INO_RE.search(rest)
|
||||||
usr_m = _USER_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,
|
inode=int(ino_m.group(1)) if ino_m else None,
|
||||||
comm=usr_m.group(1) if usr_m else "",
|
comm=usr_m.group(1) if usr_m else "",
|
||||||
pid=int(usr_m.group(2)) if usr_m else None,
|
pid=int(usr_m.group(2)) if usr_m else None,
|
||||||
|
kind=kind,
|
||||||
))
|
))
|
||||||
return socks
|
return socks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ from dataclasses import dataclass, field
|
||||||
from enodia_sentinel.alert import Severity
|
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 (
|
||||||
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
|
from enodia_sentinel.system import Socket, SystemState
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ class FakeProc:
|
||||||
ppid: int = 1
|
ppid: int = 1
|
||||||
uid: int = 0
|
uid: int = 0
|
||||||
cwd: str = "/"
|
cwd: str = "/"
|
||||||
|
fd_targets: dict = field(default_factory=dict)
|
||||||
|
|
||||||
def stdio_socket_inode(self):
|
def stdio_socket_inode(self):
|
||||||
return self._stdio_inode
|
return self._stdio_inode
|
||||||
|
|
@ -90,6 +93,71 @@ class TestDeletedExe(unittest.TestCase):
|
||||||
self.assertEqual(list(deleted_exe.detect(state, cfg())), [])
|
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):
|
class TestNewSuid(unittest.TestCase):
|
||||||
def test_new_in_writable_is_critical(self):
|
def test_new_in_writable_is_critical(self):
|
||||||
state = SystemState(
|
state = SystemState(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,15 @@ class TestDiffCores(unittest.TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
rootcheck.find_hidden_udp_ports({53, 5353, 4444}, {53, 5353}), {4444})
|
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):
|
def test_known_rootkit_module_names_normalized(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
rootcheck.find_known_rootkit_modules({"ext4", "diamorphine", "adore-ng"}),
|
rootcheck.find_known_rootkit_modules({"ext4", "diamorphine", "adore-ng"}),
|
||||||
|
|
@ -62,6 +71,33 @@ class TestSsPortsFromState(unittest.TestCase):
|
||||||
state = SystemState(sockets=socks)
|
state = SystemState(sockets=socks)
|
||||||
self.assertEqual(rootcheck.ss_udp_ports(state), {53, 5353})
|
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):
|
class TestRunIntegration(unittest.TestCase):
|
||||||
"""Drive run() with every system view monkeypatched to a known state."""
|
"""Drive run() with every system view monkeypatched to a known state."""
|
||||||
|
|
@ -71,8 +107,9 @@ class TestRunIntegration(unittest.TestCase):
|
||||||
self._saved = {}
|
self._saved = {}
|
||||||
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", "promiscuous_interfaces", "module_taints",
|
"procnet_udp_ports", "procnet_raw_protocols",
|
||||||
"kernel_taint"):
|
"procnet_protocol_kinds",
|
||||||
|
"promiscuous_interfaces", "module_taints", "kernel_taint"):
|
||||||
self._saved[name] = getattr(rootcheck, name)
|
self._saved[name] = getattr(rootcheck, name)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
|
@ -86,6 +123,8 @@ class TestRunIntegration(unittest.TestCase):
|
||||||
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set())
|
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set())
|
||||||
rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set())
|
rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set())
|
||||||
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_protocol_kinds = lambda: views.get("procnet_protocol_kinds", 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)
|
||||||
|
|
@ -101,10 +140,14 @@ class TestRunIntegration(unittest.TestCase):
|
||||||
proc_pids={1}, alive_pids={1},
|
proc_pids={1}, alive_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_protocol_kinds={"sctp", "packet"},
|
||||||
promisc=["eth0"])
|
promisc=["eth0"])
|
||||||
state = SystemState(sockets=[
|
state = SystemState(sockets=[
|
||||||
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1),
|
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: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)}
|
alerts = {a.signature: a for a in rootcheck.run(self.cfg, state)}
|
||||||
self.assertIn("rootkit_hidden_module", alerts)
|
self.assertIn("rootkit_hidden_module", alerts)
|
||||||
|
|
@ -115,6 +158,12 @@ class TestRunIntegration(unittest.TestCase):
|
||||||
self.assertIn("31337", alerts["rootkit_hidden_port"].detail)
|
self.assertIn("31337", alerts["rootkit_hidden_port"].detail)
|
||||||
self.assertIn("rootkit_hidden_udp_port", alerts)
|
self.assertIn("rootkit_hidden_udp_port", alerts)
|
||||||
self.assertIn("4444", alerts["rootkit_hidden_udp_port"].detail)
|
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.assertIn("promiscuous_interface", alerts)
|
||||||
self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC)
|
self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue