November 3, 2026
CVE-2026โ82396
stored xss โ sulu

By 0x3xpl01t3r
5 min read
ุจูุณูู ู ุงููููููู ุงูุฑููุญูู ูููฐูู ุงูุฑููุญููู ู
Discovering Stored XSS in Sulu CMS through ?inline=1
Vulnerability Research | CVE-2026โ82396 | GHSA-pp4x-ccxq-6r33 Researcher:
Abdulhafeeth MarabitSeverity: Moderate โ CVSS 3.1: 5.4 Classification: CWE-79 โ Stored Cross-Site Scripting Affected versions: Sulu CMSโค 2.6.24and3.0.x < 3.0.8Fixed versions:2.6.25and3.0.8
The finding
I discovered a stored cross-site scripting vulnerability in Sulu CMS's media download functionality. The issue was not simply that an editor could upload an HTML file. It was that the download endpoint could be instructed to render that file inline on the application's own origin, turning stored, user-controlled content into an executable browser document.
The relevant behavior was exposed through a deceptively small parameter:
GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.localGET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.localWhen the response preserves the file's text/html content type and honors the inline override, the browser can interpret the uploaded HTML as part of the Sulu origin. A victim only needs to open the crafted media URL.
Attack flow
How I approached the investigation
My starting point was Sulu's media workflow. Uploading and downloading files are ordinary CMS features, and the security implications often hide in the transition between them. The question I wanted to answer was straightforward:
Does Sulu always treat user-supplied files as untrusted downloads, or can a media URL make the browser treat them as first-party documents?
I focused on the download response rather than the upload form. Upload controls decide what gets stored; response headers decide what a browser does with it. That distinction became the key to the finding.
An uploaded HTML file, by itself, does not establish XSS. For an actual browser-execution issue, I needed three conditions to come together:
- An account with media upload permission could store a document containing JavaScript.
- The download endpoint returned that document with a scriptable MIME type.
- A request parameter could override download behavior so that the browser rendered it on the application origin.
I found the third condition in ?inline=1.
The first response: a normal media download
I began with a media file uploaded through an editor account and inspected its download response. For the comparison, I kept the media resource constant and inspected the response headers.
GET /media/123/download/proof.html HTTP/1.1
Host: sulu.local
Accept: text/html,*/*
Connection: closeGET /media/123/download/proof.html HTTP/1.1
Host: sulu.local
Accept: text/html,*/*
Connection: closeA key header in the normal download flow was the content disposition:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: attachment; filename="proof.html"HTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: attachment; filename="proof.html"This distinction matters. text/html identifies the content as an HTML document, but attachment instructs the browser to handle the response as a download rather than a document to display in the current origin.
One parameter changed the security boundary
I then kept the same media object and changed only the query string:
GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*
Connection: closeGET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*
Connection: closeIn an affected version, the decisive difference is in the response:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: inlineHTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: inlineNo change to the stored file was needed. The URL alone was enough to change how the browser was invited to handle it.
The security boundary crossed here is precise: Sulu was serving untrusted, uploaded bytes as an active document from its trusted origin. This is why the finding is a stored XSS rather than merely an unrestricted upload or an unsafe file download.
Proof of concept
To validate execution without accessing sensitive information, I used a small HTML document containing a harmless JavaScript marker:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sulu Media PoC</title>
</head>
<body>
<h1>Media rendering test</h1>
<script>
document.body.dataset.xss = 'executed';
alert('Origin: ' + window.location.origin);
</script>
</body>
</html><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sulu Media PoC</title>
</head>
<body>
<h1>Media rendering test</h1>
<script>
document.body.dataset.xss = 'executed';
alert('Origin: ' + window.location.origin);
</script>
</body>
</html>The upload was performed through Sulu's normal Media Manager with a user who had permission to add media. That is an important part of the exploit model: the attacker does not need server-side code execution or administrative rights to place the payload in storage.
The actual upload request is deployment-dependent, so the most accurate evidence is the multipart/form-data request captured from the media interface, including its real route, authenticated session, CSRF handling and returned media ID. The exploit itself hinges on the subsequent download request, not on a special upload API.
With the stored file available, the proof reduces to one request:
GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*Opening that response in a browser demonstrates whether the script runs in the Sulu origin. The alert displays window.location.origin, and the data-xss marker provides a second observable sign that the script executed. Neither requires exfiltration or access to another user's data.
Why it worked
The defect is a combination of three response-handling decisions:
- Untrusted content was stored: an editor-level user could add an HTML media object.
- Its MIME type was retained: the file could be returned as
Content-Type: text/html. - The disposition could be overridden:
?inline=1could forceContent-Disposition: inlineeven for browser-renderable document types.
The affected response also lacked defenses such as a restrictive Content Security Policy and X-Content-Type-Options: nosniff. The absence of nosniff was not the primary cause for correctly labeled text/html; the critical issue was permitting scriptable content to render inline on the application origin.
This is an origin-trust failure. The same document is far less dangerous when offered as an attachment or served from a separate, cookie-isolated media domain.
Impact
A user with media upload permission can store a malicious HTML document and direct another authenticated user to its download URL with inline=1. If the target opens the link in an affected deployment, attacker-controlled JavaScript can execute under the Sulu application's origin.
Depending on the deployment's cookie configuration, application controls, and the victim's privileges, that same-origin execution can expose accessible application data or enable actions in the victim's authenticated context. The proof of concept above intentionally demonstrates execution only; it does not claim account takeover or session extraction was performed.
The trust boundary becomes especially important when editors are less trusted than administrators. A feature intended for content management can become a path from lower-privileged uploads to script execution in a higher-privileged user's browser.
Fix and verification
The security fix was shipped in Sulu 2.6.25 and 3.0.8. Document MIME types including text/html, application/xhtml+xml, text/xml, and application/xml are forced to download as attachments even when inline=1 is present. Safe inline media behavior is preserved for supported image and PDF types.
Defense in depth: isolate the media origin
A separate, cookie-isolated origin for uploaded content provides an additional boundary if potentially scriptable files must be viewed in a browser. Even if a document executes JavaScript, it does not automatically execute with the main application's origin privileges.
The vendor fix addresses the dangerous inline disposition directly; origin isolation is a complementary architectural precaution rather than a substitute for upgrading.
The regression check is the same request against a patched build:
GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*GET /media/123/download/proof.html?inline=1 HTTP/1.1
Host: sulu.local
Accept: text/html,*/*The decisive patched behavior is:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: attachment; filename="proof.html"HTTP/1.1 200 OK
Content-Type: text/html
Content-Disposition: attachment; filename="proof.html"The patch also introduces a Content-Security-Policy: sandbox defense for applicable local-file responses. The meaningful regression is that HTML cannot be switched back to an inline executable document through the query parameter.
Closing thoughts
The interesting part of this vulnerability was not the JavaScript payload. It was the assumption hidden in an otherwise normal download endpoint: a client-controlled preference for inline rendering was allowed to override a security-sensitive disposition decision.
The investigation came down to one question, one file, and a single parameter change. The behavior only became a security issue when I followed the response all the way to the browser's origin model.
That is the lesson I took from this finding: when reviewing file-handling features, the point where stored content becomes an HTTP response deserves as much attention as the upload itself.