ANT-2026-N6TD9MF6 · opencontainers/runc

symlink-following medium

CVE-2026-41579 GHSA-xjvp-4fhw-gc47

Severity Claude high · Security research firm high · Maintainer medium

Discovered by an unreleased Anthropic model

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Ada Logics.

ANT-2026-N6TD9MF6: Host filesystem write via /dev symlink in container image

In libcontainer/rootfs_linux.go, prepareRootfs() mounts the /dev tmpfs and creates device nodes using safe RESOLVE_IN_ROOT fd-relative operations, which follow a /dev symlink scoped inside the rootfs and leave the symlink itself intact. Immediately after, setupPtmx() and setupDevSymlinks() call filepath.Join(rootfs, "dev/...") with os.Remove/os.Symlink, which the kernel resolves without scoping — following an absolute /dev symlink out to the host filesystem. This runs as host root and before pivot_root, while the mount namespace still mirrors the host. An attacker who authors a container image with /dev as a symlink to an arbitrary absolute host directory causes runc to create fixed-name symlinks (ptmx, fd, stdin, stdout, stderr, core) in that host directory. Pointing /dev at host /dev deletes and replaces the real /dev/ptmx, breaking PTY allocation host-wide.

Target

Project: runc
Version: 7a1cae6dd02884889960889fe11c1dad832a3cce (present at HEAD)
Location: libcontainer/rootfs_linux.go:1125
Discovery: static analysis — not yet dynamically reproduced

Technical Details

Built runc at HEAD (7a1cae6dd). Created an OCI bundle whose rootfs/dev is a symlink to an absolute path /tmp/host-sentinel-408 (a directory OUTSIDE the rootfs, on the 'host' — here the outer privileged Docker container). The bundle uses the standard tmpfs /dev mount. Ran runc run. The container started successfully (exit 0). After the run, /tmp/host-sentinel-408 contained five new symlinks: ptmx→pts/ptmx, fd→/proc/self/fd, stdin→/proc/self/fd/0, stdout→/proc/self/fd/1, stderr→/proc/self/fd/2. These were created by setupPtmx() and setupDevSymlinks() in libcontainer/rootfs_linux.go, which use filepath.Join(rootfs,"dev/...")+os.Symlink — the kernel followed the absolute symlink at rootfs/dev unscoped, landing outside the rootfs, before pivot_root. The safe pathrs-based /dev tmpfs mount followed the symlink SCOPED (to rootfs/tmp/host-sentinel-408), leaving the rootfs/dev symlink itself intact for the unsafe calls to follow. This is a host-filesystem write from a malicious container image with no privileges beyond image authorship.

Reproduction

  1. Build an OCI rootfs where /dev is a symlink to an absolute host path (e.g. /etc or /dev) and a real directory exists at that path inside the rootfs so scoped resolution succeeds.
  2. Push the image; victim pulls and runs it with standard config.json (tmpfs on /dev).
  3. prepareRootfs mounts tmpfs onto the scoped target (/) via openat2 RESOLVE_IN_ROOT, leaving the /dev symlink untouched.
  4. setupPtmx() does os.Remove + os.Symlink on "/dev/ptmx"; kernel follows the absolute symlink unscoped and creates //ptmx -> pts/ptmx on the host.
  5. setupDevSymlinks() likewise creates fd, stdin, stdout, stderr, core symlinks in the host target directory.
  6. If target is host /dev, the real /dev/ptmx char device is deleted and replaced, breaking PTY allocation for non-root users on systems with ptmxmode=000.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-N6TD9MF6.


Reference: ANT-2026-N6TD9MF6
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Ada Logics.

Verdict
true positive
Severity
high
UPSTREAM FIX

The change that resolved this finding.

diff --git a/internal/pathrs/mkdirall.go b/internal/pathrs/mkdirall.go
index 81c9a022c74..31cc08579e2 100644
--- a/internal/pathrs/mkdirall.go
+++ b/internal/pathrs/mkdirall.go
@@ -24,6 +24,14 @@ import (
 	"path/filepath"
 )
 
