Chapter 3 — Assume breach: lateral movement, persistence, and containment by design
In one sentence
Design as if the attacker already has a foothold, and judge every control by how much it slows their movement, how quickly it forces them to make noise, and how small it keeps the blast radius.
Where we are
Prevention fails eventually — a phish lands, a token leaks, a dependency is poisoned (G1 Ch 47). Assume-breach architecture accepts that and optimises for containment and detection instead of pretending the perimeter holds.
How it works
The attacker's post-foothold loop:
ORIENT where am I? what identity do I have? what can it reach?
(whoami, env vars, metadata, mounted secrets, token scopes,
network neighbours)
ESCALATE get a more powerful identity (IAM privesc, k8s RBAC abuse,
sudo, a service token)
MOVE reach the next system (reused creds, trust relationships,
exposed internal services, SSRF, mesh calls)
PERSIST survive a reboot / rotation (new IAM key, cron, k8s
CronJob/DaemonSet, OAuth grant, SSH key, webhook)
COLLECT find and stage the data
EXFIL move it out (cloud storage in the attacker's account, DNS,
a webhook, a pastebin)
Each step is an opportunity to CONTAIN (make it impossible) or DETECT
(make it loud).
Containment-by-design controls, mapped to the loop:
ORIENT minimise ambient discovery: no broad `list`/`describe`, no
wildcard read, workload identity scoped to exactly what the
service needs, secrets injected not mounted broadly, network
default-deny so "who's my neighbour" returns nothing.
ESCALATE remove privesc edges (Part 2 Ch 6, Part 4 Ch 17); permission
boundaries / SCPs cap the ceiling; no standing admin (JIT).
MOVE segmentation (G1 Ch 39) + identity on every hop (mTLS, Part 5)
so a reachable service still refuses an unauthenticated or
wrong-identity call; unique creds per service (no shared DB
user); short-lived everything.
PERSIST immutable infra (no in-place changes; drift = alert);
short-lived credentials so a stolen one dies; detections on
"new IAM key created", "new admission webhook", "new
CronJob", "OAuth app authorised".
COLLECT tenant isolation + data-access limits (Part 7) so even broad
read can't scoop everything; canary records that alert on
access.
EXFIL egress default-deny with an allowlist; DLP on known channels;
block/alert on writes to storage outside your accounts;
detections on large or unusual outbound.
The blast-radius question, for every component: "if this is fully compromised, exactly what is now exposed, and what stops it spreading further?" Write the answer down. If it's "everything", you have a flat trust domain and that's the finding.
Worked example
Assume-breach analysis of the web pod in SecureShop.
FOOTHOLD: RCE in the `web` pod (say, an SSTI -- Part 6 Ch 36). Attacker
has a shell as the `web` container's user.
ORIENT -- what do they find?
env: DATABASE_URL? (bad -- should be injected per-request or not here)
mounted SA token: `system:serviceaccount:edge:web` -- what can it do?
IMDS: reachable? IMDSv2 hop-limit-1 -> only from the node, and the pod
is not the node -> creds not directly reachable (good).
network: can it reach `core/orders-api`? mesh says yes, but mTLS +
an OpenFGA check means an unauthenticated call is refused (good).
Can it reach the DB directly? NetworkPolicy: `data` namespace only
accepts `core` -> no (good).
ESCALATE -- SA `edge:web` RBAC: `get`/`list` on pods/services in
`edge` only, no secrets, no create. `kubectl auth can-i --list`
confirms. No obvious privesc. (If it had `create pods` -> node ->
etcd; Ch 17.)
MOVE -- best option is to abuse `web`'s legitimate identity: call
`orders-api` as `web`. OpenFGA only lets `web` do `create:order` for
the authenticated end-user in the request context -> attacker can't
read arbitrary orders without a valid user session. Blast radius so
far: whatever `web` is legitimately allowed to do on behalf of
whoever is currently logged in. Contained.
PERSIST -- container FS is read-only; pod will be rescheduled on any
node drain; no cron. Persistence requires escalating first.
DETECT -- do we see the shell? Falco rule "shell spawned in container"
+ "outbound connection from `web` to a non-mesh destination" +
"process not in the `web` image's known set". If those fire, IR
starts (Ch 55).
VERDICT: blast radius of a full `web` compromise = "act as `web` for
the current request context; read nothing cross-tenant; no host, no
cluster, no DB". That's a designed outcome, and each "no" above is a
specific control you can point to. The gaps (env has DATABASE_URL;
detection unproven) become tickets.
Real-world scenario: Google's post-Aurora shift to assume-breach
Google has publicly documented (in its own "BeyondCorp" and
"BeyondProd" engineering papers) that its move toward an explicitly
assume-breach, zero-trust architecture was driven directly by a real
intrusion: "Operation Aurora" (disclosed January 2010), a
state-sponsored campaign that compromised Google's (and several other
major companies') internal corporate networks via a browser
zero-day and then moved laterally, treating the internal network as
implicitly trusted -- exactly the flat-trust-domain failure mode this
chapter's Chapter 3 opens with.
Google's own account of the resulting redesign maps directly onto
this chapter's containment-by-design table: no more implicit trust
from network location (every internal service authenticates every
caller, regardless of which network segment the call came from),
workload identity on every hop (the direct ancestor of this guide's
Part 5 SPIFFE material), and a deliberate assumption that ANY given
machine or credential could already be compromised. It's one of the
most influential real-world adoptions of the exact posture this
chapter teaches, published in enough technical detail to study
directly.
Practice (45 min)
- For SecureShop (or your system), pick three components: a front-end, an internal API, a CI runner. For each, write the ORIENT -> ESCALATE -> MOVE -> PERSIST -> COLLECT -> EXFIL analysis assuming full compromise.
- For every step, note: is it contained (impossible) or only detected (loud) or neither? Neither = a ticket.
- Write the one-sentence blast-radius statement for each component. Any that read "broad" or "everything" get escalated.
- Compare with your Chapter 0.6 instinct for question 1 ("shell in the web pod").
Common mistakes
- Perimeter as the plan. "We have a WAF and a VPN" is not containment.
- Flat trust domains inside the perimeter. The office network, the VPC, the cluster, the "internal" API tier — if everything inside trusts everything inside, one foothold is total.
- Standing privileges. Long-lived admin creds, permanent break-glass, a CI role that can deploy anywhere — all are persistence gifts.
- Detection you've never tested. "We'd see that" — did you run it (Ch 2)?
- No blast-radius statement. If nobody has written "if X falls, Y is exposed", nobody actually knows.
Check yourself
- List the steps of the post-foothold loop and one containment control for each.
- What is a "blast-radius statement" and when is it a finding?
- Why are long-lived credentials specifically a persistence problem?
- Contain vs detect: what's the difference in outcome, and why do you want both?
- What makes a trust domain "flat", and why is that dangerous under assume-breach?
Further reading
- Model: Google BeyondProd paper (assume-breach for microservices); NIST SP 800-207 (Zero Trust) re-read through an assume-breach lens.
- Report: Mandiant M-Trends (annual) — real dwell times, lateral-movement and persistence techniques seen in incidents.
- Talk: "Assume Breach" talks from BlackHat/BSides; the "Prevent, Detect, Respond" framing from the SANS blue-team courses.
- Book: Building Secure and Reliable Systems (Google) — Ch. on understanding and containing compromise.