Port secmon KernelModuleMonitor::is_suspicious_module to (jsecmon kernmod)
Jaime Fournier
3ae851af6183cc8332da42820b6e477091ed8c61
--- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme BUILD ?= build/rust TYPED := $(wildcard typed/*.ss) -.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check checks clean +.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check checks clean # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon # (a second --libdirs would replace, not append, the jerboa one). LIBDIRS := "$(JERBOA)/lib:$(CURDIR)" @@ -125,6 +125,12 @@ suspicious-check: netconn-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss +# Kernel-module classifier (secmon KernelModuleMonitor::is_suspicious_module): +# known-rootkit substring, short-name allow-list, long-and-vowelless. Pure +# string classification, no native lib. +kernmod-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss + # Everything that runs through the Jerboa side of the bridge, one shot. checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss @@ -140,6 +146,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_sniffer_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ make buffer-check # the agent's encrypted event ring buffer (FIFO + priority make dns-sniffer-check # DNS wire-format parser (QNAME/compression/answers) + dedup state make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + miner make netconn-check # connection classifier: bad-port, high-port-mult-1000, web→external +make kernmod-check # kernel-module classifier: rootkit substring, short name, no vowels make checks # every Jerboa-side check in one shot ``` @@ -96,5 +97,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `buffer::ring` (StoredEvent ring buffer) | `jsecmon/buffer.ss` | ✅ **untyped layer** — port of secmon's `src/buffer/ring.rs`: the agent's bounded in-memory event ring. FIFO list + monotonic seq numbering, priority eviction (`event_severity_u8` table, drop lowest-severity oldest-first, oldest-critical last), seq/time-range polling, FIFO delivery-ack (`clear_before`), and the little-endian header codec (`seq u64 ∥ ts i64 ∥ sev u8 ∥ payload`). Pure mechanics, so untyped — the one security step, ECIES payload encryption, is FFI-deferred: the caller hands `buffer-store!` opaque ciphertext bytes. `make buffer-check` reproduces secmon's three ring tests (store/seq, priority eviction, FIFO-oldest) + codec round-trip. | | `monitor::events::SuspiciousPatterns` (process-spawn classifier) | `jsecmon/suspicious.ss` | ✅ **untyped layer** — `check_suspicious(process, parent)`: shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and `str::contains` is a *literal* substring test (so `python -c.*socket` is literal, not a regex). `make suspicious-check` reproduces secmon's two events.rs tests + the other three signals + both corners. | | `monitor::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. | +| `monitor::kernel::KernelModuleMonitor` (kernel-module classifier) | `jsecmon/kernmod.ss` | ✅ **untyped layer** — `is_suspicious_module(name)`: lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case `aeiouAEIOU`. `make kernmod-check` reproduces secmon's two kernel.rs tests + each signal exercised independently + the case corners. | | `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction) and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. The AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards). | | monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last | new file mode 100644 --- /dev/null +++ b/examples/kernmod_check.ss @@ -0,0 +1,55 @@ +;;; Parity check for (jsecmon kernmod) against secmon's kernel.rs tests +;;; (test_suspicious_module_detection, test_legitimate_modules), plus the three +;;; signals exercised independently and the faithfulness corners. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/kernmod_check.ss + +(import (jerboa prelude) + (jsecmon kernmod)) + +(def fails 0) +(def (check name got want) + (let ((ok (equal? got want))) + (unless ok (set! fails (+ fails 1))) + (displayln (if ok " ok " " FAIL ") name + (if ok "" (str " got " got " want " want))))) + +;; ── secmon's two unit tests ────────────────────────────────────────────────── +(displayln "secmon kernel.rs vectors:") +(check "diamorphine suspicious" (suspicious-module? "diamorphine") #t) +(check "hidden_rootkit suspicious" (suspicious-module? "hidden_rootkit") #t) +(check "aa suspicious (too short)" (suspicious-module? "aa") #t) +(check "xbcdfg suspicious (no vowel)" (suspicious-module? "xbcdfg") #t) +(check "ext4 legitimate" (suspicious-module? "ext4") #f) +(check "nvidia legitimate" (suspicious-module? "nvidia") #f) +(check "ip legitimate short" (suspicious-module? "ip") #f) +(check "dm legitimate short" (suspicious-module? "dm") #f) + +;; ── signal 1: known-malicious substring, case-insensitive ──────────────────── +(displayln "substring signal:") +(check "reptile by exact name" (suspicious-module? "reptile") #t) +(check "Diamorphine mixed case" (suspicious-module? "Diamorphine") #t) +(check "my_keylogger contains keylog" (suspicious-module? "my_keylogger") #t) +(check "STEALTH upper contains stealth" (suspicious-module? "STEALTH") #t) +(check "backdoor_v2" (suspicious-module? "backdoor_v2") #t) + +;; ── signal 2: short-name allow-list ────────────────────────────────────────── +(displayln "short-name signal:") +(for-each (lambda (m) (check (str " " m " allowed") (suspicious-module? m) #f)) + '("ip" "dm" "sd" "sr" "nf" "if")) +(check "x (1 char, not allowed)" (suspicious-module? "x") #t) +(check "zz (2 char, not allowed)" (suspicious-module? "zz") #t) + +;; ── signal 3: long & vowelless (uses ORIGINAL case, both-case vowel set) ────── +(displayln "vowel signal:") +(check "thinkpad has vowels -> clean" (suspicious-module? "thinkpad") #f) +(check "xkcd (len 4, not > 4) clean" (suspicious-module? "xkcd") #f) +(check "xkcdz (len 5, no vowel)" (suspicious-module? "xkcdz") #t) +;; the vowel set includes upper-case, so an upper-case vowel keeps it clean +(check "BRTHX wait that's vowelless" (suspicious-module? "BRTHX") #t) +(check "BRATH upper-A is a vowel -> clean" (suspicious-module? "BRATH") #f) + +(newline) +(if (= fails 0) + (displayln "OK: kernmod matches secmon's kernel.rs behaviour.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/kernmod.ss @@ -0,0 +1,60 @@ +#!chezscheme +;;; jsecmon kernel-module classifier (secmon monitor::kernel), untyped. +;;; +;;; Port of `KernelModuleMonitor::is_suspicious_module` from secmon's +;;; src/monitor/kernel.rs: decide whether a loaded kernel module's name looks +;;; like a rootkit / evasion module, by three signals in secmon's order: +;;; 1. the lower-cased name contains any known-malicious substring +;;; (diamorphine, reptile, hide, rootkit, keylog, ...) +;;; 2. the name is 1-2 chars and not on the legitimate-short allow-list +;;; (ip dm sd sr nf if) — rootkits often use 1-2 char names +;;; 3. the name is longer than 4 chars and contains no vowel (random-looking) +;;; +;;; Pure string classification returning a boolean, exactly like the process and +;;; connection classifiers in (jsecmon suspicious) / (jsecmon netconn), so +;;; untyped. secmon hides both name lists with obfstr! (the same scheme ported to +;;; typed/obfuscate.ss); at runtime they decode to these plaintext literals. +;;; +;;; Faithfulness notes: +;;; * Only the contains-test lower-cases the name; the short-name exception +;;; and the vowel test use the ORIGINAL name (secmon's `name` vs +;;; `name_lower`). Module names are ASCII so byte-len == char-len. +;;; * The vowel set is "aeiouAEIOU" — both cases, since the vowel test runs on +;;; the un-lowercased name. +;;; +;;; Verified against secmon's kernel.rs tests in examples/kernmod_check.ss. + +(library (jsecmon kernmod) + (export suspicious-module?) + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?)) + + (def *suspicious-modules* + '("diamorphine" "reptile" "adore" "knark" "suckit" ;; known Linux rootkits + "azazel" "jynx" "vlany" "bdvl" + "hide" "hidden" "rootkit" "backdoor" "keylog" "stealth")) ;; generic tells + (def *legitimate-short* '("ip" "dm" "sd" "sr" "nf" "if")) + (def *vowels* '(#\a #\e #\i #\o #\u #\A #\E #\I #\O #\U)) + + (def (has-vowel? name) + (for/or ((c (in-string name))) (and (memv c *vowels*) #t))) + + ;; name: string -> #t if the module name looks rootkit/evasion-like, else #f. + (def (suspicious-module? name) + (let ((name-lower (string-downcase name))) + (cond + ;; 1. known-malicious substring (on the lower-cased name) + ((any (lambda (s) (string-contains name-lower s)) *suspicious-modules*) #t) + ;; 2. very short name not on the allow-list (original name) + ((and (<= (string-length name) 2) (not (member name *legitimate-short*))) #t) + ;; 3. long name with no vowel — random-looking (original name) + ((and (> (string-length name) 4) (not (has-vowel? name))) #t) + (else #f)))))