Skip to content

Commit f89bdc0

Browse files
Lutherwavesclaude
andauthored
test(docker): prove the resource limits hold under attack (#20)
Closes #5. The bounds were set but never tested against code trying to break them. Setting a limit and enforcing a limit are different claims, and only the second one matters when the workload is assumed hostile. Three adversarial cases, each asserting the HOST survives rather than only that the guest process died: - a fork bomb asking for 400 processes against MaxProcesses=64 - a memory hog doubling a string until it is killed inside a 128 MiB cap - a 512 MiB write into a 32 MiB tmpfs scratch budget Two details that decide whether these test anything at all. The progress counter has to be streamed: when a cap bites, the shell producing it dies mid-loop, so a count printed at the end never arrives and the test reads an empty string. And the memory hog has to double rather than append — linear growth by string concatenation is quadratic work and burns the whole timeout without ever reaching the cap, which passes for the wrong reason. Observed under gVisor: the fork bomb stops at 13 processes against a cap of 64, because the sentry's own tasks count against the same budget and the guest's usable share is smaller than the number configured. That direction is safe, so the assertion is one-sided. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 31c814f commit f89bdc0

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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

Comments
 (0)