CVE-2025-22241
Description
File contents overwrite the VirtKey class is called when “on-demand pillar” data is requested and uses un-validated input to create paths to the “pki directory”. The functionality is used to auto-accept Minion authentication keys based on a pre-placed “authorization file” at a specific location and is present in the default configuration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2025-22241: Salt on-demand pillar uses unvalidated input to create PKI directory paths, allowing file content overwrite and unauthorized minion key acceptance in default config.
Vulnerability
Analysis
CVE-2025-22241 is a medium-severity vulnerability in Salt (severity 5.6 CVSS 3.0) that resides in the way the VirtKey class is invoked when on-demand pillar data is requested. The class accepts unvalidated input to construct file paths under the pki_dir. This path is then used to auto-accept minion authentication keys by reading a pre-placed authorization file from a specific location. The vulnerability exists in the default configuration, meaning no special optional features need to be enabled for exploitation [1][4].
Exploitation
An attacker who can trigger an on-demand pillar request (e.g., from a minion connection) can craft the input to the VirtKey class such that the resulting path points to an arbitrary file under the pki_dir. By controlling the content of that file, the attacker can force the master to accept a minion authentication key. The exploitation requires knowledge of the on-demand pillar interface and the ability to send crafted requests to the master [1][3].
Impact
Successful exploitation allows an attacker to inject a fraudulent minion public key into the master's accepted keys store, effectively granting that minion authentication and the ability to issue jobs. This can lead to unauthorized access to the infrastructure managed by Salt, with potential for further lateral movement or compromise [1][4].
Mitigation
A fix has been released in Salt version 3006.12. Users are strongly advised to upgrade to this or a later version. The commit (9445f49) introduces input validation and path sanitization within the on-demand pillar processing [3][4]. There is no known workaround for the default configuration; upgrading is the only recommended action.
AI Insight generated on May 20, 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 |
|---|---|---|
saltPyPI | >= 3007.0rc1, < 3007.4 | 3007.4 |
saltPyPI | >= 3006.0rc1, < 3006.12 | 3006.12 |
Affected products
1Patches
19445f496fed6On-demand pillar fix
5 files changed · +193 −14
salt/master.py+16 −12 modified@@ -166,6 +166,21 @@ def rotate_secrets(cls, opts=None, event=None, use_lock=True): log.debug("Pinging all connected minions due to key rotation") salt.utils.master.ping_all_connected_minions(opts) + @classmethod + def populate_secrets(cls): + cls.secrets["aes"] = { + "secret": multiprocessing.Array( + ctypes.c_char, + salt.utils.stringutils.to_bytes( + salt.crypt.Crypticle.generate_key_string() + ), + ), + "serial": multiprocessing.Value( + ctypes.c_longlong, lock=False # We'll use the lock from 'secret' + ), + "reload": salt.crypt.Crypticle.generate_key_string, + } + class Maintenance(salt.utils.process.SignalHandlingProcess): """ @@ -701,18 +716,7 @@ def start(self): # Setup the secrets here because the PubServerChannel may need # them as well. - SMaster.secrets["aes"] = { - "secret": multiprocessing.Array( - ctypes.c_char, - salt.utils.stringutils.to_bytes( - salt.crypt.Crypticle.generate_key_string() - ), - ), - "serial": multiprocessing.Value( - ctypes.c_longlong, lock=False # We'll use the lock from 'secret' - ), - "reload": salt.crypt.Crypticle.generate_key_string, - } + SMaster.populate_secrets() log.info("Creating master process manager") # Since there are children having their own ProcessManager we should wait for kill more time.
salt/pillar/__init__.py+8 −1 modified@@ -609,10 +609,15 @@ def __init__( def __valid_on_demand_ext_pillar(self, opts): """ - Check to see if the on demand external pillar is allowed + Check to see if the on demand external pillar is allowed. + + If this check fails self.ext is set to None, this is important to + prevent an on-demand pillare from being rendered when it shoul not be + allowed. """ if not isinstance(self.ext, dict): log.error("On-demand pillar %s is not formatted as a dictionary", self.ext) + self.ext = None return False on_demand = opts.get("on_demand_ext_pillar", []) @@ -624,6 +629,7 @@ def __valid_on_demand_ext_pillar(self, opts): "The 'on_demand_ext_pillar' configuration option is " "malformed, it should be a list of ext_pillar module names" ) + self.ext = None return False if invalid_on_demand: @@ -635,6 +641,7 @@ def __valid_on_demand_ext_pillar(self, opts): ", ".join(sorted(invalid_on_demand)), ", ".join(on_demand), ) + self.ext = None return False return True
salt/utils/gitfs.py+5 −1 modified@@ -2624,7 +2624,11 @@ def init_remotes( per_remote_defaults[param] = enforce_types(key, self.opts[key]) self.remotes = [] - for remote in remotes: + for remote in list(remotes): + if not salt.utils.verify.url(remote): + log.warning("Found bad url data %r", remote) + remotes.remove(remote) + continue repo_obj = self.git_providers[self.provider]( self.opts, remote,
salt/utils/verify.py+35 −0 modified@@ -10,6 +10,7 @@ import socket import stat import sys +import urllib.parse import salt.defaults.exitcodes import salt.utils.files @@ -781,3 +782,37 @@ def win_verify_env(path, dirs, permissive=False, pki_dir="", skip_extra=False): if skip_extra is False: # Run the extra verification checks zmq_version() + + +SCHEMES = ( + "http", + "https", + "ssh", + "ftp", + "sftp", + "file", +) + + +class URLValidator: + + PCHAR = r"^([a-z,0-9,-,.,_,~,!,$,&,',(,),;,=,:,@,\,]|%\d\d)+$" + ALL_VALID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=" + + def __init__(self, schemes=SCHEMES): + self.schemes = schemes + + def __call__(self, data): + if any([x not in self.ALL_VALID for x in data]): + return False + parsed = urllib.parse.urlparse(data) + if parsed.scheme not in self.schemes: + return False + matcher = re.compile(self.PCHAR, re.IGNORECASE) + for part in parsed.path.split("/"): + if part and not matcher.match(part): + return False + return True + + +url = URLValidator()
tests/pytests/unit/test_master.py+129 −0 modified@@ -3,10 +3,13 @@ import pytest +import salt.config +import salt.crypt import salt.master import salt.utils.files import salt.utils.platform from tests.support.mock import patch +from tests.support.runtests import RUNTIME_VARS @pytest.fixture @@ -221,3 +224,129 @@ def test_pub_ret_traversal(encrypted_requests, tmp_path): "return": {}, } ) + + +def _git_pillar_base_config(tmp_path): + return { + "__role": "master", + "pki_dir": str(tmp_path / "pki"), + "cachedir": str(tmp_path / "cache"), + "sock_dir": str(tmp_path / "sock_drawer"), + "conf_file": str(tmp_path / "config.conf"), + "fileserver_backend": ["local"], + "master_job_cache": False, + "file_client": "local", + "pillar_cache": False, + "state_top": "top.sls", + "pillar_roots": { + "base": [str(tmp_path / "pillar")], + }, + "render_dirs": [str(pathlib.Path(RUNTIME_VARS.SALT_CODE_DIR) / "renderer")], + "renderer": "jinja|yaml", + "renderer_blacklist": [], + "renderer_whitelist": [], + "optimization_order": [0, 1, 2], + "on_demand_ext_pillar": [], + "git_pillar_user": "", + "git_pillar_password": "", + "git_pillar_pubkey": "", + "git_pillar_privkey": "", + "git_pillar_passphrase": "", + "git_pillar_insecure_auth": False, + "git_pillar_refspecs": salt.config._DFLT_REFSPECS, + "git_pillar_ssl_verify": True, + "git_pillar_branch": "master", + "git_pillar_base": "master", + "git_pillar_root": "", + "git_pillar_env": "", + "git_pillar_fallback": "", + } + + +@pytest.fixture +def allowed_funcs(tmp_path): + """ + Configuration with git on demand pillar allowed + """ + opts = _git_pillar_base_config(tmp_path) + opts["on_demand_ext_pillar"] = ["git"] + salt.crypt.gen_keys(str(tmp_path), "minion", 2048) + master_pki = tmp_path / "pki" + master_pki.mkdir() + accepted_pki = master_pki / "minions" + accepted_pki.mkdir() + (accepted_pki / "minion.pub").write_text((tmp_path / "minion.pub").read_text()) + + return salt.master.AESFuncs(opts=opts) + + +def test_on_demand_allowed_command_injection(allowed_funcs, tmp_path, caplog): + """ + Verify on demand pillars validate remote urls + """ + pwnpath = tmp_path / "pwn" + assert not pwnpath.exists() + load = { + "cmd": "_pillar", + "saltenv": "base", + "pillarenv": "base", + "id": "carbon", + "grains": {}, + "ver": 2, + "ext": { + "git": [ + f'base ssh://fake@git/repo\n[core]\nsshCommand = touch {pwnpath}\n[remote "origin"]\n' + ] + }, + "clean_cache": True, + } + with caplog.at_level(level="WARNING"): + ret = allowed_funcs._pillar(load) + assert not pwnpath.exists() + assert "Found bad url data" in caplog.text + + +@pytest.fixture +def not_allowed_funcs(tmp_path): + """ + Configuration with no on demand pillars allowed + """ + opts = _git_pillar_base_config(tmp_path) + opts["on_demand_ext_pillar"] = [] + salt.crypt.gen_keys(str(tmp_path), "minion", 2048) + master_pki = tmp_path / "pki" + master_pki.mkdir() + accepted_pki = master_pki / "minions" + accepted_pki.mkdir() + (accepted_pki / "minion.pub").write_text((tmp_path / "minion.pub").read_text()) + + return salt.master.AESFuncs(opts=opts) + + +def test_on_demand_not_allowed(not_allowed_funcs, tmp_path, caplog): + """ + Verify on demand pillars do not render when not allowed + """ + pwnpath = tmp_path / "pwn" + assert not pwnpath.exists() + load = { + "cmd": "_pillar", + "saltenv": "base", + "pillarenv": "base", + "id": "carbon", + "grains": {}, + "ver": 2, + "ext": { + "git": [ + f'base ssh://fake@git/repo\n[core]\nsshCommand = touch {pwnpath}\n[remote "origin"]\n' + ] + }, + "clean_cache": True, + } + with caplog.at_level(level="WARNING"): + ret = not_allowed_funcs._pillar(load) + assert not pwnpath.exists() + assert ( + "The following ext_pillar modules are not allowed for on-demand pillar data: git." + in caplog.text + )
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-7f3f-x5f5-79gwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-22241ghsaADVISORY
- docs.saltproject.io/en/3006/topics/releases/3006.12.htmlnvdWEB
- docs.saltproject.io/en/3007/topics/releases/3007.4.htmlnvdWEB
- github.com/saltstack/salt/commit/9445f496fed61b15dc4364818007e5b765b0746fghsaWEB
News mentions
0No linked articles in our index yet.