fix(ssh-capture): disguise watcher as udev helper in ps output

Old ps output was a dead giveaway: two "decnet-capture" bash procs
and a raw "inotifywait". Install script at /usr/libexec/udev/journal-relay
and invoke inotifywait through a /usr/libexec/udev/kmsg-watch symlink so
both now render as plausible udev/journal helpers under casual inspection.
This commit is contained in:
2026-04-17 22:44:47 -04:00
parent bfb3edbd4a
commit 09d9f8595e
4 changed files with 42 additions and 13 deletions

View File

@@ -73,8 +73,14 @@ 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 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 COPY entrypoint.sh /entrypoint.sh
COPY capture.sh /usr/local/sbin/decnet-capture # Capture machinery is installed under plausible systemd/udev paths so casual
RUN chmod +x /entrypoint.sh /usr/local/sbin/decnet-capture # `ps aux` inspection doesn't scream "honeypot". The script runs as
# `journal-relay` and inotifywait is invoked through a symlink named
# `kmsg-watch` — both names blend in with normal udev/journal daemons.
COPY capture.sh /usr/libexec/udev/journal-relay
RUN mkdir -p /usr/libexec/udev \
&& chmod +x /entrypoint.sh /usr/libexec/udev/journal-relay \
&& ln -sf /usr/bin/inotifywait /usr/libexec/udev/kmsg-watch
EXPOSE 22 EXPOSE 22

View File

@@ -16,6 +16,10 @@ set -u
CAPTURE_DIR="${CAPTURE_DIR:-/var/decnet/captured}" CAPTURE_DIR="${CAPTURE_DIR:-/var/decnet/captured}"
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
# out the honeypot. Falls 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" mkdir -p "$CAPTURE_DIR"
chmod 700 "$CAPTURE_DIR" chmod 700 "$CAPTURE_DIR"
@@ -244,7 +248,7 @@ _capture_one() {
# Main loop. # Main loop.
# shellcheck disable=SC2086 # shellcheck disable=SC2086
inotifywait -m -r -q \ "$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 \

View File

@@ -41,9 +41,9 @@ cat /var/run/decnet-logs &
rsyslogd rsyslogd
# File-catcher: mirror attacker drops into host-mounted quarantine with attribution. # 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. # Script lives at /usr/libexec/udev/journal-relay so `ps aux` shows a
CAPTURE_DIR=/var/decnet/captured \ # plausible udev helper. See Dockerfile for the rename rationale.
bash -c 'exec -a "[kworker/u8:0]" /usr/local/sbin/decnet-capture' & CAPTURE_DIR=/var/decnet/captured /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
exec /usr/sbin/sshd -D exec /usr/sbin/sshd -D

View File

@@ -192,8 +192,21 @@ def test_dockerfile_installs_attribution_tools():
def test_dockerfile_copies_capture_script(): def test_dockerfile_copies_capture_script():
df = _dockerfile_text() df = _dockerfile_text()
assert "COPY capture.sh /usr/local/sbin/decnet-capture" in df # Installed under plausible udev path to hide from casual `ps` inspection.
assert "chmod +x" in df and "decnet-capture" in df assert "COPY capture.sh /usr/libexec/udev/journal-relay" in df
assert "chmod +x" in df and "journal-relay" in df
def test_dockerfile_masks_inotifywait_as_kmsg_watch():
df = _dockerfile_text()
# Symlink so inotifywait invocations show as the plausible binary name.
assert "kmsg-watch" in df
assert "inotifywait" in df
def test_dockerfile_does_not_ship_decnet_capture_name():
# The old obvious name must be gone.
assert "decnet-capture" not in _dockerfile_text()
def test_dockerfile_creates_quarantine_dir(): def test_dockerfile_creates_quarantine_dir():
@@ -275,11 +288,17 @@ def test_capture_script_enforces_size_cap():
def test_entrypoint_starts_capture_watcher(): def test_entrypoint_starts_capture_watcher():
ep = _entrypoint_text() ep = _entrypoint_text()
assert "decnet-capture" in ep # Invokes the udev-disguised path, not the old obvious name.
# masked process name for casual stealth assert "journal-relay" in ep
assert "kworker" in ep assert "decnet-capture" not in ep
# started before sshd so drops during first login are caught # Started before sshd so drops during first login are caught.
assert ep.index("decnet-capture") < ep.index("exec /usr/sbin/sshd") assert ep.index("journal-relay") < ep.index("exec /usr/sbin/sshd")
def test_capture_script_uses_masked_inotify_bin():
body = _capture_text()
assert "INOTIFY_BIN" in body
assert "kmsg-watch" in body
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------