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

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

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

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

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