feat(templates): per-instance stealth via instance_seed in service servers

Every service template now pulls version strings, cluster/node UUIDs, auth
salts, greeting banners, and uptime from the seeded per-instance RNG instead
of hard-coded defaults. Scanners sweeping the fleet now see legitimately
diverging fingerprints per decky while each decky's own responses stay
internally consistent across restarts.

Covers elasticsearch, ftp, http, https, ldap, mongodb, mqtt, mssql, mysql,
postgres, redis, and smtp templates.
This commit is contained in:
2026-04-22 09:24:16 -04:00
parent 51e9e263ca
commit 3fb84ac5d0
12 changed files with 755 additions and 156 deletions

View File

@@ -5,32 +5,73 @@ Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/)
as JSON. Designed to attract automated scanners and credential stuffers. as JSON. Designed to attract automated scanners and credential stuffers.
""" """
import base64
import json import json
import os import os
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "esserver") NODE_NAME = os.environ.get("NODE_NAME", "esserver")
SERVICE_NAME = "elasticsearch" SERVICE_NAME = "elasticsearch"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
_CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA" # Real ES cluster/node UUIDs are 22-char base64 (16 random bytes,
_NODE_UUID = "dJH7Lm2sRqWvPn0kFiEtBo" # URL-safe, unpadded). Generate deterministically per instance.
def _es_uuid(namespace: str) -> str:
raw = _seed.random_bytes(16, namespace)
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
_CLUSTER_UUID = _es_uuid("es-cluster")
_NODE_UUID = _es_uuid("es-node")
_CLUSTER_NAME = os.environ.get("ES_CLUSTER_NAME") or _seed.pick([
"elasticsearch", "logs", "search-prod", "metrics", "siem-cluster",
"docker-cluster",
])
# Realistic (version, build_hash, build_date, lucene_version) tuples taken
# from real ES release metadata. Build-hashes change per release; pairing
# them correctly is what makes the version check survive a real client
# reading /_nodes and comparing against its known-versions table.
_ES_RELEASES = [
("7.17.9", "ef48222227ee6b9e70e502f0f0daa52435ee634d", "2023-01-31T05:34:43.305517834Z", "8.11.1"),
("7.17.14", "774e3bfa4d52e2834e4d9fdbb4b462fa1ba1cc5a", "2023-10-05T12:16:58.531639647Z", "8.11.1"),
("7.17.18", "8682172c2130b9a411b1bd1ff37c2f4f15f04c7b", "2024-02-02T16:43:31.000Z", "8.11.1"),
("8.10.4", "b4a62ac808e886ff032700c391f45f1408b2538c", "2023-10-11T22:04:35.506990650Z", "9.7.0"),
("8.11.4", "49b9bd5ec73c11d7b49dbd6ffc70b9ea2cdb67d0", "2023-12-19T16:57:03.000Z", "9.8.0"),
("8.12.2", "48a287ab9497e852de30327444b0809e55d46466", "2024-02-15T15:25:20.000Z", "9.9.2"),
("8.13.4", "da95df118650b55a500dcc181889ac35c6d8da7c", "2024-05-07T15:39:32.000Z", "9.10.0"),
]
_ES_VERSION, _ES_BUILD_HASH, _ES_BUILD_DATE, _ES_LUCENE = _seed.pick(_ES_RELEASES)
# Wire-compat rules in ES are hard-coded per major: pick the right ones.
if _ES_VERSION.startswith("8."):
_MIN_WIRE = "7.17.0"
_MIN_INDEX = "7.0.0"
else:
_MIN_WIRE = "6.8.0"
_MIN_INDEX = "6.0.0-beta1"
# Per-instance cluster size — shapes /_cat/nodes + /_cluster/health output.
_CLUSTER_NODES = _seed.rng.choice([1, 1, 3, 3, 3, 5, 5, 7])
_ROOT_RESPONSE = { _ROOT_RESPONSE = {
"name": NODE_NAME, "name": NODE_NAME,
"cluster_name": "elasticsearch", "cluster_name": _CLUSTER_NAME,
"cluster_uuid": _CLUSTER_UUID, "cluster_uuid": _CLUSTER_UUID,
"version": { "version": {
"number": "7.17.9", "number": _ES_VERSION,
"build_flavor": "default", "build_flavor": "default",
"build_type": "docker", "build_type": "docker",
"build_hash": "ef48222227ee6b9e70e502f0f0daa52435ee634d", "build_hash": _ES_BUILD_HASH,
"build_date": "2023-01-31T05:34:43.305517834Z", "build_date": _ES_BUILD_DATE,
"build_snapshot": False, "build_snapshot": False,
"lucene_version": "8.11.1", "lucene_version": _ES_LUCENE,
"minimum_wire_compatibility_version": "6.8.0", "minimum_wire_compatibility_version": _MIN_WIRE,
"minimum_index_compatibility_version": "6.0.0-beta1", "minimum_index_compatibility_version": _MIN_INDEX,
}, },
"tagline": "You Know, for Search", "tagline": "You Know, for Search",
} }
@@ -73,11 +114,28 @@ class ESHandler(BaseHTTPRequestHandler):
self._send_json(200, []) self._send_json(200, [])
elif path.startswith("/_cluster/"): elif path.startswith("/_cluster/"):
_log("cluster_recon", src=src, method="GET", path=self.path) _log("cluster_recon", src=src, method="GET", path=self.path)
self._send_json(200, {"cluster_name": "elasticsearch", "status": "green", self._send_json(200, {
"number_of_nodes": 3, "number_of_data_nodes": 3}) "cluster_name": _CLUSTER_NAME,
"cluster_uuid": _CLUSTER_UUID,
"status": _seed.pick(["green", "green", "green", "yellow"]),
"timed_out": False,
"number_of_nodes": _CLUSTER_NODES,
"number_of_data_nodes": _CLUSTER_NODES,
"active_primary_shards": _seed.rng.randint(5, 180),
"active_shards": _seed.rng.randint(10, 360),
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"active_shards_percent_as_number": 100.0,
})
elif path.startswith("/_nodes"): elif path.startswith("/_nodes"):
_log("nodes_recon", src=src, method="GET", path=self.path) _log("nodes_recon", src=src, method="GET", path=self.path)
self._send_json(200, {"_nodes": {"total": 3, "successful": 3, "failed": 0}, "nodes": {}}) self._send_json(200, {
"_nodes": {"total": _CLUSTER_NODES, "successful": _CLUSTER_NODES, "failed": 0},
"cluster_name": _CLUSTER_NAME,
"nodes": {_NODE_UUID: {"name": NODE_NAME, "version": _ES_VERSION,
"build_hash": _ES_BUILD_HASH}},
})
elif path.startswith("/_security/") or path.startswith("/_xpack/"): elif path.startswith("/_security/") or path.startswith("/_xpack/"):
_log("security_probe", src=src, method="GET", path=self.path) _log("security_probe", src=src, method="GET", path=self.path)
self._send_json(200, {"enabled": True, "available": True}) self._send_json(200, {"enabled": True, "available": True})

