fix(ssh-capture): hide watcher bash argv and sanitize script header
Two leaks remained after the inotifywait argv fix:
1. The bash running journal-relay showed its argv[1] (the script path)
in /proc/PID/cmdline, producing a line like
'journal-relay /usr/libexec/udev/journal-relay'
Apply argv_zap.so to that bash too.
2. argv_zap previously hardcoded PR_SET_NAME to 'kmsg-watch', which was
wrong for any caller other than inotifywait. The comm name now comes
from ARGV_ZAP_COMM so each caller can pick its own (kmsg-watch for
inotifywait, journal-relay for the watcher bash).
3. The capture.sh header started with 'SSH honeypot file-catcher' —
fatal if an attacker runs 'cat' on it. Rewritten as a plausible
systemd-journal relay helper; stray 'attacker' / 'honeypot' words
in mid-script comments stripped too.
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* gcc -O2 -fPIC -shared -o argv_zap.so argv_zap.c -ldl
|
* 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
|
#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));
|
if (end > start) memset(start, 0, (size_t)(end - start));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Short comm name mirrors the argv[0] disguise. */
|
/* Optional comm rename so /proc/self/comm mirrors the argv[0] disguise.
|
||||||
prctl(PR_SET_NAME, (unsigned long)"kmsg-watch", 0, 0, 0);
|
* 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);
|
return real_main(argc, heap_argv ? heap_argv : argv, envp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# `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
|
# the process tree stays flat (one bash, not two). Job control must be
|
||||||
# lastpipe to apply — non-interactive scripts already have it off.
|
# off for lastpipe to apply — non-interactive scripts already have it off.
|
||||||
shopt -s lastpipe
|
shopt -s lastpipe
|
||||||
set +m
|
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
|
set -u
|
||||||
|
|
||||||
CAPTURE_DIR="${CAPTURE_DIR:-/var/lib/systemd/coredump}"
|
CAPTURE_DIR="${CAPTURE_DIR:-/var/lib/systemd/coredump}"
|
||||||
CAPTURE_MAX_BYTES="${CAPTURE_MAX_BYTES:-52428800}" # 50 MiB
|
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}"
|
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
|
# Invoke inotifywait through the udev-sided symlink; fall back to the real
|
||||||
# out the honeypot. Falls back to the real binary if the symlink is missing.
|
# binary if the symlink is missing.
|
||||||
INOTIFY_BIN="${INOTIFY_BIN:-/usr/libexec/udev/kmsg-watch}"
|
INOTIFY_BIN="${INOTIFY_BIN:-/usr/libexec/udev/kmsg-watch}"
|
||||||
[ -x "$INOTIFY_BIN" ] || INOTIFY_BIN="$(command -v inotifywait)"
|
[ -x "$INOTIFY_BIN" ] || INOTIFY_BIN="$(command -v inotifywait)"
|
||||||
|
|
||||||
mkdir -p "$CAPTURE_DIR"
|
mkdir -p "$CAPTURE_DIR"
|
||||||
chmod 700 "$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() {
|
_is_ignored_path() {
|
||||||
local p="$1"
|
local p="$1"
|
||||||
case "$p" in
|
case "$p" in
|
||||||
@@ -257,7 +249,7 @@ _capture_one() {
|
|||||||
# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags
|
# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags
|
||||||
# never make it to `ps aux`.
|
# never make it to `ps aux`.
|
||||||
# shellcheck disable=SC2086
|
# 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 \
|
--event close_write --event moved_to \
|
||||||
--format '%w%f' \
|
--format '%w%f' \
|
||||||
$CAPTURE_WATCH_PATHS 2>/dev/null \
|
$CAPTURE_WATCH_PATHS 2>/dev/null \
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ rsyslogd
|
|||||||
# File-catcher: mirror attacker drops into host-mounted quarantine with attribution.
|
# File-catcher: mirror attacker drops into host-mounted quarantine with attribution.
|
||||||
# Script lives at /usr/libexec/udev/journal-relay so `ps aux` shows a
|
# Script lives at /usr/libexec/udev/journal-relay so `ps aux` shows a
|
||||||
# plausible udev helper. See Dockerfile for the rename rationale.
|
# 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 \
|
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' &
|
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
|
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout
|
||||||
|
|||||||
@@ -356,6 +356,35 @@ def test_capture_script_preloads_argv_zap():
|
|||||||
assert "LD_PRELOAD=/usr/lib/argv_zap.so" in body
|
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
|
# File-catcher: compose_fragment volume
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user