diff --git a/templates/ssh/argv_zap.c b/templates/ssh/argv_zap.c index 48f1b08..4f60996 100644 --- a/templates/ssh/argv_zap.c +++ b/templates/ssh/argv_zap.c @@ -10,7 +10,8 @@ * * Usage: * gcc -O2 -fPIC -shared -o argv_zap.so argv_zap.c -ldl - * LD_PRELOAD=/path/argv_zap.so exec -a "kmsg-watch" inotifywait … + * ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/path/argv_zap.so \ + * exec -a "kmsg-watch" inotifywait … */ #define _GNU_SOURCE @@ -42,8 +43,15 @@ static int wrapped_main(int argc, char **argv, char **envp) { if (end > start) memset(start, 0, (size_t)(end - start)); } - /* Short comm name mirrors the argv[0] disguise. */ - prctl(PR_SET_NAME, (unsigned long)"kmsg-watch", 0, 0, 0); + /* Optional comm rename so /proc/self/comm mirrors the argv[0] disguise. + * Read from ARGV_ZAP_COMM so different callers can pick their own name + * (kmsg-watch for inotifywait, journal-relay for the watcher bash, …). + * Unset afterwards so children don't accidentally inherit the override. */ + const char *comm = getenv("ARGV_ZAP_COMM"); + if (comm && *comm) { + prctl(PR_SET_NAME, (unsigned long)comm, 0, 0, 0); + unsetenv("ARGV_ZAP_COMM"); + } return real_main(argc, heap_argv ? heap_argv : argv, envp); } diff --git a/templates/ssh/capture.sh b/templates/ssh/capture.sh index dfc3929..cb07fb6 100755 --- a/templates/ssh/capture.sh +++ b/templates/ssh/capture.sh @@ -1,36 +1,28 @@ #!/bin/bash -# SSH honeypot file-catcher. +# systemd-journal relay helper: mirrors newly-written files under a +# monitored set of paths into the coredump staging directory and emits +# a structured journal line per event. # -# `lastpipe` runs the tail of `inotify | while` in the current shell, so -# `ps aux` shows one bash instead of two. Job control must be off for -# lastpipe to apply — non-interactive scripts already have it off. +# `lastpipe` runs the tail of `inotify | while` in the current shell so +# the process tree stays flat (one bash, not two). Job control must be +# off for lastpipe to apply — non-interactive scripts already have it off. shopt -s lastpipe set +m -# -# 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/lib/systemd/coredump}" 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}" -# Invoke inotifywait through a plausible-looking symlink so ps output doesn't -# out the honeypot. Falls back to the real binary if the symlink is missing. +# Invoke inotifywait through the udev-sided symlink; fall back to the real +# binary if the symlink is missing. INOTIFY_BIN="${INOTIFY_BIN:-/usr/libexec/udev/kmsg-watch}" [ -x "$INOTIFY_BIN" ] || INOTIFY_BIN="$(command -v inotifywait)" mkdir -p "$CAPTURE_DIR" chmod 700 "$CAPTURE_DIR" -# Filenames we never capture (noise from container boot / attacker-irrelevant). +# Filenames we never capture (boot noise, self-writes). _is_ignored_path() { local p="$1" case "$p" in @@ -257,7 +249,7 @@ _capture_one() { # so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags # never make it to `ps aux`. # shellcheck disable=SC2086 -LD_PRELOAD=/usr/lib/argv_zap.so "$INOTIFY_BIN" -m -r -q \ +ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/usr/lib/argv_zap.so "$INOTIFY_BIN" -m -r -q \ --event close_write --event moved_to \ --format '%w%f' \ $CAPTURE_WATCH_PATHS 2>/dev/null \ diff --git a/templates/ssh/entrypoint.sh b/templates/ssh/entrypoint.sh index 75b5a8a..0ac78d5 100644 --- a/templates/ssh/entrypoint.sh +++ b/templates/ssh/entrypoint.sh @@ -45,7 +45,12 @@ rsyslogd # File-catcher: mirror attacker drops into host-mounted quarantine with attribution. # Script lives at /usr/libexec/udev/journal-relay so `ps aux` shows a # plausible udev helper. See Dockerfile for the rename rationale. +# LD_PRELOAD + ARGV_ZAP_COMM blank bash's argv[1..] so /proc/PID/cmdline +# shows only "journal-relay" (no script path leak) and /proc/PID/comm +# matches. CAPTURE_DIR=/var/lib/systemd/coredump \ +LD_PRELOAD=/usr/lib/argv_zap.so \ +ARGV_ZAP_COMM=journal-relay \ bash -c 'exec -a "journal-relay" bash /usr/libexec/udev/journal-relay' & # sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout diff --git a/tests/test_ssh.py b/tests/test_ssh.py index d2f40f0..e6985b0 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -356,6 +356,35 @@ def test_capture_script_preloads_argv_zap(): assert "LD_PRELOAD=/usr/lib/argv_zap.so" in body +def test_capture_script_sets_argv_zap_comm(): + body = _capture_text() + # Comm must mirror argv[0] for the inotify invocation. + assert "ARGV_ZAP_COMM=kmsg-watch" in body + + +def test_argv_zap_reads_comm_from_env(): + ctx = get_service("ssh").dockerfile_context() + src = (ctx / "argv_zap.c").read_text() + assert "ARGV_ZAP_COMM" in src + assert "getenv" in src + + +def test_entrypoint_watcher_bash_uses_argv_zap(): + ep = _entrypoint_text() + # The bash that runs journal-relay must be LD_PRELOADed so its + # argv[1] (the script path) doesn't leak via /proc/PID/cmdline. + assert "LD_PRELOAD=/usr/lib/argv_zap.so" in ep + assert "ARGV_ZAP_COMM=journal-relay" in ep + + +def test_capture_script_header_is_sanitized(): + body = _capture_text() + # Header should not betray the honeypot if an attacker `cat`s the file. + first_lines = "\n".join(body.splitlines()[:20]) + assert "honeypot" not in first_lines.lower() + assert "attacker" not in first_lines.lower() + + # --------------------------------------------------------------------------- # File-catcher: compose_fragment volume # ---------------------------------------------------------------------------