Research/webmin-chooser-chroot
0-dayNo CVEMediumPublic

Webmin 2.670, chooser.cgi chroot prefix listing

Webmin 2.670 chooser.cgi treats chroot as inside the jail on a string prefix of root, then opendir of chroot plus dir lists outside. file=../ is rejected. Names, sizes, mtimes only. Filemin search stays in allowed_paths. Labbed. No CVE yet.

Name
Webmin 2.670, chooser.cgi chroot prefix listing
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Webmin
Affected
Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). Signed-in user whose chooser root is not /. Default root=/ already lists the disk.
Published
11 Nov 2026
Updated
11 Nov 2026
Tags
0-day, webmin, path, cwe-22, listing, authenticated

The File Manager already learned this. The popup did not.

Webmin 2.670 closed File Manager search so a listing stays inside allowed_paths. I still wanted the other window: the "..." button every module uses to pick a path. That CGI is chooser.cgi. It still treats a chroot string that merely starts with the allowed root as "inside", then throws the jail away.

A signed-in user who is only supposed to pick files in one folder can still see names of files outside it. They pass chroot=/that/folder/../. The picker lists names, sizes, and dates. It does not show file contents. This only matters when the picker root is not the whole disk.

Pack: abraxas/webmin-chooser-chroot (loopback client). Lab stack lab/: Dockerfile, docker-compose.yml, entrypoint.sh, run.sh, poc.py. Loopback only.

This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-22. 5.3 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N). Remote path listing. Session required. Chooser root not /.

Why this tree

I wanted the tag people actually install, not master. webmin.com/security is an unpaid VDP. Fine-grained ACLs are the intended control: a user who was given the shell module is not a leftover. A user who was given a file picker rooted at /lab/jail and can still list /lab is.

The product is Perl CGI behind miniserv.pl. Auth is a sid cookie, then per-module acl_security.pl. chooser.cgi sits at the server root. It is not a module. The jail is the global root ACL from defaultacl / the user form, plus optional otherdirs.

file_chooser_button in web-lib-funcs.pl is how the popup gets its chroot query string. Syslog, Bind, MySQL export, uploads, software roots, LDAP slapd paths: they all open this same CGI.

Pinning 2.670

Tag 2.670 published 2026-09-21. Commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b. Master at scan time was 792e4d3006a3522fe80d3cf2fffe149bf2227dfd, hundreds of commits ahead. I hunted the tag.

The skip list

Already public on or before 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 and CVE-2022-0829
  • arbitrary file write CVE-2022-30708
  • backdoor CVE-2019-15231

Filemin search on this tag is the control I almost confused with this bug. search.cgi still runs find, then greps the results against allowed_paths. test_allowed_paths is on. That listing stays in the jail. Do not refile it.

help.cgi cannot walk .. out of a help file. miniserv simplifies PATH_INFO before read_help_file. The 2.640 help-page issue is closed.

Surfaces that returned nothing here

I sent file=../ first. chooser.cgi:84 requires the path match ^(([a-z]:)?.*\/)([^\/]*)$ and not contain ... The request falls back to the allowed root. The frame still showed allowed.txt. That is the check they wrote.

Default root=/ is not a finding. defaultacl ships that. A user whose picker is the whole disk already lists the whole disk.

JWT, OAuth, password reset, SSRF, SQL, XXE, GraphQL, smuggling, cache, templates, dangling download host, package defaults, deserialization, mass assignment, CORS, hash merge, CI, session-id construction: empty on this pin after the skip list. They are other classes, not this CGI.

The leftover

Plain text
if ($in{'chroot'} && $in{'chroot'} ne '/' && $rootdir && $rootdir ne '/' &&
    ($in{'chroot'} =~ /^\Q$rootdir\E\// || $in{'chroot'} eq $rootdir)) {
	$rootdir = undef;
	}

$in{'chroot'} is /lab/jail/../. $rootdir is /lab/jail. The regex is a string prefix. /lab/jail/../ starts with /lab/jail/. The jail is cleared.

allowed_dir then does:

Plain text
return 1 if ($rootdir eq "" || $rootdir eq "/" || $rootdir eq "c:");

Undef stringifies empty. Every directory is allowed.

$dir falls back to that now-empty root and becomes /. opendir opens $in{'chroot'}.$dir, which is /lab/jail/../ plus /. Perl opendir follows it. The frame lists /lab.

.. is rejected only in file. chroot is never canonicalized. is_under_directory (the helper that does refuse .. and then compares with a trailing slash) is not called on chroot.

fileunix defaults to root. The user form keeps that unless someone clears it. The listing often stats as root even when the Webmin login is limited. Names in /root can show. Bodies never do. The columns are icon, name, nice_size, date, time.

The lab

Limited user limited. Global ACL root=/lab/jail. fileunix=root. Jail file allowed.txt. Empty witness file /lab/WEBMIN-H21-CHOOSER (filename only). Shared image webmin-lab:2.670. Compose webminh21. Loopback 127.0.0.1:18510. run.sh clones the tag.

Login. Baseline chooser: allowed.txt yes, witness no. file=../: still jail. chroot=/lab/jail/../: witness yes.

Plain text
IOC target=http://127.0.0.1:18510 user=limited pin=webmin:2.670 image=webmin-lab:2.670
IOC login-get status=200
IOC login-post status=302 sid=1 still-login=False
IOC baseline status=200 has-witness=0 has-allowed=1
IOC negative-file-dotdot status=200 has-witness=0 has-allowed=1
IOC chroot-escape status=200 chroot=/lab/jail/../ has-witness=1 has-allowed=0
SUCCESS WEBMIN-H21-CHOOSER how=live chroot-prefix-escape=yes file-dotdot-rejected=yes WEBMIN-H21-CHOOSER

Wrong turns, in order: treating File Manager search as this leftover; stopping after file=../ was rejected; using the root Webmin user (no jail); expecting the response to include file bodies; trying help.cgi .. (miniserv already strips it).

What an attacker can do

Needs a session whose chooser root is not /. In ordinary words: a coworker who was given a home-directory picker, not the whole server.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
List the jail only GET /chooser.cgi?frame=1&type=0 Recon of names inside root Lab-proven. allowed.txt present, witness absent
Walk with file=../ chooser.cgi:84 n/a (negative) Lab-proven. stayed in jail
List outside the jail GET /chooser.cgi?frame=1&type=0&chroot=/lab/jail/../ Recon: names, sizes, mtimes outside root Lab-proven. witness filename listed
List other homes / root-only dirs same sink, default fileunix=root Recon of names the Unix user can stat Inferred. lab listed /lab, not /root
Read file bodies chooser HTML columns n/a Closed. no contents

Stays closed on this leftover

Axis Method / sink Why it is closed
Recon file=../ chooser.cgi:84
Recon File Manager search / listing allowed_paths filter on this tag
Recon help.cgi .. miniserv PATH_INFO simplify
Recon Default root=/ already lists everything; not this bug
Integrity File write, extract, shell not this CGI
Recon Unauthenticated chooser needs a session

This is not RCE. It is not File Manager. It is a directory listing that ignored the jail it was given.

The fix

Canonicalize chroot (resolve_links or equivalent) before the prefix test. Reject .. in chroot the same way file already does. Do not set $rootdir undef on a string prefix. Keep the jail so allowed_dir still runs is_under_directory on the real path. A chroot that is supposed to narrow the picker must remain a subdirectory of root after resolution, not a reason to drop root.

References

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