#!/usr/bin/env python3
"""
Danfoss AK-SM 800A vulnerable-behavior emulator (pre-R4.2 / pre-R4.3.1).
Emulates, for detection-signature development, the three web-interface
weaknesses documented by Claroty Team82 and Danfoss advisories
DSA-2025-03-01 / DSA-2025-08-01:
CVE-2025-41450 /xml.cgi?action=getauth accepts a "code of the day"
(datetime-derived password) instead of the configured
admin password when specific request attributes are set.
NOTE: Claroty redacted the real attribute names and the
real generation algorithm. This emulator implements the
documented *mechanism* with a stand-in algorithm
(cotd.generate_code) and stand-in attribute names
("codeofday" / "support"). Swap in real values if you
recover them from firmware.
CVE-2025-41451 alarm-to-mail / SMTP email configuration save handler
interpolates the email password field (config item
0x1be1) into an OS shell command without sanitization.
CVE-2025-41452 action=load_nginx_headers accepts an authenticated
file upload replacing headers.conf, which is included by
the Internet-facing nginx instance. Injected location
blocks become live; requesting an injected route crashes
the web service task (sm_app) -> repeatable DoS.
Binds 127.0.0.1 only. Logs every request to captures/http-requests.jsonl
so you can diff PoC traffic against signatures.
"""
import json
import re
import shlex
import subprocess
import sys
import time
import urllib.parse
from datetime import datetime, timezone
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CAPTURES = ROOT / "captures"
STATE = ROOT / "emulator" / "state"
CAPTURES.mkdir(exist_ok=True)
STATE.mkdir(exist_ok=True)
LOG = CAPTURES / "http-requests.jsonl"
ADMIN_USER = "admin"
ADMIN_PASS = "Danfoss800A!" # emulator-only configured password
BYPASS_ATTRIBUTES = ("codeofday", "support") # stand-ins for redacted attrs
SESSIONS = {}
# set by CVE-2025-41452 handler
injected_locations = [] # list of location paths from uploaded headers.conf
injected_headers = [] # add_header directives from uploaded headers.conf
crashed_until = 0.0 # sm_app simulated crash window
CRASH_SECONDS = 8
def generate_code(day: datetime) -> str:
"""Stand-in code-of-the-day algorithm (real algorithm is not public).
Documented property preserved from the research: the code depends ONLY
on the device's current date, so anyone who can reproduce the algorithm
can authenticate on any given day.
"""
import hashlib
seed = f"AKSM-COTD-{day.year:04d}-{day.month:02d}-{day.day:02d}"
return f"{int(hashlib.md5(seed.encode()).hexdigest(), 16) % 900000 + 100000}"
def log_request(handler, body: bytes, tags):
rec = {
"ts": datetime.now(timezone.utc).isoformat(),
"method": handler.command,
"path": handler.path,
"client": handler.client_address[0],
"headers": {k: v for k, v in handler.headers.items()},
"body": body.decode("utf-8", "replace")[:4096],
"tags": tags,
}
with LOG.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
def parse_body(handler, body: bytes) -> dict:
ctype = handler.headers.get("Content-Type", "")
if "application/x-www-form-urlencoded" in ctype:
return {k: v[0] for k, v in urllib.parse.parse_qs(body.decode("utf-8", "replace")).items()}
if "text/xml" in ctype or body.strip().startswith(b"<"):
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(body)
return {child.tag: (child.text or "").strip() for child in root}
except ET.ParseError:
return {}
return {}
def xml_response(result: str, extra: str = "") -> bytes:
return f'{result}{extra}'.encode()
class AKSMHandler(BaseHTTPRequestHandler):
server_version = "nginx"
sys_version = ""
def version_string(self):
return "nginx" # AK-SM external interface fronts with nginx
def log_message(self, *args):
pass # structured jsonl log instead
# -- helpers ---------------------------------------------------------
def _send(self, code: int, body: bytes, ctype="text/xml", extra_headers=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for k, v in injected_headers: # live directives from uploaded headers.conf
self.send_header(k, v)
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _session(self) -> str:
raw = self.headers.get("Cookie", "")
jar = cookies.SimpleCookie(raw)
morsel = jar.get("SMSession")
if morsel and morsel.value in SESSIONS:
return morsel.value
return None
def _read_body(self) -> bytes:
length = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(length) if length else b""
# -- request dispatch ------------------------------------------------
def _dispatch(self):
global crashed_until
body = self._read_body()
parsed = urllib.parse.urlsplit(self.path)
params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
tags = []
# simulated post-crash window (CVE-2025-41452 DoS condition)
if time.time() < crashed_until:
tags.append("svc_unavailable_during_crash")
log_request(self, body, tags)
self._send(503, b"
503 Service Unavailable
", "text/html")
return
if parsed.path == "/" and self.command == "GET":
log_request(self, body, tags)
page = b"StoreView - LoginAK-SM 800A"
self._send(200, page, "text/html")
return
if parsed.path == "/xml.cgi":
action = params.get("action", "")
if action == "getauth":
return self._handle_getauth(body, params, tags)
if action == "set_email_config":
return self._handle_set_email_config(body, params, tags)
if action == "load_nginx_headers":
return self._handle_load_nginx_headers(body, tags)
log_request(self, body, ["unknown_action"])
self._send(400, xml_response("error", "unknown action"))
return
# injected nginx locations (CVE-2025-41452): hitting one crashes sm_app
if parsed.path in injected_locations:
crashed_until = time.time() + CRASH_SECONDS
tags += ["cve_2025_41452_injected_route_hit", "sm_app_crash"]
log_request(self, body, tags)
self._send(502, b"502 Bad Gateway
", "text/html")
return
log_request(self, body, tags)
self._send(404, b"404 Not Found
", "text/html")
do_GET = _dispatch
do_POST = _dispatch
do_PUT = _dispatch
do_DELETE = _dispatch
# -- CVE-2025-41450 --------------------------------------------------
def _handle_getauth(self, body: bytes, params: dict, tags: list):
fields = {**params, **parse_body(self, body)}
user = fields.get("username", "")
password = fields.get("password", "")
bypass = any(fields.get(attr) == "1" for attr in BYPASS_ATTRIBUTES)
if bypass:
tags.append("cve_2025_41450_cotd_attempt")
expected = generate_code(datetime.now())
if password == expected:
token = self._new_session(user or "admin", via="code_of_the_day")
tags.append("cve_2025_41450_cotd_success")
log_request(self, body, tags)
self._send(200, xml_response("ok", f"{token}admin"),
extra_headers={"Set-Cookie": f"SMSession={token}; path=/"})
return
tags.append("cve_2025_41450_cotd_bad_code")
log_request(self, body, tags)
self._send(200, xml_response("error", "authentication failed"))
return
tags.append("normal_auth")
if user == ADMIN_USER and password == ADMIN_PASS:
token = self._new_session(user, via="password")
log_request(self, body, tags)
self._send(200, xml_response("ok", f"{token}{user}"),
extra_headers={"Set-Cookie": f"SMSession={token}; path=/"})
else:
log_request(self, body, tags)
self._send(200, xml_response("error", "authentication failed"))
def _new_session(self, user: str, via: str) -> str:
import secrets
token = secrets.token_hex(16)
SESSIONS[token] = {"user": user, "via": via, "ts": time.time()}
return token
# -- CVE-2025-41451 --------------------------------------------------
def _handle_set_email_config(self, body: bytes, params: dict, tags: list):
if not self._session():
tags.append("unauth_rejected")
log_request(self, body, tags)
self._send(200, xml_response("error", "not authenticated"))
return
fields = {**params, **parse_body(self, body)}
# item 0x1be1 = email authentication password (per Claroty writeup)
smtp_password = fields.get("item_0x1be1", fields.get("smtp_password", ""))
# vulnerable firmware behavior: value formatted straight into a shell command
cmd = f"/bin/echo '{smtp_password}' > {STATE}/.smtp_secret"
tags.append("cve_2025_41451_email_config_save")
if re.search(r"[;&|`$()<>]", smtp_password):
tags.append("cve_2025_41451_shell_metachars")
log_request(self, body, tags + [f"constructed_cmd:{cmd}"])
proc = subprocess.run(cmd, shell=True, cwd=ROOT, capture_output=True, text=True)
if proc.returncode == 0:
self._send(200, xml_response("ok", "email configuration saved"))
else:
self._send(200, xml_response("error", "failed to save configuration"))
# -- CVE-2025-41452 --------------------------------------------------
def _handle_load_nginx_headers(self, body: bytes, tags: list):
global injected_locations, injected_headers
if not self._session():
tags.append("unauth_rejected")
log_request(self, body, tags)
self._send(200, xml_response("error", "not authenticated"))
return
ctype = self.headers.get("Content-Type", "")
conf = ""
if "multipart/form-data" in ctype:
boundary = ctype.split("boundary=")[-1].encode()
for part in body.split(b"--" + boundary):
if b"filename=" in part:
conf = part.split(b"\r\n\r\n", 1)[1].rstrip(b"\r\n-").decode("utf-8", "replace")
break
else:
conf = body.decode("utf-8", "replace")
conf_path = STATE / "headers.conf"
conf_path.write_text(conf)
injected_locations = re.findall(r"location\s+(?:=\s*)?(/\S*)", conf)
injected_headers = re.findall(r'add_header\s+([\w-]+)\s+"?([^";]+)"?\s*;', conf)
tags += ["cve_2025_41452_headers_conf_upload",
f"injected_locations:{','.join(injected_locations) or 'none'}",
f"injected_headers:{injected_headers or 'none'}"]
log_request(self, body, tags)
# firmware reloads the external nginx at runtime
self._send(200, xml_response("ok", "headers.conf applied, nginx reloaded"))
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
server = ThreadingHTTPServer(("127.0.0.1", port), AKSMHandler)
print(f"[emulator] AK-SM 800A (vulnerable, pre-R4.2) on http://127.0.0.1:{port}")
print(f"[emulator] logging requests to {LOG}")
print(f"[emulator] legit creds: {ADMIN_USER}:{shlex.quote(ADMIN_PASS)}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()