Webmin 2.670, session list prints a live cookie
Webmin 2.670 acl/list_sessions.cgi prints every live sessiondb key. miniserv.pl verify accepts that stored key as the sid cookie. A signed-in user with sessions=1 and switch=0 becomes that user, including root. session_ip is off by default. Labbed. No CVE yet.
- Name
- Webmin 2.670, session list prints a live cookie
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- Webmin
- Affected
- Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). Signed-in user with the acl module, sessions=1, switch=0. Two logins. Lab binds loopback only.
- Published
- 11 Nov 2026
- Updated
- 11 Nov 2026
- Tags
- 0-day, webmin, session-theft, cwe-287, remote, authenticated
The kill column was honest. The hex column was not.
I expected Webmin's session list to be a kill switch. User, IP, a relative timestamp, a link that ends the login. The first column is hex in a <tt> tag. I copied it because the kill link uses the same id. I did not expect miniserv to treat that hex as the cookie. It does.
If you give a coworker the Webmin Users permission to see who is logged in, and you refuse them the permission to switch into those accounts, they can still become whoever is listed. The page shows a hex string next to each session. That string is not a display id. Webmin will accept it as the login cookie. By default it does not check that the cookie comes from the same IP. They become that person, including the root Webmin user, until the session expires or someone cancels it. They never needed the victim's password.
This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-287. 9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Remote session theft. Signed-in user with sessions=1 and switch=0. Pack: abraxas/webmin-sessiondb-cookie (loopback client).
Webmin's own policy is that fine-grained ACLs are the control. Escalation that only works because root handed someone the shell module is out of scope. This leftover is the other case: the ACL said view and cancel, not impersonate, and impersonation still happened.
Why this product
Webmin is the control plane a lot of people put on port 10000 and then carve into "the junior admin may look, the senior admin may switch." The Users module is where that carving lives. sessions and switch are separate flags in the same ACL file. That split is documented. I wanted to know whether the list page honored it, or whether it leaked enough of the session table to make the split decorative.
I did not go looking for a default password. rpm and deb postinst write crypt=x. setup.bat's admin / admin is a Windows helper, not the package. Session DB and the HMAC key file are mode 0600. The cookie itself is 16 bytes from /dev/urandom, stored under HMAC-SHA256 when Digest::SHA loads. All of that is fine. The HTML is not.
Pinning 2.670
Tag 2.670, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b, published 2026-09-21. Tree at hunt time was that tag, not master. Master was already ahead. I hunted the tag.
The skip list
Already public, 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 bypasses in 2.653
- URL-download SSRF in 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
I read webmin.com/security and the in-repo SECURITY.md before I kept anything. Report address is security@webmin.com. No cash table.
Surfaces that returned nothing
The session-id class was empty. Default ids are 16 bytes from /dev/urandom. The Perl rand fallback only runs after urandom is marked bad. The DB key is HMAC-SHA256 when Digest::SHA is present. The DES-crypt-of-first-8-chars path is a last resort. Passwords in miniserv.users are one-way. TOTP compare is not a token you can mint from a timing oracle. Basic-auth 2FA rejection is the public fix and stays in the skip list.
switch.cgi itself is clean. It requires can_edit_user($in{'user'}) and access{'switch'}. A user with users=~ cannot edit root, so they cannot switch to root either. I confirmed that in the lab before I trusted the leftover. If switch had been open, this would have been the intended grant and I would have dropped it.
The session files staying 0600 also returned nothing. This is not a local file read.
A second class note on the same two lines (list_sessions.cgi:57 and miniserv.pl:1117) looked like a sibling. It is the same bug. Counted once.
The leftover
acl/list_sessions.cgi opens the session DB and walks every key. The only gate is $access{'sessions'}. For every live row it prints the DBM key:
push(@cols, "<tt>$k</tt>");$k is not the cookie the browser holds. New sessions are stored under the HMAC of that cookie:
$sessiondb{&hash_session_id($session_id)} =
"$user $time_now $ip";HMAC-SHA256 hex when Digest::SHA loads, which it does on a current Perl. The cookie is 32 hex chars. The DB key is 64 hex chars. The list page shows the 64.
miniserv's verify handler then does this:
local $skey = $sessiondb{$session_id} ?
$session_id :
&hash_session_id($session_id);If the cookie is already a key, hashing is skipped. The cookie parser in miniserv-lib.pl accepts [a-f0-9]+. Sixty-four hex characters are a legal sid. You send Cookie: sid=<printed key> from another client and miniserv answers 2 $user.
session_ip would have stopped the other-client part. It is a checkbox under Webmin Configuration, off unless set. setup.sh does not write it. The stolen key works from wherever you are.
acl/switch.cgi is the grant people think they withheld:
&can_edit_user($in{'user'}) && $access{'switch'} ||
&error($text{'switch_euser'});That path stayed denied in the lab. The list page is the impersonation path they did not know they shipped.
What I built
Debian bookworm image, Webmin 2.670 unpacked, setup.sh with ssl=0, login root / labroot, port 10000 published on loopback 18440. Shared image tag webmin-lab:2.670 behind a flock so sibling labs do not rebuild it twice. run.sh clones the tag at runtime into lab/src.
I planted a second user, limited / LabLimited1, with the Webmin Users module and this ACL:
users=~
sessions=1
switch=0users=~ means they can only edit themselves. Then two logins. Limited hits switch.cgi?user=root and gets "You are not allowed to switch to this user." Same user hits edit_user.cgi?user=root and gets "You are not allowed to edit this user." Same user hits list_sessions.cgi and the HTML has root's 64-char key in <tt>. A third client sends that key as sid. edit_user.cgi?user=root comes back as the root editor, not the denial.
Wrong turns, in the order they happened:
I treated the <tt> as a display id. Kill uses $k as delete_session.cgi?id=$k, so of course the page prints it. I assumed verify would HMAC whatever it was given, the way new does. It does not. It takes the raw key if that slot is occupied.
I treated switch.cgi as the only impersonation path. The ACL UI presents it that way. The list page is not labeled as switch.
I assumed session_ip was on because the configuration page exists. It is off. The lab had to assert the absence of the key, not the presence of a 0.
I almost filed the verify-handler note as a second leftover. Same two lines, same stolen login. One bug.
What an attacker can do
Needs a signed-in Webmin user who was given the Webmin Users module and sessions=1, and who was denied switch. Root, or anyone else with a live session, has to be signed in at the same time. In ordinary words: the person you trusted to look at the login list, not to become the people on it.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Confirm session_ip is off |
miniserv.conf has no session_ip= after setup.sh |
Recon | Lab-proven. session_ip=off |
| Confirm the planted ACL | acl/limited.acl users=~ sessions=1 switch=0 |
Recon | Lab-proven |
| Switch into root stays denied | GET /acl/switch.cgi?user=root as limited |
n/a (negative) | Lab-proven. switch error text |
| Edit root as limited stays denied | GET /acl/edit_user.cgi?user=root as limited |
n/a (negative) | Lab-proven. edit error text |
| Read every live sessiondb key, including root | GET /acl/list_sessions.cgi prints <tt>$k</tt> |
Recon (session secret) | Lab-proven. 64-char HMAC hex |
| Become that user by sending the printed key as the cookie | Cookie: sid=<sessiondb key> against miniserv verify |
Integrity (authn) | Lab-proven. Users editor as root |
| Act as root for the life of that session | Same stolen cookie on later module CGIs | Integrity / recon of root's modules | Inferred from the stolen identity. Lab showed the Users editor as root |
Stays closed on this leftover
| Axis | Method / sink | Why it is closed |
|---|---|---|
| Integrity | GET /acl/switch.cgi |
Still needs can_edit_user and access{'switch'}. Lab denied it |
| Integrity | GET /acl/edit_user.cgi?user=root as the limited login |
users=~ only matches self |
| Integrity | Unauthenticated caller | Needs a signed-in sessions=1 user and a second live session |
| Integrity | New session insert | new stores under hash_session_id($cookie) |
| Integrity | Session DB and HMAC key files | Mode 0600. This leftover is the HTML |
| Integrity | Shipped admin / admin |
setup.bat only. rpm/deb postinst sets crypt=x |
| Availability | Killing other sessions | delete_session.cgi is the intended sessions=1 grant |
IOC limited-acl users=~ sessions=1 switch=0
IOC session_ip session_ip=off
IOC switch.cgi status=200 denied
IOC edit_user root-as-limited denied=1
IOC stolen-key len=64
IOC replay status=200
SUCCESS WEBMIN-H27-SESSIONDB list_sessions.cgi sid=sessiondb-key became-root WEBMIN-H27-SESSION-KEYLab files: lab/ (Dockerfile, docker-compose.yml, run.sh, poc.py). Authorized lab only. It talks to loopback.
The fix
Do not print the sessiondb key. Give the UI a truncated handle that is not a lookup key, and keep the real key on the kill form as a server-side mapping if you still want one-click cancel. Make verify look up only hash_session_id($cookie). If the cookie is already a stored key, reject it. Keep switch.cgi as the only impersonation path. Turning session_ip on by default is a useful extra. It is not a substitute. A same-host coworker still wins if the key is in the HTML.
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.