View File

@@ -12,30 +12,82 @@ from twisted.internet import defer, reactor
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
from twisted.python.filepath import FilePath from twisted.python.filepath import FilePath
from twisted.python import log as twisted_log from twisted.python import log as twisted_log
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
SERVICE_NAME = "ftp" SERVICE_NAME = "ftp"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "21")) PORT = int(os.environ.get("PORT", "21"))
BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)")
# Per-instance daemon identity. Fleet-wide "vsFTPd 3.0.3" is an instant
# fingerprint of an unmaintained honeypot — real shops run a mix.
_FTP_BANNER_CHOICES = [
"220 (vsFTPd 3.0.3)",
"220 (vsFTPd 3.0.5)",
"220 ProFTPD 1.3.7a Server ready.",
"220 ProFTPD 1.3.6 Server ready.",
"220 Pure-FTPd Service ready.",
]
BANNER = os.environ.get("FTP_BANNER") or _seed.pick(_FTP_BANNER_CHOICES)
# Accept approximately this fraction of logins. Real anon-accessible
# servers succeed often; credential-harvesting scanners hitting every
# possible user/pass pair should still see plausible failures.
_LOGIN_SUCCESS_RATE = float(os.environ.get("FTP_LOGIN_SUCCESS_RATE", "0.9"))
# Optional override — if set to "never", ALL logins fail (realistic for a
# server with anonymous disabled). Handy for producing server diversity
# across the fleet.
_LOGIN_MODE = os.environ.get("FTP_LOGIN_MODE", "").strip().lower()
def _log(event_type: str, severity: int = 6, **kwargs) -> None: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
write_syslog_file(line) write_syslog_file(line)
forward_syslog(line, LOG_TARGET) forward_syslog(line, LOG_TARGET)
def _setup_bait_fs() -> str: def _setup_bait_fs() -> str:
bait_dir = Path("/tmp/ftp_bait") """Generate a per-instance bait filesystem.
No shared paths across deckies (/tmp/ftp_bait was identical on every
host), no tell-tale 'super_secret_admin_pw' strings. Filenames, byte
counts, and inline values are all derived from the per-decky seed, so
two honeypots never serve byte-identical files yet each stays stable
across restarts."""
bait_dir = Path(f"/tmp/ftp-{_seed.instance_hex(6, 'ftp-bait-dir')}")
bait_dir.mkdir(parents=True, exist_ok=True) bait_dir.mkdir(parents=True, exist_ok=True)
(bait_dir / "backup.tar.gz").write_bytes(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00") company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
(bait_dir / "db_dump.sql").write_text("CREATE TABLE users (id INT, username VARCHAR(50), password VARCHAR(50));\nINSERT INTO users VALUES (1, 'admin', 'pbkdf2:sha256:5000$...');\n") env = _seed.pick(["prod", "stage", "backup", "archive"])
(bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n") year = _seed.rng.randint(2022, 2024)
(bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n") month = _seed.rng.randint(1, 12)
# Realistic-looking rotating backups. Sizes vary per instance.
for idx in range(_seed.rng.randint(2, 5)):
tag = f"{year}{month:02d}{_seed.rng.randint(1, 28):02d}"
size = _seed.rng.randint(2048, 32768)
(bait_dir / f"{company}-{env}-{tag}.tar.gz").write_bytes(
b"\x1f\x8b\x08\x00" + _seed.random_bytes(size - 4, f"tar-{idx}")
)
# A plausible README that looks like legacy ops notes, NOT a credential
# dump. No "password = ..." strings — those are a dead giveaway.
(bait_dir / "README.txt").write_text(
f"{company} {env} drop area\n"
f"Rotation: keep last 14, nightly rsync from db{_seed.rng.randint(1,9)}.{env}\n"
f"Contact: ops-{env}@{company}.internal\n"
)
(bait_dir / ".htaccess").write_text("Options -Indexes\n")
return str(bait_dir) return str(bait_dir)
_BAIT_PATH = _setup_bait_fs()
class ServerFTP(FTP): class ServerFTP(FTP):
def connectionMade(self): def connectionMade(self):
peer = self.transport.getPeer() peer = self.transport.getPeer()
@@ -49,10 +101,20 @@ class ServerFTP(FTP):
def ftp_PASS(self, password): def ftp_PASS(self, password):
_log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password) _log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password)
# Accept everything — we're a honeypot server # Decide whether this attempt succeeds. Unseeded randomness so
# scanners can't predict which creds will "work".
import random as _rand
if _LOGIN_MODE == "never":
accept = False
elif _LOGIN_MODE == "always":
accept = True
else:
accept = _rand.random() < _LOGIN_SUCCESS_RATE
if not accept:
return defer.succeed((530, "Login incorrect."))
self.state = self.AUTHED self.state = self.AUTHED
self._user = getattr(self, "_server_user", "anonymous") self._user = getattr(self, "_server_user", "anonymous")
self.shell = FTPAnonymousShell(FilePath(_setup_bait_fs())) self.shell = FTPAnonymousShell(FilePath(_BAIT_PATH))
return defer.succeed((230, "Login successful.")) return defer.succeed((230, "Login successful."))
def ftp_RETR(self, path): def ftp_RETR(self, path):
@@ -64,6 +126,7 @@ class ServerFTP(FTP):
_log("disconnect", src_ip=peer.host, src_port=peer.port) _log("disconnect", src_ip=peer.host, src_port=peer.port)
super().connectionLost(reason) super().connectionLost(reason)
class ServerFTPFactory(FTPFactory): class ServerFTPFactory(FTPFactory):
protocol = ServerFTP protocol = ServerFTP
welcomeMessage = BANNER welcomeMessage = BANNER

View File

@@ -12,6 +12,8 @@ from pathlib import Path
from flask import Flask, request, send_from_directory from flask import Flask, request, send_from_directory
from werkzeug.serving import make_server, WSGIRequestHandler from werkzeug.serving import make_server, WSGIRequestHandler
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
logging.getLogger("werkzeug").setLevel(logging.ERROR) logging.getLogger("werkzeug").setLevel(logging.ERROR)
@@ -20,7 +22,24 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "http" SERVICE_NAME = "http"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "80")) PORT = int(os.environ.get("PORT", "80"))
SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)")
# Per-instance Server header. Every decky running one identical Apache
# version string is a one-query fleet discovery for any scanner.
# Distribution shaped toward currently-deployed-in-the-wild versions.
_SERVER_CHOICES = [
"Apache/2.4.41 (Ubuntu)",
"Apache/2.4.52 (Ubuntu)",
"Apache/2.4.54 (Debian)",
"Apache/2.4.56 (Debian)",
"Apache/2.4.57 (Debian)",
"Apache/2.4.58 (Ubuntu)",
"Apache/2.4.59 (Debian)",
"nginx/1.18.0 (Ubuntu)",
"nginx/1.22.1",
"nginx/1.24.0 (Ubuntu)",
"nginx/1.25.3",
]
SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES)
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403")) RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
FAKE_APP = os.environ.get("FAKE_APP", "") FAKE_APP = os.environ.get("FAKE_APP", "")
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}")) EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))

