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
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
- 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.
- Push the image; victim pulls and runs it with standard config.json (tmpfs on /dev).
- prepareRootfs mounts tmpfs onto the scoped target (/) via openat2 RESOLVE_IN_ROOT, leaving the /dev symlink untouched.
- setupPtmx() does os.Remove + os.Symlink on "/dev/ptmx"; kernel follows the absolute symlink unscoped and creates //ptmx -> pts/ptmx on the host.
- setupDevSymlinks() likewise creates fd, stdin, stdout, stderr, core symlinks in the host target directory.
- 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
Triage and disclosure were performed by Ada Logics.
- Verdict
- true positive
- Severity
- high
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 roothttps://github.com/opencontainers/runc/commit/864db8042dbb191028676f80addf8c35f348aee2
Dates from discovery through public reveal.
- 2026-04-08 Reported to tracker
- 2026-04-16 Sent to maintainer
- 2026-06-12 Patch released
- 2026-08-17 Publicly revealed
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"
}