- asyncio.Protocol (TCP): _transport: asyncio.Transport | None = None + cast() in connection_made; assert guards in every method that directly accesses the field. Files: pop3, smtp, mqtt, postgres, mssql, mongodb, imap, ldap, redis, mysql, sip, vnc. - asyncio.DatagramProtocol (UDP): _transport: asyncio.DatagramTransport | None = None. Files: snmp, tftp, SIPUDPProtocol. - RDP: assert new_transport is not None after start_tls() to narrow Transport | None. - FTP (Twisted): assert self.transport is not None + targeted type: ignore for imprecise Twisted stubs (misc/override/arg-type/attr-defined), IReactorTCP cast for listenTCP. - conpot: proc.stdout is None guard before iteration. - Bonus fixes surfaced by annotation: - smtp: get_payload(decode=True) bytes narrowing (arg-type on sha256) - postgres: rename shadowed `msg` param to `err_msg` in _handle_startup - mongodb: base64.binascii.Error → import binascii; binascii.Error - imap: result: list[int] = [] (var-annotated)
174 lines
6.3 KiB
Python
174 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MySQLserver.
|
|
Sends a realistic MySQL 5.7 server handshake, reads the client login
|
|
packet, extracts username, then closes with Access Denied. Logs auth
|
|
attempts as JSON.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import itertools
|
|
import os
|
|
import struct
|
|
from typing import cast
|
|
|
|
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"))
|
|
|
|
# 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:
|
|
length = len(payload)
|
|
return struct.pack("<I", length)[:3] + bytes([seq]) + payload
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
class MySQLProtocol(asyncio.Protocol):
|
|
_transport: asyncio.Transport | None = None
|
|
_peer: tuple[str, int] | None = None
|
|
|
|
def __init__(self):
|
|
self._transport = None
|
|
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: asyncio.BaseTransport) -> None:
|
|
self._transport = cast(asyncio.Transport, transport)
|
|
self._peer = cast(tuple[str, int], self._transport.get_extra_info("peername", ("?", 0)))
|
|
_log("connect", src=self._peer[0], src_port=self._peer[1],
|
|
connection_id=self._conn_id)
|
|
self._transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0))
|
|
self._greeted = True
|
|
|
|
def data_received(self, data: bytes) -> None:
|
|
assert self._transport is not None
|
|
self._buf += data
|
|
# MySQL packets: 3-byte length + 1-byte seq + payload
|
|
while len(self._buf) >= 4:
|
|
length = struct.unpack("<I", self._buf[:3] + b"\x00")[0]
|
|
if length > 1024 * 1024:
|
|
self._transport.close()
|
|
self._buf = b""
|
|
return
|
|
if len(self._buf) < 4 + length:
|
|
break
|
|
payload = self._buf[4:4 + length]
|
|
self._buf = self._buf[4 + length:]
|
|
self._handle_packet(payload)
|
|
|
|
def _handle_packet(self, payload: bytes) -> None:
|
|
assert self._peer is not None
|
|
if not payload:
|
|
return
|
|
# Login packet: capability flags (4), max_packet (4), charset (1),
|
|
# reserved (23), username (NUL-terminated), auth-response.
|
|
# mysql_native_password puts a 1-byte length followed by exactly
|
|
# 20 bytes: SHA1(password) XOR SHA1(salt + SHA1(SHA1(password))) —
|
|
# plaintext is unrecoverable but the 20 bytes ARE a credential the
|
|
# attacker knew, so they land as secret_kind="mysql_native_password".
|
|
username = "<unknown>"
|
|
auth_response = b""
|
|
if len(payload) > 32:
|
|
try:
|
|
username_start = 32
|
|
nul = payload.index(b"\x00", username_start)
|
|
username = payload[username_start:nul].decode(errors="replace")
|
|
# auth-response length byte + bytes
|
|
if len(payload) > nul + 1:
|
|
resp_len = payload[nul + 1]
|
|
if resp_len and len(payload) >= nul + 2 + resp_len:
|
|
auth_response = payload[nul + 2:nul + 2 + resp_len]
|
|
except (ValueError, IndexError):
|
|
username = "<parse_error>"
|
|
|
|
extra: dict = {}
|
|
if auth_response:
|
|
_b64 = base64.b64encode(auth_response).decode("ascii")
|
|
extra = {
|
|
"principal": username,
|
|
"secret_kind": "mysql_native_password",
|
|
"secret_printable": auth_response.hex(),
|
|
"secret_b64": _b64,
|
|
}
|
|
_log("auth", src=self._peer[0], username=username,
|
|
connection_id=self._conn_id, **extra)
|
|
# 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 "?")
|
|
|
|
|
|
async def main():
|
|
_log("startup", msg=f"MySQL server starting as {NODE_NAME}")
|
|
loop = asyncio.get_running_loop()
|
|
server = await loop.create_server(MySQLProtocol, "0.0.0.0", PORT) # nosec B104
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|