Closes the cred-coverage gap for two database services that had been capturing only the username: - MySQL — extends _handle_packet to read the auth-response after the null-terminated username. mysql_native_password puts a 1-byte length followed by 20 bytes: SHA1(password) XOR SHA1(salt + SHA1(SHA1(password))). Plaintext irrecoverable, lands as secret_kind="mysql_native_password" with the 20 hash bytes in secret_b64. Hash is canonical for "hashcat -m 11200" if an operator ever wants to crack offline. - MSSQL — fixes a pre-existing bug AND adds password capture. The prior _parse_login7_username read offsets 36/38, which is actually ibHostName/cchHostName in the Login7 layout — username sat at 40/42 and was never touched. Replaced with _parse_login7_creds() reading the correct offsets (40 username, 44 password). Login7 password is XOR-then-nibble-swap obfuscated against 0xa5; _deobfuscate_login7_password reverses it. Plaintext-recoverable, lands as secret_kind="plaintext". The pre-existing test_login7_auth_logged_and_closes only verified the error response ships and the connection closes; it didn't validate the parsed username, so the hostname-as-username bug was silent. New tests cover both the deobfuscation algorithm directly and the full ingester round-trip for both services. Sync: copies the canonical syslog_bridge.py into mysql/ and mssql/ template build contexts so service_testing tests load the version with classify_authorization + encode_secret available. 37 tests pass in the touched scope. Phases 3-7 still pending.
168 lines
6.0 KiB
Python
168 lines
6.0 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
|
|
|
|
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):
|
|
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):
|
|
self._transport = transport
|
|
self._peer = transport.get_extra_info("peername", ("?", 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):
|
|
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):
|
|
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())
|