Webmin 2.670, apache show.cgi denied-vhost IDOR
Webmin 2.670 apache/show.cgi loads virt and prints every directive. It never calls can_edit_virt. An Apache-module user denied that virtual host still reads names and values. virt_index and save_virt refuse the same id. Labbed. No CVE yet.
- Name
- Webmin 2.670, apache show.cgi denied-vhost IDOR
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- medium
- Disclosure Status
- public
- Vendor
- Webmin
- Affected
- Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). Signed-in user granted the apache module, denied that VirtualHost by the virts ACL.
- Published
- 11 Nov 2026
- Updated
- 11 Nov 2026
- Tags
- 0-day, webmin, apache, idor, cwe-639, authenticated
You cannot edit this virtual server
That sentence is the whole ACL. Webmin prints it when a limited Apache user asks for a host they were not given. I still got the other site's config as a <pre> dump. The URL is show.cgi. The object id is the ServerName they told you not to touch.
Proof of concept: abraxas/webmin-apache-show-idor (loopback client; @abraxas_null). Lab stack lab/: Dockerfile, docker-compose.yml, plant.sh, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-639. 6.5 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N). Remote IDOR. Signed-in Apache module user. Denied that virtual host.
You give a contractor the Apache module and only their website. The other virtual hosts are supposed to stay off their list. They still open one URL with the other site's name and read every directive for it: document roots, SSL certificate paths, rewrite rules, proxy backends, SetEnv values. They cannot change that host. They cannot open it in the list. The dump page forgot the check.
This is not unauthenticated. This is not a write. Granting the Apache module is not supposed to mean every vhost. virts is the per-host allow list inside that module. The list already uses it.
Why Webmin
People still run Webmin as the box's control panel. Once root creates a second account, the security story is the per-module ACL files. webmin.com/security is explicit: do not file privilege escalation that only works because root granted an untrusted user a module that can run commands or edit configs. Fine-grained ACLs are the intended control.
That sentence is why this leftover is in scope. I did not file "I was given the Apache module." I filed a user who was given the module and then denied a host, and who still printed that host.
I wanted a signed-in user the ACL already refuses. Remote. A real Medium from an untrusted actor, not a root session doing root work.
Pinning 2.670
Latest published tag on the day I cloned. Tag 2.670, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b, dated 2026-09-21. version says 2.670. master at scan time was 792e4d3006a3522fe80d3cf2fffe149bf2227dfd, hundreds of commits ahead. I hunted the tag people install, not the moving HEAD.
Default lab install: setup.sh noninteractive, ssl=0, login root / labroot, port 10000. The leftover is not the root account. The leftover is the second user.
The skip list is half the hunt
Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a changelog. On 2.670 the interesting rows were already closed:
- 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
A different caller that still reaches a new sink can be a leftover. This one is. File Manager prefix listing is the 2.670 public fix. It is not this path.
Surfaces that returned nothing
I split the tree the way the hunt contract splits it. Twenty classes came back empty on this pin: JWT, OAuth, password reset, SSRF, SQL, XXE, races, GraphQL, request smuggling, cache, templates, download host, package defaults, LLM glue, deserialization, mass assignment, CORS, hash merge, CI, session-id construction. Empty means no KEEP-bar sink after the skip list. It does not mean those classes were not searched.
The interesting leftovers on 2.670 sat in the modules that already have a can_* helper and then forget it on one CGI. Apache was that shape.
The list already knew
get_virtual_list_rows (apache-lib.pl:952) greps VirtualHost structs through can_edit_virt. The index page only links hosts you may open. index.cgi publishes the object id as the index of that VirtualHost in the full config (index.cgi:174): virt_index.cgi?virt=$idx.
can_edit_virt (apache-lib.pl:1882) is small. virts=* is everyone. Otherwise it splits the ACL and matches ServerName:port (and aliases) from virt_acl_name. That is the control.
The siblings call it. virt_index.cgi line 8. show_virt.cgi line 8. edit_virt.cgi line 8. save_virt.cgi line 8. dir_index.cgi, edit_dir.cgi, save_dir.cgi the same. Fail closed with $text{'virt_ecannot'}. In English: You cannot edit this virtual server.
show.cgi is the printer. Line 19:
($conf, $v) = &get_virtual_config($in{'virt'});
$title = &text('virt_header', &virtual_name($v));No can_edit_virt. Line 68 prints the members. Line 120 is the name and value:
&print_line($d, [ $d->{'name'}," ",$d->{'value'} ], $ind);get_virtual_config (apache-lib.pl:542) takes a decimal config index or ServerName:port. You do not need the list to hand you $idx. If you know the other site's hostname, you know the id.
show_dir.cgi:7 is the same missing check for a Directory section under a swapped virt. I did not use it as the lab oracle. The printer page was enough.
Write on the same id: manual_save.cgi loads virt at line 21 with no host check and splices the block at line 67. Gate is line 7: $access{'types'} eq '*'. A contractor who can only edit a few directive types never reaches that splice. The lab user is types=0 1 2 3 4 5. Closed.
What an attacker can do
Needs a Webmin login that already has the Apache module, with virts set to their own host. They need the other site's ServerName:port (or its config index). They do not need types=*. They do not get a shell.
virts is the per-host allow list. Think of it as "this person may open example.com, not the other vhosts on the same httpd." The list honors that. The dump page does not.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
| Dump denied vhost directive names and values | GET /apache/show.cgi?virt=denied.example:80 |
Recon (config) | Lab-proven. Witness WEBMIN-H01-APACHE-IDOR in the <pre> body. Deny string absent |
| Same dump by config index | GET /apache/show.cgi?virt=<idx> |
Recon | Source. get_virtual_config accepts a decimal index |
| Dump a Directory section under a swapped virt | GET /apache/show_dir.cgi |
Recon | Source. show_dir.cgi:7 never calls can_edit_virt |
| Own vhost still works | GET /apache/show.cgi?virt=allowed.example:80 |
n/a | Lab-proven. Own ServerAdmin, no witness |
| Negative: list page on the denied id | GET /apache/virt_index.cgi?virt=denied.example:80 |
n/a | Lab-proven. You cannot edit this virtual server. No witness |
| Negative: save on the denied id | GET /apache/save_virt.cgi?virt=denied.example:80 |
n/a | Lab-proven. Same deny string. No witness |
Stays closed on this leftover
| Axis | Method / sink | Why it is closed |
|---|---|---|
| Recon | virt_index.cgi, show_virt.cgi, dir_index.cgi |
can_edit_virt at line 8 |
| Integrity | edit_virt.cgi, save_virt.cgi, edit_dir.cgi, save_dir.cgi |
same helper |
| Integrity | manual_save.cgi splice of the block |
types must be *. Lab user is not that wide |
| Recon | File Manager prefix listing | fixed in 2.670. Not this path |
| Integrity | unauthenticated write, host RCE | out of class |
The leak is the file bodies of the vhost config directives (names and values). SSLCertificateKeyFile is a path, not the key bytes, unless someone put the key inline. SetEnv, RewriteRule, ProxyPass, AuthUserFile, ServerAdmin are the values themselves. That is still the other site's config.
I am not claiming a reverse shell. I am not claiming they can rewrite that host. The printer is the bug.
The lab
I built a Debian image from the pin, webmin-lab:2.670, shared under a flock so eleven Webmin leftovers did not rebuild the same tree eleven times. Compose project webminh01. Loopback 127.0.0.1:18490. plant.sh writes two VirtualHost blocks into a private httpd conf, points the Apache module at that file, and creates limited / limited-pass with virts=allowed.example:80 and types not *. denied.example gets ServerAdmin WEBMIN-H01-APACHE-IDOR. A stub apache2 -v is enough; httpd never has to run.
run.sh clones tag 2.670 at 3a2b2b8671c0669c129f5275b3dd1853106ae02b into lab/src, builds the image, waits for session_login.cgi, then runs poc.py.
Wrong turns, in the order they happened:
I first treated Apache-module-granted as out of policy. The hunt contract says module-grant-equals-root is not a finding. Then I saw virts on the ACL and the list already filtering through can_edit_virt. The control exists. One CGI skips it.
I almost filed show_virt.cgi. Same folder, same name shape. Line 8 checks.
I walked the UI from the list. Denied hosts are not linked. That looked closed until I put virt=denied.example:80 on show.cgi myself. The query string is the id. The list is not a capability.
I looked at manual_save.cgi for a write. It loads the denied virt with no host check. Then I read line 7. types has to be *. The lab user is a contractor, not a raw-config editor. I left the write closed and planted types=0 1 2 3 4 5 so a later pass could not "prove" it by widening the ACL.
I checked File Manager prefix listing against the 2.670 notes. Already public. Not this.
Numeric virt versus ServerName:port both resolve. The lab used the name because you do not need the config index if you know the hostname. That is the practical id.
IOC image=webmin-lab:2.670 label-version=2.670
IOC webmin-version=2.670
IOC plant witness=1 allowed-only=1 acl-virts=1
IOC login=limited sid=1
IOC allowed-show status=200 witness=0 own=1
IOC denied-virt-index status=200 denied=1 witness=0
IOC denied-save-virt status=200 denied=1 witness=0
IOC denied-show status=200 witness=1 denied-text=0
SUCCESS WEBMIN-H01-APACHE-SHOW WEBMIN-H01-APACHE-IDOROwn host prints allowed-only@allowed.example. Denied host prints the witness and does not print the deny string. virt_index and save_virt on that same id stay denied. That is the leftover.
The fix
Call can_edit_virt on show.cgi after get_virtual_config, the same two lines virt_index.cgi already has. Do the same on show_dir.cgi.
($conf, $v) = &get_virtual_config($in{'virt'});
&can_edit_virt($v) || &error($text{'virt_ecannot'});Until then, do not treat "I hid the vhost from the list" as "I hid the directives." The printer is a different CGI. It never asked.
References
- github.com/webmin/webmin tag 2.670 (
3a2b2b8671c0669c129f5275b3dd1853106ae02b) - Pack: abraxas/webmin-apache-show-idor
apache/show.cgi(show.cgi:19,:68,:120)can_edit_virt(apache-lib.pl:1882) ·get_virtual_config(apache-lib.pl:542) ·get_virtual_list_rows(apache-lib.pl:952)- Siblings that refuse:
virt_index.cgi·show_virt.cgi·edit_virt.cgi·save_virt.cgi - Nearby (not this path): File Manager listing fixed in 2.670 ·
manual_save.cgiwrite gated ontypes=* - webmin.com/security
- CWE-639
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.