Research/webmin-firewalld-csrf
0-dayNo CVEHighPublic

Webmin 2.670, firewalld index.cgi CSRF sets trusted

Webmin 2.670 firewalld/index.cgi is referer-exempt and 302s to default_zone.cgi, which runs firewall-cmd --set-default-zone trusted. Direct default_zone.cgi with a foreign Referer is blocked. SameSite=Lax still sends the cookie on a top-level GET. Labbed. No CVE yet.

Name
Webmin 2.670, firewalld index.cgi CSRF sets trusted
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Webmin
Affected
Webmin 2.670 (3a2b2b86). Logged-in FirewallD user. Top-level GET. SameSite=Lax. Linux host with firewalld. Package default referers_none=1 does not close the index hop.
Published
12 Nov 2026
Updated
12 Nov 2026
Tags
0-day, webmin, csrf, firewalld, cwe-352, remote, authenticated

trusted is a zone. It is also a GET.

https://github.com/abraxas/webmin-firewalld-csrf is the loopback lab. run.sh clones webmin/webmin tag 2.670, poc.py follows one foreign GET, and firewall-cmd is a PATH stub because the container has no firewalld. The launcher is webmin-firewalld-csrf-Abraxas-Labs.py. Authorized lab only. It talks to 127.0.0.1:18450.

This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-352. 8.0 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H). Remote CSRF. Victim Webmin session with the FirewallD module. Top-level GET. SameSite=Lax.

You are logged into Webmin, looking at the firewall. Someone sends you a link. You open it. The cookie is Lax, so an ordinary click still carries it. Webmin treats that click as "open the FirewallD home page" and skips the usual "did this come from Webmin?" check. That home page is not a dashboard. It 302s into the setter and runs firewall-cmd --set-default-zone trusted. Trusted means ACCEPT. You did not type a password on that page. You did not press confirm.

Logged out, this does nothing. Direct GET of default_zone.cgi from a foreign page is the Security Warning. A cross-site POST would not send a Lax cookie. The hop through index is the bug.

Why Webmin

Webmin is the Perl CGI box a lot of people still put on port 10000 and then forget. miniserv is its own HTTP server. Auth is a sid cookie, then per-module ACL. CSRF control is a Referer check, not a token. setup.sh writes referers_none=1 on a package install, which is the opposite of the old setup.pl story where referers were left off. I wanted to know whether that default actually closed GET mutations.

I only wanted High, or a Medium from an untrusted actor. Policy at webmin.com/security is unpaid VDP. "Root granted this user a module that runs commands" is out. A signed-in user hitting a setter the Referer check was supposed to stop is in. CSRF was slot H24.

Pinning 2.670

Latest published tag when I cloned. 2.670, 2026-09-21, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b. master was 298 commits ahead (792e4d30). Hunt the tag people install. Image label and /etc/webmin/version are both 2.670.

The skip list is half the hunt

Already public on or before this pin. I did not refile them.

  • custom-command parameter injection (fixed in 2.670)
  • File Manager search/chooser directory listing (fixed in 2.670)
  • DHCPd / MySQL / PostgreSQL / Nginx / Samba / Squid ACL bypass (2.653)
  • URL-download SSRF (2.652)
  • reflected XSS CVE-2026-49243 (2.650)
  • status-module stored XSS CVE-2026-22678 (2.641)
  • help-page root command (2.640)
  • SVG mail XSS CVE-2026-49102
  • attachment detach overwrite CVE-2026-49103
  • Basic-auth 2FA bypass CVE-2026-42210 and CVE-2026-56022
  • Squid cache-manager injection CVE-2025-67738
  • password-reset host header CVE-2025-61541
  • proxy client-cert spoof CVE-2026-56020
  • shell autocomplete CVE-2024-12828
  • File Manager CVE-2022-0824 and CVE-2022-0829
  • arbitrary file write CVE-2022-30708
  • backdoor CVE-2019-15231

Clickjacking is out of scope. The old setup.pl referer-off XSS is the non-package path. Package setup.sh:873 writes referers_none=1. That is the control this leftover walks around, not the leftover.

CSRF was supposed to be closed

init_config in web-lib-funcs.pl decides whether a CGI trusts its Referer. Unknown foreign hosts are refused when referers_none=1. The Security Warning page is real. I hit default_zone.cgi?zone=trusted with Referer: http://evil.test/ and a live sid. Status 200. Body contains Security Warning. The stub never saw --set-default-zone.

Then the same file, fifty lines earlier, sets trust=1 when SCRIPT_NAME is /index.cgi or /<module>/index.cgi. Comment: "Script is a module's index.cgi, which is normally safe." Most indexes render HTML. FirewallD's does not, not on defzone.

Plain text
if ($in{'defzone'}) {
	# Make a zone the default
	&redirect("default_zone.cgi?zone=".&urlize($in{'zone'}));
	return;
	}

