Skip to content

Confine catar extraction to the target root (symlink-safe) - #336

Merged
folbricht merged 2 commits into
masterfrom
fix/symlink-extraction-escape
May 16, 2026
Merged

folbricht merged 2 commits into
masterfrom
fix/symlink-extraction-escape

Conversation

@folbricht

Copy link
Copy Markdown
Owner

Problem

LocalFS computed every destination as filepath.Join(fs.Root, n.Name) and wrote via raw os.OpenFile/os.RemoveAll/os.Symlink/os.Chown/syscall.Chmod/syscall.Mknod with no symlink protection (no O_NOFOLLOW, no openat2/RESOLVE_BENEATH, no confinement check).

An attacker-controlled catar could:

  1. Create a symlink entry evil -> /etc (target written verbatim).
  2. Add a later entry named evil/passwd — a clean name with no .., so lexical filtering does not catch it. The kernel resolves evil through the planted symlink and the write/remove/chown/chmod lands on /etc/passwd, outside the extraction root.

Running as root, this also lets the archive set arbitrary ownership/mode on the escaped target.

Fix

  • LocalFS routed through os.Root (Go 1.25): a lazily-opened, cached root handle anchors all write/metadata operations to the extraction root. os.Root refuses to traverse any path component or symlink that escapes the root, while still following benign in-root relative symlinks — neutralizing the symlink-follow class regardless of the entry name.
  • Symlink targets kept verbatim (legitimate; matches GNU tar / casync). Safety comes from never following an escaping link, not from rejecting the target.
  • CreateDevice uses unix.Mknodat against a confined parent-directory fd (os.Root has no Mknod); xattrs applied via fd-based xattr.FSet. golang.org/x/sys promoted to a direct dependency.
  • Decoder hardening (defense-in-depth): ArchiveDecoder rejects FormatFilename names that are empty, ., .., absolute, or contain a path separator, and asserts each cumulative entry path stays within root. This also stops TarWriter from forwarding poisoned names into produced tar archives.
  • cmd/desync untar closes the writer handle after extraction.

Tests

New adversarial tests: reported symlink-write escape (out-of-root file left untouched), lexical .. escape, verbatim absolute symlink target with following refused, decoder rejection of embedded-slash names, and a regression guard for benign in-root relative symlinks.

go test ./..., go vet ./..., and GOOS=windows go build ./cmd/desync all pass with no regressions.

folbricht added 2 commits May 16, 2026 17:48
LocalFS computed every destination as filepath.Join(fs.Root, n.Name) and
wrote via raw os.OpenFile/RemoveAll/Symlink/Chown/Chmod/Mknod with no
symlink protection. An archive could plant a symlink (e.g. evil -> /etc)
and then add a later entry with a clean name (evil/passwd) whose write,
remove, chown or chmod would be resolved by the kernel through the planted
symlink, escaping the extraction root. This worked even without ".." in
names, so lexical filtering alone was insufficient.

Route all LocalFS write/metadata operations through a lazily-opened,
cached os.Root handle anchored to the extraction root. os.Root refuses to
traverse any path component or symlink that escapes the root while still
following benign in-root relative symlinks, neutralizing the symlink-
follow class regardless of the entry name. Symlink targets are kept
verbatim (legitimate, matches GNU tar/casync); safety comes from never
following an escaping link, not from rejecting the target.

os.Root has no Mknod, so CreateDevice creates the node with unix.Mknodat
against a confined parent-directory fd. xattrs are applied via fd-based
xattr.FSet on a root-opened handle (no symlink follow). golang.org/x/sys
is promoted to a direct dependency.

As defense-in-depth, ArchiveDecoder now rejects FormatFilename names that
are empty, ".", "..", absolute, or contain a path separator, and asserts
each cumulative entry path stays within the root. This also stops
TarWriter from forwarding poisoned names into produced tar archives.

Add adversarial tests covering the reported exploit, lexical escape,
verbatim absolute symlink targets, decoder rejection, and a regression
guard for benign in-root relative symlinks.
unix.Mknodat does not exist on Darwin (macOS has no mknodat(2)
syscall), breaking the macOS CI build. Move the node-creation
primitive into a per-platform createDeviceNode:

- linux: unix.Mknodat against a confined parent-directory fd
  obtained through os.Root (strong, race-free).
- other Unix (darwin/bsd): validate the parent resolves within
  the root via os.Root, then path-based unix.Mknod on the
  resolved real path. Extraction is single-threaded, so no
  concurrent attacker can swap a component after the check.

The rest of CreateDevice (Remove/Chown/Chmod/Chtimes/xattr) stays
in localfs_other.go and continues to go through os.Root on all
non-Windows platforms.
@folbricht
folbricht marked this pull request as ready for review May 16, 2026 16:13
@folbricht
folbricht merged commit d59b3c9 into master May 16, 2026
6 checks passed
@folbricht
folbricht deleted the fix/symlink-extraction-escape branch May 16, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

⌂ 换网址