VYPR
Medium severity6.3NVD Advisory· Published Jun 1, 2026· Updated Jun 1, 2026

rattler has an entry-point path traversal in noarch:python install (arbitrary file write)

CVE-2026-47425

Description

EntryPoint::FromStr in rattler_conda_types only trims whitespace, allowing path traversal via noarch:python package info/link.json to write files outside the install prefix.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

EntryPoint::FromStr in rattler_conda_types only trims whitespace, allowing path traversal via noarch:python package info/link.json to write files outside the install prefix.

Vulnerability

EntryPoint::FromStr in rattler_conda_types (affecting all versions prior to rattler 0.43.2) performs only .trim() on the command field before the linker joins it onto the install prefix and writes an executable Python script [2]. A malicious noarch:python package can ship an info/link.json with an entry-point name containing .., /, \, or an absolute path, causing the resulting file to be placed outside the prefix or clobber existing in-prefix entry-points such as bin/pip [1]. This affects consumers such as pixi install (pixi <0.69.0), rattler-build (<0.65.0), and any other user of the rattler install crate [2].

Exploitation

An attacker needs to supply a crafted noarch:python package with a malicious info/link.json file. No special network position or authentication is required beyond the ability to install such a package (e.g., via a malicious channel or dependency confusion). The EntryPoint parser only trims whitespace from the command field; it does not validate against path traversal sequences (.., /, \, or absolute paths). On installation, the linker concatenates the prefix path with the command value and writes an executable script at that location [1]. Additionally, the module and function fields are interpolated verbatim, providing a secondary code-injection surface [1].

Impact

A successful attack allows arbitrary file write outside the intended install prefix, with mode 0o775 on Unix (and a launcher .exe on Windows) [1]. The attacker can overwrite existing entry-point scripts like bin/pip, potentially achieving remote code execution or privilege escalation when the overwritten script is executed [2]. The scope is the entire install environment, and no user interaction beyond package installation is required.

Mitigation

The fix is implemented in pull request [1] and released in rattler 0.43.2. Downstream consumers must update: pixi to version 0.69.0 or later, rattler-build to 0.65.0 or later [2]. The patch adds validation at two layers: EntryPoint::FromStr now rejects path components (.., /, \, NUL, etc.) and absolute paths, and the install crate introduces a ValidatedRelativePath newtype that ensures resolved paths remain under the install prefix before any filesystem write occurs [1][2]. No workaround is available for unpatched versions.

AI Insight generated on Jun 1, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
4f06eca89aa1