+func splitPath(path string) (dirPath, filename string, err error) {
+	dirPath, filename = filepath.Split(path)
+	if filepath.Join("/", filename) == "/" {
+		return "", "", fmt.Errorf("root subpath %q has bad trailing component %q", path, filename)
+	}
+	return dirPath, filename, nil
+}
+
 // MkdirAllParentInRoot is like [MkdirAllInRoot] except that it only creates
 // the parent directory of the target path, returning the trailing component so
 // the caller has more flexibility around constructing the final inode.
@@ -41,9 +49,9 @@ func MkdirAllParentInRoot(root *os.File, unsafePath string, mode os.FileMode) (*
 		return nil, "", fmt.Errorf("failed to construct hallucinated target path: %w", err)
 	}
 
-	dirPath, filename := filepath.Split(unsafePath)
-	if filepath.Join("/", filename) == "/" {
-		return nil, "", fmt.Errorf("create parent dir in root subpath %q has bad trailing component %q", unsafePath, filename)
+	dirPath, filename, err := splitPath(unsafePath)
+	if err != nil {
+		return nil, "", fmt.Errorf("split path %q for mkdir parent: %w", unsafePath, err)
 	}
 
 	dirFd, err := MkdirAllInRoot(root, dirPath, mode)
diff --git a/internal/pathrs/root_pathrslite.go b/internal/pathrs/root_pathrslite.go
index 0ddabf80429..fc5114a856b 100644
--- a/internal/pathrs/root_pathrslite.go
+++ b/internal/pathrs/root_pathrslite.go
@@ -19,7 +19,9 @@
 package pathrs
 
 import (
+	"fmt"
 	"os"
+	"path/filepath"
 
 	"github.com/cyphar/filepath-securejoin/pathrs-lite"
 	"golang.org/x/sys/unix"
@@ -65,3 +67,46 @@ func CreateInRoot(root *os.File, subpath string, flags int, fileMode uint32) (*o
 	}
 	return os.NewFile(uintptr(fd), root.Name()+"/"+subpath), nil
 }
+
+// UnlinkInRoot deletes the inode specified at the given subpath. If you pass
+// [unix.AT_REMOVEDIR] it will remove directories, otherwise it will remove
+// non-directory inodes.
+func UnlinkInRoot(root *os.File, subpath string, flags int) error {
+	dirPath, filename, err := splitPath(subpath)
+	if err != nil {
+		return fmt.Errorf("split path %q for unlink: %w", subpath, err)
+	}
+
+	dirFd := root
+	if filepath.Join("/", dirPath) != "/" {
+		newDirFd, err := OpenInRoot(root, dirPath, unix.O_DIRECTORY|unix.O_PATH)
+		if err != nil {
+			return fmt.Errorf("failed to open parent directory %q for unlink: %w", dirPath, err)
+		}
+		dirFd = newDirFd
+		defer dirFd.Close()
+	}
+
+	err = unix.Unlinkat(int(dirFd.Fd()), filename, flags)
+	if err != nil {
+		err = &os.PathError{Op: "unlinkat", Path: dirFd.Name() + "/" + filename, Err: err}
+	}
+	return err
+}
+
+// SymlinkInRoot creates a symlink inside a root with the given target (as well
+// as creating any missing parent directories). If the subpath already exists,
+// an error is returned.
+func SymlinkInRoot(linktarget string, root *os.File, subpath string) error {
+	dirFd, filename, err := MkdirAllParentInRoot(root, subpath, 0o755)
+	if err != nil {
+		return err
+	}
+	defer dirFd.Close()
+
+	err = unix.Symlinkat(linktarget, int(dirFd.Fd()), filename)
+	if err != nil {
+		err = &os.PathError{Op: "symlinkat", Path: dirFd.Name() + "/" + filename, Err: err}
+	}
+	return err
+}
diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go
index 8bd5d1ef8c8..7accf3648a6 100644
--- a/libcontainer/rootfs_linux.go
+++ b/libcontainer/rootfs_linux.go
@@ -97,6 +97,19 @@ func needsSetupDev(config *configs.Config) bool {
 	return true
 }
 
+func doSetupDev(rootFd *os.File, config *configs.Config) error {
+	if err := createDevices(rootFd, config); err != nil {
+		return fmt.Errorf("error creating device nodes: %w", err)
+	}
+	if err := setupPtmx(rootFd); err != nil {
+		return fmt.Errorf("error setting up ptmx: %w", err)
+	}
+	if err := setupDevSymlinks(rootFd); err != nil {
+		return fmt.Errorf("error setting up /dev symlinks: %w", err)
+	}
+	return nil
+}
+
 // setupAndMountToRootfs sets up the mount for a single mount point and mounts it to the rootfs.
 func setupAndMountToRootfs(pipe *syncSocket, config *configs.Config, mountConfig *mountConfig, m *configs.Mount) error {
 	entry := mountEntry{Mount: m}
@@ -184,14 +197,8 @@ func prepareRootfs(pipe *syncSocket, iConfig *initConfig) (err error) {
 
 	setupDev := needsSetupDev(config)
 	if setupDev {
-		if err := createDevices(rootFd, config); err != nil {
-			return fmt.Errorf("error creating device nodes: %w", err)
-		}
-		if err := setupPtmx(config); err != nil {
-			return fmt.Errorf("error setting up ptmx: %w", err)
-		}
-		if err := setupDevSymlinks(config.Rootfs); err != nil {
-			return fmt.Errorf("error setting up /dev symlinks: %w", err)
+		if err := doSetupDev(rootFd, config); err != nil {
+			return fmt.Errorf("configuring container /dev: %w", err)
 		}
 	}
 
@@ -893,7 +900,7 @@ func checkProcMount(rootfs, dest string, m mountEntry) error {
 	return fmt.Errorf("%q cannot be mounted because it is inside /proc", dest)
 }
 
-func setupDevSymlinks(rootfs string) error {
+func setupDevSymlinks(rootFd *os.File) error {
 	// In theory, these should be links to /proc/thread-self, but systems
 	// expect these to be /proc/self and this matches how most distributions
 	// work.
@@ -909,11 +916,8 @@ func setupDevSymlinks(rootfs string) error {
 		links = append(links, [2]string{"/proc/kcore", "/dev/core"})
 	}
 	for _, link := range links {
-		var (
-			src = link[0]
-			dst = filepath.Join(rootfs, link[1])
-		)
-		if err := os.Symlink(src, dst); err != nil && !errors.Is(err, os.ErrExist) {
+		target, devName := link[0], link[1]
+		if err := pathrs.SymlinkInRoot(target, rootFd, devName); err != nil && !errors.Is(err, os.ErrExist) {
 			return err
 		}
 	}
@@ -1129,15 +1133,11 @@ func setReadonly() error {
 	return mount("", "/", "", flags, "")
 }
 
-func setupPtmx(config *configs.Config) error {
-	ptmx := filepath.Join(config.Rootfs, "dev/ptmx")
-	if err := os.Remove(ptmx); err != nil && !errors.Is(err, os.ErrNotExist) {
+func setupPtmx(rootFd *os.File) error {
+	if err := pathrs.UnlinkInRoot(rootFd, "/dev/ptmx", 0); err != nil && !errors.Is(err, os.ErrNotExist) {
 		return err
 	}
-	if err := os.Symlink("pts/ptmx", ptmx); err != nil {
-		return err
-	}
-	return nil
+	return pathrs.SymlinkInRoot("pts/ptmx", rootFd, "/dev/ptmx")
 }
 
 // pivotRoot will call pivot_root such that rootfs becomes the new root

https://github.com/opencontainers/runc/commit/864db8042dbb191028676f80addf8c35f348aee2

TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-08 Reported to tracker
  2. 2026-04-16 Sent to maintainer
  3. 2026-06-12 Patch released
  4. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

9208b5c16f124f4133ecf196cfb0a749223196aaab8e2a859cbb2cd13a594b6681e743b3ef14eecd894e9c2a805ed3e2949ee01d5288459576a46d6416469d42

Committed 2026-04-16 08:59 PT

Revealed 2026-08-17 10:47 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-N6TD9MF6",
  "bug_class": "Symlink-following",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-04-09T05:38:25+00:00",
  "description": "In libcontainer/rootfs_linux.go, prepareRootfs() mounts the /dev tmpfs and creates device nodes using safe RESOLVE_IN_ROOT fd-relative operations, which follow a /dev symlink scoped inside the rootfs and leave the symlink itself intact. Immediately after, setupPtmx() and setupDevSymlinks() call filepath.Join(rootfs, \"dev/...\") with os.Remove/os.Symlink, which the kernel resolves without scoping — following an absolute /dev symlink out to the host filesystem. This runs as host root and before pivot_root, while the mount namespace still mirrors the host. An attacker who authors a container image with /dev as a symlink to an arbitrary absolute host directory causes runc to create fixed-name symlinks (ptmx, fd, stdin, stdout, stderr, core) in that host directory. Pointing /dev at host /dev deletes and replaces the real /dev/ptmx, breaking PTY allocation host-wide.",
  "discovered_at": null,
  "location": "libcontainer/rootfs_linux.go:1125",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "runc",
  "reproduction": [
    "1. Build an OCI rootfs where /dev is a symlink to an absolute host path (e.g. /etc or /dev) and a real directory exists at that path inside the rootfs so scoped resolution succeeds.",
    "2. Push the image; victim pulls and runs it with standard config.json (tmpfs on /dev).",
    "3. prepareRootfs mounts tmpfs onto the scoped target (<rootfs>/<target>) via openat2 RESOLVE_IN_ROOT, leaving the <rootfs>/dev symlink untouched.",
    "4. setupPtmx() does os.Remove + os.Symlink on \"<rootfs>/dev/ptmx\"; kernel follows the absolute symlink unscoped and creates /<target>/ptmx -> pts/ptmx on the host.",
    "5. setupDevSymlinks() likewise creates fd, stdin, stdout, stderr, core symlinks in the host target directory.",
    "6. If target is host /dev, the real /dev/ptmx char device is deleted and replaced, breaking PTY allocation for non-root users on systems with ptmxmode=000."
  ],
  "technical_details": "Built runc at HEAD (7a1cae6dd). Created an OCI bundle whose rootfs/dev is a symlink to an absolute path /tmp/host-sentinel-408 (a directory OUTSIDE the rootfs, on the 'host' — here the outer privileged Docker container). The bundle uses the standard tmpfs /dev mount. Ran `runc run`. The container started successfully (exit 0). After the run, /tmp/host-sentinel-408 contained five new symlinks: ptmx→pts/ptmx, fd→/proc/self/fd, stdin→/proc/self/fd/0, stdout→/proc/self/fd/1, stderr→/proc/self/fd/2. These were created by setupPtmx() and setupDevSymlinks() in libcontainer/rootfs_linux.go, which use filepath.Join(rootfs,\"dev/...\")+os.Symlink — the kernel followed the absolute symlink at rootfs/dev unscoped, landing outside the rootfs, before pivot_root. The safe pathrs-based /dev tmpfs mount followed the symlink SCOPED (to rootfs/tmp/host-sentinel-408), leaving the rootfs/dev symlink itself intact for the unsafe calls to follow. This is a host-filesystem write from a malicious container image with no privileges beyond image authorship.",
  "title": "Host filesystem write via /dev symlink in container image",
  "vendor_severity": "high"
}