View File

@@ -14,6 +14,8 @@ from pathlib import Path
from flask import Flask, request, send_from_directory from flask import Flask, request, send_from_directory
from werkzeug.serving import make_server, WSGIRequestHandler from werkzeug.serving import make_server, WSGIRequestHandler
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
logging.getLogger("werkzeug").setLevel(logging.ERROR) logging.getLogger("werkzeug").setLevel(logging.ERROR)
@@ -22,7 +24,21 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "https" SERVICE_NAME = "https"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "443")) PORT = int(os.environ.get("PORT", "443"))
SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)")
_SERVER_CHOICES = [
"Apache/2.4.41 (Ubuntu)",
"Apache/2.4.52 (Ubuntu)",
"Apache/2.4.54 (Debian)",
"Apache/2.4.56 (Debian)",
"Apache/2.4.57 (Debian)",
"Apache/2.4.58 (Ubuntu)",
"Apache/2.4.59 (Debian)",
"nginx/1.18.0 (Ubuntu)",
"nginx/1.22.1",
"nginx/1.24.0 (Ubuntu)",
"nginx/1.25.3",
]
SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES)
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403")) RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
FAKE_APP = os.environ.get("FAKE_APP", "") FAKE_APP = os.environ.get("FAKE_APP", "")
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}")) EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))

View File

@@ -7,12 +7,53 @@ invalidCredentials error. Logs all interactions as JSON.
import asyncio import asyncio
import os import os
import re
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver") NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
SERVICE_NAME = "ldap" SERVICE_NAME = "ldap"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
# RFC 4514 distinguished-name grammar: DN is a sequence of comma-separated
# RDNs like "cn=foo,ou=people,dc=example,dc=com". Each RDN is
# attribute=value, attribute matches [A-Za-z][A-Za-z0-9-]*. We keep this
# check loose on value contents (commas can be escaped etc.) but tight on
# shape, so garbage like `"abc"` or `\x00\x00` gets rejected with
# invalidDNSyntax (34) instead of invalidCredentials (49) — that's how a
# real OpenLDAP replies.
_RDN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*=.+$")
def _is_valid_dn(dn: str) -> bool:
"""True for empty (anonymous bind) or RFC 4514-shaped DN."""
if dn == "":
return True
if len(dn) > 1024:
return False
# Split on unescaped commas. Not perfect, but catches the obvious
# "not a DN" inputs (missing '=' in some RDN, empty segments, etc.).
parts: list[str] = []
buf = ""
escape = False
for ch in dn:
if escape:
buf += ch
escape = False
continue
if ch == "\\":
buf += ch
escape = True
continue
if ch == ",":
parts.append(buf)
buf = ""
continue
buf += ch
parts.append(buf)
return all(_RDN_RE.match(p.strip()) for p in parts)
@@ -75,12 +116,14 @@ def _parse_bind_request(msg: bytes):
return "<parse_error>", "<parse_error>" return "<parse_error>", "<parse_error>"
def _bind_error_response(message_id: int) -> bytes: def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
# BindResponse: resultCode=49 (invalidCredentials), matchedDN="", errorMessage="" """BindResponse with a configurable resultCode + diagnosticMessage.
result_code = bytes([0x0a, 0x01, 0x31]) # ENUMERATED 49 49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
matched_dn = bytes([0x04, 0x00]) # empty OCTET STRING err_bytes = error_text.encode()
error_msg = bytes([0x04, 0x00]) # empty OCTET STRING result_enc = bytes([0x0a, 0x01, result_code & 0xff])
bind_resp_body = result_code + matched_dn + error_msg matched_dn = bytes([0x04, 0x00])
error_msg = bytes([0x04, len(err_bytes)]) + err_bytes
bind_resp_body = result_enc + matched_dn + error_msg
bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff]) msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
@@ -131,7 +174,17 @@ class LDAPProtocol(asyncio.Protocol):
message_id = 1 message_id = 1
dn, password = _parse_bind_request(msg) dn, password = _parse_bind_request(msg)
_log("bind", src=self._peer[0], dn=dn, password=password) _log("bind", src=self._peer[0], dn=dn, password=password)
self._transport.write(_bind_error_response(message_id)) _seed.jitter_sync(10, 60)
if dn and not _is_valid_dn(dn):
# OpenLDAP returns invalidDNSyntax (34) for malformed DNs, with
# a diagnostic like: "invalid DN syntax". Matching that exactly
# keeps the decoy consistent with what a scanner expects.
self._transport.write(_bind_error_response(
message_id, result_code=34,
error_text="invalid DN"
))
else:
self._transport.write(_bind_error_response(message_id))
def connection_lost(self, exc): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _log("disconnect", src=self._peer[0] if self._peer else "?")

View File

@@ -9,6 +9,8 @@ received messages as JSON.
import asyncio import asyncio
import os import os
import struct import struct
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mongodb") NODE_NAME = os.environ.get("NODE_NAME", "mongodb")
@@ -16,6 +18,25 @@ SERVICE_NAME = "mongodb"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "27017")) PORT = int(os.environ.get("PORT", "27017"))
# Per-instance (version, maxWireVersion) — paired per real MongoDB release.
# Wire version is locked to major/minor per upstream release notes.
_MONGO_RELEASES = [
("4.4.22", 9),
("5.0.25", 13),
("6.0.5", 17),
("6.0.14", 17),
("7.0.5", 21),
("7.0.8", 21),
("7.0.11", 21),
]
_MONGO_VERSION, _MONGO_WIRE = _seed.pick(_MONGO_RELEASES)
_MONGO_SET_NAME = os.environ.get("MONGO_REPL_SET", "") # empty = standalone
def _new_objectid() -> bytes:
"""12-byte BSON ObjectId — fresh per call."""
return _seed.fresh_bytes(12)
# Minimal BSON helpers # Minimal BSON helpers
def _bson_str(key: str, val: str) -> bytes: def _bson_str(key: str, val: str) -> bytes:
k = key.encode() + b"\x00" k = key.encode() + b"\x00"
@@ -98,14 +119,23 @@ class MongoDBProtocol(asyncio.Protocol):
opcode = struct.unpack("<I", msg[12:16])[0] opcode = struct.unpack("<I", msg[12:16])[0]
_log("message", src=self._peer[0], opcode=opcode, length=len(msg)) _log("message", src=self._peer[0], opcode=opcode, length=len(msg))
# Build a generic isMaster-style OK response # Build a generic isMaster-style OK response with this instance's
reply_doc = _bson_doc( # version pair. Fresh topologyVersion on every reply (matches real
# mongod behavior — clients use this to detect failover).
fields = [
_bson_bool("ismaster", True), _bson_bool("ismaster", True),
_bson_int32("maxWireVersion", 17), _bson_bool("helloOk", True),
_bson_int32("maxBsonObjectSize", 16777216),
_bson_int32("maxMessageSizeBytes", 48000000),
_bson_int32("maxWriteBatchSize", 100000),
_bson_int32("maxWireVersion", _MONGO_WIRE),
_bson_int32("minWireVersion", 0), _bson_int32("minWireVersion", 0),
_bson_str("version", "6.0.5"), _bson_str("version", _MONGO_VERSION),
_bson_int32("ok", 1), _bson_int32("ok", 1),
) ]
if _MONGO_SET_NAME:
fields.insert(1, _bson_str("setName", _MONGO_SET_NAME))
reply_doc = _bson_doc(*fields)
if opcode == 2013: # OP_MSG if opcode == 2013: # OP_MSG
self._transport.write(_op_msg(request_id, reply_doc)) self._transport.write(_op_msg(request_id, reply_doc))
else: else:

