Part 1 — Attack paths and adversary thinking
Before any cloud or Kubernetes specifics: the mental models that make the rest of this guide cohere. Advanced defenders reason about graphs, blast radius, and what an attacker does after the first step.
Chapter 1 — From vulnerabilities to attack paths
In one sentence
An attack path is a chain of individually-permitted steps that together move an attacker from where they are to something they want, and security work is finding those chains and cutting them at the cheapest point.
Where we are
G1 taught you to find and fix vulnerabilities one at a time. That scales badly: a real system has thousands of findings, most low-severity, and a scanner cannot tell you which three of them line up into a breach. Attackers do not think in findings. They think: I have this; it lets me reach that; that gives me the next thing.
How it works
Model the system as a directed graph.
NODES principals (users, roles, service accounts, workloads),
resources (buckets, DBs, secrets, hosts, clusters),
and "positions" (network zones, a shell in a pod).
EDGES "can do X to Y" -- an edge exists if some permission, trust
relationship, network route, or vulnerability allows it.
Label each edge with what enables it and how noisy it is.
An ATTACK PATH is a walk from a START node (attacker's assumed position)
to a GOAL node (an asset). The PATH's severity is a property of the
whole walk, not the worst single edge.
Example graph fragment (SecureShop):
[internet:anon]
| HTTP request (always)
v
[svc:web pod] --(SSRF in image proxy)--> [metadata endpoint]
| | IMDSv1: no token needed
| (mesh mTLS, but web CAN call...) v
v [role:web-node-role]
[svc:catalog-api] | s3:GetObject on exports-bucket
| (over-broad OpenFGA relation) v
v [data:exports-bucket] <-- GOAL
[data:orders table, tenant-scoped] = per-tenant invoices, all tenants
Find the chokepoints. A chokepoint is an edge that appears in many paths to
high-value goals. Cutting it removes the most risk per unit of effort. In the
fragment above, IMDSv1 enabled is a chokepoint: enforcing IMDSv2 with
hop-limit 1 breaks every SSRF-to-role path at once, cheaply, with no
application change.
Score the path, not the finding.
path_risk ~= (reachability of the start)
x (product of edge feasibilities)
x (value of the goal)
/ (probability you detect it in time)
Two "medium" edges that chain to customer PII with no detection =
a critical, and a P1 to fix. The same two edges with a high-fidelity
alert on the middle one = a managed risk.
Worked example
Take three real findings from a SecureShop scan and turn them into a decision.
F1 "S3 bucket `ss-exports` has no explicit deny for principals outside
the account" (scanner: LOW)
F2 "IAM role `web-node-role` has `s3:GetObject` on `arn:aws:s3:::ss-*`"
(scanner: INFO -- "review broad resource")
F3 "`/img?url=` parameter fetches arbitrary URLs; no allowlist"
(scanner: MEDIUM -- SSRF)
Path: anon -> F3 SSRF -> http://169.254.169.254/... (IMDSv1) ->
web-node-role creds -> F2 s3:GetObject -> `ss-exports` -> all
tenants' invoices.
Decision:
- The PATH is CRITICAL (anon -> cross-tenant PII, no detection today).
- Cheapest cut: enforce IMDSv2 hop-limit-1 on the node group
(one Terraform line, no app change) -> path broken.
- Second cut: F3 -- allowlist the image host (app change, 1 day).
- Third cut: F2 -- scope the role to `ss-public-assets` only, move
exports to a bucket only `exports-worker` can read (1-2 days).
- Add: GuardDuty `UnauthorizedAccess:IAMUser/InstanceCredential
Exfiltration` + a CloudTrail rule for `web-node-role` used from a
non-EC2 IP (detection for the residual risk).
You fixed a "critical" by shipping a one-line change today, and
scheduled the defence-in-depth. That prioritisation is the skill.
Real-world scenario: the SolarWinds attack path
The 2020 SUNBURST campaign (publicly attributed to a Russian
state-sponsored actor, tracked as UNC2452/Nobelium) is the clearest
real illustration of a multi-hop attack path at nation-state scale:
[build system] -> attackers compromised SolarWinds' SOFTWARE BUILD
PIPELINE itself (Part 3 Ch 13's exact threat model) and inserted
a backdoor into the Orion platform's legitimately-signed update.
[trusted update] -> ~18,000 organisations installed the trojaned,
validly-signed update -- the SIGNATURE was real; the SOURCE was
compromised (precisely why signing alone, without build
provenance, wasn't enough).
[selective activation] -> the backdoor stayed dormant in most
installs and activated only in a small number of high-value
targets, evading broad detection.
[cloud persistence] -> in several victim environments, the
attackers escalated to Active Directory Federation Services and
forged SAML assertions (a real-world Golden SAML, Ch 39) for
long-term, hard-to-detect access to Microsoft 365 and other
federated cloud services.
EVERY hop here is a chokepoint this chapter would flag: build-system
trust, update-signing verification, and IdP token-signing key
protection were each, independently, a place the whole chain could
have been cut far more cheaply than remediating a live nation-state
intrusion across thousands of victims.
Practice (45 min)
- Take a system you know (or SecureShop). List 8-12 nodes (principals, resources, positions) and draw every "can do X to Y" edge you're sure of. Label each edge with what enables it.
- Pick two goal nodes (something with customer data; something that grants broad control — a CI role, cluster-admin). Trace every path to each from "anonymous internet" and from "shell in a front-end pod".
- For each path, write the cheapest single edge to cut and what it costs.
- Identify your top chokepoint — the edge in the most high-value paths.
- Tools to graph this for real (later Parts): PMapper and Cartography (AWS/GCP identity + resource graphs), BloodHound / AzureHound (AD / Entra), KubeHound (Kubernetes attack paths), Cloud Katana. Run PMapper against your sandbox account once you've done Part 2.
Build it in Go (30 min) — find the chokepoints in an attack graph
Attack-path thinking becomes concrete once the graph is data. This program models SecureShop as "an attacker who controls X can reach Y" edges, lists every path from the internet to customer data, and then remediates greedily: fix the node on the most paths, re-plan, repeat.
// attackpath: model a system as a graph of "an attacker who controls X can
// reach Y", find every path from the internet to the crown jewels, and find
// the CHOKEPOINTS -- nodes that every path passes through. Fix a chokepoint
// and you cut every path at once.
//
// go run ./attackpath
package main
import (
"fmt"
"slices"
"sort"
"strings"
)
type edge struct {
to, how string
}
// SecureShop, simplified: each edge is one attacker step.
var graph = map[string][]edge{
"internet": {{"web-app", "exploit SSRF in image import"}, {"ci-runner", "malicious pull request"}, {"support-laptop", "phishing"}},
"web-app": {{"instance-metadata", "SSRF to 169.254.169.254"}, {"web-role", "code execution on the pod"}},
"instance-metadata": {{"web-role", "steal role credentials"}},
"ci-runner": {{"deploy-role", "read CI secrets"}},
"support-laptop": {{"admin-console", "session theft"}},
"admin-console": {{"customer-db", "export feature"}},
"web-role": {{"s3-exports", "s3:GetObject *"}, {"kms-tenant-keys", "kms:Decrypt *"}},
"deploy-role": {{"web-role", "iam:PassRole"}, {"kms-tenant-keys", "kms:* on all keys"}},
"s3-exports": {{"customer-data", "read exports"}},
"kms-tenant-keys": {{"customer-data", "decrypt everything"}},
"customer-db": {{"customer-data", "SELECT *"}},
}
// paths returns every simple path from src to dst (depth-first search).
func paths(src, dst string) [][]string {
var out [][]string
var walk func(node string, path []string)
walk = func(node string, path []string) {
path = append(path, node)
if node == dst {
out = append(out, slices.Clone(path))
return
}
for _, e := range graph[node] {
if !slices.Contains(path, e.to) { // no cycles
walk(e.to, path)
}
}
}
walk(src, nil)
return out
}
// chokepoints counts how many paths cross each intermediate node, most first.
func chokepoints(all [][]string) []string {
count := map[string]int{}
for _, p := range all {
for _, n := range p[1 : len(p)-1] {
count[n]++
}
}
var nodes []string
for n := range count {
nodes = append(nodes, n)
}
sort.Slice(nodes, func(i, j int) bool {
if count[nodes[i]] != count[nodes[j]] {
return count[nodes[i]] > count[nodes[j]]
}
return nodes[i] < nodes[j] // stable output for ties
})
return nodes
}
func remove(node string) {
delete(graph, node)
for from, es := range graph {
graph[from] = slices.DeleteFunc(es, func(e edge) bool { return e.to == node })
}
}
// report prints every path and the top chokepoints, and returns the worst one.
func report(title string) string {
all := paths("internet", "customer-data")
fmt.Printf("== %s: %d attack paths to customer-data\n", title, len(all))
for _, p := range all {
fmt.Println(" ", strings.Join(p, " -> "))
}
cps := chokepoints(all)
if len(cps) == 0 {
return ""
}
count := 0
for _, p := range all {
if slices.Contains(p, cps[0]) {
count++
}
}
fmt.Printf("top chokepoint: %s (on %d of %d paths)\n\n", cps[0], count, len(all))
return cps[0]
}
func main() {
// Greedy remediation: fix the node that cuts the most paths, re-plan, repeat.
// "Fixing" a node means making it unusable as a step: least privilege for a
// role, IMDSv2 for metadata, a hardened runner, phishing-resistant MFA...
for round := 1; ; round++ {
worst := report(fmt.Sprintf("round %d", round))
if worst == "" {
fmt.Println("no paths left: customer-data is unreachable from the internet")
return
}
fmt.Printf("-> fix %q\n\n", worst)
remove(worst)
}
}
$ go run ./attackpath
== round 1: 8 attack paths to customer-data
internet -> web-app -> instance-metadata -> web-role -> s3-exports -> customer-data
internet -> web-app -> instance-metadata -> web-role -> kms-tenant-keys -> customer-data
...
internet -> ci-runner -> deploy-role -> kms-tenant-keys -> customer-data
internet -> support-laptop -> admin-console -> customer-db -> customer-data
top chokepoint: web-role (on 6 of 8 paths)
-> fix "web-role"
== round 2: 2 attack paths to customer-data
top chokepoint: admin-console (on 1 of 2 paths)
-> fix "admin-console"
== round 3: 1 attack paths to customer-data
top chokepoint: ci-runner (on 1 of 1 paths)
-> fix "ci-runner"
== round 4: 0 attack paths to customer-data
no paths left: customer-data is unreachable from the internet
What to notice:
- One fix removed 6 of 8 paths. Scoping
web-role(least privilege on the role the web tier runs as) matters more than any single vulnerability fix on the web app. That's the chapter's argument, in numbers. - The intuitive fix wasn't the best one. The first draft of this lab removed the KMS node, the "obvious" crown-jewel protection, and it cut only 4 of 8 paths. Let the graph choose.
- Three fixes closed every path, spread across identity (
web-role), people (admin-consolebehind phishing-resistant MFA), and supply chain (ci-runner). Real programmes fail by funding only one of those.
Exercises:
- Give each edge a cost (how hard the step is) and find the cheapest path with Dijkstra's algorithm. That's what tools like BloodHound compute for Active Directory.
- Load the graph from your cloud inventory: roles as nodes,
sts:AssumeRoleand resource policies as edges (Chapter 6). - Feed the Chapter 54 detections into it. Which path has no detection on any step?
Common mistakes
- Treating the scanner's severity as the answer. Severity is per-finding; risk is per-path. A pile of "lows" can be a "critical".
- Fixing the last edge, not the cheapest. You don't have to fix the SSRF today if IMDSv2 breaks the path in one line today.
- Ignoring the detection term. A path you'd catch instantly is not the same risk as an identical path with no telemetry.
- Static graphs. Permissions, routes, and deployments change weekly. Attack-path analysis is continuous (a pipeline check), not a one-off.
- Only modelling "in". Model post-foothold movement — that's where assume- breach lives.
Check yourself
- What is an attack path, and why is its severity not just the max of its edges' severities?
- Define a chokepoint and why it's the highest-ROI place to cut.
- Give the rough factors in "path risk" and what each represents.
- Name two tools that build identity/attack-path graphs for cloud or AD.
- Why must attack-path analysis be continuous?
(Answers: Appendix G.)
Further reading
- Talk: "Attack Graphs" / BloodHound origin talks (Andy Robbins, Rohan Vazarkar, Will Schroeder) — DerbyCon/BlackHat. The idea that reframed AD security.
- Tool docs: PMapper (
nccgroup/PMapper), Cartography (Lyft), KubeHound (Datadog), BloodHound Community Edition. - Paper: "Practical Attack Graph Generation for Network Defense" (Ou et al.) for the academic roots; and MITRE's "Attack Flow" project for a modern notation.
- Book: Designing Secure Software, Loren Kohnfelder — Ch. on attacker modelling.