CVE-2025-22238
Description
Directory traversal attack in minion file cache creation. The master's default cache is vulnerable to a directory traversal attack. Which could be leveraged to write or overwrite 'cache' files outside of the cache directory.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Salt master's default cache is vulnerable to a directory traversal attack, allowing a minion to write cache files outside the intended directory.
Vulnerability
Overview
CVE-2025-22238 describes a directory traversal vulnerability in Salt's minion file cache creation mechanism. The master's default cache is vulnerable to a directory traversal attack that could be leveraged to write or overwrite cache files outside of the cache directory [1]. This flaw exists in the file cache creation process, where user-controlled input may include path traversal sequences that bypass directory restrictions.
Attack
Vector and Prerequisites
The attack requires local access with high privileges (CVSS:5:AV:L/AC:L/PR:H/UI:R/S:U/C:N/I:H/A:N) [1], meaning an attacker must already have a minion key and be able to send crafted requests to the master. The attack does not require network access but does require user interaction, suggesting that a privileged minion operator must initiate the operation. The vulnerability is present in both the 3006.x and 3007.x release branches, as indicated by the respective release notes [3][4].
Impact
Successful exploitation allows an attacker to write or overwrite arbitrary cache files outside of the intended cache directory. This could lead to corruption of cache data or potentially other impacts depending on how the master uses cache files, with a high integrity impact but no confidentiality or availability impact per the CVSS score. The attacker cannot read files or cause denial of service directly through this vulnerability.
Mitigation
Salt project has released versions 3006.12 [3] and 3007.4 [4] which address this vulnerability alongside several other security issues. Users should upgrade to these patched versions or later. No workaround is mentioned in the available references.
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 | >= 3006.0rc1, < 3006.12 | 3006.12 |
saltPyPI | >= 3007.0rc1, < 3007.4 | 3007.4 |
Affected products
1Patches
14b30218edf1aPrevent traversal in local_cache::save_minions
6 files changed · +142 −91
salt/exceptions.py+6 −0 modified@@ -362,6 +362,12 @@ class AuthorizationError(SaltException): """ +class SaltValidationError(SaltException): + """ + Thrown when a value fails validation + """ + + class UnsupportedAlgorithm(SaltException): """ Thrown when a requested encryption or signing algorithm is un-supported.
salt/master.py+0 −1 modified@@ -1197,7 +1197,6 @@ class AESFuncs(TransportMethods): "_file_recv", "_pillar", "_minion_event", - "_handle_minion_event", "_return", "_syndic_return", "minion_runner",
salt/returners/local_cache.py+17 −4 modified@@ -8,6 +8,7 @@ import glob import logging import os +import pathlib import shutil import time @@ -231,6 +232,8 @@ def save_minions(jid, minions, syndic_id=None): """ Save/update the serialized list of minions for a given job """ + import salt.utils.verify + # Ensure we have a list for Python 3 compatibility minions = list(minions) @@ -254,10 +257,20 @@ def save_minions(jid, minions, syndic_id=None): else: raise - if syndic_id is not None: - minions_path = os.path.join(jid_dir, SYNDIC_MINIONS_P.format(syndic_id)) - else: - minions_path = os.path.join(jid_dir, MINIONS_P) + try: + if syndic_id is not None: + name = SYNDIC_MINIONS_P.format(syndic_id) + else: + name = MINIONS_P + minions_path = salt.utils.verify.clean_join(jid_dir, name) + target_name = pathlib.Path(minions_path).resolve().name + if name != target_name: + raise salt.exceptions.SaltValidationError( + f"Filenames do not match: {name} != {target_name}" + ) + except salt.exceptions.SaltValidationError as exc: + log.error("Error %s", exc) + return try: if not os.path.exists(jid_dir):
salt/utils/verify.py+27 −11 modified@@ -17,7 +17,12 @@ import salt.utils.platform import salt.utils.user from salt._logging import LOG_LEVELS -from salt.exceptions import CommandExecutionError, SaltClientError, SaltSystemExit +from salt.exceptions import ( + CommandExecutionError, + SaltClientError, + SaltSystemExit, + SaltValidationError, +) # Original Author: Jeff Schroeder <jeffschroeder@computer.org> @@ -502,7 +507,7 @@ def _realpath_windows(path): def _realpath(path): """ - Cross platform realpath method. On Windows when python 3, this method + FCross platform realpath method. On Windows when python 3, this method uses the os.readlink method to resolve any filesystem links. All other platforms and version use ``os.path.realpath``. """ @@ -522,22 +527,33 @@ def clean_path(root, path, subdir=False, realpath=True): """ if not os.path.isabs(root): root = os.path.join(os.getcwd(), root) - root = os.path.normpath(root) + normroot = os.path.normpath(root) if not os.path.isabs(path): - path = os.path.join(root, path) - path = os.path.normpath(path) + path = os.path.join(normroot, path) + normpath = os.path.normpath(path) if realpath: - root = _realpath(root) - path = _realpath(path) + normroot = _realpath(normroot) + normpath = _realpath(normpath) if subdir: - if os.path.commonpath([path, root]) == root: - return path + if os.path.commonpath([normpath, normroot]) == normroot: + return normpath else: - if os.path.dirname(path) == root: - return path + if os.path.dirname(normpath) == normroot: + return normpath return "" +def clean_join(root, *paths, subdir=False, realpath=True): + """ + Performa a join and then check the result against the clean_path method. If + clean_path fails a SaltValidationError is raised. + """ + path = os.path.join(root, *paths) + if not clean_path(root, path, subdir, realpath): + raise SaltValidationError(f"Invalid path: {path!r}") + return path + + def valid_id(opts, id_): """ Returns if the passed id is valid
tests/pytests/unit/utils/verify/test_clean_path_link.py+0 −75 removed@@ -1,75 +0,0 @@ -""" -Ensure salt.utils.clean_path works with symlinked directories and files -""" - -import ctypes - -import pytest - -import salt.utils.verify - - -class Symlink: - """ - symlink(source, link_name) Creates a symbolic link pointing to source named - link_name - """ - - def __init__(self): - self._csl = None - - def __call__(self, source, link_name): - if self._csl is None: - self._csl = ctypes.windll.kernel32.CreateSymbolicLinkW - self._csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) - self._csl.restype = ctypes.c_ubyte - flags = 0 - if source is not None and source.is_dir(): - flags = 1 - - if self._csl(str(link_name), str(source), flags) == 0: - raise ctypes.WinError() - - -@pytest.fixture(scope="module") -def symlink(): - return Symlink() - - -@pytest.fixture -def setup_links(tmp_path, symlink): - to_path = tmp_path / "linkto" - from_path = tmp_path / "linkfrom" - if salt.utils.platform.is_windows(): - kwargs = {} - else: - kwargs = {"target_is_directory": True} - if salt.utils.platform.is_windows(): - symlink(to_path, from_path, **kwargs) - else: - from_path.symlink_to(to_path, **kwargs) - return to_path, from_path - - -def test_clean_path_symlinked_src(setup_links): - to_path, from_path = setup_links - test_path = from_path / "test" - expect_path = str(to_path / "test") - ret = salt.utils.verify.clean_path(str(from_path), str(test_path)) - assert ret == expect_path, f"{ret} is not {expect_path}" - - -def test_clean_path_symlinked_tgt(setup_links): - to_path, from_path = setup_links - test_path = to_path / "test" - expect_path = str(to_path / "test") - ret = salt.utils.verify.clean_path(str(from_path), str(test_path)) - assert ret == expect_path, f"{ret} is not {expect_path}" - - -def test_clean_path_symlinked_src_unresolved(setup_links): - to_path, from_path = setup_links - test_path = from_path / "test" - expect_path = str(from_path / "test") - ret = salt.utils.verify.clean_path(str(from_path), str(test_path), realpath=False) - assert ret == expect_path, f"{ret} is not {expect_path}"
tests/pytests/unit/utils/verify/test_clean_path.py+92 −0 modified@@ -2,10 +2,81 @@ salt.utils.clean_path works as expected """ +import ctypes +import os + +import pytest + import salt.utils.verify from tests.support.mock import patch +class Symlink: + """ + symlink(source, link_name) Creates a symbolic link pointing to source named + link_name + """ + + def __init__(self): + self._csl = None + + def __call__(self, source, link_name): + if self._csl is None: + self._csl = ctypes.windll.kernel32.CreateSymbolicLinkW + self._csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + self._csl.restype = ctypes.c_ubyte + flags = 0 + if source is not None and source.is_dir(): + flags = 1 + + if self._csl(str(link_name), str(source), flags) == 0: + raise ctypes.WinError() + + +@pytest.fixture(scope="module") +def symlink(): + return Symlink() + + +@pytest.fixture +def setup_links(tmp_path, symlink): + to_path = tmp_path / "linkto" + from_path = tmp_path / "linkfrom" + if salt.utils.platform.is_windows(): + kwargs = {} + else: + kwargs = {"target_is_directory": True} + if salt.utils.platform.is_windows(): + symlink(to_path, from_path, **kwargs) + else: + from_path.symlink_to(to_path, **kwargs) + return to_path, from_path + + +def test_clean_path_symlinked_src(setup_links): + to_path, from_path = setup_links + test_path = from_path / "test" + expect_path = str(to_path / "test") + ret = salt.utils.verify.clean_path(str(from_path), str(test_path)) + assert ret == expect_path, f"{ret} is not {expect_path}" + + +def test_clean_path_symlinked_tgt(setup_links): + to_path, from_path = setup_links + test_path = to_path / "test" + expect_path = str(to_path / "test") + ret = salt.utils.verify.clean_path(str(from_path), str(test_path)) + assert ret == expect_path, f"{ret} is not {expect_path}" + + +def test_clean_path_symlinked_src_unresolved(setup_links): + to_path, from_path = setup_links + test_path = from_path / "test" + expect_path = str(from_path / "test") + ret = salt.utils.verify.clean_path(str(from_path), str(test_path), realpath=False) + assert ret == expect_path, f"{ret} is not {expect_path}" + + def test_clean_path_valid(tmp_path): path_a = str(tmp_path / "foo") path_b = str(tmp_path / "foo" / "bar") @@ -23,3 +94,24 @@ def test_clean_path_relative_root(tmp_path): path_a = "foo" path_b = str(tmp_path / "foo" / "bar") assert salt.utils.verify.clean_path(path_a, path_b) == path_b + + +def test_clean_traverse_in_path_a(tmp_path): + path_a = str(tmp_path) + path_b = str(tmp_path / "foo" / ".." / "bar") + assert salt.utils.verify.clean_path(path_a, path_b) == os.path.normpath(path_b) + + +def test_clean_traverse_in_path_b(tmp_path): + path_a = str(tmp_path) + path_b = str(tmp_path / "foo.foo/../bar") + assert salt.utils.verify.clean_path(path_a, path_b) == os.path.normpath(path_b) + + +def test_clean_traverse_in_path_c(tmp_path): + path_a = str(tmp_path) + path_b = str(tmp_path / "foo/../bar/bang") + assert salt.utils.verify.clean_path(path_a, path_b) == "" + assert salt.utils.verify.clean_path( + path_a, path_b, subdir=True + ) == os.path.normpath(path_b)
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-r546-h3ff-q585ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-22238ghsaADVISORY
- 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/4b30218edf1a979855ea191d72b30c89f4a5a582ghsaWEB
News mentions
0No linked articles in our index yet.