VYPR
Medium severity4.2GHSA Advisory· Published Jun 13, 2025· Updated Apr 15, 2026

CVE-2025-22238

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.

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

Affected products

1

Patches

1
4b30218edf1a

Prevent traversal in local_cache::save_minions

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

News mentions

0

No linked articles in our index yet.