VYPR
Medium severity5.6GHSA Advisory· Published Jun 13, 2025· Updated Apr 15, 2026

CVE-2025-22241

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.

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

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