Webmin 2.670, ldap-useradmin mv quote command injection
Webmin 2.670 ldap-useradmin/save_user.cgi runs mv in backticks with the posted home path inside single quotes. Unix Users already uses quotemeta and forces movehome off when the ACL is 2. The LDAP copy does neither. A signed-in LDAP Users user denied the move still runs a command as miniserv. Labbed. No CVE yet.
- Name
- Webmin 2.670, ldap-useradmin mv quote command injection
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- Webmin
- Affected
- Webmin 2.670 (3a2b2b86). ldap-useradmin/save_user.cgi existing-user save. Signed-in LDAP Users user, movehome ACL 2. Existing home present, new path absent. Lab binds loopback only.
- Published
- 12 Nov 2026
- Updated
- 12 Nov 2026
- Tags
- 0-day, webmin, command-injection, cwe-78, remote, authenticated
Two user editors. One of them still trusts a quote.
Unix Users in Webmin already knew how to move a home directory. It quotes both paths. It reads the ACL that says the signed-in operator is not allowed to move anything, and it turns the checkbox off before the save runs. LDAP Users is the same form with a different backend. It kept the checkbox, kept the backticks, and dropped both of those lines.
A directory clerk with an LDAP Users login is supposed to be able to edit posixAccount fields. They are not supposed to run a shell as the Webmin server. Put a quote in the home path on that save and the mv line is two commands. The process is miniserv. On a default install that is root.
This is Webmin 2.670 (3a2b2b8671c0669c129f5275b3dd1853106ae02b). No CVE yet. CWE-78. 9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Remote command injection. Signed-in LDAP Users user, movehome denied. Lab and write-up: abraxas/webmin-ldap-mv-quote (loopback client).
Webmin's own policy is the reason this is a leftover and not a shrug. Fine-grained ACLs exist so root can grant LDAP Users without granting a shell. I did not open a public issue on webmin/webmin. I did not talk to a live webmin.com host.
Why the LDAP copy, not the Unix one
I was already in useradmin/ because that is the module every Webmin install has. save_user.cgi there is careful in a boring way. Line 477:
$in{'movehome'} = !$access{'movehome'} if ($access{'movehome'} != 1);ACL 1 is "can edit." ACL 0 is "always on." ACL 2 is "always off." If the value is not 1, the request flag is overwritten. Then line 502:
$out = &backquote_logged(
"mv ".quotemeta($ouser{'home'})." ".
quotemeta($user{'home'})." 2>&1");That is the shape a path-to-mv is supposed to have. LDAP Users is a near-copy of the same CGI, talking to OpenLDAP instead of /etc/passwd. I expected the same two lines. They are not there.
Pinning 2.670
Tag 2.670, commit 3a2b2b8671c0669c129f5275b3dd1853106ae02b, published 2026-09-21. Master was ahead. I hunted the tag. Default install. Authentic theme only if it ships in that tree. Usermin code that ships in the same repo. Third-party modules (Virtualmin, CSF, Cloudmin) out.
The public wave on this peel is closed: custom-command parameter injection, File Manager search/chooser listing, the 2.653 DHCPd/MySQL/PostgreSQL/Nginx/Samba/Squid ACL bypasses, URL-download SSRF, the 2026 XSS and Basic-auth 2FA bugs, help-page root, Squid cache-manager, shell autocomplete. I was not refiling those.
The skip list is half the hunt
Webmin's security page is blunt. If root granted an untrusted user a module that can run commands or edit root configs, that is why the ACLs exist, and it is out. "I was given the shell module" is not a bug.
LDAP Users is not the shell module. It has its own movehome radio: can edit, always on, always off. The lab user is off. The leftover is that the save still runs mv when the POST says yes. That is an ACL the module already has and does not honor. In scope.
I did not file Unix Users. Unix Users quotes. I did not file create-user makehome. mkdir there is a Perl builtin. I did not file delhome rm -rf on the delete confirm; that path already quotemetas the resolved home.
Surfaces that returned nothing
Unauthenticated save_user.cgi is a login page. MiniServ 401s a webmin* User-Agent and Python's default Accept-Encoding: identity, which is a lab annoyance, not a leftover. can_edit_user still gates which DN you can open. A leading slash is required. The ACL home prefix still applies. Those are real checks. They do not make a quote in the path safe, because the prefix check is a string prefix, and /home/labuser'; touch ... still starts with /.
ldap-useradmin/search_user.cgi builds a filter from field and what. That module is the directory editor. A normal substring search already returns every posixAccount under the user base. Not this.
Hash-merge of %in onto a log copy is closed. The three %in list-assigns in this area put the privileged hash second and only pass the result to webmin_log.
The sink
ldap-useradmin/save_user.cgi on an existing user:
$home = $in{'home'};
$home =~ /^\// || &error(&text('usave_ehome', $home));
# ...
$oldhome = $uinfo->get_value("homeDirectory");
if ($home ne $oldhome && -d $oldhome && !-e $home &&
$in{'movehome'}) {
$out = `mv '$oldhome' '$home' 2>&1`;
if ($?) { &error(&text('usave_emove', $out)); }
}No ACL overwrite. No quotemeta. Single quotes around a value the client chose. A quote in $home (or in the stored $oldhome) ends the shell word.
The editor still draws the move checkbox. edit_user.cgi line 427 always prints ui_yesno_radio("movehome", 1). Unix Users hides or forces that control from the ACL. LDAP Users shows yes, default yes.
Same class in batch_exec.cgi line 469, double quotes this time:
local $out = &backquote_logged(
"mv \"$olduser{'home'}\" ".
"\"$user{'home'}\" 2>&1");The lab user has batch=0. I did not take that path. It is the same miss, inferred.
What I built
Loopback Webmin 2.670 plus an OpenLDAP sidecar. Compose webminh22. Ports 127.0.0.1:18470 (miniserv) and 127.0.0.1:18471 (LDAP). Image webmin-lab:2.670 from the pin. run.sh clones webmin/webmin at 3a2b2b86 into lab/src. The product tree is not in the pack.
The sidecar has uid=labuser,ou=People,dc=lab,dc=invalid, home /home/labuser, already on disk. A Unix user unixuser exists so the same session can hit Unix Users with the same payload. Webmin user ldapmod / ldapmodpass gets both modules. LDAP Users ACL movehome=2. Unix Users ACL movehome=1. Create, delete, batch, and "other modules" are off.
poc.py logs in, POSTs the quote at Unix Users first, asserts the witness is absent, then POSTs the same quote at LDAP Users and asserts /tmp/WEBMIN-H22-LDAP-MV.
The home value is a path that still starts with / and still matches ACL home=/:
/home/labuser'; touch /tmp/WEBMIN-H22-LDAP-MV; echo 'LDAP save returned 302. The witness was there. Unix Users save returned 200 and the witness was not.
Wrong turns, in the order they happened.
I first treated this as out of policy. "LDAP Users can already change a homeDirectory" sounds like module-equals-root. It is not. Changing the LDAP attribute is the intended grant. Running mv as miniserv when the ACL says off is not. The Unix module already drew that line.
Then I almost labbed batch_exec.cgi because the double quotes looked louder. The lab user cannot open batch. save_user.cgi is the path a clerk actually has.
Then I poked create-user makehome. mkdir($home, ...) is Perl. No shell. The modify path is the one with backticks.
Then I wondered whether Unix Users would fire if I left movehome=1 in the POST. The lab ACL there is "can choose," so the flag survives. quotemeta still holds. That negative is the whole point of the oracle. If both saves had written the witness, I would have been looking at a lab user who was supposed to be able to move homes.
MiniServ 401 on the first HTTP helper was a User-Agent of webmin-lab plus Accept-Encoding: identity. The constraint is real. It is not the bug.
What an attacker can do
Needs a Webmin session that can open LDAP Users, plus an LDAP user whose home directory already exists on the server. The new path must not exist yet, or mv is skipped. They do not need the move permission. They do not need Unix Users. They do not need batch.
In plain language: the person you hired to edit directory accounts can run a program as the machine that hosts Webmin, even after you turned "move home directory" off for them.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Confirm LDAP Users ACL movehome=2 (off) |
/etc/webmin/ldap-useradmin/ldapmod.acl |
Recon | Lab-proven |
Confirm Unix Users ACL movehome=1 (can choose) |
/etc/webmin/useradmin/ldapmod.acl |
Recon | Lab-proven |
| Open the LDAP user editor | GET /ldap-useradmin/edit_user.cgi?dn=... |
Recon | Lab-proven. HTTP 200 |
| Run a command as miniserv via a quote in home | POST /ldap-useradmin/save_user.cgi quoted home, movehome=1 |
Integrity (command as miniserv, usually root) | Lab-proven. /tmp/WEBMIN-H22-LDAP-MV |
| Same quote against Unix Users | POST /useradmin/save_user.cgi same home |
n/a | Lab-proven closed. witness-absent |
| Same class on LDAP batch modify | POST /ldap-useradmin/batch_exec.cgi double-quoted mv |
Integrity (command as miniserv) | Inferred. Lab ACL batch=0 |
Stays closed on this leftover
| Axis | Method / sink | Why it is closed |
|---|---|---|
| Integrity | Unix Users save, same quote | quotemeta. Witness absent |
| Integrity | Unauthenticated save | Session required |
| Integrity | Create-user makehome |
Perl mkdir, no shell mv |
| Integrity | Custom-command injection | Public, fixed in 2.670 |
| Integrity | Squid cache-manager / shell autocomplete | Public, different modules |
| Integrity | Module grant equals root | Out of policy. This leftover is an ACL the module already exposes and ignores |
Availability is not in the score. The oracle is a file create, not a crash. A:N.
IOC useradmin-negative witness-absent
IOC ldap-save http=302 body=
IOC witness-path=/tmp/WEBMIN-H22-LDAP-MV
SUCCESS WEBMIN-H22-LDAP-MV ldap-useradmin-mv-quote WEBMIN-H22-LDAP-MVThe fix
Copy the two lines Unix Users already has. Honor movehome when the ACL is not "can edit." Pass both paths through quotemeta (and backquote_logged, while you are there). Do the same in batch_exec.cgi. A home directory is a path. It is not a shell word. The checkbox the ACL turned off should stay off even if the POST says yes.
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.