fix(services): Resolve protocol realism gaps and update technical debt register

- Add dynamic challenge nonces to Postgres, VNC, and SIP.
- Add basic keyspace lookup and mock data to Redis.
- Correct MSSQL TDS pre-login offset bounds.
- Support MongoDB OP_MSG handshake version checking.
- Suppress Werkzeug HTTP server headers and normalize FTPAnonymousShell response.
- Add tracking for Dynamic Bait Store (DEBT-027) via DEBT.md.
This commit is contained in:
2026-04-10 02:16:42 -04:00
parent 5cb6666d7b
commit f583b3d699
10 changed files with 220 additions and 38 deletions

10
DEBT.md
View File

@@ -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 ## 🟢 Low
### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive) ### ~~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-024~~ | ✅ | Infra | resolved |
| ~~DEBT-025~~ | ✅ | Build | resolved | | ~~DEBT-025~~ | ✅ | Build | resolved |
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) | | 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 **Estimated remaining effort:** ~10 hours

View File

@@ -7,18 +7,18 @@ forwards events as JSON to LOG_TARGET if set.
import os import os
import sys import sys
from pathlib import Path
from twisted.internet import defer, reactor 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 twisted.python import log as twisted_log
from decnet_logging import syslog_line, write_syslog_file, forward_syslog from decnet_logging 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", "")
BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)")
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)
@@ -26,6 +26,16 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
write_syslog_file(line) write_syslog_file(line)
forward_syslog(line, LOG_TARGET) 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): class ServerFTP(FTP):
def connectionMade(self): def connectionMade(self):
@@ -40,25 +50,24 @@ 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 server # Accept everything — we're a honeypot server
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()))
return defer.succeed((230, "Login successful.")) return defer.succeed((230, "Login successful."))
def ftp_RETR(self, path): def ftp_RETR(self, path):
_log("download_attempt", path=path) _log("download_attempt", path=path)
self.sendLine(b"550 File unavailable.") return super().ftp_RETR(path)
return defer.succeed(None)
def connectionLost(self, reason): def connectionLost(self, reason):
peer = self.transport.getPeer() peer = self.transport.getPeer()
_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
if __name__ == "__main__": if __name__ == "__main__":
twisted_log.startLogging(sys.stdout) twisted_log.startLogging(sys.stdout)

View File

@@ -56,8 +56,10 @@ _FAKE_APP_BODIES: dict[str, str] = {
app = Flask(__name__) 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: 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)
@@ -93,9 +95,19 @@ def catch_all(path):
elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES: elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES:
body = _FAKE_APP_BODIES[FAKE_APP] body = _FAKE_APP_BODIES[FAKE_APP]
else: else:
body = "<html><body><h1>403 Forbidden</h1></body></html>" body = (
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
"<html><head>\n"
"<title>403 Forbidden</title>\n"
"</head><body>\n"
"<h1>Forbidden</h1>\n"
"<p>You don't have permission to access this resource.</p>\n"
"<hr>\n"
f"<address>{SERVER_HEADER} Server at {NODE_NAME} Port 80</address>\n"
"</body></html>\n"
)
headers = {"Server": SERVER_HEADER, "Content-Type": "text/html", **EXTRA_HEADERS} headers = {"Content-Type": "text/html", **EXTRA_HEADERS}
return body, RESPONSE_CODE, headers return body, RESPONSE_CODE, headers

View File

@@ -47,8 +47,17 @@ def _op_reply(request_id: int, doc: bytes) -> bytes:
) )
return header + doc return header + doc
def _op_msg(request_id: int, doc: bytes) -> bytes:
payload = b"\x00" + doc
flag_bits = struct.pack("<I", 0)
msg_body = flag_bits + payload
header = struct.pack("<iiii",
16 + len(msg_body),
1,
request_id,
2013,
)
return header + msg_body
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)
@@ -93,6 +102,9 @@ class MongoDBProtocol(asyncio.Protocol):
_bson_str("version", "6.0.5"), _bson_str("version", "6.0.5"),
_bson_int32("ok", 1), _bson_int32("ok", 1),
) )
if opcode == 2013: # OP_MSG
self._transport.write(_op_msg(request_id, reply_doc))
else:
self._transport.write(_op_reply(request_id, reply_doc)) self._transport.write(_op_reply(request_id, reply_doc))
def connection_lost(self, exc): def connection_lost(self, exc):