fix: reject path traversal in python entrypoints (#2445)

https://github.com/conda/rattlerBas ZalmstraMay 19, 2026via body-scan-shorthand
3 files changed · +356 49
  • crates/rattler_conda_types/src/package/entry_point.rs+181 7 modified
    @@ -2,7 +2,117 @@
     
     use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
     use std::fmt::Display;
    +use std::path::Path;
     use std::str::FromStr;
    +use thiserror::Error;
    +
    +/// Which sub-field of an entry-point string is being validated.
    +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
    +pub enum EntryPointDottedField {
    +    /// The module name (left of `:`).
    +    Module,
    +    /// The function name (right of `:`).
    +    Function,
    +}
    +
    +impl Display for EntryPointDottedField {
    +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    +        match self {
    +            EntryPointDottedField::Module => f.write_str("module"),
    +            EntryPointDottedField::Function => f.write_str("function"),
    +        }
    +    }
    +}
    +
    +/// Errors returned by [`EntryPoint::from_str`].
    +#[derive(Debug, Clone, PartialEq, Eq, Error)]
    +pub enum ParseEntryPointError {
    +    /// Missing `=` between command and module/function.
    +    #[error("missing entry point separator '='")]
    +    MissingCommandSeparator,
    +
    +    /// Missing `:` between module and function.
    +    #[error("missing module and function separator ':'")]
    +    MissingFunctionSeparator,
    +
    +    /// Command was empty after trimming.
    +    #[error("entry point command must be non-empty")]
    +    EmptyCommand,
    +
    +    /// Command contains `/`, `\`, or NUL — would let the linker write
    +    /// outside the prefix.
    +    #[error("entry point command must not contain path separators or NUL: {0:?}")]
    +    CommandContainsPathSeparator(String),
    +
    +    /// Command is `.`, `..`, or starts with `.`.
    +    #[error("entry point command must not be a relative-traversal token or hidden name: {0:?}")]
    +    CommandIsTraversal(String),
    +
    +    /// Command is an absolute path — would discard the prefix on join.
    +    #[error("entry point command must be a relative simple name: {0:?}")]
    +    CommandIsAbsolute(String),
    +
    +    /// Module or function was empty after trimming.
    +    #[error("entry point {0} must be non-empty")]
    +    EmptyDottedName(EntryPointDottedField),
    +
    +    /// Module or function is not a valid Python dotted identifier.
    +    /// Rejected at parse time because these are interpolated verbatim
    +    /// into the generated script body.
    +    #[error("entry point {field} must be a Python dotted identifier: {name:?}")]
    +    InvalidDottedName {
    +        /// Which field failed validation.
    +        field: EntryPointDottedField,
    +        /// The offending value.
    +        name: String,
    +    },
    +}
    +
    +/// Rejects command names that would let the linker write outside the
    +/// prefix when joined onto `bin_dir`.
    +fn validate_entry_point_command(cmd: &str) -> Result<(), ParseEntryPointError> {
    +    if cmd.is_empty() {
    +        return Err(ParseEntryPointError::EmptyCommand);
    +    }
    +    if cmd.chars().any(|c| matches!(c, '/' | '\\' | '\0')) {
    +        return Err(ParseEntryPointError::CommandContainsPathSeparator(
    +            cmd.to_string(),
    +        ));
    +    }
    +    if cmd == "." || cmd == ".." || cmd.starts_with('.') {
    +        return Err(ParseEntryPointError::CommandIsTraversal(cmd.to_string()));
    +    }
    +    if Path::new(cmd).is_absolute() {
    +        return Err(ParseEntryPointError::CommandIsAbsolute(cmd.to_string()));
    +    }
    +    Ok(())
    +}
    +
    +/// Restricts `module` / `function` to Python dotted identifiers,
    +/// preventing code injection through the script-body interpolation.
    +fn validate_python_dotted_name(
    +    name: &str,
    +    field: EntryPointDottedField,
    +) -> Result<(), ParseEntryPointError> {
    +    if name.is_empty() {
    +        return Err(ParseEntryPointError::EmptyDottedName(field));
    +    }
    +    let is_valid_part = |part: &str| -> bool {
    +        let mut chars = part.chars();
    +        match chars.next() {
    +            Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
    +            _ => return false,
    +        }
    +        chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
    +    };
    +    if !name.split('.').all(is_valid_part) {
    +        return Err(ParseEntryPointError::InvalidDottedName {
    +            field,
    +            name: name.to_string(),
    +        });
    +    }
    +    Ok(())
    +}
     
     /// A struct for a single Python entry point. An entry point is a command that
     /// runs a function in a Python module. For example, the entry point
    @@ -31,19 +141,28 @@ pub struct EntryPoint {
     }
     
     impl FromStr for EntryPoint {
    -    type Err = String;
    +    type Err = ParseEntryPointError;
     
         fn from_str(s: &str) -> Result<Self, Self::Err> {
    -        let (command, module_and_function) =
    -            s.split_once('=').ok_or("missing entry point separator")?;
    +        let (command, module_and_function) = s
    +            .split_once('=')
    +            .ok_or(ParseEntryPointError::MissingCommandSeparator)?;
             let (module, function) = module_and_function
                 .split_once(':')
    -            .ok_or("missing module and function separator")?;
    +            .ok_or(ParseEntryPointError::MissingFunctionSeparator)?;
    +
    +        let command = command.trim().to_string();
    +        let module = module.trim().to_string();
    +        let function = function.trim().to_string();
    +
    +        validate_entry_point_command(&command)?;
    +        validate_python_dotted_name(&module, EntryPointDottedField::Module)?;
    +        validate_python_dotted_name(&function, EntryPointDottedField::Function)?;
     
             Ok(EntryPoint {
    -            command: command.trim().to_string(),
    -            module: module.trim().to_string(),
    -            function: function.trim().to_string(),
    +            command,
    +            module,
    +            function,
             })
         }
     }
    @@ -92,4 +211,59 @@ mod test {
     
             insta::assert_yaml_snapshot!(entry_point);
         }
    +
    +    #[test]
    +    fn test_entry_point_rejects_path_traversal_in_command() {
    +        let cases = [
    +            "../bin/pip = innocuous_pkg.evil:main",
    +            "../../../etc/passwd = innocuous_pkg.evil:main",
    +            "/tmp/PWN = innocuous_pkg.evil:main",
    +            "..\\..\\..\\AppData\\Roaming\\evil = innocuous_pkg.evil:main",
    +            "foo/bar = innocuous_pkg.evil:main",
    +            "foo\\bar = innocuous_pkg.evil:main",
    +            ".hidden = innocuous_pkg.evil:main",
    +            ".. = innocuous_pkg.evil:main",
    +            ". = innocuous_pkg.evil:main",
    +            "\0 = innocuous_pkg.evil:main",
    +            " = innocuous_pkg.evil:main",
    +        ];
    +        for case in cases {
    +            assert!(
    +                EntryPoint::from_str(case).is_err(),
    +                "expected rejection for entry point: {case:?}",
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn test_entry_point_rejects_python_code_injection() {
    +        let cases = [
    +            "jlpm = jupyterlab.jlpmapp:main(); __import__('os').system('x')",
    +            "jlpm = jupyterlab.jlpmapp; __import__('os').system('x'):main",
    +            "jlpm = ../evil:main",
    +            "jlpm = jupyterlab.jlpmapp:1main",
    +        ];
    +        for case in cases {
    +            assert!(
    +                EntryPoint::from_str(case).is_err(),
    +                "expected rejection for entry point: {case:?}",
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn test_entry_point_accepts_legitimate_names() {
    +        for s in [
    +            "jlpm = jupyterlab.jlpmapp:main",
    +            "jupyter-lab = jupyterlab.labapp:main",
    +            "pip3.11 = pip._internal.cli.main:main",
    +            "_private = pkg.mod:func",
    +            "tool = pkg:Class.method",
    +        ] {
    +            assert!(
    +                EntryPoint::from_str(s).is_ok(),
    +                "expected acceptance for entry point: {s:?}",
    +            );
    +        }
    +    }
     }
    
  • crates/rattler_conda_types/src/package/mod.rs+1 1 modified
    @@ -20,7 +20,7 @@ pub use {
         about::AboutJson,
         archive_identifier::{ArchiveIdentifier, CondaArchiveIdentifier, DistArchiveIdentifier},
         archive_type::{CondaArchiveType, DistArchiveType, WheelArchiveType},
    -    entry_point::EntryPoint,
    +    entry_point::{EntryPoint, EntryPointDottedField, ParseEntryPointError},
         files::Files,
         has_prefix::HasPrefix,
         has_prefix::HasPrefixEntry,
    
  • crates/rattler/src/install/entry_point.rs+174 41 modified
    @@ -7,10 +7,77 @@ use rattler_conda_types::{
     };
     use rattler_digest::HashingWriter;
     use rattler_digest::Sha256;
    +use std::path::{Component, PathBuf};
     use std::{fs::File, io, io::Write, path::Path};
     
     use super::Prefix;
     
    +/// Relative path proven to stay inside the install prefix when joined
    +/// to it. Only constructible via [`ensure_entry_point_relative_path`];
    +/// the write helpers in this module require it, so the compiler rejects
    +/// any path that hasn't been validated.
    +#[derive(Debug, Clone)]
    +pub(crate) struct ValidatedRelativePath(PathBuf);
    +
    +impl ValidatedRelativePath {
    +    pub(crate) fn into_path_buf(self) -> PathBuf {
    +        self.0
    +    }
    +
    +    fn absolute_under(&self, prefix: &Path) -> PathBuf {
    +        prefix.join(&self.0)
    +    }
    +}
    +
    +/// Defence-in-depth check for entry-point paths constructed outside the
    +/// parser. `Path::join` keeps `..` literal and is replaced entirely by
    +/// an absolute RHS, so we normalize components manually and verify the
    +/// result lands under `prefix`.
    +fn ensure_entry_point_relative_path(
    +    relative_path: &Path,
    +    prefix: &Path,
    +) -> Result<ValidatedRelativePath, io::Error> {
    +    if relative_path.is_absolute() {
    +        return Err(io::Error::new(
    +            io::ErrorKind::InvalidInput,
    +            format!("entry point path is absolute: {relative_path:?}"),
    +        ));
    +    }
    +
    +    let mut normalized = PathBuf::new();
    +    for component in relative_path.components() {
    +        match component {
    +            Component::Prefix(_) | Component::RootDir => {
    +                return Err(io::Error::new(
    +                    io::ErrorKind::InvalidInput,
    +                    format!("entry point path contains a root or drive: {relative_path:?}"),
    +                ));
    +            }
    +            Component::ParentDir => {
    +                if !normalized.pop() {
    +                    return Err(io::Error::new(
    +                        io::ErrorKind::InvalidInput,
    +                        format!("entry point path escapes the prefix: {relative_path:?}"),
    +                    ));
    +                }
    +            }
    +            Component::CurDir => {}
    +            Component::Normal(part) => normalized.push(part),
    +        }
    +    }
    +
    +    // Redundant after the component walk, but makes the invariant
    +    // explicit and survives future changes to the normalization.
    +    if !prefix.join(&normalized).starts_with(prefix) {
    +        return Err(io::Error::new(
    +            io::ErrorKind::InvalidInput,
    +            format!("entry point path escapes the prefix: {relative_path:?}"),
    +        ));
    +    }
    +
    +    Ok(ValidatedRelativePath(normalized))
    +}
    +
     /// Get the bytes of the windows launcher executable.
     pub fn get_windows_launcher(platform: &Platform) -> &'static [u8] {
         match platform {
    @@ -42,31 +109,31 @@ pub fn create_windows_python_entry_point(
         python_info: &PythonInfo,
         target_platform: &Platform,
     ) -> Result<[PathsEntry; 2], std::io::Error> {
    -    // Construct the path to where we will be creating the python entry point script.
    -    let relative_path_script_py = python_info
    -        .bin_dir
    -        .join(format!("{}-script.py", &entry_point.command));
    -
    -    // Write the contents of the launcher script to disk
    -    let script_path = target_dir.path().join(&relative_path_script_py);
    -    std::fs::create_dir_all(
    -        script_path
    -            .parent()
    -            .expect("since we joined with target_dir there must be a parent"),
    +    let relative_path_script_py = ensure_entry_point_relative_path(
    +        &python_info
    +            .bin_dir
    +            .join(format!("{}-script.py", &entry_point.command)),
    +        target_dir.path(),
         )?;
    +    let relative_path_script_exe = ensure_entry_point_relative_path(
    +        &python_info
    +            .bin_dir
    +            .join(format!("{}.exe", &entry_point.command)),
    +        target_dir.path(),
    +    )?;
    +
         let script_contents =
             python_entry_point_template(target_prefix, true, entry_point, python_info);
    -    let (hash, size) = write_and_hash(&script_path, script_contents)?;
    -
    -    // Construct a path to where we will create the python launcher executable.
    -    let relative_path_script_exe = python_info
    -        .bin_dir
    -        .join(format!("{}.exe", &entry_point.command));
    +    let (hash, size) = write_validated_entry_point_file(
    +        target_dir.path(),
    +        &relative_path_script_py,
    +        script_contents,
    +    )?;
     
    -    // Include the bytes of the launcher directly in the binary so we can write it to disk.
         let launcher_bytes = get_windows_launcher(target_platform);
    -    std::fs::write(
    -        target_dir.path().join(&relative_path_script_exe),
    +    write_validated_entry_point_bytes(
    +        target_dir.path(),
    +        &relative_path_script_exe,
             launcher_bytes,
         )?;
     
    @@ -77,7 +144,7 @@ pub fn create_windows_python_entry_point(
     
         Ok([
             PathsEntry {
    -            relative_path: relative_path_script_py,
    +            relative_path: relative_path_script_py.into_path_buf(),
                 // todo: clobbering of entry points not handled yet
                 original_path: None,
                 path_type: PathType::WindowsPythonEntryPointScript,
    @@ -89,7 +156,7 @@ pub fn create_windows_python_entry_point(
                 file_mode: None,
             },
             PathsEntry {
    -            relative_path: relative_path_script_exe,
    +            relative_path: relative_path_script_exe.into_path_buf(),
                 original_path: None,
                 path_type: PathType::WindowsPythonEntryPointExe,
                 no_link: false,
    @@ -115,29 +182,21 @@ pub fn create_unix_python_entry_point(
         entry_point: &EntryPoint,
         python_info: &PythonInfo,
     ) -> Result<PathsEntry, std::io::Error> {
    -    // Construct the path to where we will be creating the python entry point script.
    -    let relative_path = python_info.bin_dir.join(&entry_point.command);
    -
    -    // Write the contents of the launcher script to disk
    -    let script_path = target_dir.path().join(&relative_path);
    -    std::fs::create_dir_all(
    -        script_path
    -            .parent()
    -            .expect("since we joined with target_dir there must be a parent"),
    +    let relative_path = ensure_entry_point_relative_path(
    +        &python_info.bin_dir.join(&entry_point.command),
    +        target_dir.path(),
         )?;
    +
         let script_contents =
             python_entry_point_template(target_prefix, false, entry_point, python_info);
    -    let (hash, size) = write_and_hash(&script_path, script_contents)?;
    +    let (hash, size) =
    +        write_validated_entry_point_file(target_dir.path(), &relative_path, script_contents)?;
     
    -    // Make the script executable. This is only supported on Unix based filesystems.
         #[cfg(unix)]
    -    std::fs::set_permissions(
    -        script_path,
    -        std::os::unix::fs::PermissionsExt::from_mode(0o775),
    -    )?;
    +    set_validated_entry_point_executable(target_dir.path(), &relative_path)?;
     
         Ok(PathsEntry {
    -        relative_path,
    +        relative_path: relative_path.into_path_buf(),
             // todo: clobbering of entry points not handled yet
             original_path: None,
             path_type: PathType::UnixPythonEntryPoint,
    @@ -188,22 +247,96 @@ pub fn python_entry_point_template(
         )
     }
     
    -/// Writes the given bytes to a file and records the hash, as well as the size of the file.
    -fn write_and_hash(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<(Output<Sha256>, usize)> {
    +/// Writes `contents` to `<prefix>/<relative_path>` and returns its hash
    +/// and size.
    +fn write_validated_entry_point_file(
    +    prefix: &Path,
    +    relative_path: &ValidatedRelativePath,
    +    contents: impl AsRef<[u8]>,
    +) -> io::Result<(Output<Sha256>, usize)> {
    +    let absolute = relative_path.absolute_under(prefix);
    +    if let Some(parent) = absolute.parent() {
    +        std::fs::create_dir_all(parent)?;
    +    }
         let bytes = contents.as_ref();
    -    let mut writer = HashingWriter::<_, Sha256>::new(File::create(path)?);
    +    let mut writer = HashingWriter::<_, Sha256>::new(File::create(&absolute)?);
         writer.write_all(bytes)?;
         let (_, hash) = writer.finalize();
         Ok((hash, bytes.len()))
     }
     
    +/// Writes raw bytes to `<prefix>/<relative_path>` (Windows launcher).
    +fn write_validated_entry_point_bytes(
    +    prefix: &Path,
    +    relative_path: &ValidatedRelativePath,
    +    bytes: &[u8],
    +) -> io::Result<()> {
    +    let absolute = relative_path.absolute_under(prefix);
    +    if let Some(parent) = absolute.parent() {
    +        std::fs::create_dir_all(parent)?;
    +    }
    +    std::fs::write(absolute, bytes)
    +}
    +
    +/// Sets the executable bit on `<prefix>/<relative_path>`.
    +#[cfg(unix)]
    +fn set_validated_entry_point_executable(
    +    prefix: &Path,
    +    relative_path: &ValidatedRelativePath,
    +) -> io::Result<()> {
    +    use std::os::unix::fs::PermissionsExt;
    +    std::fs::set_permissions(
    +        relative_path.absolute_under(prefix),
    +        std::fs::Permissions::from_mode(0o775),
    +    )
    +}
    +
     #[cfg(test)]
     mod test {
    +    use super::ensure_entry_point_relative_path;
         use crate::install::PythonInfo;
         use rattler_conda_types::package::EntryPoint;
         use rattler_conda_types::{Platform, Version};
    +    use std::path::{Path, PathBuf};
         use std::str::FromStr;
     
    +    #[test]
    +    fn test_ensure_entry_point_relative_path_accepts_simple_name() {
    +        let prefix = Path::new("/opt/conda");
    +        let resolved = ensure_entry_point_relative_path(&Path::new("bin").join("pip"), prefix)
    +            .unwrap()
    +            .into_path_buf();
    +        assert_eq!(resolved, PathBuf::from("bin").join("pip"));
    +    }
    +
    +    #[test]
    +    fn test_ensure_entry_point_relative_path_rejects_escape_via_parent() {
    +        let prefix = Path::new("/opt/conda");
    +        assert!(
    +            ensure_entry_point_relative_path(Path::new("bin/../../etc/passwd"), prefix).is_err()
    +        );
    +        assert!(ensure_entry_point_relative_path(Path::new("../etc/passwd"), prefix).is_err());
    +        assert!(ensure_entry_point_relative_path(Path::new(".."), prefix).is_err());
    +    }
    +
    +    #[test]
    +    fn test_ensure_entry_point_relative_path_rejects_absolute() {
    +        let prefix = Path::new("/opt/conda");
    +        assert!(ensure_entry_point_relative_path(Path::new("/tmp/PWN"), prefix).is_err());
    +    }
    +
    +    #[test]
    +    fn test_ensure_entry_point_relative_path_normalizes_in_prefix_traversal() {
    +        // The defence-in-depth layer allows `..` as long as the final
    +        // path lands under the prefix; the parser is what rejects it
    +        // on the user-facing path.
    +        let prefix = Path::new("/opt/conda");
    +        let resolved = ensure_entry_point_relative_path(Path::new("bin/../bin/pip"), prefix)
    +            .unwrap()
    +            .into_path_buf();
    +        assert_eq!(resolved, PathBuf::from("bin").join("pip"));
    +    }
    +
         #[test]
         fn test_entry_point_script() {
             let script = super::python_entry_point_template(
    

Vulnerability mechanics

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

3

News mentions

0

No linked articles in our index yet.