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:
0
tests/docker/__init__.py
Normal file
0
tests/docker/__init__.py
Normal file
35
tests/docker/conftest.py
Normal file
35
tests/docker/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Shared fixtures for tests under `tests/docker/`.
|
||||
|
||||
All tests here are marked `docker` and excluded from the default run
|
||||
(see pyproject.toml addopts). Enable with: `pytest -m docker`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _require_docker():
|
||||
if not _docker_available():
|
||||
pytest.skip("docker daemon not reachable", allow_module_level=True)
|
||||
128
tests/docker/test_ssh_stealth_image.py
Normal file
128
tests/docker/test_ssh_stealth_image.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
End-to-end stealth assertions for the built SSH honeypot image.
|
||||
|
||||
These tests build the `templates/ssh/` Dockerfile and then introspect the
|
||||
running container to verify that:
|
||||
|
||||
- `/opt/emit_capture.py`, `/opt/syslog_bridge.py` are absent.
|
||||
- `/usr/libexec/udev/journal-relay` is absent (only the `kmsg-watch`
|
||||
symlink remains).
|
||||
- The renamed argv-zap shim is installed at the multiarch path.
|
||||
- A file drop still produces a `file_captured` RFC 5424 log line.
|
||||
|
||||
Marked `docker` so they're skipped by default (see pyproject.toml).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.services.registry import get_service
|
||||
|
||||
pytestmark = pytest.mark.docker
|
||||
|
||||
IMAGE_TAG = "decnet-ssh-stealth-test"
|
||||
|
||||
|
||||
def _run(cmd: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
check=check,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.PIPE if capture else None,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ssh_stealth_image() -> str:
|
||||
ctx = get_service("ssh").dockerfile_context()
|
||||
_run(["docker", "build", "-t", IMAGE_TAG, str(ctx)])
|
||||
yield IMAGE_TAG
|
||||
_run(["docker", "rmi", "-f", IMAGE_TAG], check=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def running_container(ssh_stealth_image):
|
||||
name = f"ssh-stealth-{uuid.uuid4().hex[:8]}"
|
||||
_run(["docker", "run", "-d", "--rm", "--name", name, ssh_stealth_image])
|
||||
# Give entrypoint time to decode + launch the capture loop.
|
||||
time.sleep(3)
|
||||
try:
|
||||
yield name
|
||||
finally:
|
||||
_run(["docker", "stop", name], check=False)
|
||||
|
||||
|
||||
def _exec(container: str, shell_cmd: str) -> str:
|
||||
return _run(["docker", "exec", container, "sh", "-c", shell_cmd]).stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-disk artifact hiding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_python_capture_sources_on_disk(running_container):
|
||||
out = _exec(
|
||||
running_container,
|
||||
'find / \\( -name "emit_capture*" -o -name "syslog_bridge*" \\) '
|
||||
'-not -path "/proc/*" 2>/dev/null',
|
||||
)
|
||||
assert out.strip() == "", f"capture python sources leaked: {out!r}"
|
||||
|
||||
|
||||
def test_no_journal_relay_file(running_container):
|
||||
out = _exec(running_container, "ls /usr/libexec/udev/")
|
||||
assert "journal-relay" not in out
|
||||
# The kmsg-watch symlink is the only expected entry.
|
||||
assert "kmsg-watch" in out
|
||||
|
||||
|
||||
def test_opt_is_empty(running_container):
|
||||
out = _exec(running_container, "ls -A /opt")
|
||||
assert out.strip() == "", f"/opt should be empty, got: {out!r}"
|
||||
|
||||
|
||||
def test_preload_shim_installed_at_multiarch_path(running_container):
|
||||
out = _exec(running_container, "ls /usr/lib/x86_64-linux-gnu/libudev-shared.so.1")
|
||||
assert "libudev-shared.so.1" in out
|
||||
|
||||
|
||||
def test_no_argv_zap_name_anywhere(running_container):
|
||||
out = _exec(
|
||||
running_container,
|
||||
'find / -name "argv_zap*" -not -path "/proc/*" 2>/dev/null',
|
||||
)
|
||||
assert out.strip() == "", f"argv_zap name leaked: {out!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime process disguise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_process_list_shows_disguised_names(running_container):
|
||||
out = _exec(running_container, "ps -eo comm")
|
||||
# Must see the cover names.
|
||||
assert "journal-relay" in out
|
||||
assert "kmsg-watch" in out
|
||||
# Must NOT see the real script / source paths in the process list.
|
||||
assert "emit_capture" not in out
|
||||
assert "argv_zap" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Functional: capture still works
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_file_drop_produces_capture_log(running_container):
|
||||
_exec(running_container, 'echo "payload-data" > /root/loot.txt')
|
||||
# Capture is async — inotify → bash → python → rsyslog → stdout.
|
||||
time.sleep(3)
|
||||
logs = _run(["docker", "logs", running_container]).stdout
|
||||
assert "file_captured" in logs, f"no capture event in logs:\n{logs}"
|
||||
assert "loot.txt" in logs
|
||||
assert "sha256=" in logs
|
||||
@@ -65,11 +65,23 @@ def test_ssh_dockerfile_context_exists():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_cowrie_vars():
|
||||
"""The old Cowrie emulation is gone — no COWRIE_* env should leak in.
|
||||
|
||||
NODE_NAME is intentionally present: it pins the decky identifier used
|
||||
by rsyslog (HOSTNAME field) and capture.sh (_hostname for file_captured
|
||||
events), so the /artifacts/{decky}/... URL lines up with the bind mount.
|
||||
"""
|
||||
env = _fragment()["environment"]
|
||||
cowrie_keys = [k for k in env if k.startswith("COWRIE_") or k == "NODE_NAME"]
|
||||
cowrie_keys = [k for k in env if k.startswith("COWRIE_")]
|
||||
assert cowrie_keys == [], f"Unexpected Cowrie vars: {cowrie_keys}"
|
||||
|
||||
|
||||
def test_node_name_matches_decky():
|
||||
"""SSH must propagate decky_name via NODE_NAME so logs/artifacts key on it."""
|
||||
frag = _fragment()
|
||||
assert frag["environment"]["NODE_NAME"] == "test-decky"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compose_fragment structure
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,6 +178,14 @@ def test_dockerfile_rsyslog_targets_pid1_stdout():
|
||||
assert "decnet-logs" not in df
|
||||
|
||||
|
||||
def test_dockerfile_disables_rsyslog_privdrop():
|
||||
# rsyslogd must stay root so it can write to PID 1's stdout fd.
|
||||
# Dropping to the syslog user makes every auth/user line silently fail.
|
||||
df = _dockerfile_text()
|
||||
assert "#$PrivDropToUser" in df
|
||||
assert "#$PrivDropToGroup" in df
|
||||
|
||||
|
||||
def test_entrypoint_starts_rsyslogd():
|
||||
assert "rsyslogd" in _entrypoint_text()
|
||||
|
||||
@@ -215,11 +235,17 @@ def test_dockerfile_installs_default_recon_tools():
|
||||
assert pkg in df, f"missing {pkg} in Dockerfile"
|
||||
|
||||
|
||||
def test_dockerfile_copies_capture_script():
|
||||
def test_dockerfile_stages_capture_script_for_inlining():
|
||||
df = _dockerfile_text()
|
||||
# Installed under plausible udev path to hide from casual `ps` inspection.
|
||||
assert "COPY capture.sh /usr/libexec/udev/journal-relay" in df
|
||||
assert "chmod +x" in df and "journal-relay" in df
|
||||
# capture.sh is no longer COPY'd to a runtime path; it's staged under
|
||||
# /tmp/build and folded into /entrypoint.sh as an XOR+gzip+base64 blob
|
||||
# by _build_stealth.py, then the staging dir is wiped in the same layer.
|
||||
assert "capture.sh" in df
|
||||
assert "/tmp/build/" in df
|
||||
assert "_build_stealth.py" in df
|
||||
assert "rm -rf /tmp/build" in df
|
||||
# The old visible install path must be gone.
|
||||
assert "/usr/libexec/udev/journal-relay" not in df
|
||||
|
||||
|
||||
def test_dockerfile_masks_inotifywait_as_kmsg_watch():
|
||||
@@ -289,18 +315,36 @@ def test_capture_script_snapshots_ss_and_utmp():
|
||||
assert "who " in body or "who --" in body
|
||||
|
||||
|
||||
def test_capture_script_writes_meta_json():
|
||||
def test_capture_script_no_longer_writes_sidecar():
|
||||
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"
|
||||
# The old .meta.json sidecar was replaced by a single syslog event that
|
||||
# carries the same metadata — see emit_capture.py.
|
||||
assert ".meta.json" not in body
|
||||
|
||||
|
||||
def test_capture_script_emits_syslog_with_attribution():
|
||||
def test_capture_script_pipes_to_emit_capture():
|
||||
body = _capture_text()
|
||||
assert "logger" in body
|
||||
# capture.sh builds the event JSON with jq and pipes to python3 reading
|
||||
# from an fd that carries the in-memory emit_capture source; no on-disk
|
||||
# emit_capture.py exists in the running container anymore.
|
||||
assert "EMIT_CAPTURE_PY" in body
|
||||
assert "python3" in body
|
||||
assert "/opt/emit_capture.py" not in body
|
||||
assert "file_captured" in body
|
||||
assert "src_ip" in body
|
||||
for key in ("attribution", "sha256", "src_ip", "ssh_user", "writer_cmdline"):
|
||||
assert key in body, f"capture field {key} missing from capture.sh"
|
||||
|
||||
|
||||
def test_ssh_dockerfile_ships_capture_emitter():
|
||||
df = _dockerfile_text()
|
||||
# Python sources are staged for the build-time inlining step, not COPY'd
|
||||
# to /opt (which would leave them world-readable for any attacker shell).
|
||||
assert "syslog_bridge.py" in df
|
||||
assert "emit_capture.py" in df
|
||||
assert "/opt/emit_capture.py" not in df
|
||||
assert "/opt/syslog_bridge.py" not in df
|
||||
# python3 is needed to run the emitter; python3-minimal keeps the image small.
|
||||
assert "python3" in df
|
||||
|
||||
|
||||
def test_capture_script_enforces_size_cap():
|
||||
@@ -343,7 +387,10 @@ def test_argv_zap_source_shipped():
|
||||
def test_dockerfile_compiles_argv_zap():
|
||||
df = _dockerfile_text()
|
||||
assert "argv_zap.c" in df
|
||||
assert "argv_zap.so" in df
|
||||
# The installed .so is disguised as a multiarch udev-companion library
|
||||
# (sits next to real libudev.so.1). The old argv_zap.so name was a tell.
|
||||
assert "/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in df
|
||||
assert "argv_zap.so" not in df
|
||||
# gcc must be installed AND purged in the same layer (image-size hygiene).
|
||||
assert "gcc" in df
|
||||
assert "apt-get purge" in df
|
||||
@@ -351,7 +398,8 @@ def test_dockerfile_compiles_argv_zap():
|
||||
|
||||
def test_capture_script_preloads_argv_zap():
|
||||
body = _capture_text()
|
||||
assert "LD_PRELOAD=/usr/lib/argv_zap.so" in body
|
||||
assert "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in body
|
||||
assert "argv_zap.so" not in body
|
||||
|
||||
|
||||
def test_capture_script_sets_argv_zap_comm():
|
||||
@@ -369,10 +417,11 @@ def test_argv_zap_reads_comm_from_env():
|
||||
|
||||
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
|
||||
# The bash that runs the capture loop must be LD_PRELOADed so the
|
||||
# (large) bash -c argument doesn't leak via /proc/PID/cmdline.
|
||||
assert "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1" in ep
|
||||
assert "ARGV_ZAP_COMM=journal-relay" in ep
|
||||
assert "argv_zap.so" not in ep
|
||||
|
||||
|
||||
def test_capture_script_header_is_sanitized():
|
||||
|
||||
143
tests/test_ssh_stealth.py
Normal file
143
tests/test_ssh_stealth.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Stealth-hardening assertions for the SSH honeypot template.
|
||||
|
||||
The three capture artifacts — syslog_bridge.py, emit_capture.py, capture.sh —
|
||||
used to land as plaintext files in the container (world-readable by the
|
||||
attacker, who is root in-container). They are now packed into /entrypoint.sh
|
||||
as XOR+gzip+base64 blobs at image-build time by _build_stealth.py.
|
||||
|
||||
These tests pin the stealth contract at the source-template level so
|
||||
regressions surface without needing a docker build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from decnet.services.registry import get_service
|
||||
|
||||
|
||||
def _ctx() -> Path:
|
||||
return get_service("ssh").dockerfile_context()
|
||||
|
||||
|
||||
def _load_build_stealth():
|
||||
path = _ctx() / "_build_stealth.py"
|
||||
spec = importlib.util.spec_from_file_location("_build_stealth", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build helper exists and is wired into the Dockerfile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_stealth_helper_shipped():
|
||||
helper = _ctx() / "_build_stealth.py"
|
||||
assert helper.exists(), "_build_stealth.py missing from SSH template"
|
||||
body = helper.read_text()
|
||||
assert "__STEALTH_KEY__" in body
|
||||
assert "__EMIT_CAPTURE_B64__" in body
|
||||
assert "__JOURNAL_RELAY_B64__" in body
|
||||
|
||||
|
||||
def test_dockerfile_invokes_build_stealth():
|
||||
df = (_ctx() / "Dockerfile").read_text()
|
||||
assert "_build_stealth.py" in df
|
||||
assert "python3 /tmp/build/_build_stealth.py" in df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint template shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_entrypoint_is_template_with_placeholders():
|
||||
ep = (_ctx() / "entrypoint.sh").read_text()
|
||||
# Pre-build template — placeholders must be present; the Docker build
|
||||
# stage substitutes them.
|
||||
assert "__STEALTH_KEY__" in ep
|
||||
assert "__EMIT_CAPTURE_B64__" in ep
|
||||
assert "__JOURNAL_RELAY_B64__" in ep
|
||||
|
||||
|
||||
def test_entrypoint_decodes_via_xor():
|
||||
ep = (_ctx() / "entrypoint.sh").read_text()
|
||||
# XOR-then-gunzip layering: base64 -> xor -> gunzip
|
||||
assert "base64 -d" in ep
|
||||
assert "gunzip" in ep
|
||||
# The decoded vars drive the capture loop.
|
||||
assert "EMIT_CAPTURE_PY" in ep
|
||||
assert "export EMIT_CAPTURE_PY" in ep
|
||||
|
||||
|
||||
def test_entrypoint_no_plaintext_python_path():
|
||||
ep = (_ctx() / "entrypoint.sh").read_text()
|
||||
assert "/opt/emit_capture.py" not in ep
|
||||
assert "/opt/syslog_bridge.py" not in ep
|
||||
assert "/usr/libexec/udev/journal-relay" not in ep
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: pack + round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_stealth_merge_and_pack_roundtrip(tmp_path, monkeypatch):
|
||||
"""Merge the real sources, pack them, and decode — assert semantic equality."""
|
||||
mod = _load_build_stealth()
|
||||
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
ctx = _ctx()
|
||||
for name in ("syslog_bridge.py", "emit_capture.py", "capture.sh", "entrypoint.sh"):
|
||||
(build / name).write_text((ctx / name).read_text())
|
||||
|
||||
monkeypatch.setattr(mod, "BUILD", build)
|
||||
out_dir = tmp_path / "out"
|
||||
out_dir.mkdir()
|
||||
|
||||
# Redirect the write target so we don't touch /entrypoint.sh.
|
||||
import pathlib
|
||||
real_path = pathlib.Path
|
||||
def fake_path(arg, *a, **kw):
|
||||
if arg == "/entrypoint.sh":
|
||||
return real_path(out_dir) / "entrypoint.sh"
|
||||
return real_path(arg, *a, **kw)
|
||||
monkeypatch.setattr(mod, "Path", fake_path)
|
||||
|
||||
rc = mod.main()
|
||||
assert rc == 0
|
||||
|
||||
rendered = (out_dir / "entrypoint.sh").read_text()
|
||||
for marker in ("__STEALTH_KEY__", "__EMIT_CAPTURE_B64__", "__JOURNAL_RELAY_B64__"):
|
||||
assert marker not in rendered, f"{marker} left in rendered entrypoint"
|
||||
|
||||
# Extract key + blobs and decode.
|
||||
import re
|
||||
key = int(re.search(r"_STEALTH_KEY=(\d+)", rendered).group(1))
|
||||
emit_b64 = re.search(r"_EMIT_CAPTURE_B64='([^']+)'", rendered).group(1)
|
||||
relay_b64 = re.search(r"_JOURNAL_RELAY_B64='([^']+)'", rendered).group(1)
|
||||
|
||||
def unpack(s: str) -> str:
|
||||
xored = base64.b64decode(s)
|
||||
gz = bytes(b ^ key for b in xored)
|
||||
return gzip.decompress(gz).decode("utf-8")
|
||||
|
||||
emit_src = unpack(emit_b64)
|
||||
relay_src = unpack(relay_b64)
|
||||
|
||||
# Merged python must contain both module bodies, with the import hack stripped.
|
||||
assert "def syslog_line(" in emit_src
|
||||
assert "def main() -> int:" in emit_src
|
||||
assert "from syslog_bridge import" not in emit_src
|
||||
assert "sys.path.insert" not in emit_src
|
||||
|
||||
# Capture loop must reference the in-memory python var, not the old path.
|
||||
assert "EMIT_CAPTURE_PY" in relay_src
|
||||
assert "/opt/emit_capture.py" not in relay_src
|
||||
assert "inotifywait" in relay_src or "INOTIFY_BIN" in relay_src
|
||||
Reference in New Issue
Block a user