That is firewalld/index.cgi:21. miniserv sees the Location header and turns it into HTTP 302 (miniserv-lib.pl:2484). The browser follows. The follow-up Referer is now the Webmin index URL. Host matches HTTP_HOST. trust=1 again, this time because it is same-origin, not because it is index. default_zone.cgi:19 calls default_firewalld_zone, which is firewall-cmd --set-default-zone plus quotemeta of the zone name (firewalld-lib.pl:396). No confirm. delete_zone.cgi asks first. zone_form.cgi is a POST form. The default-zone path is the one that just does it.

Cookie: SameSite=Lax unless no_samesite (miniserv-lib.pl:3236). Lax sends on a top-level GET. It does not send on a cross-site POST. The attack is a link, not a hidden form.

trusted is a built-in firewalld zone. Target ACCEPT. The module ships os_support=*-linux. No extra ACL on this action. Default root can open FirewallD.

Wrong turns, in the order they happened

I grepped ReadParse plus state change on GET and landed on default_zone.cgi first. That CGI is the sink. It is not the CSRF. Foreign Referer stops it. I almost wrote "referers_none holds" and moved on.

I then treated referers_none=1 as the whole control. It is the control on every CGI that is not index. The exemption is in init_config before referers_none is consulted.

I compared it to the public setup.pl referer-off XSS. Different installer, different default, different class.

I looked at clickjacking the "Set Default" button. Out of scope, and unnecessary. The button is ui_submit(..., "defzone") on a GET form. You do not need a frame. You need a URL.

I looked for a POST. create_zone.cgi is POST. delete_zone.cgi confirm is POST. Lax would not send the cookie. The leftover that survives Lax is the GET 302.

In the lab, debian:bookworm-slim has no firewalld. check_firewalld would have failed the index page before the 302. The stub answers --state, --get-default-zone, --list-all-zones, and logs --set-default-zone. Without it the hop is a UI error, not a witness.

First client did not follow the 302. Index returned 302 to default_zone.cgi and stopped. Witness empty. The browser would have followed. The second hop's Referer has to be the index URL or you are testing the negative again.

What an attacker can do

Needs the victim already signed in with FirewallD. They do not need a Webmin password of their own. They need the victim to open a link.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Set the default firewalld zone to trusted (ACCEPT) GET /firewalld/index.cgi?defzone=1&zone=trusted with a foreign Referer; 302 to default_zone.cgi; firewall-cmd --set-default-zone trusted Integrity + availability (host firewall) Lab-proven. Witness WEBMIN-H24-FIREWALLD-CSRF --set-default-zone trusted. Hops 302,302,200
Same setter for any existing zone Same index hop, zone= of a listed zone Integrity + availability Inferred. Lab used trusted. The CGI greps list_firewalld_zones
Negative control: foreign Referer on the setter GET /firewalld/default_zone.cgi?zone=trusted n/a Lab-proven. Status 200, Security Warning, witness empty
Negative control: no session Same GET without sid n/a Closed. miniserv login gate

Stays closed on this leftover

Axis Method / sink Why it is closed
Integrity Direct default_zone.cgi from a foreign origin referers_none=1 plus host mismatch
Integrity Cross-site POST SameSite=Lax does not send the cookie
Integrity delete_zone.cgi / zone_form.cgi from the same index buttons Delete confirms. Create is POST. Direct CGI still checks Referer
Integrity Unauthenticated FirewallD Login required
Integrity Clickjacking, setup.pl referers off Not this path
Integrity Host RCE, unauth miniserv Out of class

Other modules have an index.cgi. Most of them print a page. I did not turn every trusted index into a finding. FirewallD's index is a setter.

The lab

Shared image webmin-lab:2.670 from debian:bookworm-slim, setup.sh with ssl=0, login=root, password=labroot, port=10000. Compose binds 127.0.0.1:18450. referers_none=1 is the package default. The stub is copied to /usr/local/bin/firewall-cmd and written into /etc/webmin/firewalld/config.

Login POST to /session_login.cgi. Negative GET of the setter with Referer: http://evil.test/. Then browser-style GET of /firewalld/index.cgi?defzone=1&zone=trusted with the same foreign Referer, following 302s and updating Referer each hop.

Plain text
IOC image=webmin-lab:2.670 label-version=2.670 file-version=2.670
IOC login-server=MiniServ sid=1
IOC negative-status=200 warning=1 witness-empty=1
IOC csrf-hops=302,302,200 first-location=http://127.0.0.1:18450/firewalld/default_zone.cgi?zone=trusted
IOC witness=WEBMIN-H24-FIREWALLD-CSRF --set-default-zone trusted argv=--set-default-zone trusted
SUCCESS WEBMIN-H24-FIREWALLD-CSRF WEBMIN-H24-FIREWALLD-CSRF

Two 302s then 200. First Location is the setter. Second hop is same-origin, so miniserv redirects once more to index.cgi?zone=trusted after the command runs. The stub line is the oracle, not the HTML.

The fix

Do not trust index.cgi when it mutates. A module front page that 302s into firewall-cmd is not "normally safe." Require a confirm POST for --set-default-zone, the way delete_zone.cgi already asks. SameSite=Strict, or a CSRF token the GET hop cannot satisfy. Leaving referers_none=1 and exempting index does not close this.

Until then, a logged-in FirewallD session plus one click sets the default zone. trusted is ACCEPT.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.