Isolation probe
A TEE that hosts many apps makes a strong claim — your code ran here, the others’ code can’t tamper with yours — but the TEE attestation only covers what code is running. It does not by itself say what’s keeping co-hosted apps from each other’s secrets and files. That’s a job for the OCI runtime that brings up app containers.
dstack-webhost runs every app under a runtime the substrate selects, and exposes the choice publicly at /_api/substrate. A relying party who wants to check the claim can deploy a tiny app whose only job is to expose its own kernel-namespace view, and then compare:
- What the substrate says — the daemon’s stated runtime (e.g.
sysbox-runc). - What the app sees — the container’s own
/proc/self/uid_map,user_ns, etc.
A malicious substrate can lie in /_api/substrate, but it cannot forge the app’s own /proc. That’s the corroboration loop the probe demonstrates.
Live demo
The probe is deployed as a Layer-1 app on hermes-staging:
→ hermes-staging.dstack-pha-prod7.phala.network/probe/
The page fetches /_api/substrate and the app’s own /api/probe, then renders a verdict. Source: examples/isolation-probe/.
What the verdict means
uid_map is the signature. Under sysbox-runc, root inside the container (UID 0) is mapped to a non-root UID on the host — an active user-namespace remap. Plain runc shows the trivial mapping 0 0 4294967295: root inside is root outside.
sysbox-runc: "0 231072 65536" ← shifted, ns boundary active
runc default: "0 0 4294967295" ← trivial, no remap
The probe’s verdict is “consistent” when the substrate’s claim and the app’s uid_map agree on which regime is in force.
What the substrate layers on top of the runtime
The OCI runtime is one layer. dstack-webhost adds three substrate-level mechanisms that go beyond what sysbox-runc alone provides:
- Per-project Docker network. Image-runtime apps and
isolation: containerdeno apps each gettee-proj-<name>-<mode>; only the daemon is connected. A sibling app cannot reach another by IP, by container name, or by Docker DNS — the bridge drops the traffic. (proxy/runtimes.py::_ensure_project_network) - Per-project named volume.
isolation: containerapps mounttee-projdata-<name>at/data. They never see other apps’ subdirs of the daemon’s shared data volume. (proxy/runtimes.py::start_isolated) - Scoped Deno permissions.
isolation: containerdeno apps run under--deny-env,--deny-ffi,--deny-run,--deny-sys,--allow-readscoped to the project’s own files.manifest.envis passed via Deno args (not env permission), so the handler seesctx.envbut cannot read other apps’ env values.
The shared deno runtime runs each app in its own deno run process whose permissions cover only its own files, its own /daemon-data/<name> and its own /run/broker/<name> sockets (RFC 0035). Apps there cannot derive each other’s keys or read each other’s files, env or data, but they share a network namespace and a kernel, and the boundary is Deno’s permission checker. Per-project networks and kernel-level separation require isolation: container or runtime: image.
Picking the runtime
The choice on this substrate is between three OCI runtimes, all of which can be made available on stock dstack:
runc— the default. No hardening; same syscall surface as a non-containerised process.sysbox-runc— Nestybox’s hardened runc. Adds automatic user-namespace remap, virtualised/procand/sys, scoped capabilities. Already registered as a Docker runtime on stock dstack — no install step. Meaningful against namespace/capability escapes; does not shrink the host kernel’s syscall attack surface, so kernel CVEs (io_uring, BPF, etc.) remain reachable.runsc(gVisor) — Google’s userspace kernel. Sentry intercepts syscalls; only ~50 of ~330 host syscalls are reachable from an app, behind a strict seccomp filter. Addresses kernel-CVE escape as a class. Not present on stock dstack, but installable via prelaunch script (examples/runsc-prelaunch/) — the script’s hash is in the measured launch payload, the binary is pinned by sha512, the trust chain stays intact. (A privileged bootstrap container in the compose is a parallel attestation-safe path; not wired up because the prelaunch path was sufficient.)
For nested KVM (which would let gVisor use its faster KVM platform): not exposed in dstack TDX. The ptrace/systrap platform is what we use, with the perf cost measured below.
Recommendation
runsc (gVisor) is the recommended runtime for this substrate. Attestation is the product; the relying-party threat model genuinely values kernel-CVE resistance, which is the only thing runsc adds and sysbox-runc does not. The perf cost is ~20% throughput and ~2× p99 tail on a 1-vCPU CVM (see below) — tolerable for the small handlers and Layer-1 image apps this substrate hosts. sysbox-runc is the right fallback for apps whose workloads can’t accept that perf hit (a database under load, a latency-critical endpoint). Plain runc has no place on a multi-app TEE substrate when sysbox-runc is right there at zero cost.
This was a working assumption the substrate started under (DAEMON_CONTAINER_RUNTIME=sysbox-runc is what hermes-staging runs today); the comparison below is the data that turned it from “deferred until we measure” into a real recommendation.
Measured perf cost
A controlled comparison on the same dstack CVM (1 vCPU, 2 GB RAM), flipping DAEMON_CONTAINER_RUNTIME between the three runtimes and redeploying the probe each time. Workload: 20 concurrent clients hitting /probe/api/probe for 45s — moderately syscall-heavy (4 /proc reads + JSON serialise + HTTP serve per request).
| Runtime | rps | p50 | p90 | p95 | p99 |
|---|---|---|---|---|---|
runc |
221.6 | 80.3ms | 92.4ms | 111.2ms | 179.6ms |
sysbox-runc |
221.8 | 80.2ms | 93.2ms | 111.8ms | 182.7ms |
runsc (gVisor) |
175.2 | 101.6ms | 118.5ms | 137.4ms | 395.8ms |
sysbox-runc is free. Indistinguishable from runc on throughput and every latency percentile. Sysbox does its hardening at container startup (user-namespace setup, proc/sys virtualisation), not per syscall — once the container is running, syscalls go straight to the host kernel like with runc. Zero per-request cost.
runsc costs about 21% throughput, 27% median latency, and 2.2× p99 tail latency. Sentry intercepts every syscall in userspace; on a 1-vCPU CVM with a syscall-heavy workload, that’s the visible price. Workloads that are mostly network-bound without much per-request kernel work will see less. Databases, file-heavy services, anything chatty with the kernel will see more.
The runtime-choice implication:
sysbox-runcis the right default floor — namespace/cap hardening for free.runscis an opt-in upgrade when the threat model justifies ~20% throughput and ~2× p99 tail for kernel-CVE resistance. It’s not a free lunch.- Plain
runcis no faster than sysbox-runc; there’s no performance reason to prefer it on a multi-app TEE substrate.
A covert channel runsc actually closes: /proc is host-wide under runc and sysbox-runc
While digging for residual cross-app channels on hermes-staging, the most striking real finding: /proc/loadavg, /proc/stat, /proc/meminfo, and /proc/uptime are all host-wide under runc and sysbox-runc. Reading them from inside any app gives back the host’s actual values — including activity contributed by sibling apps.
Side-by-side, on the same host:
HOST runsc sysbox-runc runc
/proc/loadavg 1.89... 0.00 0.00 1.89... (host) 1.89... (host)
/proc/uptime 1001 s 0.28 s 0.05 s 1003 s (host)
/proc/stat cpu 173390 0 0 0 0 173496 (host) 173533 (host)
/proc/meminfo 2444 free 3824 (sandboxed) 2389 (host) 2379 (host)
This is a working covert channel under runc/sysbox-runc:
- App A busy-loops a CPU core in a deliberate pattern (e.g. spin 1s, idle 1s).
- App B reads
/proc/loadavgonce per second. - B reconstructs A’s signal from the load oscillation.
Bandwidth is low (loadavg updates over seconds), but the channel is structurally there and easy to demonstrate. Sysbox doesn’t help — its FUSE-virtualised /proc covers some files but lets loadavg/stat/uptime/meminfo pass through to the host’s view.
runsc (gVisor) closes this channel by synthesising those /proc entries inside Sentry — the app only ever sees its own kernel-state-equivalent, never the host’s. Sentry IS the kernel for the workload, so there’s no host view to leak.
This is the strongest concrete reason to prefer runsc on this substrate that surfaced during the comparison work — the kernel-CVE argument was structural but abstract; this is a demonstrable cross-app information leak that flipping the runtime literally closes. (Higher-bandwidth channels — cache timing, memory pressure modulation — are harder to characterise and likely remain to some degree under any runtime when apps share physical hardware.)
Disguised leaker: a leak runtime isolation does not close
The /proc covert channel above is closed by switching runtimes. The harder threat — and the more honest one — is an app whose source review passes, whose attestation chain holds, but which leaks the very thing it claims to protect via a side channel the substrate cannot see. That’s the rug-pull case: the relying party reads the source, accepts the attestation, and is wrong.
The simplest demonstration of the class, deployed live on hermes-staging at /vault/ (under runsc + isolation: container — the strongest substrate config we have):
// vault — a credential gate. Holds a secret token; only callers who present
// the matching token via /check?t=<token> get a positive response.
function check(candidate: string, SECRET: string): boolean {
if (candidate.length !== SECRET.length) return false;
let ok = true;
for (let i = 0; i < SECRET.length; i++) {
if (candidate[i] !== SECRET[i]) {
ok = false;
} else {
// jitter to harden against timing
const end = performance.now() + 50;
while (performance.now() < end) { /* spin */ }
}
}
return ok;
}
The author’s intent reads as “iterate fully so we don’t short-circuit, add per-byte work to throw off timing analysis.” The actual effect is that response time grows linearly in the matching prefix length: each correctly-guessed byte costs an extra ~50 ms of wall clock. From the public internet, with 10 timing samples per candidate per position, an attacker recovers the secret byte-by-byte:
target: https://...phala.network/vault/ length: 8 samples/byte: 10
pos 0: 'd' (best=410.6ms runner-up=378.6ms gap=32.00ms) so far: 'd'
pos 1: 'e' (best=449.1ms runner-up=443.6ms gap= 5.51ms) so far: 'de'
pos 2: 'a' (best=511.9ms runner-up=487.9ms gap=24.00ms) so far: 'dea'
pos 3: 'd' (best=603.3ms runner-up=565.1ms gap=38.16ms) so far: 'dead'
pos 4: 'b' (best=614.9ms runner-up=614.4ms gap= 0.54ms) so far: 'deadb'
pos 5: 'e' (best=717.9ms runner-up=655.4ms gap=62.49ms) so far: 'deadbe'
pos 6: 'e' (best=717.9ms runner-up=716.9ms gap= 1.01ms) so far: 'deadbee'
pos 7: 'f' (best=818.5ms runner-up=774.3ms gap=44.23ms) so far: 'deadbeef'
recovered: 'deadbeef' in 983.2s
Source for the project and the attack: examples/timing-leak-demo/. The substrate is doing its job — runsc mediates syscalls, per-project networks isolate the app, the daemon never sees the secret. The leak is inside the project’s own response time, public to anyone with curl.
Why this matters and what’s actually realistic
The “jitter to harden” loop in this demo is contrived — a timing-aware reviewer would catch it on the second read. The honest version is when the leak falls out of a normal cryptographic primitive without any fishy flag in the source:
- Textbook RSA’s
m^d mod nvia square-and-multiply: one squaring per bit ofdplus one extra multiplication per set bit. Total signing time leaks Hamming weight of the private exponent; Kocher-style chosen-message attacks recover individual bits. - ECDSA scalar multiplication via double-and-add: each signature leaks bits of that signature’s
k. Lattice attacks combine(r, s, leaked_k_bits)triples from many signatures into the private key. Real ECDSA implementations have been broken this way more than once. - AES via T-table lookups: cache-line timing leaks key bits. Bernstein 2005, OpenSSL’s CVE-2016-2107 lineage.
The takeaway is the same in all cases: the leak is in code that looks correct to source review, the attestation chain holds, and the substrate’s runtime layer cannot see it. Closing it is application-layer work — constant-time primitives in the project’s code, audits with timing-attack literacy, ideally automated timing-property tests.
A follow-up demo with one of the above (probably textbook RSA — Hamming-weight recovery is the cleanest concrete signal to land in a docs page) is on the list. The current vault is the warmup that establishes the class.
gVisor + Docker embedded DNS
When an app runs under plain runsc, outbound DNS resolution hits a real interaction bug between gVisor’s sandbox netstack and Docker’s embedded DNS resolver at 127.0.0.11. Docker forces nameserver 127.0.0.11 into /etc/resolv.conf for any container on a user-defined bridge network (which is most of them, including the per-project tee-proj-<name>-<mode> networks the substrate creates). gVisor’s own netstack doesn’t route to that address. Result: DNS lookups return “Connection refused” even though the host’s actual resolver is reachable.
HostConfig.Dns (the compose dns: field) does not fix this — it only changes the upstream resolvers Docker’s embedded DNS forwards to, not the embedded DNS address Docker writes into /etc/resolv.conf.
The substrate’s answer is a second runtime, so no app has to choose between gVisor and DNS:
runsc-hostnet— the prelaunch registers a second runtime,runsc --network=host. Network syscalls pass through to the container’s own netns, so127.0.0.11, embedded DNS and container-name discovery behave exactly as under runc, while Sentry still mediates every other syscall. The trade is explicit, not hidden: netstack isolation is given up — network syscalls reach the host kernel again./_api/substratereports this as"network_isolation": "host"(vs"sandbox"for plain runsc,"netns"for runc-family).
Plain runsc is unchanged: name resolution on a per-project bridge is still broken. docker_client.py
sets an explicit HostConfig.Dns for runsc apps (#108), but that only changes what the embedded
resolver forwards to — on a user-defined network Docker still writes 127.0.0.11, so it buys nothing
there. An app that needs netstack isolation and DNS has no answer in this substrate today.
/_api/substrate reports available_runtimes read live from Docker’s GET /info, and the daemon refuses to start when DAEMON_CONTAINER_RUNTIME names a runtime Docker does not have — the substrate’s runtime claim cannot silently drift from what the host actually runs.
Open attack surface
What this stack does not address, and is worth arguing about:
- Kernel CVEs.
sysbox-runchardens the namespace/capability boundary; it does not shrink the host kernel’s syscall attack surface. An app that exploits a kernel bug (io_uring, BPF, etc.) escapes the CVM and breaks every app’s quote. The CVM is currently on6.9.0-dstackfrom May 2024, which has a year of post-release CVEs. This is the structural reason for the gVisor argument above. The provisioning path is now demonstrated to work via prelaunch script; flipping hermes-staging onto runsc is a separate operational decision (perf, hermes compat) rather than a missing capability. - Weakest-link app on a shared kernel. Every app on the CVM shares one Linux kernel, so the effective isolation floor for any app is whatever the weakest app’s runtime exposes. A gVisor-protected app on the same CVM as a runc-protected sibling is no better off than the sibling against kernel-CVE escape — once the host kernel is compromised by the weak link, an attacker reads Sentry-protected memory from outside Sentry’s mediation. This is the structural reason
DAEMON_CONTAINER_RUNTIMEis a CVM-level switch rather than a per-manifest field; per-project runtime choice would be a foot-gun for the attestation claim. (On hermes-staging thehermesandssh-debugpeer services run under default runc because they’re outside the substrate’s “design under test” boundary; for a serious deployment they’d need to share the floor.) - Per-app resource limits. No cgroup memory/CPU/disk caps today. A hostile app can OOM the host or starve sibling CPU. Easy to add — this is unfinished work, not a hard problem.
- Same-host side channels.
/proc/loadavg, RAM pressure, page-cache timing, CPU contention —sysbox-runcdoes not virtualise these. An app pair could plausibly establish a covert channel by modulating load and timing read-back. Quantifying the bandwidth would be its own demo, not yet built. - Daemon TCB. The daemon (
tee-daemon) owns Docker, owns each app’s per-project network, and is the authority behind/_api/substrate. A daemon compromise is a total compromise. The TEE attestation pins the daemon’s image hash; a relying party walks the trust chain to the public source and audits it as part of the substrate. - Inter-mode bridges. The daemon connects itself to every per-project network so it can reverse-proxy. An app that compromised the daemon’s network stack from inside its own network could in principle pivot. The exposed surface is the daemon’s bridge interface plus its ingress port — small, but not zero.
- Verifier coverage of image-runtime apps. The current verifier page checks
tree_hashagainst GitHub; for image-runtime apps the integrity claim isimage_digestagainst a registry, and the verifier page does not yet branch on this. Audit machinery, not a runtime hole.
A natural next demo is a sibling probe that actually tries each of these channels and reports what worked. Not built yet — the substrate-level fixes (network, volume, Deno perms) closed the easier channels first; the side-channel work is harder and lower priority.
Trying it on your own CVM
The image is already published — you can deploy it directly without rebuilding:
# Make sure the substrate is configured for sysbox-runc:
# environment:
# - DAEMON_CONTAINER_RUNTIME=sysbox-runc
# Deploy as an app:
curl -X POST https://<cvm>/_api/projects \
-H "Authorization: Bearer $TEE_DAEMON_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"probe","runtime":"image",
"image":"ghcr.io/amiller/tee-isolation-probe:v1",
"image_port":8000}'
# Visit
open https://<cvm>/probe/
To rebuild from source (examples/isolation-probe/), see the README there.
Related
- Substrate endpoint — what the daemon exposes about its runtime configuration
- Platform vision (RFC 0001) — Layer 1 vs Layer 2, attestation vs isolation
- Verifier skill — agent-runnable audit of a deployed project