Skip to content

Commit 17b1fd8

Browse files
archive: Prevent symlink-directory collision chmod attack (#442)
When unpacking a tarball containing a symlink followed by a directory entry with the same path, unpack_dir previously used fs::metadata() which follows symlinks. This allowed an attacker to modify permissions on arbitrary directories outside the extraction path. The fix uses fs::symlink_metadata() to detect symlinks and refuse to treat them as valid existing directories. Document more exhaustively+consistently security caveats. Reported-by: Sergei Zimmerman <https://github.com/xokdvium> Assisted-by: OpenCode (Claude claude-opus-4-5) Signed-off-by: Colin Walters <walters@verbum.org> Co-authored-by: Colin Walters <walters@verbum.org>
1 parent de1a587 commit 17b1fd8

File tree

3 files changed

+75
-6
lines changed

3 files changed

+75
-6
lines changed

src/archive.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,21 @@ impl<R: Read> Archive<R> {
9393
/// extracting each file in turn to the location specified by the entry's
9494
/// path name.
9595
///
96-
/// This operation is relatively sensitive in that it will not write files
97-
/// outside of the path specified by `dst`. Files in the archive which have
98-
/// a '..' in their path are skipped during the unpacking process.
96+
/// # Security
97+
///
98+
/// A best-effort is made to prevent writing files outside `dst` (paths
99+
/// containing `..` are skipped, symlinks are validated). However, there
100+
/// have been historical bugs in this area, and more may exist. For this
101+
/// reason, when processing untrusted archives, stronger sandboxing is
102+
/// encouraged: e.g. the [`cap-std`] crate and/or OS-level
103+
/// containerization/virtualization.
104+
///
105+
/// If `dst` does not exist, it is created. Unpacking into an existing
106+
/// directory merges content. This function assumes `dst` is not
107+
/// concurrently modified by untrusted processes. Protecting against
108+
/// TOCTOU races is out of scope for this crate.
109+
///
110+
/// [`cap-std`]: https://docs.rs/cap-std/
99111
///
100112
/// # Examples
101113
///

src/entry.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,9 @@ impl<'a, R: Read> Entry<'a, R> {
212212
/// also be propagated to the path `dst`. Any existing file at the location
213213
/// `dst` will be overwritten.
214214
///
215-
/// This function carefully avoids writing outside of `dst`. If the file has
216-
/// a '..' in its path, this function will skip it and return false.
215+
/// # Security
216+
///
217+
/// See [`Archive::unpack`].
217218
///
218219
/// # Examples
219220
///
@@ -446,7 +447,7 @@ impl<'a> EntryFields<'a> {
446447
// If the directory already exists just let it slide
447448
fs::create_dir(dst).or_else(|err| {
448449
if err.kind() == ErrorKind::AlreadyExists {
449-
let prev = fs::metadata(dst);
450+
let prev = fs::symlink_metadata(dst);
450451
if prev.map(|m| m.is_dir()).unwrap_or(false) {
451452
return Ok(());
452453
}

tests/entry.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,59 @@ fn modify_symlink_just_created() {
408408
.unwrap();
409409
assert_eq!(contents.len(), 0);
410410
}
411+
412+
/// Test that unpacking a tarball with a symlink followed by a directory entry
413+
/// with the same name does not allow modifying permissions of arbitrary directories
414+
/// outside the extraction path.
415+
#[test]
416+
#[cfg(unix)]
417+
fn symlink_dir_collision_does_not_modify_external_dir_permissions() {
418+
use ::std::fs;
419+
use ::std::os::unix::fs::PermissionsExt;
420+
421+
let td = Builder::new().prefix("tar").tempdir().unwrap();
422+
423+
let target_dir = td.path().join("target-dir");
424+
fs::create_dir(&target_dir).unwrap();
425+
fs::set_permissions(&target_dir, fs::Permissions::from_mode(0o700)).unwrap();
426+
let before_mode = fs::metadata(&target_dir).unwrap().permissions().mode() & 0o7777;
427+
assert_eq!(before_mode, 0o700);
428+
429+
let extract_dir = td.path().join("extract-dir");
430+
fs::create_dir(&extract_dir).unwrap();
431+
432+
let mut ar = tar::Builder::new(Vec::new());
433+
434+
let mut header = tar::Header::new_gnu();
435+
header.set_size(0);
436+
header.set_entry_type(tar::EntryType::Symlink);
437+
header.set_path("foo").unwrap();
438+
header.set_link_name(&target_dir).unwrap();
439+
header.set_mode(0o777);
440+
header.set_cksum();
441+
ar.append(&header, &[][..]).unwrap();
442+
443+
let mut header = tar::Header::new_gnu();
444+
header.set_size(0);
445+
header.set_entry_type(tar::EntryType::Directory);
446+
header.set_path("foo").unwrap();
447+
header.set_mode(0o777);
448+
header.set_cksum();
449+
ar.append(&header, &[][..]).unwrap();
450+
451+
let bytes = ar.into_inner().unwrap();
452+
let mut ar = tar::Archive::new(&bytes[..]);
453+
454+
let result = ar.unpack(&extract_dir);
455+
assert!(result.is_err());
456+
457+
let symlink_path = extract_dir.join("foo");
458+
assert!(symlink_path
459+
.symlink_metadata()
460+
.unwrap()
461+
.file_type()
462+
.is_symlink());
463+
464+
let after_mode = fs::metadata(&target_dir).unwrap().permissions().mode() & 0o7777;
465+
assert_eq!(after_mode, 0o700);
466+
}

0 commit comments

Comments
 (0)