← Back to all reports

Unauthenticated Stored XSS to Admin Takeover via a Page-Builder Module

Reported Jun 1, 2026
Severity High
Platform Web
Vulnerability Class Stored XSS (CWE-79)
Target Type Appointment / Booking Platform
Impact Admin takeover, server-side code execution

The Risk

Anyone on the internet, with no account and no password, could plant a hidden trap inside the company's website. The next time a staff administrator opened the page-styling tool in the admin area, the trap quietly ran the attacker's commands using that administrator's own access. That was enough to create a brand new admin account for the attacker or take over the entire website and the server behind it. The attacker did not even need to be online when it happened, and a single web request was all it took to set it up.

The Vulnerability

The target ran an older release of a popular content management system, extended with a visual page-design plugin that lets editors tweak page styling (colours, fonts, spacing) from the admin backoffice. The plugin exposed a small set of API actions for saving and loading those style settings.

The controller behind those actions inherited from the CMS's public API base class but carried no authorization attribute. As a result, every action - including the ones that write and read style data - was reachable with no authentication at all: no cookie, no session, no anti-forgery token, no Authorization header.

A quick unauthenticated probe confirmed this. Calling the plugin's font-lookup action triggered an outbound request to a third-party fonts service and returned its error body, proving the controller executed server-side logic for a caller who had presented zero credentials.

The style settings were stored as plain text variables inside a generated stylesheet file under the web root. When the backoffice later displayed the styling panel, it read those variables back and fed them into a JavaScript eval() call to apply them live in the browser. Stored text being passed through eval() is the classic recipe for stored cross-site scripting.

The Attack

Planting the payload (unauthenticated write)

A single unauthenticated POST to the plugin's style-save action wrote an attacker-controlled style variable to the web root. The variable value was crafted to break out of the styling call and inject JavaScript:

'})+eval(String.fromCharCode(...))//

The server resolved the page, wrote the stylesheet file, and returned its path. The malicious variable was now persisted on disk, no login required.

Confirming the payload survives (the XSS source)

An unauthenticated GET to the plugin's style-load action returned the injected variable verbatim, with every breakout character intact:

{"xsstest":" '})+eval(String.fromCharCode(...))//"}

The routine that builds this response read each name: value; style-variable line from the raw stylesheet with a regular expression and no filtering, then assembled the JSON by naive string replacement. Quotes, braces and backticks all passed through unchanged. A benign backtick test value round-tripped intact as well, confirming the second possible execution path through the styling engine's inline-JavaScript support.

Execution in the admin session

The live admin bundle contained the sink that turns the stored value into running code:

var refreshLayout = function (parameters) {
    var string = 'applyStyles({' + parameters.join(',') + '})';
    eval(string);
};

With the injected value in place, the string handed to eval() became:

applyStyles({'xsstest':' '})+eval(String.fromCharCode(...))//'})

The styling call closed early, the appended eval(String.fromCharCode(...)) ran the attacker's JavaScript, and the trailing // commented out the remainder. The injected code resolved the page's own domain, confirming it ran same-origin as the authenticated administrator's session. Because the payload sits in a stored file, the attacker does not need to be online when it fires - it triggers the next time any privileged user opens the styling panel on the poisoned page.

Weaponising it

Swapping the harmless proof for a real backoffice API call let the attacker act as the administrator. One example primitive read the admin's anti-forgery token from the browser and called the user-creation endpoint to mint a fresh administrator account:

fetch('/api/internal/users/create', {
  method: 'POST', credentials: 'include',
  headers: { 'Content-Type': 'application/json',
            'X-XSRF-TOKEN': /* read from admin's cookie */ },
  body: JSON.stringify({ username: 'attacker', userGroups: ['admin'] })
})

The Impact

Running same-origin inside an administrator's backoffice session, the stored payload could:

  • Steal the administrator's anti-forgery token and ride their session.
  • Create a new administrator account fully controlled by the attacker.
  • Write a server-side template, converting backoffice access into code execution on the production server itself.

The entry point required no account, no anti-forgery token and no prior access - just one web request that anyone on the internet could send. The only barrier between planting the payload and full compromise was a single privileged user opening the styling panel on the affected page in the normal course of their work, which is why the finding was rated High rather than Critical.

Several further escalations were tested and ruled out for triage completeness. Arbitrary file write was not possible because the page identifier was parsed as an integer and the filename and directory were fixed, giving no path or extension control. Unauthenticated content tampering through the save action failed. Server-side file reads through the stylesheet's import feature were blocked by the platform's extension filter and protected-file handler, and never disclosed configuration secrets or connection strings.

Remediation

  • Require authentication on every action of the page-design plugin's controller, or upgrade the content management system to a release where these actions are gated by default. The controller must not inherit unauthenticated access from its API base class.
  • Remove the eval() patterns entirely. Pass a real object to the styling function and look up values directly instead of building and evaluating a string.
  • Validate and encode every value returned by the load action. Reject style-variable values containing quotes, braces, backticks, or import directives.
  • Disable inline JavaScript in the styling engine, or upgrade past the legacy engine version that supports it.
  • Audit administrator accounts and generated stylesheet files for any unauthorized additions left behind.