Why not just run it with a timeout?
A university's algorithms course takes weekly submissions from 2,000 students. A worker takes the next one from a queue, writes main.py to /tmp, runs python3 main.py with the first hidden input on stdin and a two-second timeout, and compares what it prints with the expected output. For honest code, that is the whole system.
But the program runs as the worker, with everything the worker has. A submission can:
- allocate 30 GB and take the machine's memory from every other run on it;
- fork until the process table is full, so nothing on the host can start;
- print forever into a file until the disk is full;
- open the directory of hidden tests and print the expected outputs;
- read the worker's environment, with the database password in it;
- ask the cloud's metadata endpoint for the machine's credentials, or scan the internal network;
- leave a file in
/tmpthat changes the next student's run.
Most of these fit in two seconds. A sandbox is the answer: an environment for running code you did not write, with a boundary around what it can see, limits on how much it can use, and a rule for whom it may talk to.
How much it may use
Limits come first, because even an honest solution can blow one. The kernel enforces them per process or per group of processes (on Linux, cgroups hold the group-wide ones), and the judge turns each into a verdict:
- Time. A CPU-time limit decides time limit exceeded fairly, since a busy host changes a program's CPU time far less than its wall-clock time. It misses a program that sleeps or blocks forever while using no CPU, so a wall-clock limit, a few times larger, stays as the backstop.
- Memory. When the group cannot be kept under its limit, the kernel's out-of-memory killer ends a process in it. The judge has to tell that kill apart from a crash: one is memory limit exceeded, the other runtime error.
- Processes. A cap on processes and threads per run turns a fork bomb into a failed run instead of a frozen host.
- Disk and output. A cap on what the program may print gives output limit exceeded, and a size cap on the scratch directory makes a write fail instead of filling the disk.
Limits contain greed, not curiosity: a tidy 40 MB program can still open the hidden tests.
How much of the machine it can see
The boundary comes in layers, each with its price in startup time and overhead, and its attack surface: the interfaces the untrusted code can reach and try to break.
An unprivileged process. Run the program as a user that owns nothing, so file permissions keep it out of the tests and the worker's files. Cheap, and worth keeping inside every other layer, but the program still sees the host's filesystem, processes and network, and talks straight to the host kernel.
A container. It narrows what the run can see (Containers & orchestration explains how), but every container on the host shares the host's kernel, which exposes hundreds of system calls, and an exploitable bug in one of them can be a way out onto the host, next to every other student's run. A seccomp filter that refuses the calls a solution has no reason to make, such as mounting a filesystem, and an unprivileged user inside narrow that path without closing it.
A user-space kernel. gVisor puts a kernel written as an ordinary program between the code and the host: it answers the program's system calls itself and uses a much smaller set of host calls to do it. That shrinks the host-kernel surface the code can reach, but gVisor itself and the narrower set of host calls it makes can still have exploitable bugs. The price is speed on workloads that make many system calls, and the occasional call it does not support.
A microVM. Firecracker starts a minimal virtual machine with its own guest kernel and only a handful of emulated devices, in a fraction of a second and with a few megabytes of overhead; AWS built it for Lambda. Hardware virtualization is the boundary: code that breaks the guest kernel has only reached its own VM, and to reach the host it still has to break the virtualization layer, KVM or the Firecracker process, which can have bugs of their own.
A language isolate. V8 isolates and WebAssembly runtimes can run many tenants inside one process, the runtime keeping each inside its own memory and letting it reach out only through functions the host hands it, light enough for platforms that run small functions (Serverless & edge functions). The trust model differs: the boundary is the runtime itself, a large program, and tenants sharing its address space make Spectre-class attacks a risk it has to mitigate. They also run only code built for them, not a judge's native C++ binaries.
The course judge picks a microVM per submission, and because any layer can have an exploitable bug, it stacks them: inside the VM the program still runs as an unprivileged user, under a seccomp filter and the limits above. The sandbox hosts run nothing else and hold no credential beyond taking work and returning results, on a network segment (Network topology: VPCs, NAT, VPN & bastions) that reaches only the workers. A refused system call, an outbound connection attempt or a run that outlived its limits is worth an alert, and kernel and hypervisor patches go out quickly, since they are the boundary.
Who it may talk to
A solution needs stdin and stdout, so the judge's sandbox gets no network at all: a network namespace with only a loopback interface, or a VM with no network device, leaves it no route out. A sandbox that needs the network, say a notebook service installing packages, sends traffic through an egress proxy with an allow-list, and the rules explicitly block the private address ranges and the cloud metadata endpoint, which Web application security: injection, XSS, CSRF & SSRF explains for server code.
Nothing secret goes in, either. The worker keeps the database password and the hidden tests, and passes the sandbox only the source and one input at a time; it reads the output and compares it with the expected answer outside. The root filesystem is read-only, with one small scratch directory for writing. Code that gets further than it should still finds no credential and no answer key.
Workers hold the credentials and the expected outputs. The sandbox gets code and one input, returns what it printed, and has no edge out: no network.
One submission, one sandbox
The file left in /tmp is a lifecycle problem. Each submission gets a fresh sandbox, which runs each of its test inputs as a new process, one after another, and is destroyed when the verdict is in, never handed to the next student with the last one's files in it.
At a Friday deadline, startup time adds up. A warm pool of sandboxes booted in advance hides it, as long as each one still serves one submission and is thrown away.
What a sandbox does not promise
A boundary stops direct reads and writes. It does not stop every leak: runs that share a CPU share its caches and timing, and a careful program can learn a little about its neighbour that way. Platforms that need more give each tenant its own cores, or its own hosts. And limits cap one run, not one student: someone who submits 500 times a minute fills the queue for everyone, which takes a per-user quota and a limit at the submit endpoint (Rate limiting, Multi-tenancy), not a tighter sandbox.
Contest night on the Online Code Judge
On the Online Code Judge Challenge, draw the sandbox as its own part beside the workers, named with its layer, with edges that keep the hidden tests and credentials on the worker side and bring back only output. Serverless Function Platform asks for the same boundary, once per tenant.