Modoboa has an OS Command Injection
Description
Modoboa is a mail hosting and management platform. Prior to version 2.7.1, exec_cmd() in modoboa/lib/sysutils.py always runs subprocess calls with shell=True. Since domain names flow directly into shell command strings without any sanitization, a Reseller or SuperAdmin can include shell metacharacters in a domain name to run arbitrary OS commands on the server. Version 2.7.1 patches the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Modoboa versions prior to 2.7.1 allow OS command injection via unsanitized domain names in shell commands, enabling arbitrary command execution by Resellers or SuperAdmins.
Root
Cause
The vulnerability exists in modoboa/lib/sysutils.py, where exec_cmd() always calls subprocess.Popen with shell=True ([1]). Domain names supplied by users are inserted directly into shell command strings without sanitization. For example, when creating a domain with DKIM enabled, a command like openssl genrsa -out {dkim_storage_dir}/{domain.name}.pem {key_size} is executed, and a domain name containing shell metacharacters (e.g., $(id>/tmp/proof).example.com) will cause the injected command to run before the intended command ([4]).
Attack
Surface
The attack is exploitable by any user with Reseller or SuperAdmin privileges, as these roles can create or modify domains. The same unsafe pattern appears in at least six locations across the codebase, including mailbox rename operations via mv (modoboa/admin/jobs.py), sa-learn calls (modoboa/amavis/lib.py), doveadm user lookups (modoboa/admin/models/mailbox.py), rrdtool graphics commands (modoboa/maillog/graphics.py), and doveadm move/delete operations (modoboa/webmail/models.py) ([4]). No additional authentication is needed beyond the existing privileged session.
Impact
An attacker who successfully injects commands can execute arbitrary OS commands on the mail server. In typical Modoboa deployments, the affected processes run as root, giving the attacker full control over the host system ([4]). This could lead to data exfiltration, service disruption, or lateral movement within the infrastructure.
Mitigation
The issue is patched in Modoboa version 2.7.1 ([3]). The fix modifies exec_cmd() to avoid using shell=True and instead passes commands as argument lists, which prevents shell metacharacter interpretation ([2]). Users should upgrade immediately; no workaround is available for unpatched versions.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
modoboaPyPI | < 2.7.1 | 2.7.1 |
Affected products
2Patches
127a7aa133d36Prevent OS command injection using exec_cmd()
10 files changed · +89 −40
modoboa/admin/api/v2/serializers.py+1 −1 modified@@ -274,7 +274,7 @@ def validate_default_mailbox_quota(self, value): def validate_dkim_keys_storage_dir(self, value): """Check that directory exists.""" if value: - code, output = exec_cmd("which openssl") + code, output = exec_cmd("which openssl", shell=True) if code: raise serializers.ValidationError( _("openssl not found, please make sure it is installed.")
modoboa/admin/app_settings.py+2 −2 modified@@ -298,7 +298,7 @@ def init_structure() -> dict: structure = copy.deepcopy(GLOBAL_PARAMETERS_STRUCT) hide_fields = False dpath = None - code, output = exec_cmd("which dovecot") + code, output = exec_cmd("which dovecot", shell=True) if not code: dpath = force_str(output).strip() else: @@ -312,7 +312,7 @@ def init_structure() -> dict: dpath = fpath if dpath: try: - code, version = exec_cmd(f"{dpath} --version") + code, version = exec_cmd(f"{dpath} --version", shell=True) except OSError: hide_fields = True else:
modoboa/admin/jobs.py+1 −1 modified@@ -35,7 +35,7 @@ def rename_mailbox(operation): f"renaming of {operation.argument} to {new_mail_home} failed (reason: {reason})" ) return - code, output = exec_cmd(f"mv {operation.argument} {new_mail_home}") + code, output = exec_cmd(["mv", operation.argument, new_mail_home]) if code: logger.critical(f"Renaming of {new_mail_home} failed (reason: {output})") return
modoboa/admin/management/commands/subcommands/_manage_dkim_keys.py+6 −2 modified@@ -44,7 +44,9 @@ def create_new_dkim_key(self, domain): if domain.dkim_key_length else self.default_key_length ) - code, output = sysutils.exec_cmd(f"openssl genrsa -out {pkey_path} {key_size}") + code, output = sysutils.exec_cmd( + ["openssl", "genrsa", "-out", pkey_path, str(key_size)] + ) if code: print( f"Failed to generate DKIM private key for domain {domain.name}: {smart_str(output)}" @@ -54,7 +56,9 @@ def create_new_dkim_key(self, domain): ) return domain.dkim_private_key_path = pkey_path - code, output = sysutils.exec_cmd(f"openssl rsa -in {pkey_path} -pubout") + code, output = sysutils.exec_cmd( + ["openssl", "rsa", "-in", pkey_path, "-pubout"] + ) if code: print( f"Failed to generate DKIM public key for domain {domain.name}: {smart_str(output)}"
modoboa/admin/models/mailbox.py+1 −1 modified@@ -147,7 +147,7 @@ def mail_home(self): if not admin_params.get("handle_mailboxes"): return None if self.__mail_home is None: - code, output = doveadm_cmd(f"user -f home {self.full_address}") + code, output = doveadm_cmd(["user", "-f", "home", self.full_address]) if code: raise lib_exceptions.InternalError( _("Failed to retrieve mailbox location (%s)") % output
modoboa/amavis/lib.py+33 −16 modified@@ -92,37 +92,28 @@ class SpamassassinClient: def __init__(self, user, recipient_db): """Constructor.""" - conf = dict(param_tools.get_global_parameters("amavis")) - self._sa_is_local = conf["sa_is_local"] - self._default_username = conf["default_user"] + self.conf = dict(param_tools.get_global_parameters("amavis")) + self._sa_is_local = self.conf["sa_is_local"] + self._default_username = self.conf["default_user"] self._recipient_db = recipient_db self._setup_cache = {} self._username_cache = [] if user.role == "SimpleUsers": - if conf["user_level_learning"]: + if self.conf["user_level_learning"]: self._username = user.email else: self._username = None self.error = None if self._sa_is_local: - self._learn_cmd = self._find_binary("sa-learn") - self._learn_cmd += " --{0} --no-sync -u {1}" self._learn_cmd_kwargs = {} self._expected_exit_codes = [0] - self._sync_cmd = self._find_binary("sa-learn") - self._sync_cmd += " -u {0} --sync" else: - self._learn_cmd = self._find_binary("spamc") - self._learn_cmd += " -d {} -p {}".format( - conf["spamd_address"], conf["spamd_port"] - ) - self._learn_cmd += " -L {0} -u {1}" self._learn_cmd_kwargs = {} self._expected_exit_codes = [5, 6] def _find_binary(self, name): """Find path to binary.""" - code, output = exec_cmd(f"which {name}") + code, output = exec_cmd(f"which {name}", shell=True) if not code: return smart_str(output).strip() known_paths = getattr(settings, "SA_LOOKUP_PATH", ("/usr/bin",)) @@ -132,6 +123,32 @@ def _find_binary(self, name): return bpath raise InternalError(_("Failed to find {} binary").format(name)) + def get_learn_cmd(self, mtype: str, username: str) -> list[str]: + result = [] + if self._sa_is_local: + result += [self._find_binary("sa-learn")] + result += [f"--{mtype}", "--no-sync", "-u", username] + else: + result += [self._find_binary("spamc")] + result += [ + "-d", + self.conf["spamd_address"], + "-p", + self.conf["spamd_port"], + "-L", + mtype, + "-u", + username, + ] + return result + + def get_sync_cmd(self, username: str) -> list[str]: + result = [] + if self._sa_is_local: + result += [self._find_binary("sa-learn")] + result += ["-u", username, "--sync"] + return result + def _get_mailbox_from_rcpt(self, rcpt): """Retrieve a mailbox from a recipient address.""" local_part, domname, extension = split_mailbox(rcpt, return_extension=True) @@ -199,7 +216,7 @@ def _learn(self, rcpt, msg, mtype): self._setup_cache[username] = True if username not in self._username_cache: self._username_cache.append(username) - cmd = self._learn_cmd.format(mtype, username) + cmd = self.get_learn_cmd(mtype, username) code, output = exec_cmd(cmd, pinput=smart_bytes(msg), **self._learn_cmd_kwargs) if code in self._expected_exit_codes: return True @@ -218,7 +235,7 @@ def done(self): """Call this method at the end of the processing.""" if self._sa_is_local: for username in self._username_cache: - cmd = self._sync_cmd.format(username) + cmd = self.get_sync_cmd(username) exec_cmd(cmd, **self._learn_cmd_kwargs)
modoboa/core/password_hashers/utils.py+2 −2 modified@@ -1,4 +1,4 @@ -""" Utils for password schemes caching/retrieval. """ +"""Utils for password schemes caching/retrieval.""" from django.conf import settings from django.core.cache import cache @@ -30,7 +30,7 @@ def get_dovecot_schemes(): if not schemes: try: - retcode, schemes = doveadm_cmd("pw -l") + retcode, schemes = doveadm_cmd(["pw", "-l"]) except OSError: schemes = default_schemes status = 2
modoboa/lib/sysutils.py+10 −5 modified@@ -13,7 +13,13 @@ from django.utils.encoding import force_str -def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs): +def exec_cmd( + cmd: str | list[str], + sudo_user: str | None = None, + pinput: str | None = None, + capture_output: bool = True, + **kwargs, +): """Execute a shell command. Run a command using the current user. Set :keyword:`sudo_user` if @@ -28,7 +34,6 @@ def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs): """ if sudo_user is not None: cmd = f"sudo -u {sudo_user} {cmd}" - kwargs["shell"] = True if pinput is not None: kwargs["stdin"] = subprocess.PIPE if capture_output: @@ -43,7 +48,7 @@ def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs): return process.returncode, output -def doveadm_cmd(params: str, pinput=None, capture_output=True, **kwargs): +def doveadm_cmd(params: list[str], pinput=None, capture_output=True, **kwargs): """Execute doveadm command. Run doveadm command using the current user. Set :keyword:`sudo_user` if @@ -57,7 +62,7 @@ def doveadm_cmd(params: str, pinput=None, capture_output=True, **kwargs): :return: return code, command output """ dpath = None - code, output = exec_cmd("which doveadm") + code, output = exec_cmd("which doveadm", shell=True) if not code: dpath = force_str(output).strip() else: @@ -75,7 +80,7 @@ def doveadm_cmd(params: str, pinput=None, capture_output=True, **kwargs): sudo_user = dovecot_user if curuser != dovecot_user else None if dpath: return exec_cmd( - f"{dpath} {params}", + [dpath] + params, sudo_user=sudo_user, pinput=pinput, capture_output=capture_output,
modoboa/maillog/graphics.py+13 −5 modified@@ -7,7 +7,7 @@ import os from django.conf import settings -from django.utils.encoding import smart_bytes, smart_str +from django.utils.encoding import smart_str from django.utils.translation import gettext as _, gettext_lazy from modoboa.admin import models as admin_models @@ -75,7 +75,7 @@ def display_name(self): def rrdtool_binary(self): """Return path to rrdtool binary.""" dpath = None - code, output = exec_cmd("which rrdtool") + code, output = exec_cmd("which rrdtool", shell=True) if not code: dpath = output.strip() else: @@ -102,9 +102,17 @@ def export(self, rrdfile, start, end): cmdargs += curve.to_rrd_command_args(rrdfile) code = 0 - cmd = f"{self.rrdtool_binary} xport --json -t --start {str(start)} --end {str(end)} " - cmd += " ".join(cmdargs) - code, output = exec_cmd(smart_bytes(cmd)) + cmd = [ + self.rrdtool_binary, + "xport", + "--json", + "-t", + "--start", + str(start), + "--end", + str(end), + ] + cmdargs + code, output = exec_cmd(cmd) if code: return []
modoboa/webmail/models.py+20 −5 modified@@ -52,9 +52,17 @@ def delete_imap_copy(self) -> bool: # TODO: use doveadm HTTP API when dovecot is not local sent_folder = self.account.parameters.get_value("sent_folder") code, output = doveadm_cmd( - f"move -u {self.account.email} {sent_folder} " - f"mailbox {constants.MAILBOX_NAME_SCHEDULED} " - f"header {constants.CUSTOM_HEADER_SCHEDULED_ID} {self.id}" + [ + "move", + "-u", + self.account.email, + sent_folder, + "mailbox", + constants.MAILBOX_NAME_SCHEDULED, + "header", + constants.CUSTOM_HEADER_SCHEDULED_ID, + self.id, + ] ) if code: self.status = constants.SchedulingState.MOVE_ERROR.value @@ -64,8 +72,15 @@ def delete_imap_copy(self) -> bool: # Try to delete mailbox when empty doveadm_cmd( - f"mailbox delete -u {self.account.email} -s -e " - f"{constants.MAILBOX_NAME_SCHEDULED}" + [ + "mailbox", + "delete", + "-u", + self.account.email, + "-s", + "-e", + constants.MAILBOX_NAME_SCHEDULED, + ] ) return True
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-wwv8-cqpr-vx3mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27602ghsaADVISORY
- github.com/modoboa/modoboa/commit/27a7aa133d3608fe8c25ae39125d1012c333cbfaghsax_refsource_MISCWEB
- github.com/modoboa/modoboa/releases/tag/2.7.1ghsax_refsource_MISCWEB
- github.com/modoboa/modoboa/security/advisories/GHSA-wwv8-cqpr-vx3mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.