View File

@@ -14,27 +14,30 @@ 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", "")
# Minimal TDS pre-login response
_PRELOGIN_RESP = bytes([ _PRELOGIN_RESP = bytes([
0x04, 0x01, 0x00, 0x2b, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=43 0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47
# VERSION option # 0. VERSION option
0x00, 0x00, 0x1a, 0x00, 0x06, 0x00, 0x00, 0x1a, 0x00, 0x06,
# ENCRYPTION option (not supported = 0x02) # 1. ENCRYPTION option
0x01, 0x00, 0x20, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x01,
# INSTOPT # 2. INSTOPT
0x02, 0x00, 0x21, 0x00, 0x01, 0x02, 0x00, 0x21, 0x00, 0x01,
# THREADID # 3. THREADID
0x03, 0x00, 0x22, 0x00, 0x04, 0x03, 0x00, 0x22, 0x00, 0x04,
# 4. MARS
0x04, 0x00, 0x26, 0x00, 0x01,
# TERMINATOR # TERMINATOR
0xff, 0xff,
# version data: 16.00.1000 # version data: 14.0.2000
0x10, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
# encryption: NOT_SUP # encryption: NOT_SUP
0x02, 0x02,
# instance name NUL # instopt
0x00, 0x00,
# thread id # thread id
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
# mars
0x00,
]) ])

View File

@@ -14,11 +14,6 @@ from decnet_logging 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", "")
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: def _error_response(message: str) -> bytes:
body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00" body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00"
return b"E" + struct.pack(">I", len(body) + 4) + body return b"E" + struct.pack(">I", len(body) + 4) + body
@@ -90,7 +85,9 @@ class PostgresProtocol(asyncio.Protocol):
database = params.get("database", "") database = params.get("database", "")
_log("startup", src=self._peer[0], username=username, database=database) _log("startup", src=self._peer[0], username=username, database=database)
self._state = "auth" 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): def _handle_password(self, payload: bytes):
pw_hash = payload.rstrip(b"\x00").decode(errors="replace") pw_hash = payload.rstrip(b"\x00").decode(errors="replace")

View File

@@ -27,6 +27,19 @@ _INFO = (
f"# Keyspace\n" f"# Keyspace\n"
).encode() ).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": elif verb == "CONFIG":
self._transport.write(b"*0\r\n") self._transport.write(b"*0\r\n")
elif verb == "KEYS": 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": elif verb == "QUIT":
self._transport.write(b"+OK\r\n") self._transport.write(b"+OK\r\n")
self._transport.close() self._transport.close()

View File

@@ -21,7 +21,7 @@ _401 = (
"To: {to}\r\n" "To: {to}\r\n"
"Call-ID: {call_id}\r\n" "Call-ID: {call_id}\r\n"
"CSeq: {cseq}\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" "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"): if method in ("REGISTER", "INVITE", "OPTIONS"):
nonce = os.urandom(8).hex()
response = _401.format( response = _401.format(
via=headers.get("via", ""), via=headers.get("via", ""),
from_=headers.get("from", ""), from_=headers.get("from", ""),
@@ -78,6 +79,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None:
call_id=headers.get("call-id", ""), call_id=headers.get("call-id", ""),
cseq=headers.get("cseq", ""), cseq=headers.get("cseq", ""),
host=NODE_NAME, host=NODE_NAME,
nonce=nonce,
) )
return response.encode() return response.encode()
return None return None

View File

@@ -14,8 +14,6 @@ NODE_NAME = os.environ.get("NODE_NAME", "desktop")
SERVICE_NAME = "vnc" SERVICE_NAME = "vnc"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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:] self._buf = self._buf[1:]
_log("security_choice", src=self._peer[0], type=chosen) _log("security_choice", src=self._peer[0], type=chosen)
# Send 16-byte challenge # Send 16-byte challenge
self._transport.write(_CHALLENGE[:16]) self._transport.write(os.urandom(16))
self._state = "auth_response" self._state = "auth_response"
elif self._state == "auth_response": elif self._state == "auth_response":

View File

@@ -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"