Implement ICS/SCADA and IMAP Bait features

This commit is contained in:
2026-04-10 01:50:08 -04:00
parent 63fb477e1f
commit 08242a4d84
112 changed files with 3239 additions and 764 deletions

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Shared RFC 5424 syslog helper for DECNET service templates.
Services call syslog_line() to format an RFC 5424 message, then
write_syslog_file() to emit it to stdout — Docker captures it, and the
host-side collector streams it into the log file.
RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
Facility: local0 (16), PEN for SD element ID: decnet@55555
"""
from datetime import datetime, timezone
from typing import Any
# ─── Constants ────────────────────────────────────────────────────────────────
_FACILITY_LOCAL0 = 16
_SD_ID = "decnet@55555"
_NILVALUE = "-"
SEVERITY_EMERG = 0
SEVERITY_ALERT = 1
SEVERITY_CRIT = 2
SEVERITY_ERROR = 3
SEVERITY_WARNING = 4
SEVERITY_NOTICE = 5
SEVERITY_INFO = 6
SEVERITY_DEBUG = 7
_MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
# ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str:
"""Escape SD-PARAM-VALUE per RFC 5424 §6.3.3."""
return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
def _sd_element(fields: dict[str, Any]) -> str:
if not fields:
return _NILVALUE
params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
return f"[{_SD_ID} {params}]"
def syslog_line(
service: str,
hostname: str,
event_type: str,
severity: int = SEVERITY_INFO,
timestamp: datetime | None = None,
msg: str | None = None,
**fields: Any,
) -> str:
"""
Return a single RFC 5424-compliant syslog line (no trailing newline).
Args:
service: APP-NAME (e.g. "http", "mysql")
hostname: HOSTNAME (decky node name)
event_type: MSGID (e.g. "request", "login_attempt")
severity: Syslog severity integer (default: INFO=6)
timestamp: UTC datetime; defaults to now
msg: Optional free-text MSG
**fields: Encoded as structured data params
"""
pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
ts = (timestamp or datetime.now(timezone.utc)).isoformat()
host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
appname = (service or _NILVALUE)[:_MAX_APPNAME]
msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
sd = _sd_element(fields)
message = f" {msg}" if msg else ""
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
def write_syslog_file(line: str) -> None:
"""Emit a syslog line to stdout for Docker log capture."""
print(line, flush=True)
def forward_syslog(line: str, log_target: str) -> None:
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
pass

View File

@@ -1,26 +1,30 @@
#!/usr/bin/env python3
"""
MQTT server (port 1883).
Parses MQTT CONNECT packets, extracts client_id, username, and password,
then returns CONNACK with return code 5 (not authorized). Logs all
interactions as JSON.
Parses MQTT CONNECT packets, extracts client_id, etc.
Responds with CONNACK.
Supports dynamic topics and retained publishes.
Logs PUBLISH commands sent by clients.
"""
import asyncio
import json
import os
import random
import struct
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
SERVICE_NAME = "mqtt"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1"
MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant")
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
# CONNACK: packet type 0x20, remaining length 2, session_present=0, return_code=5
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True)
@@ -38,45 +42,128 @@ def _read_utf8(data: bytes, pos: int):
def _parse_connect(payload: bytes):
"""Extract client_id, username, password from MQTT CONNECT payload."""
pos = 0
# Protocol name
proto_name, pos = _read_utf8(payload, pos)
# Protocol level (1 byte)
if pos >= len(payload):
return {}, pos
_proto_level = payload[pos]
pos += 1
# Connect flags (1 byte)
if pos >= len(payload):
return {}, pos
flags = payload[pos]
pos += 1
# Keep alive (2 bytes)
pos += 2
# Client ID
pos += 2 # Keep alive
client_id, pos = _read_utf8(payload, pos)
result = {"client_id": client_id, "proto": proto_name}
# Will flag
if flags & 0x04:
_, pos = _read_utf8(payload, pos) # will topic
_, pos = _read_utf8(payload, pos) # will message
# Username flag
_, pos = _read_utf8(payload, pos)
_, pos = _read_utf8(payload, pos)
if flags & 0x80:
username, pos = _read_utf8(payload, pos)
result["username"] = username
# Password flag
if flags & 0x40:
password, pos = _read_utf8(payload, pos)
result["password"] = password
return result
def _parse_subscribe(payload: bytes):
"""Returns (packet_id, [(topic, qos), ...])"""
if len(payload) < 2:
return 0, []
pos = 0
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
pos += 2
topics = []
while pos < len(payload):
topic, pos = _read_utf8(payload, pos)
if pos >= len(payload):
break
qos = payload[pos] & 0x03
pos += 1
topics.append((topic, qos))
return packet_id, topics
def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
payload = struct.pack(">H", packet_id) + bytes(granted_qos)
return bytes([0x90, len(payload)]) + payload
def _publish(topic: str, value: str, retain: bool = True) -> bytes:
topic_bytes = topic.encode()
topic_len = struct.pack(">H", len(topic_bytes))
payload = str(value).encode()
fixed = 0x31 if retain else 0x30
remaining = len(topic_len) + len(topic_bytes) + len(payload)
# variable length encoding
rem_bytes = []
while remaining > 0:
encoded = remaining % 128
remaining = remaining // 128
if remaining > 0:
encoded = encoded | 128
rem_bytes.append(encoded)
if not rem_bytes:
rem_bytes = [0]
return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
def _parse_publish(payload: bytes, qos: int):
pos = 0
topic, pos = _read_utf8(payload, pos)
packet_id = 0
if qos > 0:
if pos + 2 <= len(payload):
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
pos += 2
data = payload[pos:]
return topic, packet_id, data
def _generate_topics() -> dict:
topics: dict = {}
if MQTT_CUSTOM_TOPICS:
try:
topics = json.loads(MQTT_CUSTOM_TOPICS)
return topics
except Exception as e:
_log("config_error", severity=4, error=str(e))
if MQTT_PERSONA == "water_plant":
topics.update({
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
"plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
"plant/water/pump1/status": "RUNNING",
"plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
"plant/water/pump2/status": "STANDBY",
"plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
"plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
"plant/water/valve/inlet/state": "OPEN",
"plant/water/valve/drain/state": "CLOSED",
"plant/alarm/high_pressure": "0",
"plant/alarm/low_chlorine": "0",
"plant/alarm/pump_fault": "0",
"plant/$SYS/broker/version": "Mosquitto 2.0.15",
"plant/$SYS/broker/uptime": "2847392",
})
elif not topics:
topics = {
"device/status": "online",
"device/uptime": "3600"
}
return topics
class MQTTProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._buf = b""
self._auth = False
self._topics = _generate_topics()
def connection_made(self, transport):
self._transport = transport
@@ -85,11 +172,20 @@ class MQTTProtocol(asyncio.Protocol):
def data_received(self, data):
self._buf += data
self._process()
try:
self._process()
except Exception as e:
_log("protocol_error", severity=4, error=str(e))
if self._transport:
self._transport.close()
def _process(self):
while len(self._buf) >= 2:
pkt_type = (self._buf[0] >> 4) & 0x0f
pkt_byte = self._buf[0]
pkt_type = (pkt_byte >> 4) & 0x0f
flags = pkt_byte & 0x0f
qos = (flags >> 1) & 0x03
# Decode remaining length (variable-length encoding)
pos = 1
remaining = 0
@@ -110,11 +206,49 @@ class MQTTProtocol(asyncio.Protocol):
if pkt_type == 1: # CONNECT
info = _parse_connect(payload)
_log("auth", src=self._peer[0], **info)
self._transport.write(_CONNACK_NOT_AUTH)
self._transport.close()
_log("auth", **info)
if MQTT_ACCEPT_ALL:
self._auth = True
self._transport.write(_CONNACK_ACCEPTED)
else:
self._transport.write(_CONNACK_NOT_AUTH)
self._transport.close()
elif pkt_type == 8: # SUBSCRIBE
if not self._auth:
self._transport.close()
continue
packet_id, subs = _parse_subscribe(payload)
granted_qos = [1] * len(subs) # grant QoS 1 for all
self._transport.write(_suback(packet_id, granted_qos))
# Immediately send retained publishes matching topics
for sub_topic, _ in subs:
_log("subscribe", src=self._peer[0], topics=[sub_topic])
for t, v in self._topics.items():
# simple match: if topic ends with #, it matches prefix
if sub_topic.endswith("#"):
prefix = sub_topic[:-1]
if t.startswith(prefix):
self._transport.write(_publish(t, str(v)))
elif sub_topic == t:
self._transport.write(_publish(t, str(v)))
elif pkt_type == 3: # PUBLISH
if not self._auth:
self._transport.close()
continue
topic, packet_id, data = _parse_publish(payload, qos)
# Attacker command received!
_log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
if qos == 1:
puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
self._transport.write(puback)
elif pkt_type == 12: # PINGREQ
self._transport.write(b"\xd0\x00") # PINGRESP
elif pkt_type == 14: # DISCONNECT
self._transport.close()
else:
_log("packet", src=self._peer[0], pkt_type=pkt_type)
self._transport.close()