From a773dddd5c1114f36e6728a20c050ed421851a32 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 17 Apr 2026 22:20:05 -0400 Subject: [PATCH] feat(ssh): capture attacker-dropped files with session attribution inotifywait watches writable paths in the SSH decky and mirrors any file close_write/moved_to into a per-decky host-mounted quarantine dir. Each artifact carries a .meta.json with attacker attribution resolved by walking the writer PID's PPid chain to the sshd session leader, then cross-referencing ss and utmp for source IP/user/login time. Also emits an RFC 5424 syslog line per capture for SIEM correlation. --- decnet/services/ssh.py | 5 + templates/ssh/Dockerfile | 16 ++- templates/ssh/capture.sh | 228 ++++++++++++++++++++++++++++++++++++ templates/ssh/entrypoint.sh | 5 + tests/test_ssh.py | 140 ++++++++++++++++++++++ 5 files changed, 390 insertions(+), 4 deletions(-) create mode 100755 templates/ssh/capture.sh diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index db2ce54..e358931 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -36,12 +36,17 @@ class SSHService(BaseService): if "hostname" in cfg: env["SSH_HOSTNAME"] = cfg["hostname"] + # File-catcher quarantine: bind-mount a per-decky host dir so attacker + # drops (scp/sftp/wget) are mirrored out-of-band for forensic analysis. + # The container path is internal-only; attackers never see this mount. + quarantine_host = f"/var/lib/decnet/artifacts/{decky_name}/ssh" return { "build": {"context": str(TEMPLATES_DIR)}, "container_name": f"{decky_name}-ssh", "restart": "unless-stopped", "cap_add": ["NET_BIND_SERVICE"], "environment": env, + "volumes": [f"{quarantine_host}:/var/decnet/captured:rw"], } def dockerfile_context(self) -> Path: diff --git a/templates/ssh/Dockerfile b/templates/ssh/Dockerfile index 230d429..441fb66 100644 --- a/templates/ssh/Dockerfile +++ b/templates/ssh/Dockerfile @@ -13,15 +13,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps \ htop \ git \ + inotify-tools \ + psmisc \ + iproute2 \ + jq \ && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /var/run/sshd /root/.ssh /var/log/decnet +RUN mkdir -p /var/run/sshd /root/.ssh /var/log/decnet /var/decnet/captured \ + && chmod 700 /var/decnet /var/decnet/captured -# sshd_config: allow root + password auth +# sshd_config: allow root + password auth; VERBOSE so session lines carry +# client IP + session PID (needed for file-capture attribution). RUN sed -i \ -e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \ -e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \ -e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \ + -e 's|^#\?LogLevel.*|LogLevel VERBOSE|' \ /etc/ssh/sshd_config # rsyslog: forward auth.* and user.* to named pipe in RFC 5424 format. @@ -57,7 +64,7 @@ RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \ echo 'alias l="ls -CF"' >> /root/.bashrc && \ echo 'export HISTSIZE=1000' >> /root/.bashrc && \ echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \ - echo 'PROMPT_COMMAND='"'"'logger -p user.info -t bash "CMD uid=$UID pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc + echo 'PROMPT_COMMAND='"'"'logger -p user.info -t bash "CMD uid=$UID user=$USER src=${SSH_CLIENT%% *} pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc # Fake project files to look lived-in RUN mkdir -p /root/projects /root/backups /var/www/html && \ @@ -66,7 +73,8 @@ RUN mkdir -p /root/projects /root/backups /var/www/html && \ printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY capture.sh /usr/local/sbin/decnet-capture +RUN chmod +x /entrypoint.sh /usr/local/sbin/decnet-capture EXPOSE 22 diff --git a/templates/ssh/capture.sh b/templates/ssh/capture.sh new file mode 100755 index 0000000..cf92d49 --- /dev/null +++ b/templates/ssh/capture.sh @@ -0,0 +1,228 @@ +#!/bin/bash +# DECNET SSH honeypot file-catcher. +# +# Watches attacker-writable paths with inotifywait. On close_write/moved_to, +# copies the file to the host-mounted quarantine dir, writes a .meta.json +# with attacker attribution, and emits an RFC 5424 syslog line. +# +# Attribution chain (strongest → weakest): +# pid-chain : fuser/lsof finds writer PID → walk PPid to sshd session +# → cross-ref with `ss` to get src_ip/src_port +# utmp-only : writer PID gone (scp exited); fall back to `who --ips` +# unknown : no live session at all (unlikely under real attack) + +set -u + +CAPTURE_DIR="${CAPTURE_DIR:-/var/decnet/captured}" +CAPTURE_MAX_BYTES="${CAPTURE_MAX_BYTES:-52428800}" # 50 MiB +CAPTURE_WATCH_PATHS="${CAPTURE_WATCH_PATHS:-/root /tmp /var/tmp /home /var/www /opt /dev/shm}" + +mkdir -p "$CAPTURE_DIR" +chmod 700 "$CAPTURE_DIR" + +# Filenames we never capture (noise from container boot / attacker-irrelevant). +_is_ignored_path() { + local p="$1" + case "$p" in + "$CAPTURE_DIR"/*) return 0 ;; + /var/decnet/*) return 0 ;; + */.bash_history) return 0 ;; + */.viminfo) return 0 ;; + */ssh_host_*_key*) return 0 ;; + esac + return 1 +} + +# Resolve the writer PID best-effort. Prints the PID or nothing. +_writer_pid() { + local path="$1" + local pid + pid="$(fuser "$path" 2>/dev/null | tr -d ' \t\n')" + if [ -n "$pid" ]; then + printf '%s' "${pid%% *}" + return + fi + # Fallback: scan /proc/*/fd for an open handle on the path. + for fd_link in /proc/[0-9]*/fd/*; do + [ -L "$fd_link" ] || continue + if [ "$(readlink -f "$fd_link" 2>/dev/null)" = "$path" ]; then + printf '%s' "$(echo "$fd_link" | awk -F/ '{print $3}')" + return + fi + done +} + +# Walk PPid chain from $1 until we hit an sshd session leader. +# Prints: (empty on no match). +_walk_to_sshd() { + local pid="$1" + local depth=0 + while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$pid" != "1" ] && [ $depth -lt 20 ]; do + local cmd + cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" + # sshd session leaders look like: "sshd: root@pts/0" or "sshd: root@notty" + if echo "$cmd" | grep -qE '^sshd: [^ ]+@'; then + local user + user="$(echo "$cmd" | sed -E 's/^sshd: ([^@]+)@.*/\1/')" + printf '%s %s' "$pid" "$user" + return + fi + pid="$(awk '/^PPid:/ {print $2}' "/proc/$pid/status" 2>/dev/null)" + depth=$((depth + 1)) + done +} + +# Emit a JSON array of currently-established SSH peers. +# Each item: {pid, src_ip, src_port}. +_ss_sessions_json() { + ss -Htnp state established sport = :22 2>/dev/null \ + | awk ' + { + peer=$4; local_=$3; + # peer looks like 198.51.100.7:55342 (may be IPv6 [::1]:x) + n=split(peer, a, ":"); + port=a[n]; + ip=peer; sub(":" port "$", "", ip); + gsub(/[\[\]]/, "", ip); + # extract pid from users:(("sshd",pid=1234,fd=5)) + pid=""; + if (match($0, /pid=[0-9]+/)) { + pid=substr($0, RSTART+4, RLENGTH-4); + } + printf "{\"pid\":%s,\"src_ip\":\"%s\",\"src_port\":%s}\n", + (pid==""?"null":pid), ip, (port+0); + }' \ + | jq -s '.' +} + +# Emit a JSON array of logged-in users from utmp. +# Each item: {user, src_ip, login_at}. +_who_sessions_json() { + who --ips 2>/dev/null \ + | awk '{ printf "{\"user\":\"%s\",\"tty\":\"%s\",\"login_at\":\"%s %s\",\"src_ip\":\"%s\"}\n", $1, $2, $3, $4, $NF }' \ + | jq -s '.' +} + +_capture_one() { + local src="$1" + [ -f "$src" ] || return 0 + _is_ignored_path "$src" && return 0 + + local size + size="$(stat -c '%s' "$src" 2>/dev/null)" + [ -z "$size" ] && return 0 + if [ "$size" -gt "$CAPTURE_MAX_BYTES" ]; then + logger -p user.info -t decnet-capture "file_skipped size=$size path=$src reason=oversize" + return 0 + fi + + # Attribution first — PID may disappear after the copy races. + local writer_pid writer_comm writer_cmdline writer_uid writer_loginuid + writer_pid="$(_writer_pid "$src")" + if [ -n "$writer_pid" ] && [ -d "/proc/$writer_pid" ]; then + writer_comm="$(cat "/proc/$writer_pid/comm" 2>/dev/null)" + writer_cmdline="$(tr '\0' ' ' < "/proc/$writer_pid/cmdline" 2>/dev/null)" + writer_uid="$(awk '/^Uid:/ {print $2}' "/proc/$writer_pid/status" 2>/dev/null)" + writer_loginuid="$(cat "/proc/$writer_pid/loginuid" 2>/dev/null)" + fi + + local ssh_pid ssh_user + if [ -n "$writer_pid" ]; then + read -r ssh_pid ssh_user < <(_walk_to_sshd "$writer_pid" || true) + fi + + local ss_json who_json + ss_json="$(_ss_sessions_json 2>/dev/null || echo '[]')" + who_json="$(_who_sessions_json 2>/dev/null || echo '[]')" + + # Resolve src_ip via ss by matching ssh_pid. + local src_ip="" src_port="null" attribution="unknown" + if [ -n "${ssh_pid:-}" ]; then + local matched + matched="$(echo "$ss_json" | jq -c --argjson p "$ssh_pid" '.[] | select(.pid==$p)')" + if [ -n "$matched" ]; then + src_ip="$(echo "$matched" | jq -r '.src_ip')" + src_port="$(echo "$matched" | jq -r '.src_port')" + attribution="pid-chain" + fi + fi + if [ "$attribution" = "unknown" ] && [ "$(echo "$who_json" | jq 'length')" -gt 0 ]; then + src_ip="$(echo "$who_json" | jq -r '.[0].src_ip')" + attribution="utmp-only" + fi + + local sha + sha="$(sha256sum "$src" 2>/dev/null | awk '{print $1}')" + [ -z "$sha" ] && return 0 + + local ts base stored_as + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + base="$(basename "$src")" + stored_as="${ts}_${sha:0:12}_${base}" + + cp --preserve=timestamps,ownership "$src" "$CAPTURE_DIR/$stored_as" 2>/dev/null || return 0 + + local mtime + mtime="$(stat -c '%y' "$src" 2>/dev/null)" + + local decky="${HOSTNAME:-unknown}" + + jq -n \ + --arg captured_at "$ts" \ + --arg orig_path "$src" \ + --arg stored_as "$stored_as" \ + --arg sha "$sha" \ + --argjson size "$size" \ + --arg mtime "$mtime" \ + --arg decky "$decky" \ + --arg attribution "$attribution" \ + --arg writer_pid "${writer_pid:-}" \ + --arg writer_comm "${writer_comm:-}" \ + --arg writer_cmdline "${writer_cmdline:-}" \ + --arg writer_uid "${writer_uid:-}" \ + --arg writer_loginuid "${writer_loginuid:-}" \ + --arg ssh_pid "${ssh_pid:-}" \ + --arg ssh_user "${ssh_user:-}" \ + --arg src_ip "$src_ip" \ + --arg src_port "$src_port" \ + --argjson concurrent "$who_json" \ + --argjson ss_snapshot "$ss_json" \ + '{ + captured_at: $captured_at, + orig_path: $orig_path, + stored_as: $stored_as, + sha256: $sha, + size: $size, + mtime: $mtime, + decky: $decky, + attribution: $attribution, + writer: { + pid: ($writer_pid | if . == "" then null else tonumber? end), + comm: $writer_comm, + cmdline: $writer_cmdline, + uid: ($writer_uid | if . == "" then null else tonumber? end), + loginuid: ($writer_loginuid | if . == "" then null else tonumber? end) + }, + ssh_session: { + pid: ($ssh_pid | if . == "" then null else tonumber? end), + user: (if $ssh_user == "" then null else $ssh_user end), + src_ip: (if $src_ip == "" then null else $src_ip end), + src_port: ($src_port | if . == "null" or . == "" then null else tonumber? end) + }, + concurrent_sessions: $concurrent, + ss_snapshot: $ss_snapshot + }' > "$CAPTURE_DIR/$stored_as.meta.json" + + logger -p user.info -t decnet-capture \ + "file_captured orig_path=$src sha256=$sha size=$size stored_as=$stored_as src_ip=${src_ip:-unknown} ssh_user=${ssh_user:-unknown} attribution=$attribution" +} + +# Main loop. +# shellcheck disable=SC2086 +inotifywait -m -r -q \ + --event close_write --event moved_to \ + --format '%w%f' \ + $CAPTURE_WATCH_PATHS 2>/dev/null \ +| while IFS= read -r path; do + _capture_one "$path" & +done diff --git a/templates/ssh/entrypoint.sh b/templates/ssh/entrypoint.sh index c5c8291..889a07e 100644 --- a/templates/ssh/entrypoint.sh +++ b/templates/ssh/entrypoint.sh @@ -40,5 +40,10 @@ cat /var/run/decnet-logs & # Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above) rsyslogd +# File-catcher: mirror attacker drops into host-mounted quarantine with attribution. +# exec -a masks the process name so casual `ps` inspection doesn't reveal the honeypot. +CAPTURE_DIR=/var/decnet/captured \ + bash -c 'exec -a "[kworker/u8:0]" /usr/local/sbin/decnet-capture' & + # sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout exec /usr/sbin/sshd -D diff --git a/tests/test_ssh.py b/tests/test_ssh.py index 1573912..68e76d3 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -24,6 +24,14 @@ def _entrypoint_text() -> str: return (get_service("ssh").dockerfile_context() / "entrypoint.sh").read_text() +def _capture_script_path(): + return get_service("ssh").dockerfile_context() / "capture.sh" + + +def _capture_text() -> str: + return _capture_script_path().read_text() + + # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- @@ -166,3 +174,135 @@ def test_deaddeck_nmap_os(): def test_deaddeck_preferred_distros_not_empty(): assert len(get_archetype("deaddeck").preferred_distros) >= 1 + + +# --------------------------------------------------------------------------- +# File-catcher: Dockerfile wiring +# --------------------------------------------------------------------------- + +def test_dockerfile_installs_inotify_tools(): + assert "inotify-tools" in _dockerfile_text() + + +def test_dockerfile_installs_attribution_tools(): + df = _dockerfile_text() + for pkg in ("psmisc", "iproute2", "jq"): + assert pkg in df, f"missing {pkg} in Dockerfile" + + +def test_dockerfile_copies_capture_script(): + df = _dockerfile_text() + assert "COPY capture.sh /usr/local/sbin/decnet-capture" in df + assert "chmod +x" in df and "decnet-capture" in df + + +def test_dockerfile_creates_quarantine_dir(): + df = _dockerfile_text() + assert "/var/decnet/captured" in df + assert "chmod 700" in df + + +def test_dockerfile_ssh_loglevel_verbose(): + assert "LogLevel VERBOSE" in _dockerfile_text() + + +def test_dockerfile_prompt_command_logs_ssh_client(): + df = _dockerfile_text() + assert "PROMPT_COMMAND" in df + assert "SSH_CLIENT" in df + + +# --------------------------------------------------------------------------- +# File-catcher: capture.sh semantics +# --------------------------------------------------------------------------- + +def test_capture_script_exists_and_executable(): + import os + p = _capture_script_path() + assert p.exists(), f"capture.sh missing: {p}" + assert os.access(p, os.X_OK), "capture.sh must be executable" + + +def test_capture_script_uses_close_write_and_moved_to(): + body = _capture_text() + assert "close_write" in body + assert "moved_to" in body + assert "inotifywait" in body + + +def test_capture_script_skips_quarantine_path(): + body = _capture_text() + # Must not loop on its own writes. + assert "/var/decnet/" in body + + +def test_capture_script_resolves_writer_pid(): + body = _capture_text() + assert "fuser" in body + # walks PPid to find sshd session leader + assert "PPid" in body + assert "/proc/" in body + + +def test_capture_script_snapshots_ss_and_utmp(): + body = _capture_text() + assert "ss " in body or "ss -" in body + assert "who " in body or "who --" in body + + +def test_capture_script_writes_meta_json(): + body = _capture_text() + assert ".meta.json" in body + for key in ("attribution", "ssh_session", "writer", "sha256"): + assert key in body, f"meta key {key} missing from capture.sh" + + +def test_capture_script_emits_syslog_with_attribution(): + body = _capture_text() + assert "logger" in body + assert "file_captured" in body + assert "src_ip" in body + + +def test_capture_script_enforces_size_cap(): + body = _capture_text() + assert "CAPTURE_MAX_BYTES" in body + + +# --------------------------------------------------------------------------- +# File-catcher: entrypoint wiring +# --------------------------------------------------------------------------- + +def test_entrypoint_starts_capture_watcher(): + ep = _entrypoint_text() + assert "decnet-capture" in ep + # masked process name for casual stealth + assert "kworker" in ep + # started before sshd so drops during first login are caught + assert ep.index("decnet-capture") < ep.index("exec /usr/sbin/sshd") + + +# --------------------------------------------------------------------------- +# File-catcher: compose_fragment volume +# --------------------------------------------------------------------------- + +def test_fragment_mounts_quarantine_volume(): + frag = _fragment() + vols = frag.get("volumes", []) + assert any( + v.endswith(":/var/decnet/captured:rw") for v in vols + ), f"quarantine volume missing: {vols}" + + +def test_fragment_quarantine_host_path_layout(): + vols = _fragment()["volumes"] + host = vols[0].split(":", 1)[0] + assert host == "/var/lib/decnet/artifacts/test-decky/ssh" + + +def test_fragment_quarantine_path_per_decky(): + frag_a = get_service("ssh").compose_fragment("decky-01") + frag_b = get_service("ssh").compose_fragment("decky-02") + assert frag_a["volumes"] != frag_b["volumes"] + assert "decky-01" in frag_a["volumes"][0] + assert "decky-02" in frag_b["volumes"][0]