Lesson 11 / 12
Cloud Attack Patterns
Counting thirteen patterns on the same graph with the item-step-log-control quadruple: eight of the patterns open with a step that falls into a management-plane log, three with a step that stays only at the provider, one with a step that falls into no log at all. The pattern with the largest contribution — the instance-bound identity being given without session verification — on its own raises the radius from 6 to 23, and it is in the unlogged class. The order of controls determines the cost: by unit benefit, one control and two cost; starting from cheapest, eight controls and eight cost reach the same radius.
Published Updated
In this lesson
- Lists cloud attack patterns with the item, the step it opens, the log class, and the closing control as a quadruple
- Measures each pattern's contribution to the radius, and names the pattern that removes no items
- Applies the controls in two different orders and compares the cost of reaching the same radius
Contents
The four lessons up to this point measured items one at a time: a step a log did not see, a drift born after deployment, a neighbor sharing a host, a token that outlives its owner. Each was counted in its own context, but the connection between them was not written down. This lesson gathers that connection at the pattern level and writes each pattern with four fields: which item is misconfigured, which step it opens, which log that step falls into, which control closes it.
The patterns are told as a model. No intrusion step, no call sequence, no endpoint path, no request format, and no credential-harvesting method is written down; the code that runs is only a graph walk and a control evaluation. Testing any of these patterns in a cloud environment, without written authorization and a defined scope, is both illegal and unprofessional. The measurement is on the regional measurement network and is fictional.
CD25. Every pattern corresponds to a misconfigured item, and that item corresponds to an edge; if the item is closed, the edge cannot be walked. CD26. The pattern’s “step it opens” is the edge’s step type; the log class comes from the first lesson’s classification. CD27. A pattern’s contribution is the number of items the radius drops by when that item alone is closed. CD28. Every pattern has a closing control; the control changes the number of preconditions that must be satisfied for the pattern to work (model input). CD29. A control’s cost is the number of legitimate tasks it stops (fictional unit). CD30. Ordering is done by two metrics: the control that removes the most items per unit cost, or simply the cheapest control.
A Catalog at the Pattern Level
// pattern.mjs — cloud attack patterns AT THE PATTERN LEVEL: which edge opens when which item is // misconfigured, which log class that edge falls into, how far the radius drops when it is closed. IT IS A MODEL. // No intrusion step, call sequence, endpoint path, or request format is written down. const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|")); // Edge: source|destination|step type|duration(min)|misconfigured item that enables the edge ("-" if legitimate) export const EDGE = split(` portal-service-account|portal-instance|network-connection|3|- portal-service-account|reader-role|role-assumption|4|- portal-instance|portal-instance-identity|instance-identity-retrieval|2|instance-identity-sessionless portal-instance-identity|subscriber-database|database-query|6|- portal-instance-identity|secret-store|secret-read|4|portal-identity-secret-read secret-store|collection-workload-identity|secret-read|3|secret-store-long-lived-identity collection-workload-identity|raw-meter-bucket|object-read|5|- collection-workload-identity|measurement-queue|queue-consumption|4|- collection-workload-identity|monitoring-role|role-assumption|4|role-assumption-to-workload monitoring-role|measurement-dashboard|resource-listing|3|- portal-instance-identity|billing-batch-role|role-assumption|3|portal-identity-batch-role billing-batch-role|billing-archive-bucket|object-read|6|- billing-batch-role|key-management|key-decrypt|4|key-usage-unconditional key-management|customer-key|key-decrypt|2|key-policy-no-condition billing-batch-role|backup-role|role-assumption|3|chained-role-assumption backup-role|backup-bucket|object-read|7|- backup-role|deployment-role|role-assumption|4|deployment-to-backup-role deployment-role|template-store|object-read|5|template-store-open-write deployment-role|configuration-bucket|object-read|4|- deployment-role|workload-instance|in-instance-execution|9|deployment-role-instance-launch reader-role|report-bucket|object-read|4|- reader-role|log-bucket|object-read|5|log-bucket-open-read reader-role|configuration-bucket|object-read|6|configuration-bucket-broad-read`) .map(([k, h, t, d, y]) => ({ src: k, dst: h, type: t, dur: +d, flaw: y })); // Whether the step type's log reaches the tenant's hands (the first lesson's classification). const LOG = { "role-assumption": "management", "resource-listing": "management", "secret-read": "management", "key-decrypt": "management", "database-query": "application", "in-instance-execution": "agentless", "object-read": "provider-side", "queue-consumption": "provider-side", "instance-identity-retrieval": "unlogged", "network-connection": "unlogged" }; // Pattern: name|triggering misconfigured item|closing control|precondition count before the control|after // The step counts are the number of preconditions that must be satisfied for the pattern to work. MODEL input. export const PATTERN = split(` identity-from-metadata-service|instance-identity-sessionless|per-session-verification|1|3 broad-access-to-secret-store|portal-identity-secret-read|scope-limit-secret-access|1|2 long-lived-identity-from-secret-store|secret-store-long-lived-identity|secret-store-short-lived-identity-only|1|3 role-assumption-from-workload|role-assumption-to-workload|separate-workload-identity-from-role|1|2 lateral-role-transition|portal-identity-batch-role|narrow-role-by-identity|1|2 unconditional-key-usage|key-usage-unconditional|add-condition-to-key-usage|1|2 unconditional-key-decrypt|key-policy-no-condition|add-condition-to-key-policy|1|2 role-chain|chained-role-assumption|chain-depth-limit|1|3 from-backup-to-deployment|deployment-to-backup-role|remove-deployment-from-backup-role|1|2 write-to-template-store|template-store-open-write|make-template-store-read-only|1|2 new-instance-from-deployment|deployment-role-instance-launch|remove-instance-authority-from-deployment-role|1|2 configuration-read|configuration-bucket-broad-read|narrow-bucket-policy|1|2 log-read|log-bucket-open-read|close-log-bucket|1|2`) .map(([name, item, control, before, after]) => ({ name, item, control, before: +before, after: +after })); export const ITEMS = PATTERN.map((k) => k.item); export const START = "portal-service-account"; // Walk with an open item set: items reached and edges walked. export function walk(open) { const arrival = { [START]: 0 }, steps = []; for (;;) { let best = null; for (const e of EDGE) { if (!(e.src in arrival) || e.dst in arrival) continue; if (e.flaw !== "-" && !open.has(e.flaw)) continue; const t = arrival[e.src] + e.dur; if (best === null || t < best.t) best = { e, t }; } if (best === null) return { items: Object.keys(arrival).length, steps }; arrival[best.e.dst] = best.t; steps.push(best.e); } } 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 all = new Set(ITEMS), full = walk(all); console.log(`${PATTERN.length} patterns, ${EDGE.length} edges; radius when everything is open ` + `${full.items}/28, steps walked ${full.steps.length}`); const tbl = gen([40, 28, 14, 12, 10]); console.log("\n" + tbl(["pattern", "step opened", "log", "radius", "contrib"])); const ranked = PATTERN.map((p) => { const a = new Set(ITEMS); a.delete(p.item); const edge = EDGE.find((e) => e.flaw === p.item); return { ...p, type: edge.type, log: LOG[edge.type], radius: walk(a).items }; }).sort((a, b) => a.radius - b.radius); for (const p of ranked) console.log(tbl([p.name, p.type, p.log, `${full.items} -> ${p.radius}`, full.items - p.radius])); const visible = ranked.filter((p) => p.log === "management").length; console.log(`\n${PATTERN.length} of the patterns, ${visible} open with a step that falls into ` + `a management-plane log, ${ranked.filter((p) => p.log === "provider-side").length} with a ` + `step that stays only at the provider, ${ranked.filter((p) => p.log === "unlogged").length} ` + `with a step that falls into no log at all`); const top = ranked[0]; console.log(`the largest contribution comes from the ${top.name} pattern: on its own ` + `${top.radius} -> ${full.items}, that is ${full.items - top.radius} items, and the step it ` + `opens is in the ${top.log} class`); console.log(`the control that closes that pattern (${top.control}) turns the required ` + `precondition count ${top.before} -> ${top.after}: the software requesting the identity ` + `must now also be verified at the session level`); }
13 patterns, 23 edges; radius when everything is open 23/28, steps walked 22 pattern step opened log radius contrib identity-from-metadata-service instance-identity-retrieval unlogged 23 -> 6 17 lateral-role-transition role-assumption management 23 -> 14 9 broad-access-to-secret-store secret-read management 23 -> 17 6 long-lived-identity-from-secret-store secret-read management 23 -> 18 5 role-chain role-assumption management 23 -> 18 5 from-backup-to-deployment role-assumption management 23 -> 20 3 role-assumption-from-workload role-assumption management 23 -> 21 2 unconditional-key-usage key-decrypt management 23 -> 21 2 unconditional-key-decrypt key-decrypt management 23 -> 22 1 write-to-template-store object-read provider-side 23 -> 22 1 new-instance-from-deployment in-instance-execution agentless 23 -> 22 1 log-read object-read provider-side 23 -> 22 1 configuration-read object-read provider-side 23 -> 23 0 13 of the patterns, 8 open with a step that falls into a management-plane log, 3 with a step that stays only at the provider, 1 with a step that falls into no log at all the largest contribution comes from the identity-from-metadata-service pattern: on its own 6 -> 23, that is 17 items, and the step it opens is in the unlogged class the control that closes that pattern (per-session-verification) turns the required precondition count 1 -> 3: the software requesting the identity must now also be verified at the session level
The table should be read by a single ordering: the contribution column. Of the thirteen patterns, one grows the radius by seventeen items, one by nine, one by six; the remaining ten together do not come close to any of these. The pattern at the bottom — the configuration bucket being open to broad reads — does not change the radius at all, because that bucket is already reachable through a legitimate edge from the deployment role. A pattern’s existence does not make it a risk item; what measures that is the question of how many items go missing when it is closed. The same measure also splits the middle of the list: two patterns each bring five items, but because they open from different step types, they do not close together with a single control.
The log column gives a second ordering, and it runs opposite to the first. Eight of the thirteen patterns open with a step that falls into a management-plane log: role assumption, secret read, key decrypt. Three open with a step that stays only at the provider, one with a step that leaves no log at all, and one in an instance with no logging agent. The eight patterns that fall into a log have a combined contribution of thirty-three items; the single pattern that falls into no log has a contribution of seventeen.
The Pattern the Log Does Not See
That one pattern must be told by name, because it both gives the largest contribution and falls into no log at all: the instance-bound identity being obtained from the instance metadata service.
The path the pattern opens is born from a single design decision. When an instance is given an identity, what is asked is not who the software requesting that identity is, but where it is running. The identity is the reward for running on the instance. The consequence is this: any software that has reached the instance through an unrelated flaw holds the identity just as much as the application legitimately running on that instance does. A bug in a web layer, code coming from a dependency, or a debugging tool — all of them ask for the same identity and get it. In the graph, this is the third step of the walk, and on its own it gains seventeen items.
The second consequence is on the detection side. Granting the identity is not a management-plane call in the tenant’s account; it happens inside the instance itself. It falls into no log the tenant can read. The step that grows the radius the most is, at the same time, the step that never appears in any log — the blind prefix from the first lesson was born from exactly this step.
The closing control is the identity-granting side changing the question: the identity is given to a requester verified at the session level. In the model, this raises the pattern’s precondition from one to three — running code on the instance is no longer enough; the requesting software must also carry a session credential given to it at launch. Software that reaches the instance afterward does not have this credential.
The Order of Controls
The contribution column alone does not give an order, because controls do not cost the same. The file below puts the same thirteen controls into two separate orderings and reruns the radius at every step: the first picks, at each step, the control that removes the most items per unit cost; the second picks only the cheapest one. The second metric is not a made-up comparison; it is the natural order for a team working with a constrained budget — the items that stop no one get closed first.
// priority.mjs — the order in which controls are closed determines how fast the radius drops. IT IS A MODEL. import { PATTERN, ITEMS, walk } from "./pattern.mjs"; // The number of legitimate tasks the control stops (fictional unit, MODEL input). const COST = Object.fromEntries(` per-session-verification|2 scope-limit-secret-access|1 secret-store-short-lived-identity-only|2 separate-workload-identity-from-role|1 narrow-role-by-identity|2 add-condition-to-key-usage|1 add-condition-to-key-policy|1 chain-depth-limit|3 remove-deployment-from-backup-role|1 make-template-store-read-only|2 remove-instance-authority-from-deployment-role|1 narrow-bucket-policy|1 close-log-bucket|1`.trim().replace(/\s{2,}/g, "\n").split("\n") .map((l) => { const [a, b] = l.trim().split("|"); return [a, +b]; })); const radius = (closed) => walk(new Set(ITEMS.filter((y) => !PATTERN.some((p) => p.item === y && closed.has(p.control))))).items; // Two orderings: "unit" picks, at each step, the control that removes the most items per unit // cost; "cheap" picks only the cheapest control. function order(metric) { const closed = new Set(), seq = []; let now = radius(closed), cost = 0; while (closed.size < PATTERN.length) { let best = null; for (const p of PATTERN) { if (closed.has(p.control)) continue; const drop = now - radius(new Set([...closed, p.control])); const score = metric === "unit" ? drop / COST[p.control] : -COST[p.control]; if (best === null || score > best.score) best = { p, drop, score }; } closed.add(best.p.control); cost += COST[best.p.control]; now -= best.drop; seq.push({ control: best.p.control, drop: best.drop, cost, radius: now }); } return seq; } const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0])) .join(""); const tbl = gen([6, 48, 9, 8, 40, 9, 8]); const A = order("unit"), B = order("cheap"); console.log(tbl(["step", "by unit benefit", "radius", "cost", "cheapest first", "radius", "cost"])); for (const i of [0, 1, 2, 3, 6, 9, 12]) console.log(tbl([i + 1, A[i].control, `${A[i].radius}/28`, A[i].cost, B[i].control, `${B[i].radius}/28`, B[i].cost])); const first = (s, y) => s.findIndex((x) => x.radius <= y); console.log(`\nbringing the radius below 10: by unit benefit ${first(A, 10) + 1} controls and ` + `${A[first(A, 10)].cost} cost; starting from cheapest ${first(B, 10) + 1} controls and ` + `${B[first(B, 10)].cost} cost`); console.log(`the first control alone brings the radius from 23 -> ${A[0].radius} and ` + `costs ${A[0].cost}; the remaining ${PATTERN.length - 1} controls bring ` + `${A.at(-1).cost - A[0].cost} more cost for ${A[0].radius - A.at(-1).radius} more items`); const zero = A.filter((s) => s.drop === 0); console.log(`${zero.length} controls remove no items at all and cost a total of ` + `${zero.reduce((t, s) => t + COST[s.control], 0)}; when all thirteen are closed, ` + `the radius is ${A.at(-1).radius}/28 and the total cost is ${A.at(-1).cost}`);
step by unit benefit radius cost cheapest first radius cost 1 per-session-verification 6/28 2 scope-limit-secret-access 17/28 1 2 narrow-bucket-policy 5/28 3 separate-workload-identity-from-role 17/28 2 3 close-log-bucket 4/28 4 add-condition-to-key-usage 15/28 3 4 scope-limit-secret-access 4/28 5 add-condition-to-key-policy 15/28 4 7 narrow-role-by-identity 4/28 10 narrow-bucket-policy 11/28 7 10 chain-depth-limit 4/28 15 secret-store-short-lived-identity-only 4/28 12 13 remove-instance-authority-from-deployment-role 4/28 19 chain-depth-limit 4/28 19 bringing the radius below 10: by unit benefit 1 controls and 2 cost; starting from cheapest 8 controls and 8 cost the first control alone brings the radius from 23 -> 6 and costs 2; the remaining 12 controls bring 17 more cost for 2 more items 10 controls remove no items at all and cost a total of 15; when all thirteen are closed, the radius is 4/28 and the total cost is 19
The Cost of Ordering
Both columns apply the same thirteen controls to the same graph and arrive at the same place: when all thirteen are closed, the radius is 4 and the total cost is 19. The difference is along the way. In the by-unit-benefit ordering, the first control brings the radius from 23 down to 6 and costs two; the remaining twelve controls spend seventeen cost to remove only two more items. In the cheapest-first ordering, the same radius arrives only after eight controls and a cost of eight.
Two sentences follow from this. First: the number of controls is not a measure. At the fourth step of the second column, four controls have been closed and the radius is still 15; at the first step of the first column, one control has been closed and the radius is 6. The question “how many controls were applied” cannot stand in for the question “how far did the radius drop.” Second: ten of the thirteen controls remove no items at all in this graph and together cost fifteen. Every one of them is individually defensible — adding a condition to the key policy, making the template store read-only, limiting the chain depth — but in this graph their contribution is zero, because the target of the edges they close is also reachable through another edge.
This does not mean those controls are unnecessary; it means the measure depends on the graph. When the graph changes — an edge becomes legitimate, an item is added — a zero-contribution control can suddenly become the highest-contribution one. This is why the ordering is not done once and set aside; it is rerun with every configuration change.
Summary
- Thirteen patterns were written on the same graph with the item, the step it opens, the log class, and the closing control as a quadruple; contributions start at 17, 9, 6, 5, 5 and drop to zero.
- Eight of the patterns open with a step that falls into a management-plane log, three with a step that stays only at the provider, one with a step that falls into no log at all, one in an agentless instance.
- The pattern with the largest contribution — the instance-bound identity being granted based on where it runs — brings seventeen items on its own and is in the unlogged class; it never appears in any log.
- The control that closes that pattern changes the question: the identity is given to a requester verified at the session level, and the pattern’s precondition rises from one to three.
- One pattern does not change the radius at all, because its target is also reachable through a legitimate edge; ten of the thirteen controls remove zero items in this graph and cost fifteen.
- The ordering determines the cost: by unit benefit, one control and a cost of two; starting from cheapest, eight controls and a cost of eight reach the same radius.
Next Step
The patterns have been counted, their logs classified, and the closing controls ordered. One question remains: what can be collected when one of these patterns actually works. Earlier lessons showed that a portion of the evidence cannot be collected; the next lesson counts this by comparing it against a host image — how many items collectible on a host cannot be collected in the cloud, at which plane the isolation is done, and what evidentiary value a snapshot has.
To keep your progress and take notes, Log in
My notes
Log in to take notes.