Research/webmin-sessiondb-cookie
0-dayNo CVECriticalPublic

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:

Plain text
push(@cols, "<tt>$k</tt>");

$k is not the cookie the browser holds. New sessions are stored under the HMAC of that cookie:

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

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

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

Plain text
users=~
sessions=1
switch=0

users=~ 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
Plain text
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-KEY

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