Medium severity6.7GHSA Advisory· Published Jun 13, 2025· Updated Apr 15, 2026
CVE-2025-22237
CVE-2025-22237
Description
An attacker with access to a minion key can exploit the 'on demand' pillar functionality with a specially crafted git url which could cause and arbitrary command to be run on the master with the same privileges as the master process.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
saltPyPI | >= 3006.0rc1, < 3006.12 | 3006.12 |
saltPyPI | >= 3007.0rc1, < 3007.4 | 3007.4 |
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 by null/stub 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-fcr4-h6c4-rvvpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-22237ghsaADVISORY
- 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.