View File

@@ -12,16 +12,44 @@ import json
import os import os
import random import random
import struct import struct
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
SERVICE_NAME = "mqtt" SERVICE_NAME = "mqtt"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "1883")) PORT = int(os.environ.get("PORT", "1883"))
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1"
MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant") # Default to auth-required. A broker that accepts literally anyone with any
# client_id / username was realistic for devices on a flat OT LAN pre-2018,
# but in 2024+ it's a tell. Operators who *want* the anonymous-broker decoy
# still set MQTT_ACCEPT_ALL=1 explicitly.
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "0") == "1"
# Optional cred list (user:pass comma-separated). If set, only those combos
# succeed even when ACCEPT_ALL is off — lets operators plant credential bait.
_MQTT_CREDS: set[tuple[str, str]] = set()
for combo in os.environ.get("MQTT_CREDS", "").split(","):
combo = combo.strip()
if ":" in combo:
u, _, p = combo.partition(":")
_MQTT_CREDS.add((u, p))
_PERSONA_CHOICES = ["water_plant", "building_hvac", "solar_farm", "factory_line"]
MQTT_PERSONA = os.environ.get("MQTT_PERSONA") or _seed.pick(_PERSONA_CHOICES)
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "") MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
# Fleet-diverse broker ID. Real mosquitto versions in the wild right now.
_BROKER_VERSION = os.environ.get("MQTT_BROKER_VERSION") or _seed.pick([
"mosquitto version 1.6.9",
"mosquitto version 2.0.11",
"mosquitto version 2.0.15",
"mosquitto version 2.0.17",
"mosquitto version 2.0.18",
"HiveMQ CE 2024.4",
"EMQX 5.3.2",
])
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00" _CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05" _CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
@@ -133,27 +161,45 @@ def _generate_topics() -> dict:
_log("config_error", severity=4, error=str(e)) _log("config_error", severity=4, error=str(e))
if MQTT_PERSONA == "water_plant": if MQTT_PERSONA == "water_plant":
site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"])
topics.update({ topics.update({
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}", f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
"plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}", f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
"plant/water/pump1/status": "RUNNING", f"{site}/water/pump1/status": "RUNNING",
"plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}", f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
"plant/water/pump2/status": "STANDBY", f"{site}/water/pump2/status": "STANDBY",
"plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}", f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
"plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}", f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
"plant/water/valve/inlet/state": "OPEN", f"{site}/water/valve/inlet/state": "OPEN",
"plant/water/valve/drain/state": "CLOSED", f"{site}/water/valve/drain/state": "CLOSED",
"plant/alarm/high_pressure": "0", f"{site}/alarm/high_pressure": "0",
"plant/alarm/low_chlorine": "0", f"{site}/alarm/low_chlorine": "0",
"plant/alarm/pump_fault": "0", f"{site}/alarm/pump_fault": "0",
"plant/$SYS/broker/version": "Mosquitto 2.0.15",
"plant/$SYS/broker/uptime": "2847392",
}) })
elif not topics: elif MQTT_PERSONA == "building_hvac":
floor = _seed.rng.randint(1, 12)
for i in range(_seed.rng.randint(4, 10)):
topics[f"bldg/floor{floor}/zone{i}/temp"] = f"{random.uniform(20.0, 24.5):.1f}"
topics[f"bldg/floor{floor}/zone{i}/setpoint"] = f"{random.uniform(21.0, 23.0):.1f}"
topics[f"bldg/floor{floor}/ahu/status"] = _seed.pick(["RUNNING", "RUNNING", "IDLE"])
elif MQTT_PERSONA == "solar_farm":
for arr in range(1, _seed.rng.randint(4, 9)):
topics[f"solar/array{arr}/power_kw"] = f"{random.uniform(40.0, 180.0):.1f}"
topics[f"solar/array{arr}/irradiance"] = f"{random.uniform(500, 950):.0f}"
elif MQTT_PERSONA == "factory_line":
line = _seed.pick(["A", "B", "C"])
for m in range(1, _seed.rng.randint(3, 7)):
topics[f"line{line}/machine{m}/state"] = _seed.pick(["RUN", "RUN", "IDLE", "FAULT"])
topics[f"line{line}/machine{m}/cycle_count"] = str(_seed.rng.randint(1000, 999_999))
if not topics:
topics = { topics = {
"device/status": "online", "device/status": "online",
"device/uptime": "3600" "device/uptime": str(_seed.uptime_seconds()),
} }
# $SYS keys match every real broker.
topics["$SYS/broker/version"] = _BROKER_VERSION
topics["$SYS/broker/uptime"] = f"{_seed.uptime_seconds()} seconds"
topics["$SYS/broker/clients/connected"] = str(_seed.rng.randint(2, 24))
return topics return topics
@@ -211,7 +257,13 @@ class MQTTProtocol(asyncio.Protocol):
if pkt_type == 1: # CONNECT if pkt_type == 1: # CONNECT
info = _parse_connect(payload) info = _parse_connect(payload)
_log("auth", **info) _log("auth", **info)
if MQTT_ACCEPT_ALL: # Decide connection: accept-all > cred list > deny.
cred = (info.get("username", ""), info.get("password", ""))
accepted = (
MQTT_ACCEPT_ALL
or (cred in _MQTT_CREDS if _MQTT_CREDS else False)
)
if accepted:
self._auth = True self._auth = True
self._transport.write(_CONNACK_ACCEPTED) self._transport.write(_CONNACK_ACCEPTED)
else: else:

View File

