Research/webmin-mysql-csv-write
0-dayNo CVEHighPublic

Webmin 2.670, mysql csv.cgi writes outside bpath

Webmin 2.670 mysql/csv.cgi writes $in{file} when buser is set and never checks bpath. backup_db.cgi already refuses the same path. Root write when buser is root. Labbed. No CVE yet.

Name
Webmin 2.670, mysql csv.cgi writes outside bpath
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Webmin
Affected
Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). Signed-in MySQL module user with buser set and a bpath jail that does not contain the target. edonly must be off.
Published
11 Nov 2026
Updated
11 Nov 2026
Tags
0-day, webmin, path-write, cwe-22, authenticated, mysql

Backup already knew this path was forbidden

I posted the same absolute path at two CGI scripts on Webmin 2.670. Backup Database answered Invalid backup path and left /tmp empty. CSV export wrote the table as root. The loopback pack that does that twice in one run is https://github.com/abraxas/webmin-mysql-csv-write (client, lab/: Dockerfile, docker-compose.yml, entrypoint.sh, run.sh, poc.py). Authorized lab only. Loopback.

This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-22. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H). Remote path write. Signed-in MySQL-module user. buser set. bpath is a jail.

A coworker with a Webmin MySQL login is supposed to dump files into one folder. The backup button honors that folder. The CSV export form does not. They can overwrite a path the backup ACL already refused. When the backup Unix user is root, that write is as the Webmin process, usually root. They still need that login. They still cannot take a mysqldump outside the jail.

This is a write. It is not a file read. It is not unauthenticated. It is not the custom-command bug that 2.670 already closed.

Why Webmin

Webmin is the panel people still put on a public port and then try to fence with module ACLs. webmin.com/security is explicit: if root granted an untrusted user a module that can run commands or edit configs, that is not a leftover. Fine-grained ACLs are the intended control. I wanted a case where the ACL exists, a sibling CGI already enforces it, and one exporter forgot.

MySQL Database Server has that pair. Backup file directory is bpath. Backup Unix user is buser. Default ACL is bpath=/ and buser=root. That is the full grant. The interesting user is the one an admin already jailed.

Pinning 2.670

Latest published tag on 2026-09-21. Commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b. master was hundreds of commits ahead when I pinned. I hunted the tag people install, not master. Image label webmin-lab:2.670. /etc/webmin/version is 2.670.

I did not request webmin.com. I did not open a GitHub issue.

The skip list is half the hunt

Already public on this pin. I did not refile them.

  • 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 from 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 CGI that still reaches a new sink can be a leftover. This one is.

Surfaces that returned nothing

I looked at the CSV where box first. csv.cgi appends it after can_edit_db and edonly. The same user already has Execute SQL. That is the product, not a leftover. Same story on view_table.cgi search and on PostgreSQL exec.cgi.

Two-arg open(FH, "<$path") on Perl 5.34 does not treat a trailing | as a pipe. The man and ipsec opens are not this bug.

I walked File Manager listing, download SSRF, and the 2.653 ACL bypasses long enough to confirm they were the public ones. Then I went back to MySQL dumps.

bpath=/ is not a jail. I almost filed the default ACL. That would have been "root granted the module." The policy already answered.

The leftover

csv_form.cgi shows a destination file field only when $access{'buser'} is set. That is the same flag Backup Database uses to decide whether a local dump is allowed at all.

csv.cgi then does this:

Plain text
if ($in{'dest'}) {
	$in{'file'} =~ /^([a-z]:)?\/\S/ || &error($text{'csv_efile'});
	$access{'buser'} || &error($text{'cvs_ebuser'});
	}

Absolute path. buser set. No bpath. dest=1 is the file. dest=0 is the browser.

When buser is root:

Plain text
&open_tempfile(OUT, ">$in{'file'}");

open_tempfile writes a sibling temp file and close_tempfile renames onto $in{'file'}. That is a replace of any path the miniserv user can write.

When buser is anyone else, the same path goes through command_as_user and cat > plus quote_path. Shell metacharacters are not this leftover. The file op still ignores bpath.

The sibling that remembered:

Plain text
if (!$in{'dest'} && !&is_under_directory($access{'bpath'}, $in{'file'})) {
	&error($text{'backup_epath'}."<br>".
	       &text('backup_eunder', "<tt>$access{'bpath'}</tt>"));
	}

