diff --git a/DEBT.md b/DEBT.md index ea7369b..a3da844 100644 --- a/DEBT.md +++ b/DEBT.md @@ -108,6 +108,13 @@ Bait emails are hardcoded strings. A modular framework to dynamically inject per --- +### DEBT-027 — Dynamic Bait Store +**Files:** `templates/redis/server.py`, `templates/ftp/server.py` +The bait store and honeypot files are hardcoded. A dynamic injection framework should be created to populate this payload across different honeypots. +**Status:** Deferred — out of current scope. + +--- + ## 🟢 Low ### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive) @@ -158,6 +165,7 @@ Bait emails are hardcoded strings. A modular framework to dynamically inject per | ~~DEBT-024~~ | ✅ | Infra | resolved | | ~~DEBT-025~~ | ✅ | Build | resolved | | DEBT-026 | 🟡 Medium | Features | deferred (out of scope) | +| DEBT-027 | 🟡 Medium | Features | deferred (out of scope) | -**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning), DEBT-026 (modular mailboxes) +**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store) **Estimated remaining effort:** ~10 hours diff --git a/templates/ftp/server.py b/templates/ftp/server.py index c01a080..9f0ed6f 100644 --- a/templates/ftp/server.py +++ b/templates/ftp/server.py @@ -7,18 +7,18 @@ forwards events as JSON to LOG_TARGET if set. import os import sys +from pathlib import Path from twisted.internet import defer, reactor -from twisted.protocols.ftp import FTP, FTPFactory +from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell +from twisted.python.filepath import FilePath from twisted.python import log as twisted_log from decnet_logging 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", "") - - - +BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)") def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) @@ -26,6 +26,16 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None: write_syslog_file(line) forward_syslog(line, LOG_TARGET) +def _setup_bait_fs() -> str: + bait_dir = Path("/tmp/ftp_bait") + 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") + + return str(bait_dir) class ServerFTP(FTP): def connectionMade(self): @@ -40,25 +50,24 @@ class ServerFTP(FTP): def ftp_PASS(self, password): _log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password) - # Accept everything — we're a server + # Accept everything — we're a honeypot server self.state = self.AUTHED self._user = getattr(self, "_server_user", "anonymous") + self.shell = FTPAnonymousShell(FilePath(_setup_bait_fs())) return defer.succeed((230, "Login successful.")) def ftp_RETR(self, path): _log("download_attempt", path=path) - self.sendLine(b"550 File unavailable.") - return defer.succeed(None) + return super().ftp_RETR(path) def connectionLost(self, reason): peer = self.transport.getPeer() _log("disconnect", src_ip=peer.host, src_port=peer.port) super().connectionLost(reason) - class ServerFTPFactory(FTPFactory): protocol = ServerFTP - + welcomeMessage = BANNER if __name__ == "__main__": twisted_log.startLogging(sys.stdout) diff --git a/templates/http/server.py b/templates/http/server.py index 87410cb..3d5bd82 100644 --- a/templates/http/server.py +++ b/templates/http/server.py @@ -56,8 +56,10 @@ _FAKE_APP_BODIES: dict[str, str] = { app = Flask(__name__) - - +@app.after_request +def _fix_server_header(response): + response.headers["Server"] = SERVER_HEADER + return response def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) @@ -93,9 +95,19 @@ def catch_all(path): elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES: body = _FAKE_APP_BODIES[FAKE_APP] else: - body = "

403 Forbidden

" + body = ( + "\n" + "\n" + "403 Forbidden\n" + "\n" + "

Forbidden

\n" + "

You don't have permission to access this resource.

