Webmin 2.670, detach.cgi unclosed-script XSS
Webmin 2.670 mailboxes/detach.cgi serves a text/html attachment after filter_javascript. That filter deletes a script element only when a closing tag is present. An anonymous sender plants an unclosed script. The victim opens View. Cookie is HttpOnly. Script still runs on the Webmin origin. Labbed. No CVE yet.
- Name
- Webmin 2.670, detach.cgi unclosed-script XSS
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Webmin
- Affected
- Webmin 2.670 (3a2b2b86). Anonymous mail sender. Victim is a mailboxes user who clicks View. Not CVE-2026-49102 (SVG forced text/plain).
- Published
- 11 Nov 2026
- Updated
- 11 Nov 2026
- Tags
- 0-day, webmin, xss, stored-xss, cwe-79, remote
They closed SVG. HTML still needed a closing tag.
https://github.com/abraxas/webmin-detach-xss is the loopback lab. I mailed Webmin 2.670 an HTML attachment with a <script> that never ended. View served it as text/html on the admin origin. CVE-2026-49102 already forces image/svg through text/plain in detach.cgi. filter_javascript still waits for </script> before it will delete a script. detach.cgi never calls header(), so that response also has no CSP.
Anyone who can send mail to a box Webmin reads can plant that file. The person who clicks View is not downloading an attachment. They are loading the sender's HTML as a Webmin page, while they are still logged in. The session cookie is HttpOnly, so a script cannot read it with document.cookie. Same-origin fetch still carries it. This is not a shell without a click.
This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-79. 8.0 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N). Remote stored XSS. Anonymous sender. Victim opens View. Pack: abraxas/webmin-detach-xss (loopback client).
Webmin's own policy is unpaid. Reports go to security@webmin.com. I did not mail that address. I did not open a GitHub issue. I did not talk to a live webmin.com.
Why this product, why this tag
Webmin is a Perl CGI control panel that still sits on port 10000 on a lot of boxes, often as root. Mailboxes is a core module. Root has it on a default install. You do not need to grant someone the shell module for this one. You need a mailbox the panel will read, and an admin who opens mail from the panel instead of from mutt.
I pinned tag 2.670, published 2026-09-21, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b. That is the tag people install. Master on the scan date was 792e4d3006a3522fe80d3cf2fffe149bf2227dfd, 298 commits ahead. I did not hunt master. Sibling fixes on HEAD do not close a leftover on the tag.
The skip list is most of the mail XSS story
Before you look for leftovers, you write down the CVEs that already ate this neighborhood.
- CVE-2026-49102. SVG mail XSS.
detach.cgi:76-83forcesimage/svgand.svg/.svgznames totext/plain. Closed. That is the first file I opened. It is not this bug. - CVE-2026-49103. Attachment detach overwrite. Write, not script. Closed.
- CVE-2026-49243. Reflected XSS, 2.650. Closed.
- CVE-2026-22678. Status-module stored XSS, 2.641. Closed.
- Help-page root command, 2.640. Closed.
- Custom-command parameter injection, fixed in 2.670. Closed.
- File Manager search/chooser listing, fixed in 2.670. Closed.
I was not looking for another SVG. I was looking for the HTML twin they left standing after they nailed SVG to text/plain.
Surfaces that returned nothing
The HTML body path looks like XSS until you read iframe_body. The message body is stuffed into an iframe with sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox". No allow-scripts. find_body also skips any part whose Content-Disposition starts with attachment. Inline HTML is not this leftover. I spent a morning proving that, then stopped treating the body as the sink.
save=1 on detach is a download. Content-Disposition: Attachment. The browser will not execute it as a page. The View link does not set save.
type= on detach is worse than the leftover I kept. If the caller picks a type and the part is not SVG, the raw bytes print under that type and the filter is skipped. The View link from attachments_table does not set type=. I almost labbed the override. The leftover that ships on the View button is the filtered path.
HttpOnly is a real default. miniserv-lib.pl adds it unless no_httponly is on. That kills cookie theft into a foreign origin. It does not kill same-origin script. I had to write that down so I would not call this "session steal via document.cookie" in a table.
The leftover
mailboxes/detach.cgi does not call header(). CGI pages that go through header() get:
print "Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval'; frame-src 'self'; child-src 'self'\n";Detach prints its own Content-type and the part. No CSP. For a non-SVG part, when save is off and the type is text/html or application/xhtml, it prints:
print &safe_urls(&filter_javascript($attach->{'data'}));filter_javascript is honest about what it does. It disables javascript tags. The implementation is a pair:
$rv =~ s/<\s*script[^>]*>([\000-\377]*?)<\s*\/script\s*>//gi;No close tag, no match, no strip. Event-handler prefixes and javascript: mangles still run. They do not save you from an unclosed <script> that is the whole document.
view_mail.cgi builds the View href through attachments_table in folders-lib.pl. Regular attachments get detach.cgi/<filename>?...&attach=<idx>. Same origin. Referer is view_mail.cgi, so miniserv's referer check is happy. The document runs as the user who opened the message.
The default CSP, even if it had been sent, allows 'unsafe-inline'. Calling header() is necessary and not sufficient. The filter is the hole.
Wrong turns, in the order they happened
I opened SVG first because that is the CVE with a number. Lines 76-83 are a wall. I closed that notebook.
I opened the body iframe second. Sandbox without allow-scripts. find_body skips attachments. I needed Content-Disposition: attachment so the part stayed in the table.
I assumed filter_javascript stripped every script. I read the regex. It does not.
I treated HttpOnly as a stop. It is a stop for cookie exfil. It is not a stop for acting as the session.
I almost used type=text/html as the oracle. That skips the filter. The View button does not send it. The leftover that an admin actually clicks is the filtered path. That is the one I labbed.
What I built
No SMTP. I planted /var/mail/root inside the container with a multipart message: a plain body, then payload.html as text/html / Content-Disposition: attachment, body <html><body><script>WEBMIN-H07-XSS-WITNESS and no close. mail_system=6, auto=0, mail_dir=/var/mail so Webmin reads the file mailbox and does not rewrite it.
Login as root / labroot. list_mail.cgi, then view_mail.cgi. Pull the View href. GET detach.cgi. Oracle is status 200, Content-Type: text/html, no Content-Security-Policy, <script> present, </script> absent, witness still in the body.
run.sh clones webmin/webmin at tag 2.670 into lab/src, builds webmin-lab:2.670 if it is missing, binds 127.0.0.1:18410, tears the stack down on the way out.
IOC planted-mbox /var/mail/root unclosed-script
IOC mailbox-config auto=0 mail_dir=/var/mail mail_system=6
IOC detach-url http://127.0.0.1:18410/mailboxes/detach.cgi/payload%2Ehtml?idx=0&folder=0&user=root&attach=1
IOC detach status=200 content-type=text/html csp=0 script=1 close=0 witness=1
IOC detach-body <html><body><script>WEBMIN-H07-XSS-WITNESS
SUCCESS WEBMIN-H07-DETACH-XSS detach.cgi text/html unclosed-script WEBMIN-H07-XSS-WITNESSThe lab is HTTP. I did not drive a browser. Execution in Chromium is inferred from text/html plus an unclosed script plus no CSP. That is enough for the class. It is not a screenshot of alert.
What an attacker can do
Needs a mailbox Webmin will read, and one click on View. They do not need a Webmin account.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Plant an unclosed <script> as a text/html attachment |
mailbox with Content-Disposition: attachment |
Integrity (stored markup) | Lab-proven. /var/mail/root |
| Keep the part out of the sandboxed body iframe | find_body skips attachment disposition |
Recon | Lab-proven. Attachment table |
| Serve that part as HTML on the Webmin origin | GET /mailboxes/detach.cgi/... View |
Integrity (origin) | Lab-proven. text/html |
Keep the script after filter_javascript |
unclosed <script> |
Integrity | Lab-proven. script=1 close=0 witness=1 |
| Skip CSP on that response | no header() |
Integrity | Lab-proven. csp=0 |
| Act as the victim on same-origin Webmin CGIs | browser script, sid still sent |
Integrity of that session (users, config, commands the ACL already allows) | Inferred. HTTP lab, not a browser. Cookie is HttpOnly |
Stays closed on this leftover
| Axis | Method / sink | Why it is closed |
|---|---|---|
| Integrity | SVG attachment | CVE-2026-49102. Forced text/plain |
| Integrity | HTML message body | iframe sandbox without allow-scripts |
| Recon | document.cookie |
sid is HttpOnly by default |
| Integrity | save=1 download |
Content-Disposition: Attachment |
| Integrity | Unauthenticated detach.cgi |
miniserv session still required |
| Integrity | Overwrite on detach | CVE-2026-49103. Different bug |
Webmin as root is the usual install. A script in that origin can do whatever the logged-in user can do in the panel. I did not call that OS RCE. I called it stored XSS with UI:R.
The fix
Strip an unclosed <script> in filter_javascript, not only a matched pair. Or stop serving attachments as HTML: always text/plain, or always Content-Disposition: attachment. Call header() from detach.cgi so the response actually carries a CSP. Drop 'unsafe-inline' from that CSP if the goal is to survive the next filter miss.
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.