feat(ssh-stealth): hide capture artifacts via XOR+gzip entrypoint blob

The /opt/emit_capture.py, /opt/syslog_bridge.py, and
/usr/libexec/udev/journal-relay files were plaintext and world-readable
to any attacker root-shelled into the SSH honeypot — revealing the full
capture logic on a single cat.

Pack all three into /entrypoint.sh as XOR+gzip+base64 blobs at build
time (_build_stealth.py), then decode in-memory at container start and
exec the capture loop from a bash -c string. No .py files under /opt,
no journal-relay file under /usr/libexec/udev, no argv_zap name
anywhere. The LD_PRELOAD shim is installed as
/usr/lib/x86_64-linux-gnu/libudev-shared.so.1 — sits next to the real
libudev.so.1 and blends into the multiarch layout.

A 1-byte random XOR key is chosen at image build so a bare
'base64 -d | gunzip' probe on the visible entrypoint returns binary
noise instead of readable Python.

Docker-dependent tests live under tests/docker/ behind a new 'docker'
pytest marker (excluded from the default run, same pattern as fuzz /
live / bench).
This commit is contained in:
2026-04-18 05:34:50 -04:00
parent b0e00a6cc4
commit 39dafaf384
12 changed files with 733 additions and 72 deletions

View File

@@ -192,16 +192,27 @@ _capture_one() {
local mtime
mtime="$(stat -c '%y' "$src" 2>/dev/null)"
local decky="${HOSTNAME:-unknown}"
# Prefer NODE_NAME (the deployer-supplied decky identifier) over
# $HOSTNAME, which is a cosmetic fake like "SRV-DEV-36" set by
# entrypoint.sh. The UI and the artifact bind mount both key on the
# decky name, so using $HOSTNAME here makes /artifacts/{decky}/... URLs
# unresolvable.
local decky="${NODE_NAME:-${HOSTNAME:-unknown}}"
# One syslog line, no sidecar. Flat summary fields ride as top-level SD
# params (searchable pills in the UI); bulky nested structures (writer
# cmdline, concurrent_sessions, ss_snapshot) are base64-packed into a
# single meta_json_b64 SD param by emit_capture.py.
jq -n \
--arg _hostname "$decky" \
--arg _service "ssh" \
--arg _event_type "file_captured" \
--arg captured_at "$ts" \
--arg orig_path "$src" \
--arg stored_as "$stored_as" \
--arg sha "$sha" \
--arg sha256 "$sha" \
--argjson size "$size" \
--arg mtime "$mtime" \
--arg decky "$decky" \
--arg attribution "$attribution" \
--arg writer_pid "${writer_pid:-}" \
--arg writer_comm "${writer_comm:-}" \
@@ -215,41 +226,37 @@ _capture_one() {
--argjson concurrent "$who_json" \
--argjson ss_snapshot "$ss_json" \
'{
_hostname: $_hostname,
_service: $_service,
_event_type: $_event_type,
captured_at: $captured_at,
orig_path: $orig_path,
stored_as: $stored_as,
sha256: $sha,
sha256: $sha256,
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)
},
writer_pid: $writer_pid,
writer_comm: $writer_comm,
writer_uid: $writer_uid,
ssh_pid: $ssh_pid,
ssh_user: $ssh_user,
src_ip: $src_ip,
src_port: (if $src_port == "null" or $src_port == "" then "" else $src_port end),
writer_cmdline: $writer_cmdline,
writer_loginuid: $writer_loginuid,
concurrent_sessions: $concurrent,
ss_snapshot: $ss_snapshot
}' > "$CAPTURE_DIR/$stored_as.meta.json"
logger -p user.info -t systemd-journal \
"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"
}' \
| python3 <(printf '%s' "$EMIT_CAPTURE_PY")
}
# Main loop.
# LD_PRELOAD argv_zap.so blanks argv[1..] after inotifywait parses its args,
# LD_PRELOAD libudev-shared.so.1 blanks argv[1..] after inotifywait parses its args,
# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags
# never make it to `ps aux`.
# shellcheck disable=SC2086
ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/usr/lib/argv_zap.so "$INOTIFY_BIN" -m -r -q \
ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1 "$INOTIFY_BIN" -m -r -q \
--event close_write --event moved_to \
--format '%w%f' \
$CAPTURE_WATCH_PATHS 2>/dev/null \