\n" + "
\n" + f"
{SERVER_HEADER} Server at {NODE_NAME} Port 80
\n" + "\n" + ) - headers = {"Server": SERVER_HEADER, "Content-Type": "text/html", **EXTRA_HEADERS} + headers = {"Content-Type": "text/html", **EXTRA_HEADERS} return body, RESPONSE_CODE, headers diff --git a/templates/mongodb/server.py b/templates/mongodb/server.py index 62b6b96..7ce68be 100644 --- a/templates/mongodb/server.py +++ b/templates/mongodb/server.py @@ -47,8 +47,17 @@ def _op_reply(request_id: int, doc: bytes) -> bytes: ) return header + doc - - +def _op_msg(request_id: int, doc: bytes) -> bytes: + payload = b"\x00" + doc + flag_bits = struct.pack(" None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) @@ -93,7 +102,10 @@ class MongoDBProtocol(asyncio.Protocol): _bson_str("version", "6.0.5"), _bson_int32("ok", 1), ) - self._transport.write(_op_reply(request_id, reply_doc)) + if opcode == 2013: # OP_MSG + self._transport.write(_op_msg(request_id, reply_doc)) + else: + self._transport.write(_op_reply(request_id, reply_doc)) def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") diff --git a/templates/mssql/server.py b/templates/mssql/server.py index 0a42f4c..3502878 100644 --- a/templates/mssql/server.py +++ b/templates/mssql/server.py @@ -14,27 +14,30 @@ NODE_NAME = os.environ.get("NODE_NAME", "dbserver") SERVICE_NAME = "mssql" LOG_TARGET = os.environ.get("LOG_TARGET", "") -# Minimal TDS pre-login response _PRELOGIN_RESP = bytes([ - 0x04, 0x01, 0x00, 0x2b, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=43 - # VERSION option + 0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47 + # 0. VERSION option 0x00, 0x00, 0x1a, 0x00, 0x06, - # ENCRYPTION option (not supported = 0x02) + # 1. ENCRYPTION option 0x01, 0x00, 0x20, 0x00, 0x01, - # INSTOPT + # 2. INSTOPT 0x02, 0x00, 0x21, 0x00, 0x01, - # THREADID + # 3. THREADID 0x03, 0x00, 0x22, 0x00, 0x04, + # 4. MARS + 0x04, 0x00, 0x26, 0x00, 0x01, # TERMINATOR 0xff, - # version data: 16.00.1000 - 0x10, 0x00, 0x03, 0xe8, 0x00, 0x00, + # version data: 14.0.2000 + 0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00, # encryption: NOT_SUP 0x02, - # instance name NUL + # instopt 0x00, # thread id - 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + # mars + 0x00, ]) diff --git a/templates/postgres/server.py b/templates/postgres/server.py index 05d5e3d..c012624 100644 --- a/templates/postgres/server.py +++ b/templates/postgres/server.py @@ -14,11 +14,6 @@ from decnet_logging 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", "") -SALT = b"\xde\xad\xbe\xef" - -# AuthenticationMD5Password: 'R' + length(12) + auth_type(5) + salt(4) -_AUTH_MD5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + SALT - def _error_response(message: str) -> bytes: body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00" return b"E" + struct.pack(">I", len(body) + 4) + body @@ -90,7 +85,9 @@ class PostgresProtocol(asyncio.Protocol): database = params.get("database", "") _log("startup", src=self._peer[0], username=username, database=database) self._state = "auth" - self._transport.write(_AUTH_MD5) + salt = os.urandom(4) + auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt + self._transport.write(auth_md5) def _handle_password(self, payload: bytes): pw_hash = payload.rstrip(b"\x00").decode(errors="replace") diff --git a/templates/redis/server.py b/templates/redis/server.py index 196cb42..756803c 100644 --- a/templates/redis/server.py +++ b/templates/redis/server.py @@ -27,6 +27,19 @@ _INFO = ( f"# Keyspace\n" ).encode() +_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", +} + @@ -134,7 +147,31 @@ class RedisProtocol(asyncio.Protocol): elif verb == "CONFIG": self._transport.write(b"*0\r\n") elif verb == "KEYS": - self._transport.write(b"*0\r\n") + pattern = args[0] if args else "*" + keys = list(_FAKE_STORE.keys()) + if pattern.endswith('*') and pattern != '*': + prefix = pattern[:-1].encode() + keys = [k for k in keys if k.startswith(prefix)] + elif pattern != '*': + pat = pattern.encode() + 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) + elif verb == "GET": + key = args[0].encode() if args else b"" + if key in _FAKE_STORE: + self._transport.write(_bulk(_FAKE_STORE[key].decode())) + else: + self._transport.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) + elif verb == "TYPE": + self._transport.write(b"+string\r\n") + elif verb == "TTL": + self._transport.write(b":-1\r\n") elif verb == "QUIT": self._transport.write(b"+OK\r\n") self._transport.close() diff --git a/templates/sip/server.py b/templates/sip/server.py index 414aff7..a84c0c7 100644 --- a/templates/sip/server.py +++ b/templates/sip/server.py @@ -21,7 +21,7 @@ _401 = ( "To: {to}\r\n" "Call-ID: {call_id}\r\n" "CSeq: {cseq}\r\n" - 'WWW-Authenticate: Digest realm="{host}", nonce="decnet0000", algorithm=MD5\r\n' + 'WWW-Authenticate: Digest realm="{host}", nonce="{nonce}", algorithm=MD5\r\n' "Content-Length: 0\r\n\r\n" ) @@ -71,6 +71,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None: ) if method in ("REGISTER", "INVITE", "OPTIONS"): + nonce = os.urandom(8).hex() response = _401.format( via=headers.get("via", ""), from_=headers.get("from", ""), @@ -78,6 +79,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None: call_id=headers.get("call-id", ""), cseq=headers.get("cseq", ""), host=NODE_NAME, + nonce=nonce, ) return response.encode() return None diff --git a/templates/vnc/server.py b/templates/vnc/server.py index fcb1c88..7f8637f 100644 --- a/templates/vnc/server.py +++ b/templates/vnc/server.py @@ -14,8 +14,6 @@ NODE_NAME = os.environ.get("NODE_NAME", "desktop") SERVICE_NAME = "vnc" LOG_TARGET = os.environ.get("LOG_TARGET", "") -# RFB challenge — fixed so captured responses are reproducible -_CHALLENGE = bytes(range(16)) * 1 + b"\x10\x11\x12\x13\x14\x15\x16\x17" # 24 bytes @@ -63,7 +61,7 @@ class VNCProtocol(asyncio.Protocol): self._buf = self._buf[1:] _log("security_choice", src=self._peer[0], type=chosen) # Send 16-byte challenge - self._transport.write(_CHALLENGE[:16]) + self._transport.write(os.urandom(16)) self._state = "auth_response" elif self._state == "auth_response": diff --git a/tests/service_testing/test_redis.py b/tests/service_testing/test_redis.py new file mode 100644 index 0000000..4ad6f33 --- /dev/null +++ b/tests/service_testing/test_redis.py @@ -0,0 +1,104 @@ +import importlib.util +import sys +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_fake_decnet_logging() -> ModuleType: + mod = ModuleType("decnet_logging") + mod.syslog_line = MagicMock(return_value="") + mod.write_syslog_file = MagicMock() + mod.forward_syslog = MagicMock() + mod.SEVERITY_WARNING = 4 + mod.SEVERITY_INFO = 6 + return mod + + +def _load_redis(): + env = {"NODE_NAME": "testredis"} + for key in list(sys.modules): + if key in ("redis_server", "decnet_logging"): + del sys.modules[key] + + sys.modules["decnet_logging"] = _make_fake_decnet_logging() + + spec = importlib.util.spec_from_file_location("redis_server", "templates/redis/server.py") + mod = importlib.util.module_from_spec(spec) + with patch.dict("os.environ", env, clear=False): + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def redis_mod(): + return _load_redis() + + +def _make_protocol(mod): + proto = mod.RedisProtocol() + transport = MagicMock() + written: list[bytes] = [] + transport.write.side_effect = written.append + proto.connection_made(transport) + written.clear() + return proto, transport, written + + +def _send(proto, *lines: bytes) -> None: + for line in lines: + proto.data_received(line) + + +def test_auth_accepted(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"AUTH password\r\n") + assert b"".join(written) == b"+OK\r\n" + + +def test_keys_wildcard(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"*2\r\n$4\r\nKEYS\r\n$1\r\n*\r\n") + response = b"".join(written) + assert response.startswith(b"*10\r\n") + assert b"config:aws_access_key" in response + + +def test_keys_prefix(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"*2\r\n$4\r\nKEYS\r\n$6\r\nuser:*\r\n") + response = b"".join(written) + assert response.startswith(b"*2\r\n") + assert b"user:admin" in response + + +def test_get_valid_key(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"*2\r\n$3\r\nGET\r\n$13\r\ncache:api_key\r\n") + response = b"".join(written) + assert response == b"$38\r\nsk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n" + + +def test_get_invalid_key(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"*2\r\n$3\r\nGET\r\n$7\r\nunknown\r\n") + response = b"".join(written) + assert response == b"$-1\r\n" + + +def test_scan(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"*1\r\n$4\r\nSCAN\r\n") + response = b"".join(written) + assert response.startswith(b"*2\r\n$1\r\n0\r\n*10\r\n") + + +def test_type_and_ttl(redis_mod): + proto, _, written = _make_protocol(redis_mod) + _send(proto, b"TYPE somekey\r\n") + assert b"".join(written) == b"+string\r\n" + written.clear() + + _send(proto, b"TTL somekey\r\n") + assert b"".join(written) == b":-1\r\n"