fix(vm): refuse an unsafe jailer chroot base
After creating <state>/jail, refuse it unless it is a real directory (a symlink is not followed), owned by root and not writable by group or others. The jailer runs as root and builds per-VM chroots there, so a directory others can write to could redirect it. An existing directory is only checked, never chmod-ed. The runner's own euid is accepted next to root so the check is testable without root (the jailer itself needs root).
- commit
- 982af7c268ea039b95c44db08b22e8ac15264ec9
- author
- brice · · 2026-10-12 22:02 UTC
- parent
- bbd315b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +107 -2
M internal/sandbox/vm/driver.go +37 -2
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "context" | |
| 16 | 16 | "errors" | |
| 17 | 17 | "fmt" | |
| 18 | + | "io/fs" | |
| 18 | 19 | "net" | |
| 19 | 20 | "net/netip" | |
| 20 | 21 | "os" | |
| @@ -128,8 +129,8 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 128 | 129 | if !cfg.JailerOff { | |
| 129 | 130 | // Jailer 1.17 canonicalizes --chroot-base-dir and fails when it does not | |
| 130 | 131 | // exist ("Failed to canonicalize path"); older releases created it. | |
| 131 | - | if err := os.MkdirAll(jailBase(cfg.StateDir), 0o700); err != nil { | |
| 132 | - | return fail(fmt.Errorf("jailer chroot base: %w", err)) | |
| 132 | + | if err := ensureJailBase(jailBase(cfg.StateDir)); err != nil { | |
| 133 | + | return fail(err) | |
| 133 | 134 | } | |
| 134 | 135 | for _, p := range []string{cfg.KernelPath(), cfg.BaseImagePath()} { | |
| 135 | 136 | if err := checkWorldReadable(p); err != nil { | |
| @@ -429,6 +430,40 @@ func checkSocketPaths(stateDir, execName string, jailed bool) error { | |||
| 429 | 430 | return nil | |
| 430 | 431 | } | |
| 431 | 432 | ||
| 433 | + | // ensureJailBase creates the jailer's chroot base and refuses one that others | |
| 434 | + | // could use to redirect the jailer: anything but a real directory (a symlink is | |
| 435 | + | // not followed), owned by neither root nor the runner's own user, or writable by | |
| 436 | + | // group or others. An existing directory is checked, never chmod-ed. | |
| 437 | + | func ensureJailBase(dir string) error { | |
| 438 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 439 | + | return fmt.Errorf("jailer chroot base: %w", err) | |
| 440 | + | } | |
| 441 | + | fi, err := os.Lstat(dir) | |
| 442 | + | if err != nil { | |
| 443 | + | return fmt.Errorf("jailer chroot base: %w", err) | |
| 444 | + | } | |
| 445 | + | return checkJailBaseInfo(dir, fi, os.Geteuid()) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func checkJailBaseInfo(dir string, fi fs.FileInfo, euid int) error { | |
| 449 | + | if !fi.IsDir() { | |
| 450 | + | return fmt.Errorf("jailer chroot base %s is not a directory (%v): remove it", dir, fi.Mode().Type()) | |
| 451 | + | } | |
| 452 | + | if fi.Mode().Perm()&0o022 != 0 { | |
| 453 | + | return fmt.Errorf("jailer chroot base %s is writable by group or others (mode %04o): chmod go-w it", dir, fi.Mode().Perm()) | |
| 454 | + | } | |
| 455 | + | st, ok := fi.Sys().(*syscall.Stat_t) | |
| 456 | + | if !ok { | |
| 457 | + | return fmt.Errorf("jailer chroot base %s: cannot read its owner", dir) | |
| 458 | + | } | |
| 459 | + | // The jailer needs root, so the runner is root in production; accepting its own | |
| 460 | + | // user as well keeps the driver testable without root. | |
| 461 | + | if int(st.Uid) != 0 && int(st.Uid) != euid { | |
| 462 | + | return fmt.Errorf("jailer chroot base %s is owned by uid %d, not root: chown it", dir, st.Uid) | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | ||
| 432 | 467 | func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig { | |
| 433 | 468 | cfg := s.d.cfg | |
| 434 | 469 | return vmConfig{ | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "context" | 15 | "context" | ||
| 16 | "errors" | 16 | "errors" | ||
| 17 | "fmt" | 17 | "fmt" | ||
| 18 | + | "io/fs" | |||
| 18 | "net" | 19 | "net" | ||
| 19 | "net/netip" | 20 | "net/netip" | ||
| 20 | "os" | 21 | "os" | ||
| @@ -128,8 +129,8 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 128 | if !cfg.JailerOff { | 129 | if !cfg.JailerOff { | ||
| 129 | // Jailer 1.17 canonicalizes --chroot-base-dir and fails when it does not | 130 | // Jailer 1.17 canonicalizes --chroot-base-dir and fails when it does not | ||
| 130 | // exist ("Failed to canonicalize path"); older releases created it. | 131 | // exist ("Failed to canonicalize path"); older releases created it. | ||
| 131 | - | if err := os.MkdirAll(jailBase(cfg.StateDir), 0o700); err != nil { | 132 | + | if err := ensureJailBase(jailBase(cfg.StateDir)); err != nil { |
| 132 | - | return fail(fmt.Errorf("jailer chroot base: %w", err)) | 133 | + | return fail(err) |
| 133 | } | 134 | } | ||
| 134 | for _, p := range []string{cfg.KernelPath(), cfg.BaseImagePath()} { | 135 | for _, p := range []string{cfg.KernelPath(), cfg.BaseImagePath()} { | ||
| 135 | if err := checkWorldReadable(p); err != nil { | 136 | if err := checkWorldReadable(p); err != nil { | ||
| @@ -429,6 +430,40 @@ func checkSocketPaths(stateDir, execName string, jailed bool) error { | |||||
| 429 | return nil | 430 | return nil | ||
| 430 | } | 431 | } | ||
| 431 | 432 | ||||
| 433 | + | // ensureJailBase creates the jailer's chroot base and refuses one that others | |||
| 434 | + | // could use to redirect the jailer: anything but a real directory (a symlink is | |||
| 435 | + | // not followed), owned by neither root nor the runner's own user, or writable by | |||
| 436 | + | // group or others. An existing directory is checked, never chmod-ed. | |||
| 437 | + | func ensureJailBase(dir string) error { | |||
| 438 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 439 | + | return fmt.Errorf("jailer chroot base: %w", err) | |||
| 440 | + | } | |||
| 441 | + | fi, err := os.Lstat(dir) | |||
| 442 | + | if err != nil { | |||
| 443 | + | return fmt.Errorf("jailer chroot base: %w", err) | |||
| 444 | + | } | |||
| 445 | + | return checkJailBaseInfo(dir, fi, os.Geteuid()) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func checkJailBaseInfo(dir string, fi fs.FileInfo, euid int) error { | |||
| 449 | + | if !fi.IsDir() { | |||
| 450 | + | return fmt.Errorf("jailer chroot base %s is not a directory (%v): remove it", dir, fi.Mode().Type()) | |||
| 451 | + | } | |||
| 452 | + | if fi.Mode().Perm()&0o022 != 0 { | |||
| 453 | + | return fmt.Errorf("jailer chroot base %s is writable by group or others (mode %04o): chmod go-w it", dir, fi.Mode().Perm()) | |||
| 454 | + | } | |||
| 455 | + | st, ok := fi.Sys().(*syscall.Stat_t) | |||
| 456 | + | if !ok { | |||
| 457 | + | return fmt.Errorf("jailer chroot base %s: cannot read its owner", dir) | |||
| 458 | + | } | |||
| 459 | + | // The jailer needs root, so the runner is root in production; accepting its own | |||
| 460 | + | // user as well keeps the driver testable without root. | |||
| 461 | + | if int(st.Uid) != 0 && int(st.Uid) != euid { | |||
| 462 | + | return fmt.Errorf("jailer chroot base %s is owned by uid %d, not root: chown it", dir, st.Uid) | |||
| 463 | + | } | |||
| 464 | + | return nil | |||
| 465 | + | } | |||
| 466 | + | ||||
| 432 | func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig { | 467 | func (s *vmSandbox) vmConfig(spec sandbox.JobSpec, netw netpolicy.Net, f vmFiles) vmConfig { | ||
| 433 | cfg := s.d.cfg | 468 | cfg := s.d.cfg | ||
| 434 | return vmConfig{ | 469 | return vmConfig{ | ||
M internal/sandbox/vm/vm_test.go +70 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "errors" | |
| 10 | 10 | "fmt" | |
| 11 | 11 | "io" | |
| 12 | + | "io/fs" | |
| 12 | 13 | "net" | |
| 13 | 14 | "net/http" | |
| 14 | 15 | "net/netip" | |
| @@ -1096,6 +1097,75 @@ func TestNewRejectsAStateDirThatMakesASocketPathTooLong(t *testing.T) { | |||
| 1096 | 1097 | } | |
| 1097 | 1098 | } | |
| 1098 | 1099 | ||
| 1100 | + | // GO-003: the chroot base must be a real directory, owned by root (or the | |
| 1101 | + | // runner's own user), not writable by group or others; an existing one is never | |
| 1102 | + | // chmod-ed into compliance. | |
| 1103 | + | func TestEnsureJailBase(t *testing.T) { | |
| 1104 | + | root := t.TempDir() | |
| 1105 | + | good := filepath.Join(root, "good") | |
| 1106 | + | if err := ensureJailBase(good); err != nil { | |
| 1107 | + | t.Fatal(err) | |
| 1108 | + | } | |
| 1109 | + | if fi, _ := os.Stat(good); fi.Mode().Perm() != 0o700 { | |
| 1110 | + | t.Fatalf("created with %v", fi.Mode().Perm()) | |
| 1111 | + | } | |
| 1112 | + | if err := ensureJailBase(good); err != nil { // idempotent | |
| 1113 | + | t.Fatal(err) | |
| 1114 | + | } | |
| 1115 | + | for name, mode := range map[string]os.FileMode{"group-writable": 0o770, "other-writable": 0o707, "sticky-world": 0o777} { | |
| 1116 | + | d := filepath.Join(root, name) | |
| 1117 | + | _ = os.Mkdir(d, 0o700) | |
| 1118 | + | _ = os.Chmod(d, mode) | |
| 1119 | + | if err := ensureJailBase(d); err == nil || !strings.Contains(err.Error(), "writable by group or others") { | |
| 1120 | + | t.Errorf("%s: %v", name, err) | |
| 1121 | + | } | |
| 1122 | + | if fi, _ := os.Stat(d); fi.Mode().Perm() != mode { | |
| 1123 | + | t.Errorf("%s: mode was changed to %v", name, fi.Mode().Perm()) | |
| 1124 | + | } | |
| 1125 | + | } | |
| 1126 | + | rx := filepath.Join(root, "rx") | |
| 1127 | + | _ = os.Mkdir(rx, 0o755) // readable by others is fine | |
| 1128 | + | _ = os.Chmod(rx, 0o755) | |
| 1129 | + | if err := ensureJailBase(rx); err != nil { | |
| 1130 | + | t.Errorf("0755 refused: %v", err) | |
| 1131 | + | } | |
| 1132 | + | file := filepath.Join(root, "file") | |
| 1133 | + | _ = os.WriteFile(file, nil, 0o600) | |
| 1134 | + | if err := ensureJailBase(file); err == nil { | |
| 1135 | + | t.Error("a file accepted") | |
| 1136 | + | } | |
| 1137 | + | link := filepath.Join(root, "link") | |
| 1138 | + | _ = os.Symlink(good, link) | |
| 1139 | + | if err := ensureJailBase(link); err == nil || !strings.Contains(err.Error(), "not a directory") { | |
| 1140 | + | t.Errorf("a symlink accepted: %v", err) | |
| 1141 | + | } | |
| 1142 | + | } | |
| 1143 | + | ||
| 1144 | + | type fakeInfo struct { | |
| 1145 | + | fs.FileInfo | |
| 1146 | + | uid uint32 | |
| 1147 | + | } | |
| 1148 | + | ||
| 1149 | + | func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } | |
| 1150 | + | ||
| 1151 | + | func TestJailBaseOwner(t *testing.T) { | |
| 1152 | + | fi, err := os.Stat(t.TempDir()) | |
| 1153 | + | if err != nil { | |
| 1154 | + | t.Fatal(err) | |
| 1155 | + | } | |
| 1156 | + | d := "/x/jail" | |
| 1157 | + | mine := os.Geteuid() | |
| 1158 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, 0}, mine); err != nil { | |
| 1159 | + | t.Errorf("root-owned refused: %v", err) | |
| 1160 | + | } | |
| 1161 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, uint32(mine)}, mine); err != nil { | |
| 1162 | + | t.Errorf("own user refused: %v", err) | |
| 1163 | + | } | |
| 1164 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, uint32(mine) + 4242}, mine); err == nil || !strings.Contains(err.Error(), "owned by uid") { | |
| 1165 | + | t.Errorf("foreign owner accepted: %v", err) | |
| 1166 | + | } | |
| 1167 | + | } | |
| 1168 | + | ||
| 1099 | 1169 | // GO-001: a Firecracker with only the jailer's --start-time-us is ours only when | |
| 1100 | 1170 | // it lives in our jail base; a jailer only when it was given our chroot base. | |
| 1101 | 1171 | func TestScanIsScopedToOurJailBase(t *testing.T) { | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "errors" | 9 | "errors" | ||
| 10 | "fmt" | 10 | "fmt" | ||
| 11 | "io" | 11 | "io" | ||
| 12 | + | "io/fs" | |||
| 12 | "net" | 13 | "net" | ||
| 13 | "net/http" | 14 | "net/http" | ||
| 14 | "net/netip" | 15 | "net/netip" | ||
| @@ -1096,6 +1097,75 @@ func TestNewRejectsAStateDirThatMakesASocketPathTooLong(t *testing.T) { | |||||
| 1096 | } | 1097 | } | ||
| 1097 | } | 1098 | } | ||
| 1098 | 1099 | ||||
| 1100 | + | // GO-003: the chroot base must be a real directory, owned by root (or the | |||
| 1101 | + | // runner's own user), not writable by group or others; an existing one is never | |||
| 1102 | + | // chmod-ed into compliance. | |||
| 1103 | + | func TestEnsureJailBase(t *testing.T) { | |||
| 1104 | + | root := t.TempDir() | |||
| 1105 | + | good := filepath.Join(root, "good") | |||
| 1106 | + | if err := ensureJailBase(good); err != nil { | |||
| 1107 | + | t.Fatal(err) | |||
| 1108 | + | } | |||
| 1109 | + | if fi, _ := os.Stat(good); fi.Mode().Perm() != 0o700 { | |||
| 1110 | + | t.Fatalf("created with %v", fi.Mode().Perm()) | |||
| 1111 | + | } | |||
| 1112 | + | if err := ensureJailBase(good); err != nil { // idempotent | |||
| 1113 | + | t.Fatal(err) | |||
| 1114 | + | } | |||
| 1115 | + | for name, mode := range map[string]os.FileMode{"group-writable": 0o770, "other-writable": 0o707, "sticky-world": 0o777} { | |||
| 1116 | + | d := filepath.Join(root, name) | |||
| 1117 | + | _ = os.Mkdir(d, 0o700) | |||
| 1118 | + | _ = os.Chmod(d, mode) | |||
| 1119 | + | if err := ensureJailBase(d); err == nil || !strings.Contains(err.Error(), "writable by group or others") { | |||
| 1120 | + | t.Errorf("%s: %v", name, err) | |||
| 1121 | + | } | |||
| 1122 | + | if fi, _ := os.Stat(d); fi.Mode().Perm() != mode { | |||
| 1123 | + | t.Errorf("%s: mode was changed to %v", name, fi.Mode().Perm()) | |||
| 1124 | + | } | |||
| 1125 | + | } | |||
| 1126 | + | rx := filepath.Join(root, "rx") | |||
| 1127 | + | _ = os.Mkdir(rx, 0o755) // readable by others is fine | |||
| 1128 | + | _ = os.Chmod(rx, 0o755) | |||
| 1129 | + | if err := ensureJailBase(rx); err != nil { | |||
| 1130 | + | t.Errorf("0755 refused: %v", err) | |||
| 1131 | + | } | |||
| 1132 | + | file := filepath.Join(root, "file") | |||
| 1133 | + | _ = os.WriteFile(file, nil, 0o600) | |||
| 1134 | + | if err := ensureJailBase(file); err == nil { | |||
| 1135 | + | t.Error("a file accepted") | |||
| 1136 | + | } | |||
| 1137 | + | link := filepath.Join(root, "link") | |||
| 1138 | + | _ = os.Symlink(good, link) | |||
| 1139 | + | if err := ensureJailBase(link); err == nil || !strings.Contains(err.Error(), "not a directory") { | |||
| 1140 | + | t.Errorf("a symlink accepted: %v", err) | |||
| 1141 | + | } | |||
| 1142 | + | } | |||
| 1143 | + | ||||
| 1144 | + | type fakeInfo struct { | |||
| 1145 | + | fs.FileInfo | |||
| 1146 | + | uid uint32 | |||
| 1147 | + | } | |||
| 1148 | + | ||||
| 1149 | + | func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } | |||
| 1150 | + | ||||
| 1151 | + | func TestJailBaseOwner(t *testing.T) { | |||
| 1152 | + | fi, err := os.Stat(t.TempDir()) | |||
| 1153 | + | if err != nil { | |||
| 1154 | + | t.Fatal(err) | |||
| 1155 | + | } | |||
| 1156 | + | d := "/x/jail" | |||
| 1157 | + | mine := os.Geteuid() | |||
| 1158 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, 0}, mine); err != nil { | |||
| 1159 | + | t.Errorf("root-owned refused: %v", err) | |||
| 1160 | + | } | |||
| 1161 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, uint32(mine)}, mine); err != nil { | |||
| 1162 | + | t.Errorf("own user refused: %v", err) | |||
| 1163 | + | } | |||
| 1164 | + | if err := checkJailBaseInfo(d, fakeInfo{fi, uint32(mine) + 4242}, mine); err == nil || !strings.Contains(err.Error(), "owned by uid") { | |||
| 1165 | + | t.Errorf("foreign owner accepted: %v", err) | |||
| 1166 | + | } | |||
| 1167 | + | } | |||
| 1168 | + | ||||
| 1099 | // GO-001: a Firecracker with only the jailer's --start-time-us is ours only when | 1169 | // GO-001: a Firecracker with only the jailer's --start-time-us is ours only when | ||
| 1100 | // it lives in our jail base; a jailer only when it was given our chroot base. | 1170 | // it lives in our jail base; a jailer only when it was given our chroot base. | ||
| 1101 | func TestScanIsScopedToOurJailBase(t *testing.T) { | 1171 | func TestScanIsScopedToOurJailBase(t *testing.T) { | ||