Webmin 2.670, postfix save_opts_misc skips general ACL
Webmin 2.670 postfix/save_opts_misc.cgi writes main.cf and reloads Postfix with no general or startstop check. A Postfix-module user with those ACL flags off still changes live mail config. general.cgi and save_opts.cgi refuse. Labbed. No CVE yet.
- Name
- Webmin 2.670, postfix save_opts_misc skips general ACL
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Webmin
- Affected
- Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). Signed-in Postfix module user with general=0 and/or startstop=0. Not the 2.653 module ACL bypasses.
- Published
- 11 Nov 2026
- Updated
- 11 Nov 2026
- Tags
- 0-day, webmin, postfix, acl, cwe-285, authenticated
I was not allowed to open General Options. I was allowed to save them.
The proof of concept is on GitHub: abraxas/webmin-postfix-opts-acl (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, entrypoint.sh, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-285. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H). Remote ACL bypass. Signed-in Postfix module user. Not unauthenticated. Not the 2.653 module ACL bypasses.
A Webmin admin can hand someone the Postfix module and then uncheck two boxes: edit general options, and start or stop the daemon. That person is supposed to look at the queue, maybe aliases, and leave the live mail wiring alone. The General Options page even refuses to render. The CGI that page posts to does not ask. It writes main.cf and reloads Postfix.
They cannot log in from the internet with no account. They cannot skip the Postfix module grant. They can still change which networks are trusted, where mail is relayed, and what filter processes it.
Why Webmin
Webmin is the panel people put on a mail host when they do not want to teach postconf to every operator. The product is explicit about fine-grained ACLs. webmin.com/security says privilege escalation that only works because root granted an untrusted user a module that can run commands or edit configs is not a bug. Those ACLs are the intended control. So the interesting leftover is not "has Postfix." It is "the ACL said no, and a CGI in that module still said yes."
I wanted a leftover that survived the 2.653 module pass. DHCPd, MySQL, PostgreSQL, Nginx, Samba, and Squid already had ACL skips closed. Postfix was not on that list. That is a smell, not a finding. The finding is the CGI that never imported %access.
Pinning 2.670
Tag 2.670, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b, published 2026-09-21. Master at scan time was 792e4d3006a3522fe80d3cf2fffe149bf2227dfd, hundreds of commits ahead. Hunt the tag people install. I did not request webmin.com. I did not open a GitHub issue on webmin/webmin.
The skip list is half the hunt
Already public, already closed on this pin, not refiled:
- 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 / CVE-2022-0829
- Arbitrary file write CVE-2022-30708
- Backdoor CVE-2019-15231
"Has the Postfix module" is also not a leftover. Root gave that user a module that edits mail config. The KEEP bar is the flags inside that module.
Surfaces that returned nothing
I read save_opts.cgi first because the name says save options. Line 16 is $access{'general'} || &error($text{'opts_ecannot'}). That CGI is closed.
I read reload.cgi, start.cgi, and stop.cgi. All three refuse $access{'startstop'}. Those CGIs are closed.
I read the other save_opts_*.cgi files. Canonical, virtual, header, body, transport, relocated, bcc, sni, dependent: each checks its own flag. Those CGIs are closed.
JWT, OAuth, password reset, SSRF, SQL, XXE, GraphQL, request smuggling, cache, templates, deserialization, CORS, hash merge: empty on this pin after the skip list. Empty means no KEEP-bar sink in that class. It does not mean the class was not searched.
The leftover sat next to a form that already knew how to say no.
The leftover
postfix/general.cgi is the General Options page. Line 16:
$access{'general'} || &error($text{'general_ecannot'});English string: You are not allowed to edit General Options. Then it prints a form that posts to save_opts_misc.cgi. The knobs on that form are the live Postfix ones: myorigin, mydestination, relayhost, inet_interfaces, mail_name, mynetworks, queue_directory, content_filter, and the rest of sample-misc.cf. option_freefield("mail_name") renders an input named mail_name_def.
postfix/save_opts_misc.cgi is the POST target. It ours $err, %in, %text. It does not our %access. It does not test general. It does not test startstop. Comment at the top: "Save Postfix options ; special case in which we need to regenerate the relocated table." Special case. No ACL.
&lock_postfix_files();
&before_save();
&save_options(\%in, [ "myhostname", "mydomain" ]);
&ensure_map("relocated_maps");
&after_save();
&unlock_postfix_files();
®enerate_relocated_table();
$err = &reload_postfix();save_options walks every posted key that ends in _def, strips the suffix, and calls set_current_value. set_current_value runs postconf -e through backquote_logged with quotemeta on the value. Then reload_postfix runs postfix reload (or the configured reload_cmd) if the daemon is up.
So a user the ACL told not to edit general options, and not to start or stop Postfix, can still change main.cf and reload the daemon. They do not need the form. They POST mail_name_def= to /postfix/save_opts_misc.cgi with a session cookie and a Postfix referer.
The sibling save CGI, save_opts.cgi, does the check this one skipped. The reload CGI, reload.cgi, does the other check. Two doors locked. The hallway open.
postconf -e is not a shell. This is not command injection. It is an authorization skip on a config writer that then reloads the MTA.
Wrong turns, in the order they happened
I opened save_opts.cgi and almost closed the class. The name matches. The check is there. A denied POST and an unchanged mail_name look like a miss if you stop.
I then opened general.cgi, saw the same $access{'general'} on GET, and treated a locked page as a locked sink. The Save button lives on that page. The CGI it posts to is a different file. Direct POST does not visit the form.
I folded Postfix into the 2.653 module list. DHCPd, MySQL, PostgreSQL, Nginx, Samba, Squid. Postfix is not in that patch. The 2.653 changelog on this tag is theme and packaging. The ACL work was other modules.
I almost filed "has the Postfix module." The policy already answered that. Fine-grained ACLs exist because root will grant modules. The leftover is the flag that does not bind.
I looked at regenerate_relocated_table and expected a relocated check, because the comment talks about the relocated map. save_opts_relocated.cgi checks $access{'relocated'}. This CGI does not. The relocated regenerate is a side effect of a general-options save, not the ACL.
Unauthenticated POST returns the login page. Needs a sid.
A reverse shell. Theatre. The oracle is postconf -h mail_name plus a grep of main.cf.
The lab
Debian bookworm image, Webmin 2.670 from the pin, Postfix local-only, miniserv on loopback 10000 published as 127.0.0.1:18500. Shared image tag webmin-lab:2.670. run.sh clones the pin. An overlay creates user limited / lablimited with webmin.acl postfix and postfix/limited.acl copied from defaultacl then general=0 startstop=0. ssl=0. Root is labroot and is not the caller.
Login as limited. GET /postfix/general.cgi is 200 with You are not allowed to edit General Options. POST /postfix/save_opts.cgi with mail_name_def=WEBMIN-H11-POSTFIX-ACL is denied. postconf -h mail_name is still Postfix. POST /postfix/save_opts_misc.cgi with the same field is 302 to /postfix/. postconf -h mail_name is the witness. /etc/postfix/main.cf has the witness.
IOC image=webmin-lab:2.670 label-version=2.670
IOC webmin-version=2.670
IOC limited-acl general=0 startstop=0
IOC before-mail_name=Postfix witness=0
IOC login-page status=200
IOC login status=302 sid=1
IOC general.cgi status=200 denied=1
IOC save_opts.cgi status=200 denied=1 redirect=
IOC after-denied-mail_name=Postfix witness=0
IOC save_opts_misc.cgi status=302 location=http://127.0.0.1:18500/postfix/ error=0
IOC after-mail_name=WEBMIN-H11-POSTFIX-ACL main.cf=1
SUCCESS WEBMIN-H11-POSTFIX-ACL WEBMIN-H11-POSTFIX-ACLLab wrong turns, also in order: image without postfix so postconf was missing until the Dockerfile and the entrypoint both install it; ssl_enforce bouncing the session; the testing cookie; crypt() returning * on a bad salt so the limited user never landed. None of those are the leftover. The leftover is the CGI that never asked.
What an attacker can do
Needs a Webmin login that already has the Postfix module, with general=0 and/or startstop=0. Not a random internet client. Not root. The ACL is supposed to be the fence inside that module.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Write mail_name into live main.cf |
POST /postfix/save_opts_misc.cgi mail_name_def= then save_options → postconf -e |
Integrity | Lab-proven. Witness in postconf -h mail_name and /etc/postfix/main.cf |
| Write other General Options keys | same CGI, any posted *_def (myorigin, mynetworks, content_filter, queue_directory, inet_interfaces, relayhost, …) |
Integrity | Inferred. Same save_options loop. Lab oracle is mail_name |
| Reload Postfix after the write | reload_postfix at save_opts_misc.cgi:33 |
Availability | Source. CGI always calls it. Lab oracle is the config write |
| Negative: open the General Options form | GET /postfix/general.cgi |
n/a | Lab-proven denied |
| Negative: the sibling save CGI | POST /postfix/save_opts.cgi |
n/a | Lab-proven denied. mail_name unchanged |
Stays closed on this leftover
| Axis | Method / sink | Why it is closed |
|---|---|---|
| Integrity | POST /postfix/save_opts.cgi |
$access{'general'} |
| Recon | GET /postfix/general.cgi |
$access{'general'} |
| Availability | reload.cgi / start.cgi / stop.cgi |
$access{'startstop'} |
| Integrity | Other save_opts_*.cgi |
Each checks its own flag |
| Recon | Anonymous miniserv | Needs a sid |
| Integrity | 2.653 module ACL skips | Different modules, already patched |
| Integrity | Host shell without a Postfix grant | Out of class |
What that means on a real mail host: mynetworks is who Postfix will relay for. relayhost is where outbound mail goes. content_filter is which program sees the body. inet_interfaces is which addresses accept SMTP. queue_directory is where the spool lives. The lab planted a banner string because that is a clean oracle. The same POST will take the other keys.
They do not get a root shell from this CGI. They do get to retune the MTA the ACL said they must not retune, and they get the daemon to pick that up.
The fix
At the top of save_opts_misc.cgi, after ReadParse:
$access{'general'} || &error($text{'general_ecannot'});
$access{'startstop'} || &error($text{'reload_ecannot'});and our %access with the rest. The form on general.cgi is not an access check. Every other save_opts_*.cgi in this module already does the first half. reload.cgi already does the second.
Until then, do not grant the Postfix module to a user you do not want rewriting main.cf. The general=0 / startstop=0 flags do not bind this CGI.
References
- github.com/webmin/webmin tag 2.670 (
3a2b2b8671c0669c129f5275b3dd1853106ae02b) postfix/save_opts_misc.cgipostfix/general.cgipostfix/save_opts.cgipostfix/reload.cgipostfix/postfix-lib.pl- webmin.com/security
- CWE-285
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.