@@ -8,37 +8,73 @@ a login failed error. Logs auth attempts as JSON.
import asyncio import asyncio
import os import os
import struct import struct
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "dbserver") NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
SERVICE_NAME = "mssql" SERVICE_NAME = "mssql"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
_PRELOGIN_RESP = bytes([ # Real SQL Server release families. Pairing (major, minor, build) makes a
0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47 # subsequent OSQL/sqlcmd version probe line up with what MS published.
# 0. VERSION option # Builds are taken from publicly documented latest-CU numbers.
0x00, 0x00, 0x1a, 0x00, 0x06, _MSSQL_RELEASES = [
# 1. ENCRYPTION option # (name, major, minor, build, subbuild)
0x01, 0x00, 0x20, 0x00, 0x01, ("SQL Server 2016", 13, 0, 6419, 0),
# 2. INSTOPT ("SQL Server 2017", 14, 0, 2000, 0),
0x02, 0x00, 0x21, 0x00, 0x01, ("SQL Server 2017", 14, 0, 3460, 0),
# 3. THREADID ("SQL Server 2019", 15, 0, 2000, 0),
0x03, 0x00, 0x22, 0x00, 0x04, ("SQL Server 2019", 15, 0, 4335, 1),
# 4. MARS ("SQL Server 2022", 16, 0, 1000, 0),
0x04, 0x00, 0x26, 0x00, 0x01, ("SQL Server 2022", 16, 0, 4115, 2),
# TERMINATOR ]
0xff, _MSSQL_NAME, _VER_MAJ, _VER_MIN, _VER_BUILD, _VER_SUB = _seed.pick(_MSSQL_RELEASES)
# version data: 14.0.2000
0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
# encryption: NOT_SUP def _build_prelogin_response() -> bytes:
0x02, """TDS PRELOGIN response. Version option carries
# instopt major(1) minor(1) build(2, network order) subbuild(2, network order)."""
0x00, version_data = (
# thread id bytes([_VER_MAJ & 0xff, _VER_MIN & 0xff])
0x00, 0x00, 0x00, 0x00, + struct.pack(">H", _VER_BUILD & 0xffff)
# mars + struct.pack(">H", _VER_SUB & 0xffff)
0x00, )
]) # Option directory + data. Offsets are from start of directory.
# Five options: VERSION, ENCRYPTION, INSTOPT, THREADID, MARS.
# Data fields, in order:
encryption = b"\x02" # NOT_SUP
instopt = b"\x00"
threadid = struct.pack("<I", _seed.rng.randint(100, 9000))
mars = b"\x00"
directory = b""
data = b""
# Directory header is 5 bytes per option + 1 terminator; compute offsets
# from end of terminator.
dir_size = 5 * 5 + 1
running_offset = dir_size
def add_option(token: int, chunk: bytes) -> None:
nonlocal directory, data, running_offset
directory += bytes([token]) + struct.pack(">H", running_offset) + struct.pack(">H", len(chunk))
data += chunk
running_offset += len(chunk)
add_option(0x00, version_data)
add_option(0x01, encryption)
add_option(0x02, instopt)
add_option(0x03, threadid)
add_option(0x04, mars)
directory += b"\xff"
payload = directory + data
total_len = 8 + len(payload)
header = struct.pack(">BBHBBBB", 0x04, 0x01, total_len, 0x00, 0x00, 0x01, 0x00)
return header + payload
_PRELOGIN_RESP = _build_prelogin_response()

View File

@@ -7,32 +7,56 @@ attempts as JSON.
""" """
import asyncio import asyncio
import itertools
import os import os
import struct import struct
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "dbserver") NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
SERVICE_NAME = "mysql" SERVICE_NAME = "mysql"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "3306")) PORT = int(os.environ.get("PORT", "3306"))
_MYSQL_VER = os.environ.get("MYSQL_VERSION", "5.7.38-log")
# Minimal MySQL server greeting (protocol v10) — version string is configurable # Per-instance version. Real fleets never run one identical point release
_GREETING = ( # across every host — weighted mix of still-in-the-wild 5.7/8.0 builds.
b"\x0a" # protocol version 10 _MYSQL_VER = os.environ.get("MYSQL_VERSION") or _seed.pick_weighted([
+ _MYSQL_VER.encode() + b"\x00" # server version + NUL ("5.7.38-log", 1),
+ b"\x01\x00\x00\x00" # connection id = 1 ("5.7.43-log", 2),
+ b"\x70\x76\x21\x6d\x61\x67\x69\x63" # auth-plugin-data part 1 ("5.7.44-log", 2),
+ b"\x00" # filler ("8.0.32", 2),
+ b"\xff\xf7" # capability flags low ("8.0.35", 3),
+ b"\x21" # charset utf8 ("8.0.36", 3),
+ b"\x02\x00" # status flags ("8.0.39", 2),
+ b"\xff\x81" # capability flags high ("8.0.40", 1),
+ b"\x15" # auth plugin data length ])
+ b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" # reserved (10 bytes)
+ b"\x21\x4f\x7d\x25\x3e\x55\x4d\x7c\x67\x75\x5e\x31\x00" # auth part 2 # Monotonic per-process counter for connection IDs. Seeded with a
+ b"mysql_native_password\x00" # auth plugin name # per-instance base so two deckies never hand out id=1 to the same scanner.
) _CONN_ID_SEQ = itertools.count(_seed.rng.randint(17, 65_000))
def _build_greeting(conn_id: int, salt: bytes) -> bytes:
"""MySQL protocol v10 Initial Handshake Packet. salt is 20 bytes
(8 + 12 split across two sections) and must be freshly random per
connection — it's the challenge the client hashes its password against."""
assert len(salt) == 20
return (
b"\x0a"
+ _MYSQL_VER.encode() + b"\x00"
+ struct.pack("<I", conn_id)
+ salt[:8]
+ b"\x00"
+ b"\xff\xf7"
+ b"\x21"
+ b"\x02\x00"
+ b"\xff\x81"
+ b"\x15"
+ b"\x00" * 10
+ salt[8:] + b"\x00"
+ b"mysql_native_password\x00"
)
def _make_packet(payload: bytes, seq: int = 0) -> bytes: def _make_packet(payload: bytes, seq: int = 0) -> bytes:
@@ -54,12 +78,17 @@ class MySQLProtocol(asyncio.Protocol):
self._peer = None self._peer = None
self._buf = b"" self._buf = b""
self._greeted = False self._greeted = False
self._conn_id = next(_CONN_ID_SEQ) & 0xFFFFFFFF
# 20-byte scramble; fresh per connection so two handshakes to the
# same decky never present identical auth challenges.
self._salt = _seed.fresh_bytes(20)
def connection_made(self, transport): def connection_made(self, transport):
self._transport = transport self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0)) self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1]) _log("connect", src=self._peer[0], src_port=self._peer[1],
transport.write(_make_packet(_GREETING, seq=0)) connection_id=self._conn_id)
transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0))
self._greeted = True self._greeted = True
def data_received(self, data): def data_received(self, data):
@@ -81,19 +110,24 @@ class MySQLProtocol(asyncio.Protocol):
if not payload: if not payload:
return return
# Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated) # Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated)
username = "<unknown>"
if len(payload) > 32: if len(payload) > 32:
try: try:
# skip capability(4) + max_pkt(4) + charset(1) + reserved(23) = 32 bytes
username_start = 32 username_start = 32
nul = payload.index(b"\x00", username_start) nul = payload.index(b"\x00", username_start)
username = payload[username_start:nul].decode(errors="replace") username = payload[username_start:nul].decode(errors="replace")
except (ValueError, IndexError): except (ValueError, IndexError):
username = "<parse_error>" username = "<parse_error>"
_log("auth", src=self._peer[0], username=username) _log("auth", src=self._peer[0], username=username,
# Send Access Denied error connection_id=self._conn_id)
err = b"\xff" + struct.pack("<H", 1045) + b"#28000Access denied for user\x00" # Real mysqld includes client IP in the error text.
self._transport.write(_make_packet(err, seq=2)) src_ip = self._peer[0] if self._peer else "?"
self._transport.close() msg = f"Access denied for user '{username}'@'{src_ip}' (using password: YES)"
err = b"\xff" + struct.pack("<H", 1045) + b"#28000" + msg.encode()
_seed.jitter_sync(15, 90)
if self._transport and not self._transport.is_closing():
self._transport.write(_make_packet(err, seq=2))
self._transport.close()
def connection_lost(self, exc): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _log("disconnect", src=self._peer[0] if self._peer else "?")

View File

@@ -9,14 +9,43 @@ returns an error. Logs all interactions as JSON.
import asyncio import asyncio
import os import os
import struct import struct
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "pgserver") NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
SERVICE_NAME = "postgres" SERVICE_NAME = "postgres"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "5432")) PORT = int(os.environ.get("PORT", "5432"))
def _error_response(message: str) -> bytes:
body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00" # Per-instance list of "existing" databases. A real server knows which dbs
# it hosts and returns SQLSTATE 3D000 "database does not exist" for anything
# else — refusing with "password authentication failed" for every single
# probe is a strong honeypot signal.
_BASE_DBS = {"postgres", "template0", "template1"}
_APP_DB_CHOICES = [
["app", "app_prod"],
["webapp", "sessions"],
["erp", "erp_hist"],
["django", "django_cache"],
["rails_production"],
["wordpress"],
["gitlabhq_production"],
["metrics", "grafana"],
]
_DATABASES = _BASE_DBS | set(_seed.pick(_APP_DB_CHOICES))
def _error_response(severity: str, sqlstate: str, message: str) -> bytes:
"""Wire-level PG ErrorResponse. Fields: S (localized severity), V
(non-localized severity, PG 9.6+), C (SQLSTATE), M (message)."""
body = (
b"S" + severity.encode() + b"\x00"
+ b"V" + severity.encode() + b"\x00"
+ b"C" + sqlstate.encode() + b"\x00"
+ b"M" + message.encode() + b"\x00"
+ b"\x00"
)
return b"E" + struct.pack(">I", len(body) + 4) + body return b"E" + struct.pack(">I", len(body) + 4) + body
@@ -90,8 +119,18 @@ class PostgresProtocol(asyncio.Protocol):
if k: if k:
params[k] = v params[k] = v
username = params.get("user", "") username = params.get("user", "")
database = params.get("database", "") database = params.get("database", "") or username
self._username = username
self._database = database
_log("startup", src=self._peer[0], username=username, database=database) _log("startup", src=self._peer[0], username=username, database=database)
# If the requested DB doesn't exist on this instance, real Postgres
# rejects *before* asking for a password. Short-circuit so the decoy
# matches that behavior and exposes the per-decky DB list.
if database and database not in _DATABASES:
msg = f'database "{database}" does not exist'
self._transport.write(_error_response("FATAL", "3D000", msg))
self._transport.close()
return
self._state = "auth" self._state = "auth"
salt = os.urandom(4) salt = os.urandom(4)
auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt
@@ -99,8 +138,13 @@ class PostgresProtocol(asyncio.Protocol):
def _handle_password(self, payload: bytes): def _handle_password(self, payload: bytes):
pw_hash = payload.rstrip(b"\x00").decode(errors="replace") pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
_log("auth", src=self._peer[0], pw_hash=pw_hash) _log("auth", src=self._peer[0], pw_hash=pw_hash,
self._transport.write(_error_response("password authentication failed")) username=getattr(self, "_username", ""),
database=getattr(self, "_database", ""))
user = getattr(self, "_username", "")
msg = f'password authentication failed for user "{user}"'
_seed.jitter_sync(20, 90)
self._transport.write(_error_response("FATAL", "28P01", msg))
self._transport.close() self._transport.close()
def connection_lost(self, exc): def connection_lost(self, exc):