Backup dest=0 is the local file. CSV inverted the flag. Same module. Same ACL keys. One check.

is_under_directory lives in web-lib-funcs.pl. It already rejects .. and resolves links. CSV never calls it.

Wrong turns in the order they happened

I treated the CSV where clause as SQL injection. can_edit_db plus not-edonly already has exec.cgi. Out.

I treated two-arg open as a pipe. Perl 5.34 does not. Out.

I lined this up next to the custom-command parameter bug. That one is closed on 2.670. Different module. Out.

I almost filed default bpath=/. That is the full grant. The leftover is a user whose jail already denied the path.

I thought buser unset might still write. The form hides the field. The CGI errors. The leftover starts after buser is set.

Theatre: a reverse shell.

What an attacker can do

Needs a signed-in Webmin user who was given the MySQL module, a backup Unix user, and a backup directory that is not /. edonly must be off. They do not need create-database, drop, stop, or permissions admin. They do not need module config.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Write a CSV to an absolute path outside bpath as root POST /mysql/csv.cgi dest=1 file=/tmp/h32-csv-write.csv when buser=root Integrity (host file write) Lab-proven. File contains WEBMIN-H32-CSV-WRITE
Overwrite an existing path the miniserv user can write same sink, open_tempfile then rename Integrity Inferred from open_tempfile / close_tempfile. Lab wrote a new /tmp file
Write as a non-root buser same CGI, cat > as that Unix user Integrity Source. Lab used buser=root
Knock the host over by replacing a config the process later reads same write Availability Inferred. Not the lab oracle
Negative control: Backup Database to the same path POST /mysql/backup_db.cgi dest=0 file=/tmp/h32-csv-write.csv n/a Lab-proven. Invalid backup path. File not created

Stays closed on this leftover

Axis Method / sink Why it is closed
Integrity Backup Database local dump is_under_directory($access{'bpath'}, $in{'file'})
Integrity CSV to the browser dest=0 prints text/plain to STDOUT
Integrity CSV with buser unset Form hides the file field. CGI errors
Integrity CSV when edonly=1 dbase_ecannot
Integrity Custom-command parameter injection Fixed in 2.670
Integrity Unauthenticated write Session required
Integrity Shell metacharacters on the non-root branch quote_path
Recon Reading a file the user cannot already SELECT CSV writes query output. It does not read host files

I did not treat crontab or authorized_keys overwrite as proven RCE. A write as root can become that later. The lab oracle is the CSV bytes.

The lab

Shared image webmin-lab:2.670. run.sh clones webmin/webmin tag 2.670 at 3a2b2b8671c0669c129f5275b3dd1853106ae02b when that image is missing. Compose webminh32. Webmin on 127.0.0.1:18420. MariaDB sibling. ssl=0. no_pam=1. Login page is HTTP.

The limited user is csvuser / csvpass. Module ACL:

Plain text
dbs=labdb
create=0
delete=0
stop=0
perms=0
bpath=/var/webmin/mysql-jail
buser=root
edonly=0
noconfig=1

Table labdb.items has one row, note=WEBMIN-H32-CSV-WRITE. Target /tmp/h32-csv-write.csv is outside the jail. Backup POST is denied and does not create the file. CSV POST creates it. The file is "WEBMIN-H32-CSV-WRITE".

Plain text
IOC image=webmin-lab:2.670 label-version=2.670
IOC webmin-version=2.670 expect=2.670
IOC acl=dbs=labdb;create=0;delete=0;stop=0;perms=0;bpath=/var/webmin/mysql-jail;buser=root;edonly=0;indexes=0;views=0;noconfig=1;
IOC login-page status=200
IOC login status=302 sid=1
IOC backup-neg http=200 denied=1 wrote=0
IOC csv-file='"WEBMIN-H32-CSV-WRITE"'
SUCCESS WEBMIN-H32-MYSQL-CSV WEBMIN-H32-CSV-WRITE

The fix

Call is_under_directory on $in{'file'} the way backup_db.cgi already does. On CSV, dest=1 is the file, so the condition is inverted from backup. Refuse unless the path sits under $access{'bpath'}. Do it before open_tempfile and before the cat > branch.

Until then, a MySQL user with buser set and a jail is not jailed on CSV export.

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