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:
@@ -5,32 +5,73 @@ Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/)
|
||||
as JSON. Designed to attract automated scanners and credential stuffers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "esserver")
|
||||
SERVICE_NAME = "elasticsearch"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
_CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA"
|
||||
_NODE_UUID = "dJH7Lm2sRqWvPn0kFiEtBo"
|
||||
# Real ES cluster/node UUIDs are 22-char base64 (16 random bytes,
|
||||
# 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 = {
|
||||
"name": NODE_NAME,
|
||||
"cluster_name": "elasticsearch",
|
||||
"cluster_name": _CLUSTER_NAME,
|
||||
"cluster_uuid": _CLUSTER_UUID,
|
||||
"version": {
|
||||
"number": "7.17.9",
|
||||
"number": _ES_VERSION,
|
||||
"build_flavor": "default",
|
||||
"build_type": "docker",
|
||||
"build_hash": "ef48222227ee6b9e70e502f0f0daa52435ee634d",
|
||||
"build_date": "2023-01-31T05:34:43.305517834Z",
|
||||
"build_hash": _ES_BUILD_HASH,
|
||||
"build_date": _ES_BUILD_DATE,
|
||||
"build_snapshot": False,
|
||||
"lucene_version": "8.11.1",
|
||||
"minimum_wire_compatibility_version": "6.8.0",
|
||||
"minimum_index_compatibility_version": "6.0.0-beta1",
|
||||
"lucene_version": _ES_LUCENE,
|
||||
"minimum_wire_compatibility_version": _MIN_WIRE,
|
||||
"minimum_index_compatibility_version": _MIN_INDEX,
|
||||
},
|
||||
"tagline": "You Know, for Search",
|
||||
}
|
||||
@@ -73,11 +114,28 @@ class ESHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, [])
|
||||
elif path.startswith("/_cluster/"):
|
||||
_log("cluster_recon", src=src, method="GET", path=self.path)
|
||||
self._send_json(200, {"cluster_name": "elasticsearch", "status": "green",
|
||||
"number_of_nodes": 3, "number_of_data_nodes": 3})
|
||||
self._send_json(200, {
|
||||
"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"):
|
||||
_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/"):
|
||||
_log("security_probe", src=src, method="GET", path=self.path)
|
||||
self._send_json(200, {"enabled": True, "available": True})
|
||||
|
||||
@@ -12,30 +12,82 @@ from twisted.internet import defer, reactor
|
||||
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python import log as twisted_log
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
||||
SERVICE_NAME = "ftp"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
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 / "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")
|
||||
(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")
|
||||
(bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n")
|
||||
(bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n")
|
||||
company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
|
||||
env = _seed.pick(["prod", "stage", "backup", "archive"])
|
||||
year = _seed.rng.randint(2022, 2024)
|
||||
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)
|
||||
|
||||
|
||||
_BAIT_PATH = _setup_bait_fs()
|
||||
|
||||
|
||||
class ServerFTP(FTP):
|
||||
def connectionMade(self):
|
||||
peer = self.transport.getPeer()
|
||||
@@ -49,10 +101,20 @@ class ServerFTP(FTP):
|
||||
|
||||
def ftp_PASS(self, 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._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."))
|
||||
|
||||
def ftp_RETR(self, path):
|
||||
@@ -64,6 +126,7 @@ class ServerFTP(FTP):
|
||||
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
||||
super().connectionLost(reason)
|
||||
|
||||
|
||||
class ServerFTPFactory(FTPFactory):
|
||||
protocol = ServerFTP
|
||||
welcomeMessage = BANNER
|
||||
|
||||
@@ -12,6 +12,8 @@ from pathlib import Path
|
||||
|
||||
from flask import Flask, request, send_from_directory
|
||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
@@ -20,7 +22,24 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
||||
SERVICE_NAME = "http"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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"))
|
||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||
|
||||
@@ -14,6 +14,8 @@ from pathlib import Path
|
||||
|
||||
from flask import Flask, request, send_from_directory
|
||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
@@ -22,7 +24,21 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
||||
SERVICE_NAME = "https"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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"))
|
||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||
|
||||
@@ -7,12 +7,53 @@ invalidCredentials error. Logs all interactions as JSON.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
|
||||
SERVICE_NAME = "ldap"
|
||||
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>"
|
||||
|
||||
|
||||
def _bind_error_response(message_id: int) -> bytes:
|
||||
# BindResponse: resultCode=49 (invalidCredentials), matchedDN="", errorMessage=""
|
||||
result_code = bytes([0x0a, 0x01, 0x31]) # ENUMERATED 49
|
||||
matched_dn = bytes([0x04, 0x00]) # empty OCTET STRING
|
||||
error_msg = bytes([0x04, 0x00]) # empty OCTET STRING
|
||||
bind_resp_body = result_code + matched_dn + error_msg
|
||||
def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
|
||||
"""BindResponse with a configurable resultCode + diagnosticMessage.
|
||||
49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
|
||||
err_bytes = error_text.encode()
|
||||
result_enc = bytes([0x0a, 0x01, result_code & 0xff])
|
||||
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
|
||||
|
||||
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
|
||||
@@ -131,7 +174,17 @@ class LDAPProtocol(asyncio.Protocol):
|
||||
message_id = 1
|
||||
dn, password = _parse_bind_request(msg)
|
||||
_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):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
@@ -9,6 +9,8 @@ received messages as JSON.
|
||||
import asyncio
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mongodb")
|
||||
@@ -16,6 +18,25 @@ SERVICE_NAME = "mongodb"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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
|
||||
def _bson_str(key: str, val: str) -> bytes:
|
||||
k = key.encode() + b"\x00"
|
||||
@@ -98,14 +119,23 @@ class MongoDBProtocol(asyncio.Protocol):
|
||||
opcode = struct.unpack("<I", msg[12:16])[0]
|
||||
_log("message", src=self._peer[0], opcode=opcode, length=len(msg))
|
||||
|
||||
# Build a generic isMaster-style OK response
|
||||
reply_doc = _bson_doc(
|
||||
# Build a generic isMaster-style OK response with this instance's
|
||||
# version pair. Fresh topologyVersion on every reply (matches real
|
||||
# mongod behavior — clients use this to detect failover).
|
||||
fields = [
|
||||
_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_str("version", "6.0.5"),
|
||||
_bson_str("version", _MONGO_VERSION),
|
||||
_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
|
||||
self._transport.write(_op_msg(request_id, reply_doc))
|
||||
else:
|
||||
|
||||
@@ -12,16 +12,44 @@ import json
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
|
||||
SERVICE_NAME = "mqtt"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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", "")
|
||||
|
||||
# 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_NOT_AUTH = b"\x20\x02\x00\x05"
|
||||
|
||||
@@ -133,27 +161,45 @@ def _generate_topics() -> dict:
|
||||
_log("config_error", severity=4, error=str(e))
|
||||
|
||||
if MQTT_PERSONA == "water_plant":
|
||||
site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"])
|
||||
topics.update({
|
||||
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||
"plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
||||
"plant/water/pump1/status": "RUNNING",
|
||||
"plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
||||
"plant/water/pump2/status": "STANDBY",
|
||||
"plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
||||
"plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
||||
"plant/water/valve/inlet/state": "OPEN",
|
||||
"plant/water/valve/drain/state": "CLOSED",
|
||||
"plant/alarm/high_pressure": "0",
|
||||
"plant/alarm/low_chlorine": "0",
|
||||
"plant/alarm/pump_fault": "0",
|
||||
"plant/$SYS/broker/version": "Mosquitto 2.0.15",
|
||||
"plant/$SYS/broker/uptime": "2847392",
|
||||
f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||
f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
||||
f"{site}/water/pump1/status": "RUNNING",
|
||||
f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
||||
f"{site}/water/pump2/status": "STANDBY",
|
||||
f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
||||
f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
||||
f"{site}/water/valve/inlet/state": "OPEN",
|
||||
f"{site}/water/valve/drain/state": "CLOSED",
|
||||
f"{site}/alarm/high_pressure": "0",
|
||||
f"{site}/alarm/low_chlorine": "0",
|
||||
f"{site}/alarm/pump_fault": "0",
|
||||
})
|
||||
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 = {
|
||||
"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
|
||||
|
||||
|
||||
@@ -211,7 +257,13 @@ class MQTTProtocol(asyncio.Protocol):
|
||||
if pkt_type == 1: # CONNECT
|
||||
info = _parse_connect(payload)
|
||||
_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._transport.write(_CONNACK_ACCEPTED)
|
||||
else:
|
||||
|
||||
@@ -8,37 +8,73 @@ a login failed error. Logs auth attempts as JSON.
|
||||
import asyncio
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||
SERVICE_NAME = "mssql"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
_PRELOGIN_RESP = bytes([
|
||||
0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47
|
||||
# 0. VERSION option
|
||||
0x00, 0x00, 0x1a, 0x00, 0x06,
|
||||
# 1. ENCRYPTION option
|
||||
0x01, 0x00, 0x20, 0x00, 0x01,
|
||||
# 2. INSTOPT
|
||||
0x02, 0x00, 0x21, 0x00, 0x01,
|
||||
# 3. THREADID
|
||||
0x03, 0x00, 0x22, 0x00, 0x04,
|
||||
# 4. MARS
|
||||
0x04, 0x00, 0x26, 0x00, 0x01,
|
||||
# TERMINATOR
|
||||
0xff,
|
||||
# version data: 14.0.2000
|
||||
0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
|
||||
# encryption: NOT_SUP
|
||||
0x02,
|
||||
# instopt
|
||||
0x00,
|
||||
# thread id
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
# mars
|
||||
0x00,
|
||||
])
|
||||
# Real SQL Server release families. Pairing (major, minor, build) makes a
|
||||
# subsequent OSQL/sqlcmd version probe line up with what MS published.
|
||||
# Builds are taken from publicly documented latest-CU numbers.
|
||||
_MSSQL_RELEASES = [
|
||||
# (name, major, minor, build, subbuild)
|
||||
("SQL Server 2016", 13, 0, 6419, 0),
|
||||
("SQL Server 2017", 14, 0, 2000, 0),
|
||||
("SQL Server 2017", 14, 0, 3460, 0),
|
||||
("SQL Server 2019", 15, 0, 2000, 0),
|
||||
("SQL Server 2019", 15, 0, 4335, 1),
|
||||
("SQL Server 2022", 16, 0, 1000, 0),
|
||||
("SQL Server 2022", 16, 0, 4115, 2),
|
||||
]
|
||||
_MSSQL_NAME, _VER_MAJ, _VER_MIN, _VER_BUILD, _VER_SUB = _seed.pick(_MSSQL_RELEASES)
|
||||
|
||||
|
||||
def _build_prelogin_response() -> bytes:
|
||||
"""TDS PRELOGIN response. Version option carries
|
||||
major(1) minor(1) build(2, network order) subbuild(2, network order)."""
|
||||
version_data = (
|
||||
bytes([_VER_MAJ & 0xff, _VER_MIN & 0xff])
|
||||
+ struct.pack(">H", _VER_BUILD & 0xffff)
|
||||
+ struct.pack(">H", _VER_SUB & 0xffff)
|
||||
)
|
||||
# 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()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,32 +7,56 @@ attempts as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||
SERVICE_NAME = "mysql"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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
|
||||
_GREETING = (
|
||||
b"\x0a" # protocol version 10
|
||||
+ _MYSQL_VER.encode() + b"\x00" # server version + NUL
|
||||
+ b"\x01\x00\x00\x00" # connection id = 1
|
||||
+ b"\x70\x76\x21\x6d\x61\x67\x69\x63" # auth-plugin-data part 1
|
||||
+ b"\x00" # filler
|
||||
+ b"\xff\xf7" # capability flags low
|
||||
+ b"\x21" # charset utf8
|
||||
+ b"\x02\x00" # status flags
|
||||
+ b"\xff\x81" # capability flags high
|
||||
+ 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
|
||||
+ b"mysql_native_password\x00" # auth plugin name
|
||||
)
|
||||
# Per-instance version. Real fleets never run one identical point release
|
||||
# across every host — weighted mix of still-in-the-wild 5.7/8.0 builds.
|
||||
_MYSQL_VER = os.environ.get("MYSQL_VERSION") or _seed.pick_weighted([
|
||||
("5.7.38-log", 1),
|
||||
("5.7.43-log", 2),
|
||||
("5.7.44-log", 2),
|
||||
("8.0.32", 2),
|
||||
("8.0.35", 3),
|
||||
("8.0.36", 3),
|
||||
("8.0.39", 2),
|
||||
("8.0.40", 1),
|
||||
])
|
||||
|
||||
# Monotonic per-process counter for connection IDs. Seeded with a
|
||||
# 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:
|
||||
@@ -54,12 +78,17 @@ class MySQLProtocol(asyncio.Protocol):
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
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):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
transport.write(_make_packet(_GREETING, seq=0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1],
|
||||
connection_id=self._conn_id)
|
||||
transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0))
|
||||
self._greeted = True
|
||||
|
||||
def data_received(self, data):
|
||||
@@ -81,19 +110,24 @@ class MySQLProtocol(asyncio.Protocol):
|
||||
if not payload:
|
||||
return
|
||||
# Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated)
|
||||
username = "<unknown>"
|
||||
if len(payload) > 32:
|
||||
try:
|
||||
# skip capability(4) + max_pkt(4) + charset(1) + reserved(23) = 32 bytes
|
||||
username_start = 32
|
||||
nul = payload.index(b"\x00", username_start)
|
||||
username = payload[username_start:nul].decode(errors="replace")
|
||||
except (ValueError, IndexError):
|
||||
username = "<parse_error>"
|
||||
_log("auth", src=self._peer[0], username=username)
|
||||
# Send Access Denied error
|
||||
err = b"\xff" + struct.pack("<H", 1045) + b"#28000Access denied for user\x00"
|
||||
self._transport.write(_make_packet(err, seq=2))
|
||||
self._transport.close()
|
||||
_log("auth", src=self._peer[0], username=username,
|
||||
connection_id=self._conn_id)
|
||||
# Real mysqld includes client IP in the error text.
|
||||
src_ip = self._peer[0] if self._peer else "?"
|
||||
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):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
@@ -9,14 +9,43 @@ returns an error. Logs all interactions as JSON.
|
||||
import asyncio
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
|
||||
SERVICE_NAME = "postgres"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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
|
||||
|
||||
|
||||
@@ -90,8 +119,18 @@ class PostgresProtocol(asyncio.Protocol):
|
||||
if k:
|
||||
params[k] = v
|
||||
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)
|
||||
# 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"
|
||||
salt = os.urandom(4)
|
||||
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):
|
||||
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
|
||||
_log("auth", src=self._peer[0], pw_hash=pw_hash)
|
||||
self._transport.write(_error_response("password authentication failed"))
|
||||
_log("auth", src=self._peer[0], pw_hash=pw_hash,
|
||||
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()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
|
||||
@@ -7,38 +7,111 @@ KEYS, and arbitrary commands. Logs every command and argument as JSON.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
|
||||
SERVICE_NAME = "redis"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
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 = (
|
||||
f"# Server\n"
|
||||
f"redis_version:{_REDIS_VER}\n"
|
||||
f"redis_mode:standalone\n"
|
||||
f"os:{_REDIS_OS}\n"
|
||||
f"arch_bits:64\n"
|
||||
f"tcp_port:6379\n"
|
||||
f"uptime_in_seconds:864000\n"
|
||||
f"connected_clients:1\n"
|
||||
f"# Keyspace\n"
|
||||
).encode()
|
||||
# Per-instance realistic version pick (weighted toward still-supported lines).
|
||||
_REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([
|
||||
("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2),
|
||||
("7.0.15", 2), ("7.0.14", 1),
|
||||
("6.2.14", 2), ("6.2.16", 1),
|
||||
])
|
||||
# Kernel line matching plausible Debian/Ubuntu LTS minor ranges.
|
||||
_REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([
|
||||
"Linux 5.15.0-118-generic x86_64",
|
||||
"Linux 6.1.0-21-amd64 x86_64",
|
||||
"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 = {
|
||||
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
||||
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
||||
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
|
||||
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"}',
|
||||
b"config:db_password": b"Pr0dDB!2024#Secure",
|
||||
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
|
||||
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
b"rate_limit:192.168.1.1": b"42",
|
||||
# AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns
|
||||
# the canonical "Client sent AUTH, but no password is set" error, matching a
|
||||
# real redis-server with requirepass unset.
|
||||
_REQUIREPASS = os.environ.get("REDIS_PASSWORD", "")
|
||||
|
||||
|
||||
def _info_block() -> bytes:
|
||||
uptime = _seed.uptime_seconds()
|
||||
uptime_days = max(1, uptime // 86400)
|
||||
# Minimal but plausible subset; real redis INFO has ~150 keys.
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._parser = RESPParser()
|
||||
self._authed = not _REQUIREPASS # auth satisfied iff no password set
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
@@ -129,6 +213,16 @@ class RedisProtocol(asyncio.Protocol):
|
||||
for cmd in self._parser.feed(data):
|
||||
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):
|
||||
if not parts:
|
||||
return
|
||||
@@ -137,15 +231,40 @@ class RedisProtocol(asyncio.Protocol):
|
||||
_log("command", src=self._peer[0], cmd=verb, args=args[:8])
|
||||
|
||||
if verb == "AUTH":
|
||||
password = args[0] if args else ""
|
||||
password = args[-1] if args else ""
|
||||
_log("auth", src=self._peer[0], password=password)
|
||||
self._transport.write(b"+OK\r\n")
|
||||
elif verb == "INFO":
|
||||
self._transport.write(f"${len(_INFO)}\r\n".encode() + _INFO + b"\r\n")
|
||||
if not _REQUIREPASS:
|
||||
self._write(
|
||||
_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":
|
||||
self._transport.write(b"+PONG\r\n")
|
||||
self._write(b"+PONG\r\n")
|
||||
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":
|
||||
pattern = args[0] if args else "*"
|
||||
keys = list(_FAKE_STORE.keys())
|
||||
@@ -157,26 +276,35 @@ class RedisProtocol(asyncio.Protocol):
|
||||
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)
|
||||
self._transport.write(resp)
|
||||
self._write(resp)
|
||||
elif verb == "GET":
|
||||
key = args[0].encode() if args else b""
|
||||
if key in _FAKE_STORE:
|
||||
self._transport.write(_bulk(_FAKE_STORE[key].decode()))
|
||||
self._write(_bulk(_FAKE_STORE[key].decode()))
|
||||
else:
|
||||
self._transport.write(b"$-1\r\n")
|
||||
self._write(b"$-1\r\n")
|
||||
elif verb == "SCAN":
|
||||
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)
|
||||
self._transport.write(resp)
|
||||
self._write(resp)
|
||||
elif verb == "TYPE":
|
||||
self._transport.write(b"+string\r\n")
|
||||
self._write(b"+string\r\n")
|
||||
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":
|
||||
self._transport.write(b"+OK\r\n")
|
||||
self._transport.close()
|
||||
self._write(b"+OK\r\n")
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
else:
|
||||
self._transport.write(_err("unknown command"))
|
||||
self._write(_err(f"unknown command '{verb.lower()}'"))
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
@@ -21,8 +21,11 @@ The DATA state machine (and the 502-per-line bug) is fixed in both modes.
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import random as _rand
|
||||
import re
|
||||
import time
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
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"))
|
||||
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_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:
|
||||
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:
|
||||
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return "".join(random.choices(chars, k=12))
|
||||
"""Postfix-style queue ID.
|
||||
|
||||
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]:
|
||||
@@ -108,6 +143,9 @@ class SMTPProtocol(asyncio.Protocol):
|
||||
rcpt_to=",".join(self._rcpt_to),
|
||||
body_bytes=len(body),
|
||||
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._in_data = False
|
||||
self._data_buf = []
|
||||
@@ -172,9 +210,30 @@ class SMTPProtocol(asyncio.Protocol):
|
||||
elif cmd == "RCPT":
|
||||
addr = args.split(":", 1)[1].strip() if ":" in args else args
|
||||
if OPEN_RELAY:
|
||||
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")
|
||||
match = _ADDR_RE.match(addr)
|
||||
if not match:
|
||||
_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:
|
||||
_log("rcpt_denied", src=self._peer[0], value=addr,
|
||||
severity=SEVERITY_WARNING)
|
||||
@@ -246,7 +305,14 @@ class SMTPProtocol(asyncio.Protocol):
|
||||
_log("auth_attempt", src=self._peer[0],
|
||||
username=username, password=password,
|
||||
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")
|
||||
else:
|
||||
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
|
||||
|
||||
Reference in New Issue
Block a user