View File

@@ -7,38 +7,111 @@ KEYS, and arbitrary commands. Logs every command and argument as JSON.
import asyncio import asyncio
import os import os
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "cache-server") NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
SERVICE_NAME = "redis" SERVICE_NAME = "redis"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "6379")) PORT = int(os.environ.get("PORT", "6379"))
_REDIS_VER = os.environ.get("REDIS_VERSION", "7.2.7")
_REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0")
_INFO = ( # Per-instance realistic version pick (weighted toward still-supported lines).
f"# Server\n" _REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([
f"redis_version:{_REDIS_VER}\n" ("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2),
f"redis_mode:standalone\n" ("7.0.15", 2), ("7.0.14", 1),
f"os:{_REDIS_OS}\n" ("6.2.14", 2), ("6.2.16", 1),
f"arch_bits:64\n" ])
f"tcp_port:6379\n" # Kernel line matching plausible Debian/Ubuntu LTS minor ranges.
f"uptime_in_seconds:864000\n" _REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([
f"connected_clients:1\n" "Linux 5.15.0-118-generic x86_64",
f"# Keyspace\n" "Linux 6.1.0-21-amd64 x86_64",
).encode() "Linux 5.10.0-30-amd64 x86_64",
"Linux 6.5.0-27-generic x86_64",
])
_RUN_ID = _seed.instance_hex(20, "redis-run")
_PROCESS_ID = _seed.rng.randint(120, 32000)
_TCP_PORT_STR = str(PORT)
_FAKE_STORE = { # AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}', # the canonical "Client sent AUTH, but no password is set" error, matching a
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}', # real redis-server with requirepass unset.
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ", _REQUIREPASS = os.environ.get("REDIS_PASSWORD", "")
b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}', def _info_block() -> bytes:
b"config:db_password": b"Pr0dDB!2024#Secure", uptime = _seed.uptime_seconds()
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE", uptime_days = max(1, uptime // 86400)
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", # Minimal but plausible subset; real redis INFO has ~150 keys.
b"rate_limit:192.168.1.1": b"42", text = (
"# Server\r\n"
f"redis_version:{_REDIS_VER}\r\n"
f"redis_git_sha1:00000000\r\n"
f"redis_git_dirty:0\r\n"
f"redis_build_id:{_seed.instance_hex(8, 'redis-build')}\r\n"
"redis_mode:standalone\r\n"
f"os:{_REDIS_OS}\r\n"
"arch_bits:64\r\n"
f"process_id:{_PROCESS_ID}\r\n"
f"run_id:{_RUN_ID}\r\n"
f"tcp_port:{_TCP_PORT_STR}\r\n"
f"uptime_in_seconds:{uptime}\r\n"
f"uptime_in_days:{uptime_days}\r\n"
"hz:10\r\n"
"# Clients\r\n"
"connected_clients:1\r\n"
"maxclients:10000\r\n"
"# Memory\r\n"
f"used_memory:{_seed.rng.randint(800_000, 12_000_000)}\r\n"
"mem_fragmentation_ratio:1.12\r\n"
"# Stats\r\n"
f"total_connections_received:{_seed.rng.randint(50, 9000)}\r\n"
f"total_commands_processed:{_seed.rng.randint(5_000, 2_000_000)}\r\n"
"# Keyspace\r\n"
)
return text.encode()
def _build_fake_store() -> dict[bytes, bytes]:
"""Per-instance plausible cache content. No embedded DECNET-identifying
strings; keys / values shaped like what real apps leave in redis."""
n_sessions = _seed.rng.randint(3, 14)
store: dict[bytes, bytes] = {}
app_slug = _seed.pick(["api", "web", "worker", "shop", "admin", "cms"])
env_slug = _seed.pick(["prod", "stage", "live"])
for i in range(n_sessions):
sid = _seed.instance_hex(16, f"sess-{i}")
uid = _seed.rng.randint(1000, 999_999)
store[f"session:{sid}".encode()] = (
f'{{"uid":{uid},"exp":{int(_seed.boot_epoch()) + 86400 * 7}}}'
).encode()
for i in range(_seed.rng.randint(2, 6)):
store[f"cache:{app_slug}:feed:{i}".encode()] = (
_seed.instance_hex(24, f"feed-{i}").encode()
)
store[f"stats:{app_slug}:{env_slug}:requests".encode()] = (
str(_seed.rng.randint(5_000, 900_000)).encode()
)
return store
_FAKE_STORE = _build_fake_store()
# Config presented via CONFIG GET — realistic subset of a default redis.conf.
_CONFIG = {
"maxmemory": "0",
"maxmemory-policy": "noeviction",
"maxclients": "10000",
"timeout": "0",
"tcp-keepalive": "300",
"databases": "16",
"save": "3600 1 300 100 60 10000",
"appendonly": "no",
"loglevel": "notice",
"dir": "/var/lib/redis",
"bind": "127.0.0.1 -::1",
"protected-mode": "yes",
"supervised": "systemd",
} }
@@ -114,11 +187,22 @@ class RESPParser:
return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1) return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1)
def _config_get(pattern: str) -> bytes:
"""Emulate `CONFIG GET <pattern>` — returns alternating key/value bulks."""
import fnmatch
matches = [(k, v) for k, v in _CONFIG.items() if fnmatch.fnmatchcase(k, pattern)]
out = f"*{len(matches) * 2}\r\n".encode()
for k, v in matches:
out += _bulk(k) + _bulk(v)
return out
class RedisProtocol(asyncio.Protocol): class RedisProtocol(asyncio.Protocol):
def __init__(self): def __init__(self):
self._transport = None self._transport = None
self._peer = None self._peer = None
self._parser = RESPParser() self._parser = RESPParser()
self._authed = not _REQUIREPASS # auth satisfied iff no password set
def connection_made(self, transport): def connection_made(self, transport):
self._transport = transport self._transport = transport
@@ -129,6 +213,16 @@ class RedisProtocol(asyncio.Protocol):
for cmd in self._parser.feed(data): for cmd in self._parser.feed(data):
self._handle_command(cmd) self._handle_command(cmd)
def _write(self, payload: bytes) -> None:
"""Writes with per-response jitter. Unseeded so two connections to
the same decky don't get an identical latency fingerprint. Honeypot
throughput targets are low; a few ms of blocking sleep here is fine
and avoids the asyncio-task plumbing the synchronous protocol model
doesn't otherwise need."""
_seed.jitter_sync(2, 40)
if self._transport and not self._transport.is_closing():
self._transport.write(payload)
def _handle_command(self, parts): def _handle_command(self, parts):
if not parts: if not parts:
return return
@@ -137,15 +231,40 @@ class RedisProtocol(asyncio.Protocol):
_log("command", src=self._peer[0], cmd=verb, args=args[:8]) _log("command", src=self._peer[0], cmd=verb, args=args[:8])
if verb == "AUTH": if verb == "AUTH":
password = args[0] if args else "" password = args[-1] if args else ""
_log("auth", src=self._peer[0], password=password) _log("auth", src=self._peer[0], password=password)
self._transport.write(b"+OK\r\n") if not _REQUIREPASS:
elif verb == "INFO": self._write(
self._transport.write(f"${len(_INFO)}\r\n".encode() + _INFO + b"\r\n") _err("Client sent AUTH, but no password is set. "
"Did you mean AUTH <username> <password>?")
)
elif password == _REQUIREPASS:
self._authed = True
self._write(b"+OK\r\n")
else:
self._write(_err("WRONGPASS invalid username-password pair or user is disabled."))
return
if not self._authed:
self._write(_err("NOAUTH Authentication required."))
return
if verb == "INFO":
info = _info_block()
self._write(f"${len(info)}\r\n".encode() + info + b"\r\n")
elif verb == "PING": elif verb == "PING":
self._transport.write(b"+PONG\r\n") self._write(b"+PONG\r\n")
elif verb == "CONFIG": elif verb == "CONFIG":
self._transport.write(b"*0\r\n") sub = args[0].upper() if args else ""
if sub == "GET" and len(args) >= 2:
self._write(_config_get(args[1]))
elif sub == "SET":
self._write(b"+OK\r\n")
elif sub == "RESETSTAT":
self._write(b"+OK\r\n")
else:
self._write(_err(
"Unknown CONFIG subcommand or wrong number of arguments for '"
f"{sub.lower() or '?'}'"
))
elif verb == "KEYS": elif verb == "KEYS":
pattern = args[0] if args else "*" pattern = args[0] if args else "*"
keys = list(_FAKE_STORE.keys()) keys = list(_FAKE_STORE.keys())
@@ -157,26 +276,35 @@ class RedisProtocol(asyncio.Protocol):
keys = [k for k in keys if k == pat] keys = [k for k in keys if k == pat]
resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys) resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._transport.write(resp) self._write(resp)
elif verb == "GET": elif verb == "GET":
key = args[0].encode() if args else b"" key = args[0].encode() if args else b""
if key in _FAKE_STORE: if key in _FAKE_STORE:
self._transport.write(_bulk(_FAKE_STORE[key].decode())) self._write(_bulk(_FAKE_STORE[key].decode()))
else: else:
self._transport.write(b"$-1\r\n") self._write(b"$-1\r\n")
elif verb == "SCAN": elif verb == "SCAN":
keys = list(_FAKE_STORE.keys()) keys = list(_FAKE_STORE.keys())
resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys) resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._transport.write(resp) self._write(resp)
elif verb == "TYPE": elif verb == "TYPE":
self._transport.write(b"+string\r\n") self._write(b"+string\r\n")
elif verb == "TTL": elif verb == "TTL":
self._transport.write(b":-1\r\n") self._write(b":-1\r\n")
elif verb == "DBSIZE":
self._write(f":{len(_FAKE_STORE)}\r\n".encode())
elif verb == "COMMAND":
self._write(b"*0\r\n")
elif verb == "CLIENT":
self._write(b"+OK\r\n")
elif verb == "SELECT":
self._write(b"+OK\r\n")
elif verb == "QUIT": elif verb == "QUIT":
self._transport.write(b"+OK\r\n") self._write(b"+OK\r\n")
self._transport.close() if self._transport:
self._transport.close()
else: else:
self._transport.write(_err("unknown command")) self._write(_err(f"unknown command '{verb.lower()}'"))
def connection_lost(self, exc): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _log("disconnect", src=self._peer[0] if self._peer else "?")

