This commit is contained in:
Caffeine Fueled 2026-06-16 02:44:31 +02:00
commit 1fd4b9f7c1
Signed by: cf7
GPG key ID: CA295D643074C68C
3 changed files with 427 additions and 0 deletions

29
Containerfile Normal file
View file

@ -0,0 +1,29 @@
# Low-interaction SSH honeypot image (Podman/Docker compatible).
FROM python:3.12-slim
# Don't write .pyc, flush stdout/stderr immediately so `podman logs` is live.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HONEYPOT_HOST_KEY=/data/host_key \
HONEYPOT_LOG_DIR=/data/logs \
HONEYPOT_BIND=0.0.0.0 \
HONEYPOT_PORT=2222
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ssh_honeypot.py .
# Run as an unprivileged user. /data is where the host key and logs persist;
# mount a volume here so they survive container restarts.
RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin honeypot \
&& mkdir -p /data/logs \
&& chown -R honeypot:honeypot /data
USER honeypot
VOLUME ["/data"]
EXPOSE 2222
ENTRYPOINT ["python", "ssh_honeypot.py"]

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
paramiko==3.5.*

397
ssh_honeypot.py Normal file
View file

@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Low-interaction SSH honeypot.
Accepts SSH connections, lets clients *attempt* to authenticate, logs the
source address and the credentials they submit, then always rejects the auth
and closes the connection. It never grants a shell or runs commands.
This is a defensive / threat-intelligence tool. Only run it on systems and
networks you are authorized to monitor.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import signal
import socket
import threading
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
import paramiko
# Spoofed version string presented to clients. Looks like a stock Ubuntu
# OpenSSH so the honeypot blends in with real hosts. paramiko requires the
# string to start with "SSH-2.0-".
DEFAULT_BANNER = "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4"
# --------------------------------------------------------------------------- #
# Logging
# --------------------------------------------------------------------------- #
# Dedicated loggers so we can route structured vs. human-readable records to
# different handlers without interfering with paramiko's own logging.
_event_logger = logging.getLogger("honeypot.events") # -> human-readable
_json_logger = logging.getLogger("honeypot.json") # -> JSON Lines
def setup_logging(log_dir: str) -> None:
"""Configure three sinks: JSON Lines file, text file, and stdout.
Both ``_event_logger`` and ``_json_logger`` are configured here. They do
not propagate to the root logger so paramiko's transport chatter stays out
of our event logs.
"""
os.makedirs(log_dir, exist_ok=True)
# JSON Lines: one json.dumps() record per line, no extra formatting.
json_handler = RotatingFileHandler(
os.path.join(log_dir, "honeypot.jsonl"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
json_handler.setFormatter(logging.Formatter("%(message)s"))
_json_logger.addHandler(json_handler)
_json_logger.setLevel(logging.INFO)
_json_logger.propagate = False
# Human-readable: rotating file + stdout (stdout is what `podman logs` sees).
# Local-time, space-separated, no timezone clutter — easy to scan in a terminal.
text_fmt = logging.Formatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S")
text_handler = RotatingFileHandler(
os.path.join(log_dir, "honeypot.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
text_handler.setFormatter(text_fmt)
stdout_handler = logging.StreamHandler() # defaults to stderr/stdout stream
stdout_handler.setFormatter(text_fmt)
_event_logger.addHandler(text_handler)
_event_logger.addHandler(stdout_handler)
_event_logger.setLevel(logging.INFO)
_event_logger.propagate = False
# Keep paramiko's internal logging quiet but capture genuine errors.
logging.getLogger("paramiko").setLevel(logging.WARNING)
def _sanitize(value) -> str:
"""Escape control / non-printable characters in an attacker-controlled string.
The human-readable log is meant to be viewed in a terminal (``cat``, ``tail``,
``podman logs``). Without this, a hostile value e.g. a crafted SSH client
version banner could carry ANSI escape sequences (move the cursor, clear the
screen, recolour) or embedded newlines that forge extra log lines. The JSON
sink is already safe because ``json.dumps`` escapes these, so this only guards
the human sink. ``repr``-rendered fields (username/password) are likewise safe.
"""
return "".join(ch if ch.isprintable() or ch == " " else repr(ch)[1:-1]
for ch in str(value))
def _human_message(event: str, fields: dict) -> str:
"""Render a concise, terminal-friendly one-liner for an event.
Each event type gets a purpose-built layout (aligned source column, only the
fields that matter) instead of a generic key=value dump, so a stream of
attempts is easy to read at a glance.
"""
src = f"{fields['src_ip']}:{fields.get('src_port', '?')}" if "src_ip" in fields else ""
if event == "login_attempt":
if fields.get("auth_method") == "password":
cred = f"user={fields.get('username')!r} pass={fields.get('password')!r}"
else:
cred = (f"user={fields.get('username')!r} "
f"key={fields.get('key_type')}/{fields.get('key_fingerprint')}")
client = _sanitize(fields.get("client_version", ""))
return f"{src:<21} {cred} [{fields.get('auth_method')}] {client}".rstrip()
if event == "connection":
return f"{src:<21} new connection"
if event == "disconnect":
return f"{src:<21} disconnected"
if event == "connection_error":
return f"{src:<21} error: {_sanitize(fields.get('error'))}"
if event == "server_start":
return (f"listening on {fields.get('bind')}:{fields.get('port')} "
f"banner={fields.get('banner')!r} fingerprint={fields.get('host_key_fingerprint')}")
if event == "server_stop":
return "shutting down"
if event == "host_key_generated":
return f"generated host key {fields.get('fingerprint')} at {fields.get('path')}"
# Fallback for any event without a custom layout.
return " ".join(f"{k}={fields[k]!r}" for k in fields)
def log_event(event: str, **fields) -> None:
"""Emit one event to both the JSON Lines sink and the human-readable sink."""
# RFC3339 / ISO-8601 UTC with millisecond precision and a trailing 'Z'.
# This is what Grafana Loki / Promtail parse natively, and millisecond
# precision keeps bursty attempts correctly ordered.
ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
record = {"timestamp": ts, "event": event, **fields}
# JSON Lines for automation/Loki: one object per line. ensure_ascii=False
# keeps non-ASCII credentials readable; sort_keys gives stable field order.
_json_logger.info(json.dumps(record, ensure_ascii=False, sort_keys=True))
# Aligned, human-readable rendering of the same event for the terminal.
_event_logger.info(f"{event:<18} {_human_message(event, fields)}")
# --------------------------------------------------------------------------- #
# Host key
# --------------------------------------------------------------------------- #
def load_or_create_host_key(path: str) -> paramiko.PKey:
"""Load the persisted RSA host key, generating one on first run.
A stable host key keeps the server's fingerprint constant across restarts,
so returning scanners see a consistent host. RSA is used because paramiko
can both generate and persist it directly.
"""
if os.path.exists(path):
return paramiko.RSAKey(filename=path)
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
key = paramiko.RSAKey.generate(2048)
# Write with 0600 perms (the key file controls the host identity).
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.close(fd)
key.write_private_key_file(path)
os.chmod(path, 0o600)
log_event("host_key_generated", path=path, fingerprint=key.fingerprint)
return key
# --------------------------------------------------------------------------- #
# SSH server logic
# --------------------------------------------------------------------------- #
class HoneypotServer(paramiko.ServerInterface):
"""Logs every auth attempt and refuses all of them (low-interaction)."""
def __init__(self, peer: tuple[str, int]) -> None:
self.peer_ip, self.peer_port = peer
self.client_version = "" # filled in once the transport handshake runs
# Offer password + publickey so clients reveal which they intend to use.
def get_allowed_auths(self, username: str) -> str:
return "password,publickey"
def check_auth_password(self, username: str, password: str) -> int:
log_event(
"login_attempt",
src_ip=self.peer_ip,
src_port=self.peer_port,
username=username,
password=password,
auth_method="password",
client_version=self.client_version,
)
return paramiko.common.AUTH_FAILED
def check_auth_publickey(self, username: str, key: paramiko.PKey) -> int:
log_event(
"login_attempt",
src_ip=self.peer_ip,
src_port=self.peer_port,
username=username,
auth_method="publickey",
key_type=key.get_name(),
key_fingerprint=key.fingerprint,
client_version=self.client_version,
)
return paramiko.common.AUTH_FAILED
# Reject anything that would constitute interaction. We never get here with
# a successful auth, but be explicit and safe regardless.
def check_channel_request(self, kind: str, chanid: int) -> int:
return paramiko.common.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def check_channel_shell_request(self, channel) -> bool:
return False
def check_channel_exec_request(self, channel, command) -> bool:
return False
def check_channel_pty_request(self, *args, **kwargs) -> bool:
return False
def handle_connection(
client_sock: socket.socket,
addr: tuple[str, int],
host_key: paramiko.PKey,
banner: str,
max_attempts: int,
timeout: int,
) -> None:
"""Run the SSH handshake for one client and log its auth attempts."""
peer_ip, peer_port = addr
log_event("connection", src_ip=peer_ip, src_port=peer_port)
client_sock.settimeout(timeout)
transport = paramiko.Transport(client_sock)
transport.local_version = banner
transport.add_server_key(host_key)
# Limit auth tries so a single client can't hold a thread open forever.
transport.auth_timeout = timeout
transport.banner_timeout = timeout
server = HoneypotServer(addr)
try:
transport.start_server(server=server)
server.client_version = transport.remote_version or ""
# The client authenticates against HoneypotServer (which always fails).
# We just wait for the transport to be torn down or the attempts to run
# out. paramiko enforces its own internal auth-attempt limit; we bound
# the wall-clock time via the join timeout.
transport.join(timeout=timeout)
except (paramiko.SSHException, EOFError, socket.error, OSError) as exc:
log_event(
"connection_error",
src_ip=peer_ip,
src_port=peer_port,
error=f"{type(exc).__name__}: {exc}",
)
except Exception as exc: # never let one client kill the accept loop
log_event(
"connection_error",
src_ip=peer_ip,
src_port=peer_port,
error=f"unexpected {type(exc).__name__}: {exc}",
)
finally:
try:
transport.close()
except Exception:
pass
try:
client_sock.close()
except Exception:
pass
log_event("disconnect", src_ip=peer_ip, src_port=peer_port)
# --------------------------------------------------------------------------- #
# Server bootstrap
# --------------------------------------------------------------------------- #
class Honeypot:
def __init__(self, args: argparse.Namespace, host_key: paramiko.PKey) -> None:
self.args = args
self.host_key = host_key
self._stop = threading.Event()
self._sock: socket.socket | None = None
def stop(self, *_) -> None:
self._stop.set()
if self._sock is not None:
try:
self._sock.close() # unblocks accept()
except Exception:
pass
def serve(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.args.bind, self.args.port))
sock.listen(100)
self._sock = sock
log_event(
"server_start",
bind=self.args.bind,
port=self.args.port,
banner=self.args.banner,
host_key_fingerprint=self.host_key.fingerprint,
)
while not self._stop.is_set():
try:
client_sock, addr = sock.accept()
except OSError:
break # socket closed by stop()
thread = threading.Thread(
target=handle_connection,
args=(
client_sock,
addr,
self.host_key,
self.args.banner,
self.args.max_attempts,
self.args.timeout,
),
daemon=True,
)
thread.start()
log_event("server_stop")
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def _env_default(name: str, fallback: str) -> str:
return os.environ.get(name, fallback)
def parse_args(argv=None) -> argparse.Namespace:
p = argparse.ArgumentParser(description="Low-interaction SSH honeypot.")
p.add_argument("--bind", default=_env_default("HONEYPOT_BIND", "0.0.0.0"),
help="Listen address (env HONEYPOT_BIND, default 0.0.0.0)")
p.add_argument("--port", type=int,
default=int(_env_default("HONEYPOT_PORT", "2222")),
help="Listen port (env HONEYPOT_PORT, default 2222)")
p.add_argument("--host-key", default=_env_default("HONEYPOT_HOST_KEY", "./host_key"),
help="Path to the persisted host key (env HONEYPOT_HOST_KEY)")
p.add_argument("--log-dir", default=_env_default("HONEYPOT_LOG_DIR", "./logs"),
help="Directory for log files (env HONEYPOT_LOG_DIR)")
p.add_argument("--banner", default=_env_default("HONEYPOT_BANNER", DEFAULT_BANNER),
help="Spoofed SSH version string (env HONEYPOT_BANNER)")
p.add_argument("--max-attempts", type=int,
default=int(_env_default("HONEYPOT_MAX_ATTEMPTS", "3")),
help="Auth attempts before disconnect (env HONEYPOT_MAX_ATTEMPTS)")
p.add_argument("--timeout", type=int,
default=int(_env_default("HONEYPOT_TIMEOUT", "15")),
help="Per-connection timeout in seconds (env HONEYPOT_TIMEOUT)")
return p.parse_args(argv)
def main(argv=None) -> int:
args = parse_args(argv)
setup_logging(args.log_dir)
if not args.banner.startswith("SSH-2.0-"):
log_event("config_error", error="banner must start with 'SSH-2.0-'")
return 2
host_key = load_or_create_host_key(args.host_key)
honeypot = Honeypot(args, host_key)
# Clean shutdown on Ctrl-C / container stop.
signal.signal(signal.SIGINT, honeypot.stop)
signal.signal(signal.SIGTERM, honeypot.stop)
try:
honeypot.serve()
except OSError as exc:
log_event("fatal", error=f"{type(exc).__name__}: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())