Research/webmin-detach-xss
0-dayNo CVEHighPublic

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-83 forces image/svg and .svg / .svgz names to text/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:

Plain text
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:

Plain text
print &safe_urls(&filter_javascript($attach->{'data'}));

filter_javascript is honest about what it does. It disables javascript tags. The implementation is a pair:

Plain text
$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.

Plain text
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-WITNESS

The 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.