View File

@@ -21,8 +21,11 @@ The DATA state machine (and the 502-per-line bug) is fixed in both modes.
import asyncio import asyncio
import base64 import base64
import os import os
import random import random as _rand
import string import re
import time
import instance_seed as _seed
from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mailserver") NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
@@ -31,9 +34,27 @@ LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "25")) PORT = int(os.environ.get("PORT", "25"))
OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1" OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1"
# In open-relay mode, optionally restrict which creds succeed. Blank means
# "accept anything". Format: "user1,user2,..." — any name not in the list
# gets a 535 instead of 235, so the relay looks realistically selective.
_AUTH_WHITELIST = {u.strip() for u in os.environ.get("SMTP_AUTH_WHITELIST", "").split(",") if u.strip()}
# Open-relay filtering. Even compromised/misconfigured relays aren't pure
# tarpits — Postfix rejects malformed addresses at RCPT time, and many drop
# a small fraction of external recipients under greylisting or reputation
# checks. Accepting literally every RCPT is a honeypot tell.
_ADDR_RE = re.compile(r"^<?([^\s<>@]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})>?$")
_BLOCKED_TLDS = {"invalid", "test", "localhost", "local", "example"}
_RCPT_DROP_RATE = float(os.environ.get("SMTP_RCPT_DROP_RATE", "0.08"))
_SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)") _SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)")
_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME) _SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME)
# Postfix's queue-ID character set (real one: excludes vowels and look-alikes
# like 0/O, 1/I, so scanners that know Postfix's alphabet are satisfied).
_QUEUE_CHARS = "BCDFGHJKLMNPQRSTVWXYZ23456789"
_Q_BASE = len(_QUEUE_CHARS)
def _log(event_type: str, severity: int = 6, **kwargs) -> None: def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -42,9 +63,23 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
def _rand_msg_id() -> str: def _rand_msg_id() -> str:
"""Return a Postfix-style 12-char alphanumeric queue ID.""" """Postfix-style queue ID.
chars = string.ascii_uppercase + string.digits
return "".join(random.choices(chars, k=12)) Real Postfix derives its short queue IDs from the message's arrival
microseconds, base-encoded with a vowel-free alphabet — so IDs are
monotonically increasing and visually distinctive. We encode the current
microsecond count with Postfix's actual character set, then append a
short per-instance suffix so two deckies never emit identical IDs at
the same instant.
"""
us = int(time.time() * 1_000_000)
out: list[str] = []
while us and len(out) < 10:
us, r = divmod(us, _Q_BASE)
out.append(_QUEUE_CHARS[r])
base = "".join(reversed(out)) or _QUEUE_CHARS[0]
suffix_idx = _seed.rng.randint(0, _Q_BASE - 1)
return base + _QUEUE_CHARS[suffix_idx]
def _decode_auth_plain(blob: str) -> tuple[str, str]: def _decode_auth_plain(blob: str) -> tuple[str, str]:
@@ -108,6 +143,9 @@ class SMTPProtocol(asyncio.Protocol):
rcpt_to=",".join(self._rcpt_to), rcpt_to=",".join(self._rcpt_to),
body_bytes=len(body), body_bytes=len(body),
msg_id=msg_id) msg_id=msg_id)
# Real MTAs take tens of ms to queue; instantaneous replies
# on DATA are a tell.
_seed.jitter_sync(30, 180)
self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode()) self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
self._in_data = False self._in_data = False
self._data_buf = [] self._data_buf = []
@@ -172,9 +210,30 @@ class SMTPProtocol(asyncio.Protocol):
elif cmd == "RCPT": elif cmd == "RCPT":
addr = args.split(":", 1)[1].strip() if ":" in args else args addr = args.split(":", 1)[1].strip() if ":" in args else args
if OPEN_RELAY: if OPEN_RELAY:
self._rcpt_to.append(addr) match = _ADDR_RE.match(addr)
_log("rcpt_to", src=self._peer[0], value=addr) if not match:
self._transport.write(b"250 2.1.5 Ok\r\n") _log("rcpt_rejected_syntax", src=self._peer[0], value=addr,
severity=SEVERITY_WARNING)
self._transport.write(
b"501 5.1.3 Bad recipient address syntax\r\n"
)
elif match.group(2).rsplit(".", 1)[-1].lower() in _BLOCKED_TLDS:
_log("rcpt_rejected_tld", src=self._peer[0], value=addr,
severity=SEVERITY_WARNING)
self._transport.write(
b"550 5.1.2 <" + addr.encode()
+ b">: Recipient address rejected: Domain not found\r\n"
)
elif _rand.random() < _RCPT_DROP_RATE:
_log("rcpt_greylisted", src=self._peer[0], value=addr)
self._transport.write(
b"451 4.7.1 <" + addr.encode()
+ b">: Recipient address rejected: Greylisted, try again later\r\n"
)
else:
self._rcpt_to.append(addr)
_log("rcpt_to", src=self._peer[0], value=addr)
self._transport.write(b"250 2.1.5 Ok\r\n")
else: else:
_log("rcpt_denied", src=self._peer[0], value=addr, _log("rcpt_denied", src=self._peer[0], value=addr,
severity=SEVERITY_WARNING) severity=SEVERITY_WARNING)
@@ -246,7 +305,14 @@ class SMTPProtocol(asyncio.Protocol):
_log("auth_attempt", src=self._peer[0], _log("auth_attempt", src=self._peer[0],
username=username, password=password, username=username, password=password,
severity=SEVERITY_WARNING) severity=SEVERITY_WARNING)
if OPEN_RELAY: if not OPEN_RELAY:
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
return
# Open-relay mode: still be selective so the decoy doesn't look like a
# tarpit that accepts literally anything. If no whitelist is set,
# accept; otherwise gate on username presence.
accepted = not _AUTH_WHITELIST or username in _AUTH_WHITELIST
if accepted:
self._transport.write(b"235 2.7.0 Authentication successful\r\n") self._transport.write(b"235 2.7.0 Authentication successful\r\n")
else: else:
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n") self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")