Files
DECNET/templates/ssh/_build_stealth.py
anti 39dafaf384 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).
2026-04-18 05:34:50 -04:00

90 lines
2.5 KiB
Python

#!/usr/bin/env python3
"""
Build-time helper: merge capture Python sources, XOR+gzip+base64 pack them
and the capture.sh loop, and render the final /entrypoint.sh from its
templated form.
Runs inside the Docker build. Reads from /tmp/build/, writes /entrypoint.sh.
"""
from __future__ import annotations
import base64
import gzip
import random
import sys
from pathlib import Path
BUILD = Path("/tmp/build")
def _merge_python() -> str:
bridge = (BUILD / "syslog_bridge.py").read_text()
emit = (BUILD / "emit_capture.py").read_text()
def _clean(src: str) -> tuple[list[str], list[str]]:
"""Return (future_imports, other_lines) with noise stripped."""
futures: list[str] = []
rest: list[str] = []
for line in src.splitlines():
ls = line.lstrip()
if ls.startswith("from __future__"):
futures.append(line)
elif ls.startswith("sys.path.insert") or ls.startswith("from syslog_bridge"):
continue
else:
rest.append(line)
return futures, rest
b_fut, b_rest = _clean(bridge)
e_fut, e_rest = _clean(emit)
# Deduplicate future imports and hoist to the very top.
seen: set[str] = set()
futures: list[str] = []
for line in (*b_fut, *e_fut):
stripped = line.strip()
if stripped not in seen:
seen.add(stripped)
futures.append(line)
header = "\n".join(futures)
body = "\n".join(b_rest) + "\n\n" + "\n".join(e_rest)
return (header + "\n" if header else "") + body
def _pack(text: str, key: int) -> str:
gz = gzip.compress(text.encode("utf-8"))
xored = bytes(b ^ key for b in gz)
return base64.b64encode(xored).decode("ascii")
def main() -> int:
key = random.SystemRandom().randint(1, 255)
merged_py = _merge_python()
capture_sh = (BUILD / "capture.sh").read_text()
emit_b64 = _pack(merged_py, key)
relay_b64 = _pack(capture_sh, key)
tpl = (BUILD / "entrypoint.sh").read_text()
rendered = (
tpl.replace("__STEALTH_KEY__", str(key))
.replace("__EMIT_CAPTURE_B64__", emit_b64)
.replace("__JOURNAL_RELAY_B64__", relay_b64)
)
for marker in ("__STEALTH_KEY__", "__EMIT_CAPTURE_B64__", "__JOURNAL_RELAY_B64__"):
if marker in rendered:
print(f"build: placeholder {marker} still present after render", file=sys.stderr)
return 1
Path("/entrypoint.sh").write_text(rendered)
Path("/entrypoint.sh").chmod(0o755)
return 0
if __name__ == "__main__":
sys.exit(main())