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:
89
templates/ssh/_build_stealth.py
Normal file
89
templates/ssh/_build_stealth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user