|
| 1 | +//go:build integration |
| 2 | + |
| 3 | +// Adversarial tests for the resource bounds. Every other test here asks whether |
| 4 | +// a limit was *set*; these ask whether it *holds* when code inside the sandbox |
| 5 | +// actively tries to break it, and whether the host survives the attempt. |
| 6 | +// |
| 7 | +// The host assertions assume the test process runs on the same machine as the |
| 8 | +// Docker daemon, which is how the integration suite is meant to be run. They |
| 9 | +// read /proc and statfs directly rather than asking the daemon, because the |
| 10 | +// daemon is exactly the component whose containment claim is under test. |
| 11 | +package docker |
| 12 | + |
| 13 | +import ( |
| 14 | + "context" |
| 15 | + "os" |
| 16 | + "path/filepath" |
| 17 | + "strconv" |
| 18 | + "strings" |
| 19 | + "syscall" |
| 20 | + "testing" |
| 21 | + "time" |
| 22 | + |
| 23 | + "github.com/blox-eng/openblox/pkg/sandbox" |
| 24 | +) |
| 25 | + |
| 26 | +// lastCounter reads the highest value of a streamed "<prefix><n>" progress line. |
| 27 | +// The attacks here kill the shell producing them, so the useful number is the |
| 28 | +// last one that made it out rather than anything printed at the end. |
| 29 | +func lastCounter(t *testing.T, out, prefix string) int { |
| 30 | + t.Helper() |
| 31 | + last := -1 |
| 32 | + for _, line := range strings.Split(out, "\n") { |
| 33 | + _, value, found := strings.Cut(strings.TrimSpace(line), prefix) |
| 34 | + if !found { |
| 35 | + continue |
| 36 | + } |
| 37 | + n, err := strconv.Atoi(strings.TrimSpace(value)) |
| 38 | + if err != nil { |
| 39 | + continue |
| 40 | + } |
| 41 | + if n > last { |
| 42 | + last = n |
| 43 | + } |
| 44 | + } |
| 45 | + if last < 0 { |
| 46 | + t.Fatalf("no %q progress line in output:\n%s", prefix, out) |
| 47 | + } |
| 48 | + return last |
| 49 | +} |
| 50 | + |
| 51 | +// hostProcessCount counts processes on the HOST, not in the sandbox. |
| 52 | +func hostProcessCount(t *testing.T) int { |
| 53 | + t.Helper() |
| 54 | + entries, err := filepath.Glob("/proc/[0-9]*") |
| 55 | + if err != nil { |
| 56 | + t.Fatalf("glob /proc = %v", err) |
| 57 | + } |
| 58 | + return len(entries) |
| 59 | +} |
| 60 | + |
| 61 | +// hostAvailableBytes reports MemAvailable, which accounts for reclaimable page |
| 62 | +// cache and so is the honest measure of "could the host still run something". |
| 63 | +func hostAvailableBytes(t *testing.T) int64 { |
| 64 | + t.Helper() |
| 65 | + b, err := os.ReadFile("/proc/meminfo") |
| 66 | + if err != nil { |
| 67 | + t.Fatalf("read /proc/meminfo = %v", err) |
| 68 | + } |
| 69 | + for _, line := range strings.Split(string(b), "\n") { |
| 70 | + if !strings.HasPrefix(line, "MemAvailable:") { |
| 71 | + continue |
| 72 | + } |
| 73 | + fields := strings.Fields(line) |
| 74 | + if len(fields) < 2 { |
| 75 | + break |
| 76 | + } |
| 77 | + kb, err := strconv.ParseInt(fields[1], 10, 64) |
| 78 | + if err != nil { |
| 79 | + break |
| 80 | + } |
| 81 | + return kb * 1024 |
| 82 | + } |
| 83 | + t.Fatal("MemAvailable missing from /proc/meminfo") |
| 84 | + return 0 |
| 85 | +} |
| 86 | + |
| 87 | +func hostFreeDiskBytes(t *testing.T, path string) int64 { |
| 88 | + t.Helper() |
| 89 | + var st syscall.Statfs_t |
| 90 | + if err := syscall.Statfs(path, &st); err != nil { |
| 91 | + t.Fatalf("statfs(%q) = %v", path, err) |
| 92 | + } |
| 93 | + return int64(st.Bavail) * int64(st.Bsize) |
| 94 | +} |
| 95 | + |
| 96 | +// assertHostStillUsable proves the host can still do real work. A containment |
| 97 | +// claim that leaves the machine wedged has failed even if the sandbox died. |
| 98 | +func assertHostStillUsable(t *testing.T, b *Backend, name string) { |
| 99 | + t.Helper() |
| 100 | + start := time.Now() |
| 101 | + sb := create(t, b, name) |
| 102 | + res, err := sb.Exec(context.Background(), sandbox.Command{ |
| 103 | + Argv: []string{"echo", "alive"}, |
| 104 | + Timeout: 30 * time.Second, |
| 105 | + }) |
| 106 | + if err != nil { |
| 107 | + t.Fatalf("host could not run a second sandbox after the attack: %v", err) |
| 108 | + } |
| 109 | + if got := strings.TrimSpace(string(res.Stdout)); got != "alive" { |
| 110 | + t.Errorf("second sandbox stdout = %q, want %q", got, "alive") |
| 111 | + } |
| 112 | + // Generous, because this is a liveness check and not a benchmark. It fails |
| 113 | + // only if the host is genuinely struggling. |
| 114 | + if elapsed := time.Since(start); elapsed > 90*time.Second { |
| 115 | + t.Errorf("host took %s to start a trivial sandbox — it is degraded", elapsed) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +func TestForkBombIsBoundedByTheProcessCap(t *testing.T) { |
| 120 | + b := newTestBackend(t) |
| 121 | + |
| 122 | + const maxProcs = 64 |
| 123 | + sb := create(t, b, "openblox-test-forkbomb", sandbox.WithResources(sandbox.Resources{ |
| 124 | + MaxProcesses: maxProcs, |
| 125 | + MemoryBytes: 512 << 20, |
| 126 | + DiskBytes: 64 << 20, |
| 127 | + })) |
| 128 | + |
| 129 | + hostProcsBefore := hostProcessCount(t) |
| 130 | + |
| 131 | + // Ask for far more processes than the cap allows, reporting progress as we |
| 132 | + // go. The count has to be streamed rather than printed at the end: when the |
| 133 | + // cap bites, the shell itself dies mid-loop and never reaches a closing |
| 134 | + // statement. `echo` is a builtin, so each line costs no process of its own |
| 135 | + // and the output survives the shell's death. |
| 136 | + const attempts = 400 |
| 137 | + res, err := sb.Exec(context.Background(), sandbox.Command{ |
| 138 | + Argv: []string{"sh", "-c", ` |
| 139 | + i=0 |
| 140 | + while [ "$i" -lt ` + strconv.Itoa(attempts) + ` ]; do |
| 141 | + sleep 300 & |
| 142 | + i=$((i+1)) |
| 143 | + echo "spawned=$i" |
| 144 | + done |
| 145 | + `}, |
| 146 | + Timeout: 60 * time.Second, |
| 147 | + }) |
| 148 | + if err != nil { |
| 149 | + t.Fatalf("fork bomb Exec = %v", err) |
| 150 | + } |
| 151 | + |
| 152 | + got := lastCounter(t, string(res.Stdout), "spawned=") |
| 153 | + |
| 154 | + // The headline assertion: the sandbox asked for 400 processes and did not |
| 155 | + // get them. |
| 156 | + if got >= attempts { |
| 157 | + t.Errorf("sandbox spawned %d processes with MaxProcesses=%d — the cap is not enforced", got, maxProcs) |
| 158 | + } |
| 159 | + // It must also stop somewhere at or below the cap, not merely somewhere |
| 160 | + // below what it asked for. Note the observed figure sits well under the cap |
| 161 | + // under gVisor: the sentry's own tasks are counted against the same budget, |
| 162 | + // so the guest's usable share is smaller than the number configured. That |
| 163 | + // direction is safe, so the bound is one-sided. |
| 164 | + if got > maxProcs { |
| 165 | + t.Errorf("sandbox spawned %d processes, above its MaxProcesses=%d", got, maxProcs) |
| 166 | + } |
| 167 | + |
| 168 | + // The host must not have absorbed the overflow. Under gVisor the guest's |
| 169 | + // tasks are not host processes at all, so this should barely move; under a |
| 170 | + // runtime where the cap silently did nothing it would climb by hundreds. |
| 171 | + hostProcsAfter := hostProcessCount(t) |
| 172 | + if delta := hostProcsAfter - hostProcsBefore; delta > 100 { |
| 173 | + t.Errorf("host process count grew by %d (%d -> %d) — the fork bomb reached the host", |
| 174 | + delta, hostProcsBefore, hostProcsAfter) |
| 175 | + } |
| 176 | + |
| 177 | + assertHostStillUsable(t, b, "openblox-test-forkbomb-witness") |
| 178 | +} |
| 179 | + |
| 180 | +func TestMemoryHogIsKilledAndTheHostSurvives(t *testing.T) { |
| 181 | + b := newTestBackend(t) |
| 182 | + |
| 183 | + const memCap = 128 << 20 // 128 MiB |
| 184 | + sb := create(t, b, "openblox-test-oom", sandbox.WithResources(sandbox.Resources{ |
| 185 | + MemoryBytes: memCap, |
| 186 | + DiskBytes: 32 << 20, |
| 187 | + MaxProcesses: 64, |
| 188 | + })) |
| 189 | + |
| 190 | + hostAvailBefore := hostAvailableBytes(t) |
| 191 | + |
| 192 | + // Repeated doubling of a shell string: anonymous memory, no files, so this |
| 193 | + // tests the memory cap rather than the tmpfs budget. Doubling (rather than |
| 194 | + // appending a fixed block) matters — linear growth by string concatenation |
| 195 | + // is quadratic work and spends the whole timeout without ever reaching the |
| 196 | + // cap, which looks like a passing test for the wrong reason. |
| 197 | + // |
| 198 | + // 40 doublings from 16 bytes is ~17 TiB, so completing the loop is only |
| 199 | + // possible if nothing bounded it at all. |
| 200 | + const doublings = 40 |
| 201 | + res, err := sb.Exec(context.Background(), sandbox.Command{ |
| 202 | + Argv: []string{"sh", "-c", ` |
| 203 | + s=xxxxxxxxxxxxxxxx |
| 204 | + n=0 |
| 205 | + while [ "$n" -lt ` + strconv.Itoa(doublings) + ` ]; do |
| 206 | + s=$s$s |
| 207 | + n=$((n+1)) |
| 208 | + echo "doubled=$n" |
| 209 | + done |
| 210 | + `}, |
| 211 | + Timeout: 120 * time.Second, |
| 212 | + }) |
| 213 | + if err != nil { |
| 214 | + t.Fatalf("memory hog Exec = %v", err) |
| 215 | + } |
| 216 | + if res.ExitCode == 0 { |
| 217 | + t.Errorf("memory hog exited 0 — it allocated without bound inside a %d-byte cap", memCap) |
| 218 | + } |
| 219 | + |
| 220 | + // Where it died is the substantive check: the last successful doubling puts |
| 221 | + // an upper bound on how much it ever held. Allow an order of magnitude over |
| 222 | + // the cap to absorb allocator slack, and still catch a cap that did nothing. |
| 223 | + reached := lastCounter(t, string(res.Stdout), "doubled=") |
| 224 | + if held := int64(16) << uint(reached); held > 8*int64(memCap) { |
| 225 | + t.Errorf("sandbox grew to ~%d bytes against a %d-byte cap (%d doublings)", held, memCap, reached) |
| 226 | + } |
| 227 | + |
| 228 | + // The host is the real subject here: a sandbox that cannot be capped takes |
| 229 | + // the machine with it. Allow a wide margin for unrelated activity, but not |
| 230 | + // enough to hide the sandbox having eaten far past its cap. |
| 231 | + hostAvailAfter := hostAvailableBytes(t) |
| 232 | + if drop := hostAvailBefore - hostAvailAfter; drop > 4*int64(memCap) { |
| 233 | + t.Errorf("host MemAvailable fell by %d bytes against a %d-byte sandbox cap — the cap did not contain it", |
| 234 | + drop, memCap) |
| 235 | + } |
| 236 | + |
| 237 | + assertHostStillUsable(t, b, "openblox-test-oom-witness") |
| 238 | +} |
| 239 | + |
| 240 | +func TestFillingScratchHitsTheDiskCapNotTheHost(t *testing.T) { |
| 241 | + b := newTestBackend(t) |
| 242 | + |
| 243 | + const diskCap = 32 << 20 // 32 MiB |
| 244 | + sb := create(t, b, "openblox-test-diskfill", sandbox.WithResources(sandbox.Resources{ |
| 245 | + MemoryBytes: 256 << 20, |
| 246 | + DiskBytes: diskCap, |
| 247 | + MaxProcesses: 64, |
| 248 | + })) |
| 249 | + |
| 250 | + // /var/lib/docker is where an escape would land, so that is the filesystem |
| 251 | + // worth watching rather than the root of wherever the test happens to run. |
| 252 | + const watch = "/var/lib/docker" |
| 253 | + hostFreeBefore := hostFreeDiskBytes(t, watch) |
| 254 | + |
| 255 | + // Write an order of magnitude past the scratch budget. Because scratch is |
| 256 | + // tmpfs, hitting the cap is also what stops this from becoming a memory |
| 257 | + // exhaustion by another route. |
| 258 | + res, err := sb.Exec(context.Background(), sandbox.Command{ |
| 259 | + Argv: []string{"sh", "-c", "dd if=/dev/zero of=/tmp/fill bs=1M count=512 2>&1; echo exit=$?"}, |
| 260 | + Timeout: 120 * time.Second, |
| 261 | + }) |
| 262 | + if err != nil { |
| 263 | + t.Fatalf("disk fill Exec = %v", err) |
| 264 | + } |
| 265 | + if strings.Contains(string(res.Stdout), "exit=0") { |
| 266 | + t.Errorf("wrote 512 MiB into a %d-byte scratch budget without error:\n%s", diskCap, res.Stdout) |
| 267 | + } |
| 268 | + |
| 269 | + // tmpfs is RAM-backed, so a correctly bounded sandbox cannot have touched |
| 270 | + // the host's disk at all. Only a large regression should trip this. |
| 271 | + hostFreeAfter := hostFreeDiskBytes(t, watch) |
| 272 | + if consumed := hostFreeBefore - hostFreeAfter; consumed > 256<<20 { |
| 273 | + t.Errorf("host free space on %s fell by %d bytes — sandbox writes reached the host disk", |
| 274 | + watch, consumed) |
| 275 | + } |
| 276 | + |
| 277 | + assertHostStillUsable(t, b, "openblox-test-diskfill-witness") |
| 278 | +} |
0 commit comments