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:
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:
&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:
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:
dbs=labdb
create=0
delete=0
stop=0
perms=0
bpath=/var/webmin/mysql-jail
buser=root
edonly=0
noconfig=1Table 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".
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-WRITEThe 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.