#14761·gvisor

devpts: mode= and gid= are applied to the mount root instead of replica devices

Author: kvanzuijlenCreated Sep 15, 2026Updated Sep 15, 2026

Description

pkg/sentry/fsimpl/devpts/devpts.go applies the mode= mount option to the root directory of the devpts mount. Linux applies it to replica (slave) devices and keeps the mount root at 0755.

Expected: a non-root process in a container can open /dev/ptmx. Observed: open("/dev/ptmx") returns EACCES. The process cannot stat /dev/pts/ptmx either.

Container runtimes mount devpts with nosuid,noexec,newinstance,ptmxmode=0666,mode=0620,gid=5 per the OCI specification. gVisor therefore sets /dev/pts itself to 0620, which gives other users no search bit, so they cannot traverse the directory to reach ptmx. The ptmx node is already 0666 and is not the obstacle.

Both of the following ran Docker Engine 29.7.2 from the same docker:29.7.2-dind image, with the same docker run --rm --user 1000:1000 alpine:3.22. The runtime is the only difference.

runc:

inner: version=29.7.2 runtime=runc
drwxr-xr-x    2 root     root             0 /dev/pts
crw-rw-rw-    1 root     root        5,   2 /dev/pts/ptmx
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
open /dev/ptmx: OK

gVisor (GKE Sandbox):

drw--w----    2 root     tty              0 /dev/pts
crw-rw-rw-    1 root     tty         5,   2 /dev/pts/ptmx
none on /dev/pts type devpts (rw,noexec,nosuid)
(cannot stat /dev/pts/ptmx)
open /dev/ptmx: FAILED -- Permission denied

Running the same command as root succeeds under both, which isolates the failure to directory traversal rather than to the device. 0620 root:tty is exactly the mode=620 and gid=5 visible in the runc mount line, so the options reach gVisor and are applied to the wrong inode.

Where the two implementations differ

Linux, fs/devpts/inode.c:

  • The root inode is fixed, line 379: inode->i_mode = S_IFDIR | S_IRUGO | S_IXUGO | S_IWUSR (0755).
  • A replica takes the option, line 518: init_special_inode(inode, S_IFCHR|opts->mode, MKDEV(UNIX98_PTY_SLAVE_MAJOR, index)).

gVisor, pkg/sentry/fsimpl/devpts/devpts.go:

  • The root takes the option, line 185: root.InodeAttrs.InitWithIDs(ctx, opts.uid, opts.gid, linux.UNNAMED_MAJOR, devMinor, 1, linux.ModeDirectory|opts.mode)
  • A replica ignores it and is hardcoded, line 281: replica.InodeAttrs.Init(ctx, creds, i.InodeAttrs.DevMajor(), i.InodeAttrs.DevMinor(), uint64(idx+3), linux.ModeCharacterDevice|0600)

So there are two deviations, not one. Replicas also miss opts.gid: they are created with creds, while Linux gives them opts->gid, which is what lets the tty group write to another user's terminal.

ptmx already follows the spec, line 197, which is why only the directory blocks access: master.InodeAttrs.InitWithIDs(..., linux.ModeCharacterDevice|opts.ptmxMode).

The option handling arrived in 639488b65a18 ("fsimpl/devpts: handle mode, ptmxmode, uid, gid mount options", 2023-10-24). It was adapted from tmpfs, where mode= does set the root directory. Six warning strings still name tmpfs, for example line 76: ctx.Warningf("tmpfs.FilesystemType.GetFilesystem: invalid mode: %q", modeStr).

Worth noting the default is unaffected: lines 67 and 68 set mode: 0555 and ptmxMode: 0666, so a plain mount -t devpts leaves the directory traversable. The failure appears only when standard container options are supplied.

Steps to reproduce

Any container runtime on gVisor. No Java or buildpacks involved.

bash
docker run --rm --user 1000:1000 alpine:3.22 sh -c '
  ls -ld /dev/pts
  ls -l /dev/pts/ptmx
  if (exec 3<>/dev/ptmx); then echo "open OK"; else echo "open FAILED"; fi
'

Under runc this prints open OK. Under gVisor it prints open FAILED and cannot stat the node.

Possible solution

Keep the devpts root at 0755, and apply mode= and gid= when a replica is created in allocateTerminal, matching fs/devpts/inode.c. Both changes are needed: moving mode alone would still leave replicas owned by the creating credentials instead of the tty group.

TEST(BasicPtyTest, SetMode) in test/syscalls/linux/pty.cc encodes the current behaviour. It mounts newinstance,mode=0600,ptmxmode=0620 and asserts EXPECT_EQ(st.st_mode, 0600 | S_IFDIR) on the directory, so it would need updating alongside the fix. The test begins with SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SYS_ADMIN))), and mounting devpts requires that capability, so it is skipped wherever it is absent.

Impact

Any workload that allocates a PTY as a non-root user fails. Cloud Native Buildpacks cannot build images under gVisor: the CNB lifecycle runs as uid 1000, and libpak, which every Paketo buildpack uses to run subprocesses, always allocates a PTY (paketo-buildpacks/libpak#468). pack build and Spring Boot's bootBuildImage both fail with unable to start PTY / open /dev/ptmx: permission denied.

runsc version

GKE Sandbox manages runsc. The node does not expose its version.

docker version (if using docker)

Docker Engine 29.7.2 on both sides. In the sandbox it runs as a Docker-in-Docker sidecar started with --iptables=false --ip6tables=false --data-root=/d and a tmpfs at the data root. The runc comparison ran the same docker:29.7.2-dind image on a GitHub-hosted runner.

uname

/proc/version reports Linux version 4.19.0-gvisor.

kubectl (if using Kubernetes)

GKE 1.36.3-gke.1537000. Node pool: imageType COS_CONTAINERD, sandboxConfig.type GVISOR.