---
title: 'Container and Hypervisor Security'
source: 'https://academia.sh/en/courses/cloud-security/container-and-hypervisor-security'
course: 'Cloud and Virtualization Security'
language: en
updated: '2026-09-26T03:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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

```js
// 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

```js
// 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.
