Research/webmin-ldap-mv-quote
0-dayNo CVECriticalPublic

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:

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

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

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

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

Plain text
/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.

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

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