Lesson 09 / 12
Container and Hypervisor Security
Measuring escape radius in a multi-tenant environment: with 18 workloads, three tenants, and six hosts, a container escape in a shared placement reaches 18 items in the worst case, spilling into another tenant in seven of eight workloads. Separating by tenant zeroes out cross-tenant escape while raising our own worst-case radius to 21; separating by identity leaves the worst case at 18, lowers the average from 12.5 to 8.5, and raises the host count from 6 to 13. Strengthening isolation raises the item count required to cross from 1 to 3, but does not lower the radius at all.
Published Updated
In this lesson
- Counts the neighboring workloads an escape reaches, and the items reached from those workloads' identities
- Measures the radius within its own tenant and the escape that spills into another tenant separately
- Separates isolation strength and placement separation as two distinct levers: shows that one changes the crossing condition, the other the items reached
Contents
Up to this point, every misconfigured item lived inside the tenant’s own account. One item does not fit this description: the boundary of the partition or the guest system the workload runs on. The tenant does not write that boundary — the provider sets it up — and when it falls, the set of items reached is no longer bounded by the tenant’s account, because other tenants’ workloads may be running on the same physical host.
The items shared between guest and host, the number of items that must be wrong at the same time to cross the boundary, and how far container and guest-system isolation differ on that measure were measured in the IT and Systems Foundations course. They are not repeated here; they are taken as input. This lesson has a single question: once the boundary falls, how many items are reached, and how does that number change between a single-tenant host and a shared host. The escape is a model: no exploit step, call sequence, or code is written; the boundary’s fall is simply assumed and what follows is counted. Testing a partition or guest boundary on a real system, without written authorization and a defined scope, is both illegal and unprofessional.
CD13. The model consists of three tenants, 18 workloads, and eight-core hosts; every workload is tied to a tenant, an identity, and a core requirement (fictional). CD14. Placement is first-fit; the separation rule is none, by tenant, or by identity. CD15. The escape model is this: the boundary falls on one workload, and every workload on the same host is reached. Spread between hosts is not part of the model. CD16. The identities of our own workloads that are reached become input to the access graph; the radius is the number of items reached from those identities. CD17. Other tenants’ graphs are closed to the tenant; that side is counted only as the number of workloads and tenants reached. CD18. The shared-item and required-item counts for each isolation type are taken from the earlier course; the guest system’s one-core-per-workload overhead is fixed in the model.
The Neighbor the Escape Reaches
// placement.mjs — the radius of a container escape: the workloads on the same host and their identities. // IT IS A MODEL. The escape is a MODEL step; no exploit step, call sequence, or code is written. const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|")); // Workload: name|tenant|identity|core requirement. Three tenants, 18 workloads (fictional). export const WORKLOAD = split(` portal-1|measurement|portal-instance-identity|2 b-portal|tenant-b|b-identity-1|3 c-gateway|tenant-c|c-identity-1|3 collection-1|measurement|collection-workload-identity|3 b-stream|tenant-b|b-identity-2|3 c-account|tenant-c|c-identity-2|3 portal-2|measurement|portal-instance-identity|2 b-batch|tenant-b|b-identity-3|4 collection-2|measurement|collection-workload-identity|3 c-report|tenant-c|c-identity-3|2 billing-batch|measurement|billing-batch-role|4 b-archive|tenant-b|b-identity-3|2 backup|measurement|backup-role|2 b-dashboard|tenant-b|b-identity-4|1 c-backup|tenant-c|c-identity-3|1 report|measurement|reader-role|1 b-testing|tenant-b|b-identity-4|1 dashboard|measurement|monitoring-role|1` .replace(/\s{2,}/g, "\n")).map(([name, tenant, identity, c]) => ({ name, tenant, identity, c: +c })); // Access graph (the graph from the previous lessons, without the duration column): source|target const EDGE = split(` portal-service-account|portal-instance portal-service-account|reader-role portal-instance|portal-instance-identity portal-instance-identity|subscriber-database portal-instance-identity|secret-store secret-store|collection-workload-identity collection-workload-identity|raw-meter-bucket collection-workload-identity|measurement-queue collection-workload-identity|monitoring-role monitoring-role|measurement-dashboard portal-instance-identity|billing-batch-role billing-batch-role|billing-archive-bucket billing-batch-role|key-management key-management|customer-key billing-batch-role|backup-role backup-role|backup-bucket backup-role|deployment-role deployment-role|template-store deployment-role|configuration-bucket deployment-role|workload-instance reader-role|report-bucket reader-role|log-bucket reader-role|configuration-bucket`.replace(/\s{2,}/g, "\n")); // The set of items reached from an identity (breadth-first search). export function reach(start) { const reached = new Set(start), queue = [...start]; while (queue.length) { const n = queue.shift(); for (const [k, h] of EDGE) if (k === n && !reached.has(h)) { reached.add(h); queue.push(h); } } return reached; } // Placement: first-fit host. The separation rule can be "none", "tenant", or "identity". export function place(separation, overhead = 0, CAPACITY = 8) { const host = []; for (const w of WORKLOAD.map((i) => ({ ...i, c: i.c + overhead }))) { const label = separation === "none" ? null : separation === "tenant" ? w.tenant : w.identity; let h = host.find((x) => x.remaining >= w.c && (label === null || x.label === label)); if (!h) { h = { name: `host-${host.length + 1}`, label, remaining: CAPACITY, workloads: [] }; host.push(h); } h.remaining -= w.c; h.workloads.push(w); } return host; } // Escape model: the boundary falls on one workload, and every workload on the same host is reached. export function escape(host, startWorkload) { const h = host.find((x) => x.workloads.some((w) => w.name === startWorkload)); const reached = h.workloads; const ours = reached.filter((w) => w.tenant === "measurement"); const others = reached.filter((w) => w.tenant !== "measurement"); const items = reach(ours.map((w) => w.identity)); return { host: h.name, workloads: reached.length, ownWorkloads: ours.length, otherWorkloads: others.length, otherTenants: new Set(others.map((w) => w.tenant)).size, identities: new Set(reached.map((w) => w.identity)).size, items: items.size }; } const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0])) .join(""); if (import.meta.url === `file://${process.argv[1]}`) { const row = gen([24, 8, 11, 14, 20, 15, 10]); console.log(`${WORKLOAD.length} workloads, ${new Set(WORKLOAD.map((w) => w.tenant)).size} tenants, ` + `host capacity 8 cores; total demand ${WORKLOAD.reduce((t, w) => t + w.c, 0)} cores`); console.log("\n" + row(["separation rule", "hosts", "occupancy", "escape host", "workloads reached", "other tenants", "radius"])); for (const [name, separation] of [["none (shared)", "none"], ["by tenant", "tenant"], ["by identity", "identity"]]) { const h = place(separation); const r = escape(h, "portal-1"); const occupancy = Math.round(100 * WORKLOAD.reduce((t, w) => t + w.c, 0) / (h.length * 8)); console.log(row([name, h.length, `${occupancy}%`, r.host, `${r.workloads} (${r.ownWorkloads} own)`, r.otherTenants, `${r.items}/28`])); } // Escape from every workload: worst-case and average radius, in the shared and tenant-separated setups. console.log(); const row2 = gen([24, 14, 12, 18, 16]); console.log(row2(["separation rule", "worst case", "average", "to other tenant", "own items"])); for (const [name, separation] of [["none (shared)", "none"], ["by tenant", "tenant"], ["by identity", "identity"]]) { const h = place(separation); const r = WORKLOAD.filter((w) => w.tenant === "measurement").map((w) => escape(h, w.name)); const avg = (r.reduce((t, x) => t + x.items, 0) / r.length).toFixed(1); console.log(row2([name, `${Math.max(...r.map((x) => x.items))}/28`, avg, `${r.filter((x) => x.otherWorkloads > 0).length}/${r.length} escapes`, `${(r.reduce((t, x) => t + x.otherWorkloads, 0) / r.length).toFixed(1)} workloads`])); } }
18 workloads, 3 tenants, host capacity 8 cores; total demand 41 cores separation rule hosts occupancy escape host workloads reached other tenants radius none (shared) 6 85% host-1 3 (1 own) 2 18/28 by tenant 7 73% host-1 4 (4 own) 0 21/28 by identity 13 39% host-1 2 (2 own) 0 18/28 separation rule worst case average to other tenant own items none (shared) 18/28 12.5 7/8 escapes 1.3 workloads by tenant 21/28 16.9 0/8 escapes 0.0 workloads by identity 18/28 8.5 0/8 escapes 0.0 workloads
The top table looks at a single escape. In the shared placement, the portal workload’s host has three workloads, and only one of them is our own tenant’s; the escape reaches two other tenants. Eighteen of our own items are reached. In the tenant-separated placement, all four workloads on the same host are ours, and the radius climbs to 21. Cross-tenant escape is zero; our own radius is bigger.
Our Own Item and the Other Tenant
The bottom table runs the escape separately from each of the eight workloads and shows the two columns moving in opposite directions. In the shared placement, our own worst-case radius is 18, average 12.5; but seven of the eight escapes reach another tenant, and each escape reaches an average of 1.3 foreign workloads. Separating by tenant brings those seven escapes to zero — no escape crosses the tenant boundary — but it raises our own worst-case radius from 18 to 21, and the average from 12.5 to 16.9.
The reason it rises is clear and must be stated: separating by tenant concentrates our own workloads on the same hosts. When the foreign neighbor is removed, our own neighbor takes its place, and our own neighbor’s identity is a node in the access graph. Tenant separation is done not because it lowers the measure, but because it lowers someone else’s measure.
Separating by identity does not rescue the worst case either: it still reads 18. The reason is that a single identity is a hub in the graph — the portal’s instance-bound identity alone reaches 18 items, whether or not it has a neighbor. What separating by identity lowers is the average: from 12.5 to 8.5. Playing with placement never shortens a hub identity’s own radius.
The Neighbor’s Invisibility
All three columns in the table look measurable, but only one of them can actually be measured by the tenant. The tenant cannot count which host it stands on or who else is on that host: the placement criterion is a request made to the provider, and its answer shows up neither in a log nor in an inventory. The “to other tenant” column can be computed in this model because we built the placement ourselves; a real tenant never sees that number directly.
On the detection side, this is another form of the first lesson’s unlogged-step class. When an escape happens, the only thing that can show up in the tenant’s log is the use of its own identities: the move to the neighboring workload, movement across the host, and the spillover into a foreign tenant fall into none of the tenant’s logs. This is why the measure of cross-tenant escape is not a detection number for the tenant, but a contract question: which separation criterion is applied, how it is verified, and whether the tenant will be notified when an incident occurs in the neighbor set must be written down in advance. If it is not written down, the last column of the table is empty for the tenant.
Isolation Strength or Placement
// isolation.mjs — isolation strength and placement separation are measured separately. IT IS A MODEL. import { WORKLOAD, place, escape } from "./placement.mjs"; // Isolation type: the number of shared items and the number of items that must be wrong AT THE // SAME TIME to cross the boundary were measured in the IT and Systems Foundations course; here they // are INPUT. overhead: extra core requirement per workload (the guest system's own load). const ISOLATION = { "container (same core)": { shared: 12, required: 1, overhead: 0 }, "guest (hypervisor)": { shared: 5, required: 3, overhead: 1 }, }; const SEPARATION = [["none", "none"], ["tenant", "tenant"], ["identity", "identity"]]; const OURS = WORKLOAD.filter((w) => w.tenant === "measurement"); const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0])) .join(""); const row = gen([25, 10, 10, 8, 11, 12, 18]); console.log(row(["isolation", "separation", "required", "hosts", "occupancy", "worst case", "to other tenant"])); const result = {}; for (const [name, y] of Object.entries(ISOLATION)) for (const [label, separation] of SEPARATION) { const host = place(separation, y.overhead); const demand = WORKLOAD.reduce((t, w) => t + w.c + y.overhead, 0); const r = OURS.map((w) => escape(host, w.name)); const cross = r.filter((x) => x.otherWorkloads > 0).length; result[`${name}|${separation}`] = { worst: Math.max(...r.map((x) => x.items)), cross, host: host.length, avg: (r.reduce((s, x) => s + x.items, 0) / r.length).toFixed(1) }; console.log(row([name, label, y.required, host.length, `${Math.round(100 * demand / (host.length * 8))}%`, `${Math.max(...r.map((x) => x.items))}/28`, `${cross}/${OURS.length} escapes`])); } const b = result["container (same core)|none"], k = result["guest (hypervisor)|none"]; const ba = result["container (same core)|tenant"], ka = result["guest (hypervisor)|identity"]; console.log(`\nstrengthening isolation takes the required item count from 1 -> 3; on the same ` + `placement, both isolation types give the same radius (${b.worst}/28). Isolation type changes ` + `the crossing condition, not the items reached once crossed`); console.log(`the guest system's one-core-per-workload overhead takes the host count from ` + `${b.host} -> ${k.host}; the worst-case radius stays the same at ${k.worst}/28`); console.log(`separating by tenant lowers cross-tenant escapes from ${b.cross}/${OURS.length} -> ` + `${ba.cross}/${OURS.length} but increases our own radius: worst case ${b.worst} -> ${ba.worst}, ` + `average ${b.avg} -> ${ba.avg}`); console.log(`separating by identity does not lower the worst-case radius (${ka.worst}/28) because ` + `the widest identity alone reaches that many items; what it lowers is the average: ` + `${b.avg} -> ${ka.avg}. The cost is ${b.host} -> ${ka.host} hosts`);
isolation separation required hosts occupancy worst case to other tenant container (same core) none 1 6 85% 18/28 7/8 escapes container (same core) tenant 1 7 73% 21/28 0/8 escapes container (same core) identity 1 13 39% 18/28 0/8 escapes guest (hypervisor) none 3 8 92% 18/28 8/8 escapes guest (hypervisor) tenant 3 9 82% 18/28 0/8 escapes guest (hypervisor) identity 3 13 57% 18/28 0/8 escapes strengthening isolation takes the required item count from 1 -> 3; on the same placement, both isolation types give the same radius (18/28). Isolation type changes the crossing condition, not the items reached once crossed the guest system's one-core-per-workload overhead takes the host count from 6 -> 8; the worst-case radius stays the same at 18/28 separating by tenant lowers cross-tenant escapes from 7/8 -> 0/8 but increases our own radius: worst case 18 -> 21, average 12.5 -> 16.9 separating by identity does not lower the worst-case radius (18/28) because the widest identity alone reaches that many items; what it lowers is the average: 12.5 -> 8.5. The cost is 6 -> 13 hosts
Two Levers
The table lays the two levers side by side and shows that they are not written into the same column. Isolation strength changes the “required” column: crossing the boundary in a partition sharing the same core takes only one item being wrong, while at the guest-system boundary three items must be wrong at the same time. This is the boundary’s crossing condition. Placement separation, on the other hand, changes the “worst case” and “to other tenant” columns; it determines what is found once the boundary is crossed.
That the two must not be confused shows up in the table’s two separation rows: with no placement separation and with separation by identity alike, container and guest isolation give the same number in the “worst case” column. Moving to the guest system takes the host count from 6 to 8, moves occupancy from 85% to 92%, and leaves the radius at 18. This is the course’s second example of a “control that does not lower the radius”; its difference from the first — the log — is that this time the control genuinely does something: it raises the boundary’s crossing condition from one item to three, but it does not touch the number of items reached once crossed.
The cost column must also be read. Separating by identity zeroes out cross-tenant escape and lowers the average from 12.5 to 8.5, but leaves the worst case at 18; in return, the host count climbs from 6 to 13 and occupancy drops from 85% to 39%. The middle option — separating by tenant — brings cross-tenant escape from seven escapes to zero, adds one host, and grows our own worst-case radius by three items. None of the three rows brings the worst case below 18; the only way to lower it lies outside this lesson, and it runs through narrowing the hub identity’s own authority.
Summary
- In the shared placement, a container escape reaches 18 of our own items in the worst case; seven of the eight escapes reach another tenant, and each escape makes an average of 1.3 foreign workloads reachable.
- Separating by tenant brings cross-tenant escape from seven to zero, but raises our own worst-case radius from 18 to 21, and the average from 12.5 to 16.9: when the foreign neighbor is removed, our own neighbor takes its place.
- Separating by identity leaves the worst case at 18 and only lowers the average from 12.5 to 8.5; the cost is the host count climbing from 6 to 13 and occupancy dropping from 85% to 39%.
- Isolation strength changes the boundary’s crossing condition: one item is enough in a partition sharing the same core, while the guest-system boundary requires three items to be wrong at the same time.
- The radius is 18 or 21 in all six rows; neither the isolation type nor the guest system’s extra core load lowers it, because the hub identity alone reaches that many items.
- The tenant cannot count the neighbor set: the placement criterion is a request made to the provider, and the part of the escape that spills into the neighbor falls into none of the tenant’s logs, which is why it must be written into the contract.
Next Step
This entire lesson’s measurement assumed that the workload is standing at the moment of escape: it sits on the host, its identity is usable, its neighbor is visible. Some workloads do not satisfy this assumption. In a function-execution environment, a workload lasts seconds, finishes its work, and disappears; it leaves behind neither a host record nor a process to examine. The next lesson measures the window in which a log can be collected for these workloads: how many seconds a workload lives, and how many items are lost if they are not collected while it lives.
To keep your progress and take notes, Log in
My notes
Log in to take notes.