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

PackageAffected versionsPatched versions
saltPyPI
>= 3006.0rc1, < 3006.123006.12
saltPyPI
>= 3007.0rc1, < 3007.43007.4

Affected products

1

Patches

1
9445f496fed6

On-demand pillar fix

https://github.com/saltstack/saltDaniel A. WozniakApr 25, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.