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

View File

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

View File

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

View File

@@ -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("<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:
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 "?")

View File

@@ -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,
])

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

View File

@@ -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()

View File

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

View File

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