devpts: mode= and gid= are applied to the mount root instead of replica devices
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: OKgVisor (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 deniedRunning 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.
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.
Source: google/gvisor