Medium severity6.2NVD Advisory· Published Jan 30, 2026· Updated Apr 15, 2026
CVE-2025-62349
CVE-2025-62349
Description
Salt contains an authentication protocol version downgrade weakness that can allow a malicious minion to bypass newer authentication/security features by using an older request payload format, enabling minion impersonation and circumventing protections introduced in response to prior issues.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
saltPyPI | >= 3006.12, < 3006.17 | 3006.17 |
saltPyPI | >= 3007.4, < 3007.9 | 3007.9 |
Affected products
1- Package: https://pypi.org/project/salt
Patches
13d5708acae16Add minimum_auth_version to enforce all security
6 files changed · +658 −1
changelog/68467.fixed.md+1 −0 added@@ -0,0 +1 @@ +Fixed authentication protocol version downgrade vulnerability (CVE-2025-62349) by adding `minimum_auth_version` configuration option to prevent minions from bypassing security features through protocol downgrade attacks.
doc/ref/configuration/master.rst+57 −0 modified@@ -2062,6 +2062,63 @@ master's request channel. Valid values are ``PKCS1v15-SHA1`` and ``PKCS1v15-SHA224``. Minions must be at version ``3006.9`` or greater if this is changed from the default setting. +.. conf_master:: minimum_auth_version + +``minimum_auth_version`` +------------------------ + +.. versionadded:: 3006.17,3007.9 + +Default: ``0`` + +Enforce a minimum authentication protocol version from minions connecting to the master. +This setting protects against authentication downgrade attacks (CVE-2025-62349) where a +malicious minion attempts to use an older, less secure authentication protocol version to +bypass security features introduced in newer protocol versions. + +Authentication protocol versions and their security features: + +- **Version 0/1**: No message signing, no nonce, no security features (legacy, insecure) +- **Version 2**: Message signing and nonce, but missing TTL validation, token validation, + and minion ID matching (partially secure) +- **Version 3+**: Full security with message signing, nonce, TTL checks, token validation, + minion ID matching, and session keys (recommended) + +**Important Security Considerations:** + +The default value of ``0`` allows all authentication protocol versions for backward +compatibility during rolling upgrades (where the master is typically upgraded before minions). + +**Recommended value:** ``3`` - Once all minions in your infrastructure have been upgraded +to a version that supports protocol version 3 or higher, set this value to ``3`` to ensure +maximum security. + +**Upgrade Path:** + +1. Upgrade your Salt Master to a version supporting ``minimum_auth_version`` +2. Keep the default value of ``0`` during the minion upgrade process +3. Upgrade all minions to a version supporting authentication protocol v3+ +4. Set ``minimum_auth_version: 3`` in the master configuration +5. Restart the Salt Master to enforce the new security requirement + +.. code-block:: yaml + + # Default - allows all versions (backward compatible but less secure) + minimum_auth_version: 0 + + # Recommended - enforces modern authentication protocol (secure) + minimum_auth_version: 3 + +.. warning:: + Setting ``minimum_auth_version`` to a value higher than what your minions support + will prevent those minions from authenticating. Ensure all minions are upgraded + before increasing this value. Check your minion versions before changing this setting. + +.. note:: + When a minion's authentication is rejected due to insufficient protocol version, + a warning message will be logged on the master including the minion ID and the + protocol version it attempted to use. + ``ssl`` -------
salt/channel/server.py+16 −1 modified@@ -136,7 +136,22 @@ def handle_message(self, payload): ): log.warn("bad load received on socket") raise salt.ext.tornado.gen.Return("bad load") - version = payload.get("version", 0) + try: + version = int(payload.get("version", 0)) + except ValueError: + version = 0 + + # Enforce minimum authentication protocol version to prevent downgrade attacks + minimum_version = self.opts.get("minimum_auth_version", 0) + if minimum_version > 0 and version < minimum_version: + log.warning( + "Rejected authentication attempt using protocol version %d " + "(minimum required: %d)", + version, + minimum_version, + ) + raise salt.ext.tornado.gen.Return("bad load") + try: payload = self._decode_payload(payload, version) except Exception as exc: # pylint: disable=broad-except
salt/config/__init__.py+3 −0 modified@@ -1006,6 +1006,8 @@ def _gather_buffer_space(): "publish_signing_algorithm": str, "request_server_ttl": int, "request_server_aes_session": int, + # Minimum authentication protocol version to accept from minions + "minimum_auth_version": int, } ) @@ -1666,6 +1668,7 @@ def _gather_buffer_space(): "publish_signing_algorithm": "PKCS1v15-SHA1", "request_server_aes_session": 0, "request_server_ttl": 0, + "minimum_auth_version": 0, } )
tests/pytests/functional/channel/test_auth_downgrade.py+319 −0 added@@ -0,0 +1,319 @@ +""" +Functional tests for authentication version downgrade attacks. + +These tests demonstrate the vulnerability where a malicious minion can +downgrade its authentication protocol version to bypass security features. +""" + +import ctypes +import multiprocessing +import pathlib + +import pytest + +import salt.channel.client +import salt.channel.server +import salt.config +import salt.crypt +import salt.daemons.masterapi +import salt.ext.tornado.gen +import salt.ext.tornado.ioloop +import salt.master +import salt.payload +import salt.utils.event +import salt.utils.files +import salt.utils.platform +import salt.utils.process +import salt.utils.stringutils +from salt.master import SMaster + +pytestmark = [ + pytest.mark.skip_on_spawning_platform( + reason="These tests are currently broken on spawning platforms. Need to be rewritten.", + ), + pytest.mark.timeout_unless_on_windows(120), +] + + +@pytest.fixture +def channel_minion_id(): + return "test-minion-downgrade" + + +@pytest.fixture +def auth_pki_dir(tmp_path): + """Setup PKI directory structure for functional auth tests.""" + if salt.utils.platform.is_darwin(): + # To avoid 'OSError: AF_UNIX path too long' + _root_dir = pathlib.Path("/tmp").resolve() / tmp_path.name + root_dir = _root_dir + else: + root_dir = tmp_path + + master_pki = root_dir / "master_pki" + minion_pki = root_dir / "minion_pki" + + master_pki.mkdir(parents=True) + minion_pki.mkdir(parents=True) + (master_pki / "minions").mkdir() + (master_pki / "minions_pre").mkdir() + (master_pki / "minions_rejected").mkdir() + (master_pki / "minions_denied").mkdir() + + # Generate keys + salt.crypt.gen_keys(str(master_pki), "master", 4096) + salt.crypt.gen_keys(str(minion_pki), "minion", 4096) + + yield root_dir + + if salt.utils.platform.is_darwin(): + import shutil + + shutil.rmtree(str(root_dir), ignore_errors=True) + + +def _create_functional_master_opts( + auth_pki_dir, channel_minion_id, minimum_auth_version=0 +): + """Helper to create master configuration with specified minimum_auth_version.""" + opts = { + "master_uri": "tcp://127.0.0.1:44506", + "interface": "127.0.0.1", + "ret_port": 44506, + "ipv6": False, + "sock_dir": str(auth_pki_dir / "sock"), + "cachedir": str(auth_pki_dir / "cache"), + "pki_dir": str(auth_pki_dir / "master_pki"), + "id": "master", + "__role": "master", + "transport": "zeromq", + "keysize": 4096, + "max_minions": 0, + "auto_accept": True, + "open_mode": False, + "key_pass": None, + "publish_port": 44505, + "auth_mode": 1, + "auth_events": True, + "publish_session": 86400, + "request_server_ttl": 300, # 5 minutes + "worker_threads": 1, + "cluster_id": None, + "master_sign_pubkey": False, + "sign_pub_messages": False, + "minimum_auth_version": minimum_auth_version, + } + (auth_pki_dir / "sock").mkdir(exist_ok=True) + (auth_pki_dir / "cache").mkdir(exist_ok=True) + (auth_pki_dir / "cache" / "sessions").mkdir(exist_ok=True) + + # Accept the minion key + minion_pub = auth_pki_dir / "minion_pki" / "minion.pub" + master_minions = auth_pki_dir / "master_pki" / "minions" / channel_minion_id + if minion_pub.exists(): + with salt.utils.files.fopen(str(minion_pub)) as src: + with salt.utils.files.fopen(str(master_minions), "w") as dst: + dst.write(src.read()) + + return opts + + +@pytest.fixture +def functional_master_opts(auth_pki_dir, channel_minion_id): + """Master configuration for functional tests (default: minimum_auth_version=3).""" + return _create_functional_master_opts( + auth_pki_dir, channel_minion_id, minimum_auth_version=3 + ) + + +@pytest.fixture +def functional_minion_opts(auth_pki_dir, functional_master_opts, channel_minion_id): + """Minion configuration for functional tests.""" + return { + "master_uri": "tcp://127.0.0.1:44506", + "interface": "127.0.0.1", + "ret_port": 44506, + "ipv6": False, + "sock_dir": str(functional_master_opts["sock_dir"]), + "pki_dir": str(auth_pki_dir / "minion_pki"), + "id": channel_minion_id, + "__role": "minion", + "transport": "zeromq", + "keysize": 4096, + "encryption_algorithm": salt.crypt.OAEP_SHA1, + "signing_algorithm": salt.crypt.PKCS1v15_SHA1, + "master_port": 44506, + "master_ip": "127.0.0.1", + } + + +@pytest.mark.parametrize( + "minimum_auth_version,attack_version,should_pass", + [ + pytest.param( + 0, + 0, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=0 allows all versions", + strict=True, + ), + ), + pytest.param( + 0, + 1, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=0 allows all versions", + strict=True, + ), + ), + pytest.param( + 0, + 2, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=0 allows all versions", + strict=True, + ), + ), + pytest.param(1, 0, True, id="v1-rejects-v0"), + pytest.param( + 1, + 1, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=1 allows v1", strict=True + ), + ), + pytest.param( + 1, + 2, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=1 allows v2", strict=True + ), + ), + pytest.param(2, 0, True, id="v2-rejects-v0"), + pytest.param(2, 1, True, id="v2-rejects-v1"), + pytest.param( + 2, + 2, + False, + marks=pytest.mark.xfail( + reason="Vulnerable: minimum_auth_version=2 allows v2", strict=True + ), + ), + pytest.param(3, 0, True, id="v3-rejects-v0"), + pytest.param(3, 1, True, id="v3-rejects-v1"), + pytest.param(3, 2, True, id="v3-rejects-v2-SECURE"), + ], +) +async def test_replay_attack_via_version_downgrade( + auth_pki_dir, + functional_minion_opts, + channel_minion_id, + minimum_auth_version, + attack_version, + should_pass, +): + """ + REGRESSION TEST: CVE-TBD - Replay Attack via Version Downgrade + + This parameterized test verifies that version downgrade attacks are prevented + based on the minimum_auth_version configuration. + + Attack Scenario: + 1. A legitimate minion sends a version 3 authenticated message + 2. An attacker captures the encrypted payload + 3. The attacker attempts to replay it with an older version to bypass security + + Test Matrix: + - minimum_auth_version=0: VULNERABLE - accepts all versions (xfail) + - minimum_auth_version=1: Partially secure - rejects v0 only + - minimum_auth_version=2: Partially secure - rejects v0, v1 + - minimum_auth_version=3: SECURE - rejects v0, v1, v2 (recommended) + + This test uses xfail for vulnerable configurations to document the security risk. + """ + # Create master opts with the specified minimum_auth_version + master_opts = _create_functional_master_opts( + auth_pki_dir, channel_minion_id, minimum_auth_version + ) + + # Setup master secrets + 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), + } + + # Create server channel + server_channel = salt.channel.server.ReqServerChannel.factory(master_opts) + server_channel.auto_key = salt.daemons.masterapi.AutoKey(master_opts) + server_channel.cache_cli = False + server_channel.event = salt.utils.event.get_master_event( + master_opts, master_opts["sock_dir"], listen=False + ) + server_channel.master_key = salt.crypt.MasterKeys(master_opts) + + try: + # Verify the configuration + assert ( + master_opts.get("minimum_auth_version") == minimum_auth_version + ), f"Master should have minimum_auth_version={minimum_auth_version}" + + # Read minion public key + with salt.utils.files.fopen( + str(auth_pki_dir / "minion_pki" / "minion.pub"), "r" + ) as fp: + pub_key = fp.read() + + # Create auth load for the attack version + load = { + "cmd": "_auth", + "id": channel_minion_id, + "pub": pub_key, + "enc_algo": salt.crypt.OAEP_SHA1, + "sig_algo": salt.crypt.PKCS1v15_SHA1, + } + + # Add nonce for version 2+ + if attack_version >= 2: + load["nonce"] = "test-nonce" + + # Create payload for handle_message + payload = {"enc": "clear", "load": load, "version": attack_version} + + # Call handle_message which will enforce minimum_auth_version + ret = await server_channel.handle_message(payload) + + # Check if attack was blocked + if should_pass: + # Attack should be blocked (old version rejected) + # With proper minimum_auth_version, handle_message returns "bad load" + assert ret == "bad load", ( + f"Expected 'bad load' for rejected version {attack_version} " + f"with minimum {minimum_auth_version}" + ) + else: + # Vulnerable: attack succeeds (old version accepted) + # Assert that auth SHOULD be rejected (but it won't be - causing xfail) + # This assertion will FAIL for vulnerable configs, documenting the security issue + assert ret == "bad load", ( + f"SECURITY ISSUE: Version {attack_version} was accepted " + f"with minimum_auth_version={minimum_auth_version}" + ) + + finally: + server_channel.close() + if "aes" in SMaster.secrets: + SMaster.secrets.pop("aes") + + +# Note: Additional functional tests for ID mismatch and token bypass +# have been covered in the unit tests. This single functional test +# demonstrates the replay attack scenario which is the most critical +# end-to-end attack vector.
tests/pytests/unit/channel/test_server.py+262 −0 modified@@ -1,6 +1,18 @@ +import ctypes +import multiprocessing +import uuid + import pytest import salt.channel.server as server +import salt.crypt +import salt.daemons.masterapi +import salt.master +import salt.payload +import salt.utils.event +import salt.utils.files +import salt.utils.stringutils +from salt.master import SMaster @pytest.fixture @@ -86,3 +98,253 @@ def test_req_server_validate_token_removes_token_id_traversal(root_dir): } assert reqsrv.validate_token(payload) is False assert "tok" not in payload["load"] + + +# ============================================================================ +# Auth Version Downgrade Attack Regression Tests +# ============================================================================ + + +@pytest.fixture +def pki_dir(tmp_path): + """Setup PKI directory structure for auth tests.""" + master_pki = tmp_path / "master" + minion_pki = tmp_path / "minion" + + master_pki.mkdir() + minion_pki.mkdir() + (master_pki / "minions").mkdir() + (master_pki / "minions_pre").mkdir() + (master_pki / "minions_rejected").mkdir() + (master_pki / "minions_denied").mkdir() + + # Generate master keys + salt.crypt.gen_keys(str(master_pki), "master", 4096) + + # Generate minion keys + salt.crypt.gen_keys(str(minion_pki), "minion", 4096) + + return tmp_path + + +@pytest.fixture +def auth_master_opts(pki_dir, tmp_path): + """Master configuration for auth tests.""" + opts = { + "master_uri": "tcp://127.0.0.1:4506", + "interface": "127.0.0.1", + "ret_port": 4506, + "ipv6": False, + "sock_dir": str(tmp_path / "sock"), + "cachedir": str(tmp_path / "cache"), + "pki_dir": str(pki_dir / "master"), + "id": "master", + "__role": "master", + "keysize": 4096, + "max_minions": 0, + "auto_accept": False, + "open_mode": False, + "key_pass": None, + "publish_port": 4505, + "auth_mode": 1, + "auth_events": True, + "publish_session": 86400, + "request_server_ttl": 300, # 5 minutes + "master_sign_pubkey": False, + "sign_pub_messages": False, + "cluster_id": None, + "transport": "zeromq", + "minimum_auth_version": 3, # Enforce version 3+ for security + } + (tmp_path / "sock").mkdir(exist_ok=True) + (tmp_path / "cache").mkdir(exist_ok=True) + (tmp_path / "cache" / "sessions").mkdir(exist_ok=True) + return opts + + +@pytest.fixture +def auth_minion_opts(pki_dir, auth_master_opts): + """Minion configuration for auth tests.""" + return { + "master_uri": "tcp://127.0.0.1:4506", + "interface": "127.0.0.1", + "ret_port": 4506, + "ipv6": False, + "sock_dir": str(auth_master_opts["sock_dir"]), + "pki_dir": str(pki_dir / "minion"), + "id": "test-minion", + "__role": "minion", + "keysize": 4096, + "encryption_algorithm": salt.crypt.OAEP_SHA1, + "signing_algorithm": salt.crypt.PKCS1v15_SHA1, + } + + +@pytest.fixture +def setup_accepted_minion(pki_dir, auth_minion_opts, auth_master_opts): + """ + Setup a pre-accepted minion by copying its public key to master's + minions directory. + """ + minion_pub = pki_dir / "minion" / "minion.pub" + master_minions_dir = pki_dir / "master" / "minions" + accepted_key = master_minions_dir / auth_minion_opts["id"] + + # Copy minion public key to master's accepted keys + with salt.utils.files.fopen(str(minion_pub)) as src: + with salt.utils.files.fopen(str(accepted_key), "w") as dst: + dst.write(src.read()) + + return accepted_key + + +@pytest.fixture +def req_server(auth_master_opts): + """Create a ReqServerChannel instance for testing.""" + # Setup master secrets + SMaster.secrets["aes"] = { + "secret": multiprocessing.Array( + ctypes.c_char, + salt.utils.stringutils.to_bytes(salt.crypt.Crypticle.generate_key_string()), + ), + "reload": salt.crypt.Crypticle.generate_key_string, + } + + server_channel = server.ReqServerChannel.factory(auth_master_opts) + server_channel.auto_key = salt.daemons.masterapi.AutoKey(auth_master_opts) + server_channel.cache_cli = False + server_channel.event = salt.utils.event.get_master_event( + auth_master_opts, auth_master_opts["sock_dir"], listen=False + ) + server_channel.master_key = salt.crypt.MasterKeys(auth_master_opts) + + yield server_channel + + server_channel.close() + if "aes" in SMaster.secrets: + SMaster.secrets.pop("aes") + + +async def test_auth_version_downgrade_v3_to_v0( + pki_dir, auth_minion_opts, req_server, setup_accepted_minion +): + """ + REGRESSION TEST: CVE-2025-62349 - Auth Version Downgrade Attack + + Test that a minion cannot downgrade from version 3 to version 0 + to bypass security features like token validation. + + EXPECTED BEHAVIOR: + - Master should reject authentication attempts with version < 3 + from minions that previously authenticated with version 3 + - Or enforce version 3+ security features regardless of claimed version + + CURRENT BEHAVIOR (VULNERABLE): + - Test will FAIL - minion can successfully downgrade (vulnerability exists) + - After fix is implemented, this test will PASS + """ + # Read minion public key + with salt.utils.files.fopen(str(pki_dir / "minion" / "minion.pub"), "r") as fp: + pub_key = fp.read() + + # Simulate version 0 auth load (no nonce, no signing) + load_v0 = { + "cmd": "_auth", + "id": auth_minion_opts["id"], + "pub": pub_key, + "enc_algo": auth_minion_opts["encryption_algorithm"], + "sig_algo": auth_minion_opts["signing_algorithm"], + } + + # Create payload for handle_message (version 0) + payload = {"enc": "clear", "load": load_v0, "version": 0} + + # Call handle_message which will enforce minimum_auth_version + ret = await req_server.handle_message(payload) + + # REGRESSION TEST: Version 0 should be REJECTED + # With minimum_auth_version=3, version 0 should return "bad load" + assert ret == "bad load", "Expected 'bad load' for rejected version 0 auth" + + +@pytest.mark.parametrize("downgrade_version", [0, 1, 2]) +async def test_auth_version_downgrade_from_v3( + pki_dir, auth_minion_opts, req_server, setup_accepted_minion, downgrade_version +): + """ + REGRESSION TEST: CVE-2025-62349 - Auth Version Downgrade Attack (Parametrized) + + Test that minions cannot downgrade to versions 0, 1, or 2 to bypass + version 3 security features: + - v0: No message signing, no nonce + - v1: No message signing, no nonce + - v2: Message signing but no token validation, no TTL checks, no ID matching + - v3+: Full security (token validation, TTL, ID matching, session keys) + + EXPECTED BEHAVIOR: + - All downgrade attempts should be rejected + + CURRENT BEHAVIOR (VULNERABLE): + - Test will FAIL - downgrades are accepted, bypassing security + - After fix is implemented, this test will PASS + """ + with salt.utils.files.fopen(str(pki_dir / "minion" / "minion.pub"), "r") as fp: + pub_key = fp.read() + + load = { + "cmd": "_auth", + "id": auth_minion_opts["id"], + "pub": pub_key, + "enc_algo": auth_minion_opts["encryption_algorithm"], + "sig_algo": auth_minion_opts["signing_algorithm"], + } + + # Add nonce for v2+ (but not for v0/v1) + if downgrade_version >= 2: + load["nonce"] = uuid.uuid4().hex + + # Create payload for handle_message + payload = {"enc": "clear", "load": load, "version": downgrade_version} + + # Call handle_message which will enforce minimum_auth_version + ret = await req_server.handle_message(payload) + + # REGRESSION TEST: Old versions should be REJECTED + # With minimum_auth_version=3, versions 0, 1, 2 should return "bad load" + assert ( + ret == "bad load" + ), f"Expected 'bad load' for rejected version {downgrade_version} auth" + + +def test_handle_message_version_extraction(auth_master_opts): + """ + REGRESSION TEST: CVE-2025-62349 - Version Extraction from Untrusted Payload + + Test that the master should have minimum_auth_version configured. + + EXPECTED BEHAVIOR: + - Master opts should have minimum_auth_version set + - The commented code at salt/channel/server.py:144-145 should be enabled + + CURRENT BEHAVIOR (VULNERABLE): + - Test will FAIL - minimum_auth_version is not in opts + - After fix is implemented, this test will PASS + """ + # The current code at salt/channel/server.py:139-145 shows: + # version = payload.get("version", 0) + # #if version < self.opts["minimum_auth_version"]: + # # raise salt.ext.tornado.gen.Return("bad load") + + # REGRESSION TEST: Verify minimum_auth_version exists in opts + # Currently this will FAIL because the option doesn't exist + assert ( + "minimum_auth_version" in auth_master_opts + ), "Expected minimum_auth_version to be configured in master opts" + assert ( + auth_master_opts["minimum_auth_version"] >= 3 + ), "Expected minimum auth version to be at least 3" + + +# Note: The remaining security bypasses (token, TTL, ID mismatch, session keys) +# are already tested via the parametrized downgrade tests above and the +# functional tests. The key regression test is ensuring old versions are rejected.
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
6- github.com/advisories/GHSA-vcf3-26xf-fw4mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-62349ghsaADVISORY
- docs.saltproject.io/en/latest/topics/releases/3006.17.htmlnvdWEB
- docs.saltproject.io/en/latest/topics/releases/3007.9.htmlnvdWEB
- github.com/saltstack/salt/commit/3d5708acae16d039a1e2b5529c8e14a0d3255611ghsaWEB
- github.com/saltstack/salt/issues/68467ghsaWEB
News mentions
0No linked articles in our index yet.