Attacker-Defender Game Theory#

Security is fundamentally adversarial. Unlike floods or earthquakes, attackers observe your defenses and adapt. A firewall rule that blocks 99% of today’s traffic tells you nothing about tomorrow’s attacker who has already read your vendor’s documentation.

Game theory gives us a language for this: two players, each choosing strategies while anticipating the other’s choice. Neither needs to see the other’s move. The defender rarely knows what the attacker will do, only which options the attacker has, and has to plan against all of them. This is still perhaps an imperfect assumption, but we will use it in this notebook to introduce the basics through security scenarios – how attackers pick targets, how defenders allocate budgets, and why, in zero-sum settings, the equilibrium strategy is the defensible one to aim for.

Section 6 checks where attackers actually went, using the CISA Known Exploited Vulnerabilities catalog.

Setup#

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import linprog

from decision_security.synth import make_rng

rng = make_rng(42)

plt.rcParams.update({
    "font.family": "serif",
    "font.size": 10,
    "axes.labelsize": 11,
    "axes.titlesize": 12,
    "xtick.labelsize": 9,
    "ytick.labelsize": 9,
    "legend.fontsize": 9,
    "figure.dpi": 150,
    "axes.spines.top": False,
    "axes.spines.right": False,
})

PRIMARY = "#1A1A1A"
ACCENT = "#E74C3C"
DARK_BG = "#34495E"
LIGHT_GRAY = "#95A5A6"

1. The Security Game#

A defender picks one of three attack surfaces to protect – network, endpoint, and application. An attacker picks one surface to target.

The payoff rule is deliberately stark:

  • If the defender protected the surface the attacker targets, the loss is low: $100K.

  • If not, the loss is high: $1M.

This gives a 3x3 payoff matrix (from the defender’s perspective, losses are negative).

surfaces = ["Network", "Endpoint", "Application"]
low_loss = -100_000     # attacked surface was defended
high_loss = -1_000_000  # attacked surface was not defended

# Rows = surface defended, columns = surface attacked
payoff = np.full((3, 3), high_loss, dtype=float)
np.fill_diagonal(payoff, low_loss)

payoff_df = pd.DataFrame(
    payoff / 1000,
    index=[f"Defend {s}" for s in surfaces],
    columns=[f"Attack {s}" for s in surfaces],
)
payoff_df.index.name = "Defender \\ Attacker"

print("Payoff matrix (defender loss, $K):")
print(payoff_df.to_string())
print()

fig, ax = plt.subplots(figsize=(7, 4.5))
from matplotlib.colors import LinearSegmentedColormap
LOSS_CMAP = LinearSegmentedColormap.from_list("loss", ["#F4F6F6", DARK_BG])  # one hue: higher loss, darker
im = ax.imshow(-payoff / 1000, cmap=LOSS_CMAP, aspect="auto")

ax.set_xticks(range(3))
ax.set_xticklabels([f"Attack\n{s}" for s in surfaces])
ax.set_yticks(range(3))
ax.set_yticklabels([f"Defend\n{s}" for s in surfaces])
ax.set_title("Defender Loss ($K) by Strategy Pair", pad=12)

for i in range(3):
    for j in range(3):
        val = -payoff[i, j] / 1000
        ax.text(j, i, f"${val:.0f}K", ha="center", va="center",
                fontsize=11, fontweight="bold",
                color="white" if val > 500 else PRIMARY)

for spine in ax.spines.values():
    spine.set_visible(True)

cbar = fig.colorbar(im, ax=ax, shrink=0.8)
cbar.set_label("Defender loss ($K)")
plt.tight_layout()
plt.show()
Payoff matrix (defender loss, $K):
                     Attack Network  Attack Endpoint  Attack Application
Defender \ Attacker                                                     
Defend Network               -100.0          -1000.0             -1000.0
Defend Endpoint             -1000.0           -100.0             -1000.0
Defend Application          -1000.0          -1000.0              -100.0
../_images/241c7aeda117cc07769ba927eef7cc8cc7d976dc846c93530831e64ca85d76b7.png
# Zero-sum: attacker gain = defender loss. Sections 4-5 relax this (effort costs and
# shared infrastructure make real games non-zero-sum).
# No pure-strategy Nash equilibrium exists in this game: the defender wants to match,
# the attacker to mismatch.

print("Pure strategy check (zero-sum: attacker gain = defender loss):")
print("="*55)
for d_idx, d_name in enumerate(surfaces):
    a_best = np.argmin(payoff[d_idx, :])   # attacker's best response
    d_best = np.argmax(payoff[:, a_best])  # defender's best response to it
    stable = "STABLE" if d_best == d_idx else "EXPLOITABLE"
    print(f"Defender protects {d_name:12s} -> "
          f"Attacker targets {surfaces[a_best]:12s} -> "
          f"Defender should switch to {surfaces[d_best]:12s}  [{stable}]")

print()
print("No pure-strategy Nash equilibrium: every fixed defense is exploitable.")

# Mixed NE of this symmetric game: both players randomize uniformly.
mixed_def = np.array([1/3, 1/3, 1/3])
mixed_atk = np.array([1/3, 1/3, 1/3])

expected_loss_mixed = mixed_def @ payoff @ mixed_atk

expected_loss_fixed = high_loss  # attacker avoids the defended surface

print(f"\nMixed-strategy NE: defend each surface with p = 1/3")
print(f"Expected loss under mixed strategy:  ${-expected_loss_mixed:,.0f}")
print(f"Expected loss under fixed strategy:  ${-expected_loss_fixed:,.0f}")
print(f"\nA predictable defender loses {-expected_loss_fixed/-expected_loss_mixed:.1f}x "
      f"as much as one who randomizes.")
Pure strategy check (zero-sum: attacker gain = defender loss):
=======================================================
Defender protects Network      -> Attacker targets Endpoint     -> Defender should switch to Endpoint      [EXPLOITABLE]
Defender protects Endpoint     -> Attacker targets Network      -> Defender should switch to Network       [EXPLOITABLE]
Defender protects Application  -> Attacker targets Network      -> Defender should switch to Network       [EXPLOITABLE]

No pure-strategy Nash equilibrium: every fixed defense is exploitable.

Mixed-strategy NE: defend each surface with p = 1/3
Expected loss under mixed strategy:  $700,000
Expected loss under fixed strategy:  $1,000,000

A predictable defender loses 1.4x as much as one who randomizes.

Any fixed, predictable defense is exploitable. The attacker avoids the defended surface. The only unexploitable strategy is to randomize in the right proportions.

In the symmetric case above, uniform randomization is optimal. In practice, surfaces have different values at risk, which changes the equilibrium probabilities.

# An undefended surface loses its full value; a defended one, 10% of it.
value_at_risk = np.array([2_000_000, 1_000_000, 500_000])  # Network, Endpoint, Application
defended_frac = 0.10

# M[i, j] = defender loss when defending surface i and the attacker hits surface j.
M = np.tile(value_at_risk, (3, 1)).astype(float)
np.fill_diagonal(M, defended_frac * value_at_risk)

# Minimax LP over z = [x0, x1, x2, v]: min v  s.t.  sum_i x_i M[i,j] <= v for every j,
# sum_i x_i = 1, x >= 0.
c = np.array([0.0, 0.0, 0.0, 1.0])
A_ub = np.hstack([M.T, -np.ones((3, 1))])
b_ub = np.zeros(3)
A_eq = np.array([[1.0, 1.0, 1.0, 0.0]])
b_eq = np.array([1.0])
bounds = [(0, 1)] * 3 + [(None, None)]

res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="highs")
x_star = res.x[:3]
game_value = res.x[3]

print("Asymmetric value at risk:", {s: f"${v/1e6:.1f}M" for s, v in zip(surfaces, value_at_risk)})
print("=" * 55)
print("Defender's optimal mixed defense (vs uniform 1/3):")
for s, p in zip(surfaces, x_star):
    print(f"  Defend {s:12s}: p = {p:.2f}")
print(f"\nGame value (worst-case expected loss): ${game_value:,.0f}")
if np.all(np.diff(x_star[np.argsort(-value_at_risk)]) <= 1e-9):
    print("Higher-value surfaces get defended more often.")
Asymmetric value at risk: {'Network': '$2.0M', 'Endpoint': '$1.0M', 'Application': '$0.5M'}
=======================================================
Defender's optimal mixed defense (vs uniform 1/3):
  Defend Network     : p = 0.70
  Defend Endpoint    : p = 0.30
  Defend Application : p = 0.00

Game value (worst-case expected loss): $733,333
Higher-value surfaces get defended more often.

2. Budget Allocation as a Colonel Blotto Game#

The Colonel Blotto game models resource allocation across multiple battlefields. Whoever spends more on a given battlefield wins it.

Our version:

  • Defender has $300K to allocate across 3 battlefields: perimeter, detection, response.

  • Attacker has $300K to allocate across the same 3 battlefields.

  • Defender “wins” if they win at least 2 of 3 battlefields.

battlefields = ["Perimeter", "Detection", "Response"]
atk_budget = 300_000

# Defender allocations, each summing to 300K
def_strategies = {
    "Even split":       np.array([100, 100, 100]) * 1000,
    "Perimeter heavy":  np.array([200, 50, 50]) * 1000,
    "Detection heavy":  np.array([50, 200, 50]) * 1000,
    "Response heavy":   np.array([50, 50, 200]) * 1000,
    "Perimeter+Detect": np.array([140, 140, 20]) * 1000,
    "Detect+Response":  np.array([20, 140, 140]) * 1000,
    "Perim+Response":   np.array([140, 20, 140]) * 1000,
}

# Attackers tend to concentrate: mix spiky Dirichlet profiles with an occasional even spread.
alpha_profiles = np.array([
    [5.0, 0.5, 0.5],   # concentrate on perimeter
    [0.5, 5.0, 0.5],   # concentrate on detection
    [0.5, 0.5, 5.0],   # concentrate on response
    [3.0, 3.0, 0.5],   # split two fields
    [0.5, 3.0, 3.0],
    [3.0, 0.5, 3.0],
    [1.0, 1.0, 1.0],   # spread evenly
])

# Common random numbers: score every defense against the same 1000 attacker draws.
n_rounds = 1000
profile_idx = rng.integers(0, len(alpha_profiles), n_rounds)
atk_allocs = np.empty((n_rounds, 3))
for k in range(len(alpha_profiles)):
    mask = profile_idx == k
    if mask.any():
        atk_allocs[mask] = rng.dirichlet(alpha_profiles[k], mask.sum()) * atk_budget

results = {}
for name, d_alloc in def_strategies.items():
    wins = (d_alloc[None, :] > atk_allocs).sum(axis=1)
    ties = (d_alloc[None, :] == atk_allocs).sum(axis=1)
    bf_won = wins + 0.5 * ties
    results[name] = np.mean(bf_won >= 2)

fig, ax = plt.subplots(figsize=(9, 5))
strats = list(results.keys())
rates = [results[s] for s in strats]
colors = [ACCENT if r == max(rates) else DARK_BG for r in rates]

bars = ax.barh(range(len(strats)), rates, color=colors, edgecolor="white", height=0.6)
ax.set_yticks(range(len(strats)))
ax.set_yticklabels(strats)
ax.set_xlabel("Defender win rate (2+ battlefields won)")
ax.set_title("Colonel Blotto: Defender Win Rate by Allocation Strategy")
ax.set_xlim(0, 1)

for i, (bar, rate) in enumerate(zip(bars, rates)):
    ax.text(rate + 0.02, i, f"{rate:.0%}", va="center", fontsize=9, color=PRIMARY)

plt.tight_layout()
plt.show()

best = strats[int(np.argmax(rates))]
print(f"Best strategy: {best} ({max(rates):.0%} win rate)")
print(f"Even split:    {results['Even split']:.0%} win rate")
print()
if best == "Even split":
    print("Against this attacker mixture the even split is hardest to beat: the attacker")
    print("must outspend it on two fields at once.")
../_images/023957deaa3a4d5fdf1661e6f7b9670381ac2c634213287ac4ca3dee0c27838d.png
Best strategy: Even split (68% win rate)
Even split:    68% win rate

Against this attacker mixture the even split is hardest to beat: the attacker
must outspend it on two fields at once.

The Blotto game captures a real security tension: you can’t be strong everywhere. With equal budgets, neither side has an inherent advantage – the outcome depends on allocation strategy and on what the other side is likely to do.

Against the concentrate-prone attacker mixture modeled here, the even split is the hardest allocation to beat. To win, the attacker has to outspend the defender on two of three battlefields simultaneously.

Two caveats. First, this ranking is conditional on the assumed attacker distribution: change how the attacker spreads its budget and the best defense changes with it. Second, no fixed split is truly optimal – the game-theoretic solution to Blotto is a continuous mixed strategy, randomizing over allocations so the attacker can never tune against a known posture. The even split wins here because it does best against this attacker mixture, not because spreading evenly is a universal law. A best-responding attacker can beat any fixed split, the even one included.

3. Moving Target Defense#

If the defender plays a fixed strategy, the attacker eventually learns it. A moving target defense randomizes the defender’s posture, denying the attacker a stable target to exploit.

We simulate 500 rounds of the Section 1 game:

  • Rounds 1-250: Defender always protects Network (fixed strategy). The attacker learns this after a single round and exploits it.

  • Rounds 251-500: Defender randomizes uniformly across surfaces (mixed strategy). The attacker can’t predict.

n_rounds = 500
switch_point = 250

cumulative = np.zeros(n_rounds)
rng_sim = make_rng(99)

# Frequency-count learner: attack the surface defended least so far (ties at random).
# A scripted rule, not a strategic optimizer.
defend_counts = np.zeros(3)

for r in range(n_rounds):
    if r < switch_point:
        defender_choice = 0                        # fixed: always defend Network
    else:
        defender_choice = rng_sim.integers(0, 3)   # mixed: uniform

    candidates = np.flatnonzero(defend_counts == defend_counts.min())
    attacker_choice = int(rng_sim.choice(candidates))

    loss = 100_000 if defender_choice == attacker_choice else 1_000_000
    cumulative[r] = (cumulative[r-1] if r > 0 else 0) + loss
    defend_counts[defender_choice] += 1

fig, ax = plt.subplots(figsize=(10, 5))

rounds = np.arange(n_rounds)
ax.plot(rounds[:switch_point], cumulative[:switch_point] / 1e6,
        color=ACCENT, lw=2, label="Fixed defense (Network only)")
ax.plot(rounds[switch_point-1:], cumulative[switch_point-1:] / 1e6,
        color=DARK_BG, lw=2, label="Mixed defense (randomized)")

ax.axvline(switch_point, color=LIGHT_GRAY, ls="--", lw=1, alpha=0.8)
ax.text(switch_point + 5, cumulative[switch_point] / 1e6 * 0.4,
        "Switch to\nmixed strategy", fontsize=9, color=LIGHT_GRAY)

avg_loss_mixed = (1/3) * 100_000 + (2/3) * 1_000_000  # expected loss per round at the mixed NE
reference_line = np.cumsum(np.full(n_rounds, avg_loss_mixed)) / 1e6
ax.plot(rounds, reference_line, color=LIGHT_GRAY, ls=":", lw=1,
        label=f"Reference: mixed from start", alpha=0.7)

ax.set_xlabel("Round")
ax.set_ylabel("Cumulative loss ($M)")
ax.set_title("Fixed vs. Moving Target Defense: Cumulative Losses")
ax.legend(loc="upper left")
plt.tight_layout()
plt.show()

avg_per_round_fixed = np.mean(np.diff(np.concatenate([[0], cumulative[:switch_point]])))
avg_per_round_mixed = np.mean(np.diff(np.concatenate([[cumulative[switch_point-1]], cumulative[switch_point:]])))

print(f"Average loss per round (fixed phase):  ${avg_per_round_fixed:,.0f}")
print(f"Average loss per round (mixed phase):  ${avg_per_round_mixed:,.0f}")
print(f"Reduction from randomization:          {1 - avg_per_round_mixed/avg_per_round_fixed:.0%}")
../_images/451c0ca552064acfa9ce79417930a010dbff3f8cea65116e2e0306ac96c09164.png
Average loss per round (fixed phase):  $1,000,000
Average loss per round (mixed phase):  $661,600
Reduction from randomization:          34%

Under fixed defense, the frequency-count attacker learns which surface is always protected and attacks elsewhere, so the slope is $1M/round from the start. After the switch to a mixed strategy the slope flattens – the attacker’s tally no longer points anywhere useful, and one-third of the time it lands on the defended surface.

A caveat: our attacker is a scripted frequency learner, not a strategic optimizer. The mixed phase came in slightly below the equilibrium $700K/round by chance. No attacker can push the expected loss above that value, which is the guarantee randomization buys (an expected 30% reduction here).

This is still the operational argument for randomized security controls like rotating honeypot placements.

4. Asymmetric Information#

Real security games involve uncertainty on both sides. The attacker doesn’t know the defender’s exact controls. The defender doesn’t know the attacker’s sophistication. Game theory handles this through Bayesian games, where each player has a type drawn from a known distribution.

In a single interaction, an attacker decides how aggressively to target an organization. The attacker can probe (low effort, low payoff) or commit (high effort, high payoff if the target is weak) – or walk away. A rational attacker only acts when some strategy beats the break-even payoff of zero.

  • If the target is well-resourced, a committed attack still fails and the attacker wastes effort.

  • If the target is under-resourced, a committed attack succeeds for a large payoff.

The key question: does the attacker know the defender’s type?

def_types = ["Well-resourced", "Under-resourced"]
p_well = 0.7  # share of well-resourced orgs; the attacker does not see the type

# The attacker also has an outside option, don't attack (payoff 0), added below.
atk_strats = ["Probe", "Commit"]

# Defender loss, [well-resourced, under-resourced]. A probe does modest damage either way;
# a committed attack is blocked by strong defenses and devastating to weak ones.
loss_if_probe = np.array([50_000, 200_000])
loss_if_commit = np.array([80_000, 2_000_000])

loss_matrix = np.column_stack([loss_if_probe, loss_if_commit])

# Attacker payoff = a fraction of the defender's loss minus effort cost.
atk_gain_frac = 0.20
probe_cost = 20_000
commit_cost = 200_000

atk_payoff = np.column_stack([
    loss_if_probe * atk_gain_frac - probe_cost,
    loss_if_commit * atk_gain_frac - commit_cost,
])

print("Defender loss by scenario:")
print("="*55)
loss_df = pd.DataFrame(
    loss_matrix,
    index=def_types,
    columns=atk_strats,
)
print(loss_df.map(lambda x: f"${x:,.0f}").to_string())
print()

print("Attacker payoff by scenario (gain - effort cost; outside option = $0):")
print("="*55)
atk_df = pd.DataFrame(
    atk_payoff,
    index=[f"vs {t}" for t in def_types],
    columns=atk_strats,
)
print(atk_df.map(lambda x: f"${x:,.0f}").to_string())
print()

atk_payoff_full = np.column_stack([atk_payoff, np.zeros(2)])
atk_strats_full = atk_strats + ["Don't attack"]

def defender_loss(type_i, choice):
    """Defender loss given the attacker's action (index 2 = don't attack)."""
    return 0.0 if choice == 2 else float(loss_matrix[type_i, choice])

# Perfect information: the attacker best-responds to each type.
atk_choice_perfect = [int(np.argmax(atk_payoff_full[i])) for i in range(2)]
loss_perfect = np.array([defender_loss(i, atk_choice_perfect[i]) for i in range(2)])
expected_loss_perfect = p_well * loss_perfect[0] + (1 - p_well) * loss_perfect[1]

print("With perfect information:")
for i, dt in enumerate(def_types):
    chosen = atk_strats_full[atk_choice_perfect[i]]
    print(f"  vs {dt}: attacker chooses '{chosen}' "
          f"(payoff ${atk_payoff_full[i, atk_choice_perfect[i]]:,.0f}) "
          f"-> defender loss ${loss_perfect[i]:,.0f}")

# No information: one action for all targets, chosen on pooled expected payoff.
exp_atk_probe = p_well * atk_payoff[0, 0] + (1 - p_well) * atk_payoff[1, 0]
exp_atk_commit = p_well * atk_payoff[0, 1] + (1 - p_well) * atk_payoff[1, 1]
exp_atk = np.array([exp_atk_probe, exp_atk_commit, 0.0])

atk_choice_uncertain = int(np.argmax(exp_atk))
if atk_choice_uncertain == 2:
    expected_loss_uncertain = 0.0
else:
    expected_loss_uncertain = (
        p_well * loss_matrix[0, atk_choice_uncertain]
        + (1 - p_well) * loss_matrix[1, atk_choice_uncertain]
    )

print(f"\nWithout information:")
print(f"  Expected attacker payoff from Probe:       ${exp_atk_probe:,.0f}")
print(f"  Expected attacker payoff from Commit:      ${exp_atk_commit:,.0f}")
print(f"  Expected attacker payoff from Don't attack: $0")
print(f"  Rational choice: '{atk_strats_full[atk_choice_uncertain]}'")
print(f"\nExpected defender loss (perfect info):   ${expected_loss_perfect:,.0f}")
print(f"Expected defender loss (uncertain):      ${expected_loss_uncertain:,.0f}")
info_advantage = expected_loss_perfect - expected_loss_uncertain
print(f"Defender's information advantage:         ${info_advantage:,.0f}")
Defender loss by scenario:
=======================================================
                    Probe      Commit
Well-resourced    $50,000     $80,000
Under-resourced  $200,000  $2,000,000

Attacker payoff by scenario (gain - effort cost; outside option = $0):
=======================================================
                       Probe     Commit
vs Well-resourced   $-10,000  $-184,000
vs Under-resourced   $20,000   $200,000

With perfect information:
  vs Well-resourced: attacker chooses 'Don't attack' (payoff $0) -> defender loss $0
  vs Under-resourced: attacker chooses 'Commit' (payoff $200,000) -> defender loss $2,000,000

Without information:
  Expected attacker payoff from Probe:       $-1,000
  Expected attacker payoff from Commit:      $-68,800
  Expected attacker payoff from Don't attack: $0
  Rational choice: 'Don't attack'

Expected defender loss (perfect info):   $600,000
Expected defender loss (uncertain):      $0
Defender's information advantage:         $600,000
from matplotlib.patches import Rectangle

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))

# Left: defender loss heatmap
ax = axes[0]
im = ax.imshow(loss_matrix / 1000, cmap=LOSS_CMAP, aspect="auto")
ax.set_xticks(range(2))
ax.set_xticklabels(atk_strats)
ax.set_yticks(range(2))
ax.set_yticklabels(def_types)
ax.set_title("Defender Loss by Scenario ($K)")
ax.set_xlabel("Attacker strategy")
ax.set_ylabel("Defender type")
for spine in ax.spines.values():
    spine.set_visible(True)

for i in range(2):
    for j in range(2):
        val = loss_matrix[i, j] / 1000
        ax.text(j, i, f"${val:.0f}K", ha="center", va="center",
                fontsize=11, fontweight="bold",
                color="white" if val > 500 else PRIMARY)
    # Perfect info: border on the attacker's best action, or a note if it abstains
    if atk_choice_perfect[i] < 2:
        rect = Rectangle((atk_choice_perfect[i] - 0.48, i - 0.48), 0.96, 0.96,
                          linewidth=2.5, edgecolor=ACCENT, facecolor="none")
        ax.add_patch(rect)
    else:
        ax.text(0.5, i + 0.35, "attacker abstains", ha="center", va="center",
                fontsize=8, color=PRIMARY, style="italic")

ax.text(0.5, -0.22, "Red border = attacker's optimal choice with perfect info",
        transform=ax.transAxes, ha="center", fontsize=8, color=LIGHT_GRAY)

# Right: expected loss with and without information
ax = axes[1]
scenarios = ["Attacker knows\ndefender type", "Attacker\nuncertain"]
losses = [expected_loss_perfect / 1000, expected_loss_uncertain / 1000]
colors_bar = [ACCENT, DARK_BG]
bars = ax.bar(scenarios, losses, color=colors_bar, width=0.5, edgecolor="white")

for bar, val in zip(bars, losses):
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(losses) * 0.03,
            f"${val:.0f}K", ha="center", va="bottom", fontsize=10, fontweight="bold")

ax.set_ylabel("Expected defender loss ($K)")
deters = expected_loss_uncertain < expected_loss_perfect
ax.set_title("Ambiguity Deters the Attacker" if deters
             else "Ambiguity Does Not Deter the Attacker")
ax.set_ylim(0, max(losses) * 1.3 if max(losses) > 0 else 1)

if losses[0] != losses[1]:
    gap = losses[0] - losses[1]
    mid_y = (losses[0] + losses[1]) / 2
    # Arrow sits in the gap between the two bars, inside the axes
    ax.annotate("", xy=(0.5, losses[1]), xytext=(0.5, losses[0]),
                arrowprops=dict(arrowstyle="<->", color=ACCENT, lw=1.5))
    ax.text(0.54, mid_y, f"${abs(gap):.0f}K\n{'saved' if gap > 0 else 'added'}",
            fontsize=9, color=ACCENT, va="center", ha="left")

plt.tight_layout()
plt.show()

# Narrate from the computed choices
import textwrap

skipped = [def_types[i] for i in range(2) if atk_choice_perfect[i] == 2]
engaged = [(def_types[i], atk_strats_full[atk_choice_perfect[i]].lower())
           for i in range(2) if atk_choice_perfect[i] != 2]

if engaged:
    acts = " and ".join(f"{act}s against {dt.lower()} ones" for dt, act in engaged)
else:
    acts = "finds no profitable move anywhere"

if skipped and engaged:
    perfect_story = (f"With perfect information the attacker calibrates effort: it walks away "
                     f"from {', '.join(t.lower() for t in skipped)} targets (no action clears "
                     f"the $0 outside option) and {acts}.")
elif engaged:
    perfect_story = (f"With perfect information the attacker engages every target type: "
                     f"it {acts}.")
else:
    perfect_story = ("With perfect information the attacker abstains against every target "
                     "type -- no action clears the $0 outside option.")

print()
print(textwrap.fill(perfect_story, 80))

contribution = np.array([p_well, 1 - p_well]) * loss_perfect
if expected_loss_perfect > 0:
    top = int(np.argmax(contribution))
    print(textwrap.fill(
        f"{def_types[top]} orgs carry {contribution[top] / expected_loss_perfect:.0%} of the "
        f"${expected_loss_perfect:,.0f} expected loss.", 80))

print()
print(textwrap.fill(
    f"When the attacker is uncertain it must apply one action to the whole population. "
    f"With {p_well:.0%} of targets {def_types[0].lower()}, the pooled payoffs are "
    f"${exp_atk_probe:,.0f} (probe) and ${exp_atk_commit:,.0f} (commit) against a $0 "
    f"outside option, so the rational choice is "
    f"'{atk_strats_full[atk_choice_uncertain]}' and expected defender loss is "
    f"${expected_loss_uncertain:,.0f}.", 80))

if expected_loss_uncertain < expected_loss_perfect:
    print(textwrap.fill(
        f"Ambiguity is worth ${expected_loss_perfect - expected_loss_uncertain:,.0f} to the "
        f"defender here (population average) -- the game-theoretic case for not disclosing "
        f"your exact security posture.", 80))
else:
    print(textwrap.fill(
        "Ambiguity buys the defender nothing here: the attacker's pooled choice is at least "
        "as damaging as its per-target choices.", 80))

print()
pooled_best = max(exp_atk_probe, exp_atk_commit)
pooled_loss = np.array([p_well, 1 - p_well]) @ loss_matrix   # if the attacker probes / commits on all
if pooled_best < 0:
    stops = [atk_strats[j] for j in range(2) if pooled_loss[j] >= expected_loss_perfect]
    tail = (f"it stops paying only once the pooled best action is {' or '.join(stops)}"
            if stops else "it keeps paying whichever action the attacker pools on")
    print(textwrap.fill(
        f"Sensitivity: this deterrence result hinges on atk_gain_frac ({atk_gain_frac:.2f}) "
        f"and the probe/commit effort costs (${probe_cost/1e3:,.0f}K / ${commit_cost/1e3:,.0f}K); "
        f"the best pooled payoff sits ${abs(pooled_best):,.0f} below break-even. Raise the gain "
        f"fraction or cut attacker costs and the pooled EV turns positive, so the attacker acts "
        f"on everyone. Ambiguity then shrinks the loss less (${pooled_loss[0]:,.0f} if it "
        f"probes, ${pooled_loss[1]:,.0f} if it commits, vs ${expected_loss_perfect:,.0f} with "
        f"perfect information), and {tail}.", 80))
else:
    print(textwrap.fill(
        f"With these parameters the best pooled payoff is already positive "
        f"(${pooled_best:,.0f}), so uncertainty does not deter -- the attacker acts against "
        f"the whole population regardless of what it knows.", 80))
../_images/8a90b4d570f570170daa49528876b23713483c280875edf36befb94f127bb71a.png
With perfect information the attacker calibrates effort: it walks away from
well-resourced targets (no action clears the $0 outside option) and commits
against under-resourced ones.
Under-resourced orgs carry 100% of the $600,000 expected loss.

When the attacker is uncertain it must apply one action to the whole population.
With 70% of targets well-resourced, the pooled payoffs are $-1,000 (probe) and
$-68,800 (commit) against a $0 outside option, so the rational choice is 'Don't
attack' and expected defender loss is $0.
Ambiguity is worth $600,000 to the defender here (population average) -- the
game-theoretic case for not disclosing your exact security posture.

Sensitivity: this deterrence result hinges on atk_gain_frac (0.20) and the
probe/commit effort costs ($20K / $200K); the best pooled payoff sits $1,000
below break-even. Raise the gain fraction or cut attacker costs and the pooled
EV turns positive, so the attacker acts on everyone. Ambiguity then shrinks the
loss less ($95,000 if it probes, $656,000 if it commits, vs $600,000 with
perfect information), and it stops paying only once the pooled best action is
Commit.

5. The Free-Rider Problem in Shared Security#

Two organizations share a supply chain. Each independently decides whether to invest $200K in security:

Org B invests

Org B doesn’t invest

Org A invests

Both: $300K

A: $450K, B: $250K

Org A doesn’t invest

A: $250K, B: $450K

Both: $400K

If both invest, each pays the $200K investment plus $100K in residual breach losses. If neither invests, both face $400K in expected breach losses. If only one invests, both face $250K in breach losses: the free-rider pays only that, the investor pays it plus the $200K investment ($450K total).

This is a Prisoner’s Dilemma: each org is individually better off not investing ($250K free-ride vs $300K invest when partner cooperates; $400K vs $450K when partner defects), but the joint optimum is mutual investment.

These four numbers are illustrative, not calibrated from a breach-loss model. What makes this a Prisoner’s Dilemma is the ordering of the payoffs (temptation < reward < punishment < sucker, in cost terms). The code cell verifies that ordering.

# Payoffs = minus total cost (investment + breach loss). Illustrative values; what
# must hold is the Prisoner's Dilemma ordering, asserted below rather than assumed.
# One investment cuts both orgs' breach loss from $400K to $250K; two cut it to $100K.
payoff_a = np.array([
    [-300_000, -450_000],    # A invests: [B invests, B doesn't]
    [-250_000, -400_000],    # A doesn't: [B invests, B doesn't]
])

payoff_b = np.array([
    [-300_000, -250_000],    # rows = A's choice, cols = B's choice
    [-450_000, -400_000],
])

# PD ordering in costs: T < R < P < S
T = -payoff_a[1, 0]   # temptation: free-ride while partner invests
R = -payoff_a[0, 0]   # reward:     both invest
P = -payoff_a[1, 1]   # punishment: neither invests
S = -payoff_a[0, 1]   # sucker:     invest while partner defects
assert T < R < P < S, "Payoffs do not satisfy the Prisoner's Dilemma ordering"
print(f"PD ordering check (costs): T=${T/1e3:.0f}K < R=${R/1e3:.0f}K "
      f"< P=${P/1e3:.0f}K < S=${S/1e3:.0f}K  [OK]")
print()

strategies = ["Invest", "Don't invest"]

print("Payoff matrix (total cost to each org):")
print("="*60)
print(f"{'':20s} {'B invests':>15s}  {'B doesnt invest':>15s}")
for i, sa in enumerate(strategies):
    row = []
    for j in range(2):
        row.append(f"A:${-payoff_a[i,j]/1000:.0f}K, B:${-payoff_b[i,j]/1000:.0f}K")
    print(f"A {sa:16s}  {row[0]:>22s}  {row[1]:>22s}")

print()

print("Dominance check for Org A:")
for j, sb in enumerate(strategies):
    a_invest = payoff_a[0, j]
    a_defect = payoff_a[1, j]
    better = "Invest" if a_invest > a_defect else "Don't invest"
    print(f"  If B {sb:16s}: A prefers '{better}' "
          f"(${-a_invest/1000:.0f}K vs ${-a_defect/1000:.0f}K)")

ne_cost = -payoff_a[1, 1]
opt_cost = -payoff_a[0, 0]
print()
print("Nash equilibrium: (Don't invest, Don't invest)")
print(f"  NE payoff:     ${ne_cost/1000:.0f}K each")
print(f"  Optimal payoff: ${opt_cost/1000:.0f}K each (both invest)")
print(f"  Social waste:   ${(2*ne_cost - 2*opt_cost)/1000:.0f}K total")
PD ordering check (costs): T=$250K < R=$300K < P=$400K < S=$450K  [OK]

Payoff matrix (total cost to each org):
============================================================
                           B invests  B doesnt invest
A Invest                  A:$300K, B:$300K        A:$450K, B:$250K
A Don't invest            A:$250K, B:$450K        A:$400K, B:$400K

Dominance check for Org A:
  If B Invest          : A prefers 'Don't invest' ($300K vs $250K)
  If B Don't invest    : A prefers 'Don't invest' ($450K vs $400K)

Nash equilibrium: (Don't invest, Don't invest)
  NE payoff:     $400K each
  Optimal payoff: $300K each (both invest)
  Social waste:   $200K total
fig, ax = plt.subplots(figsize=(7, 5))

costs_a = -payoff_a / 1000   # Org A's cost, $K

im = ax.imshow(costs_a, cmap=LOSS_CMAP, aspect="auto",
               vmin=200, vmax=500)

ax.set_xticks(range(2))
ax.set_xticklabels([f"Org B\n{s}" for s in ["Invests", "Doesn't invest"]])
ax.set_yticks(range(2))
ax.set_yticklabels([f"Org A\n{s}" for s in ["Invests", "Doesn't invest"]])
ax.set_title("Supply Chain Security: Org A's Total Cost ($K)\n"
             "(Prisoner's Dilemma)", pad=12)

for spine in ax.spines.values():
    spine.set_visible(True)

for i in range(2):
    for j in range(2):
        val = costs_a[i, j]
        is_nash = (i == 1 and j == 1)
        is_optimal = (i == 0 and j == 0)
        label = f"${val:.0f}K"
        if is_nash:
            label += "\n(Nash eq.)"
        if is_optimal:
            label += "\n(Social opt.)"
        ax.text(j, i, label, ha="center", va="center",
                fontsize=10, fontweight="bold",
                color="white" if val > 350 else PRIMARY)

# Box the Nash equilibrium
from matplotlib.patches import Rectangle
rect = Rectangle((0.5, 0.5), 1, 1, linewidth=3, edgecolor=ACCENT,
                  facecolor="none", linestyle="--")
ax.add_patch(rect)

cbar = fig.colorbar(im, ax=ax, shrink=0.8)
cbar.set_label("Total cost to Org A ($K)")

plt.tight_layout()
plt.show()

ne_cost = -payoff_a[1, 1]
opt_cost = -payoff_a[0, 0]
gap = ne_cost - opt_cost

print(f"This ${gap/1000:.0f}K gap per organization is why markets alone don't produce")
print("adequate supply chain security. Mechanisms that change the game:")
print("  - Regulatory mandates (DORA, NIS2): require in-scope entities to manage supply-chain risk")
print("  - Information sharing (ISACs): reduce the cost of mutual investment")
print("  - Contract requirements: shift liability to the non-investor")
print("  - Insurance incentives: lower premiums for verified investment")
../_images/b567f81a0a895de269fcf796efb99739e79a4fb1da1eb5c95b2000b4c9fb3423.png
This $100K gap per organization is why markets alone don't produce
adequate supply chain security. Mechanisms that change the game:
  - Regulatory mandates (DORA, NIS2): require in-scope entities to manage supply-chain risk
  - Information sharing (ISACs): reduce the cost of mutual investment
  - Contract requirements: shift liability to the non-investor
  - Insurance incentives: lower premiums for verified investment

6. Real Data: Where Attackers Actually Went#

The models above assume an attacker who adapts to the defender. The CISA Known Exploited Vulnerabilities (KEV) catalog records vulnerabilities confirmed as exploited in the wild, with the vendor and the date CISA added them. Treat each vendor’s share of KEV entries in a year as the attacker’s observed “mixed strategy” over targets, and ask two questions: how concentrated is it, and how predictable is it from one year to the next?

The catalog launched in November 2021 with a backlog of older vulnerabilities, and the backfill ran into 2022, so the analysis starts in 2023. The current year is partial. The snapshot in data/ keeps the result reproducible; load_kev(refresh=True) downloads the current catalog.

from _data import load_kev

kev = load_kev()
# Strip stray spaces in vendor names; renames (e.g. VMware -> Broadcom, HP -> HPE) are not merged
kev["vendorProject"] = kev["vendorProject"].str.strip()
kev["year"] = kev["dateAdded"].dt.year
FIRST_YEAR = 2023
years = list(range(FIRST_YEAR, kev["year"].max() + 1))
recent = kev[kev["year"] >= FIRST_YEAR]

top_vendors = recent["vendorProject"].value_counts().head(6).index
share = (pd.crosstab(recent["year"], recent["vendorProject"], normalize="index")[top_vendors] * 100)
share["All other vendors"] = 100 - share.sum(axis=1)

summary = pd.DataFrame({
    "KEV entries": recent.groupby("year").size(),
    "vendors": recent.groupby("year")["vendorProject"].nunique(),
})
seen = set(kev.loc[kev["year"] < FIRST_YEAR, "vendorProject"])
new_share = {}
for y in years:
    v = kev.loc[kev["year"] == y, "vendorProject"]
    new_share[y] = (~v.isin(seen)).mean() * 100
    seen |= set(v)
summary["entries from vendors new to KEV (%)"] = pd.Series(new_share).round(0)

print(f"Share of KEV entries per year (%), last year partial (snapshot to {kev['dateAdded'].max():%Y-%m-%d}):\n")
print(share.round(1).to_string())
print()
print(summary.to_string())
Share of KEV entries per year (%), last year partial (snapshot to 2026-10-16):

vendorProject  Microsoft  Apple  Cisco  Google  Ivanti  Fortinet  All other vendors
year                                                                               
2023                14.4   11.2    3.7     3.7     1.6       1.1               64.2
2024                19.4    3.8    3.2     4.8     5.9       2.2               60.8
2025                15.9    3.7    3.3     2.9     2.9       3.3               68.2
2026                15.2    3.4    7.2     3.0     2.3       3.4               65.5

      KEV entries  vendors  entries from vendors new to KEV (%)
year                                                           
2023          187       67                                 18.0
2024          186       64                                 20.0
2025          245       99                                 27.0
2026          264      101                                 23.0

Backtest a defender who best-responds to history. Each year the defender picks the K vendors to prioritize – extra patching speed, compensating controls, monitoring – and we measure the share of that year’s KEV entries that fall on those vendors. Three ways to choose:

  • last year: the top K vendors of the previous year;

  • all history: the top K vendors over all previous years since 2022;

  • hindsight: the top K vendors of the year itself – not available in practice, it is the upper bound.

K = 10


def coverage(chosen, y):
    return kev.loc[kev["year"] == y, "vendorProject"].isin(chosen).mean()


def top_k(mask):
    return kev.loc[mask, "vendorProject"].value_counts().head(K).index


rows = []
for y in years:
    rows.append({
        "year": y,
        "last year": coverage(top_k(kev["year"] == y - 1), y),
        "all history": coverage(top_k((kev["year"] >= 2022) & (kev["year"] < y)), y),
        "hindsight": coverage(top_k(kev["year"] == y), y),
    })
backtest = pd.DataFrame(rows).set_index("year")

fig, ax = plt.subplots(figsize=(8, 4.5))
styles = {"last year": (DARK_BG, "o-"), "all history": (LIGHT_GRAY, "s-"), "hindsight": (ACCENT, "^--")}
for col, (color, style) in styles.items():
    ax.plot(backtest.index, backtest[col] * 100, style, color=color, lw=2, label=col)
ax.set_ylim(0, 100)
ax.set_xticks(years)
ax.set_xlabel("Year")
ax.set_ylabel(f"KEV entries on the {K} prioritized vendors (%)")
ax.set_title(f"Prioritizing {K} vendors from history vs hindsight")
ax.legend()
plt.tight_layout()
plt.show()

print((backtest * 100).round(0).to_string())
print(f"\nMean coverage -- last year: {backtest['last year'].mean():.0%}, "
      f"all history: {backtest['all history'].mean():.0%}, hindsight: {backtest['hindsight'].mean():.0%}")
../_images/e1118101e0a74230cf70ae7147d7363a0d2d7cfc90c5566a3d7636eb17744f46.png
      last year  all history  hindsight
year                                   
2023       44.0         44.0       52.0
2024       39.0         44.0       54.0
2025       35.0         36.0       41.0
2026       41.0         39.0       44.0

Mean coverage -- last year: 40%, all history: 41%, hindsight: 48%

At the top, attacker attention is fairly persistent: one vendor leads every year, and choosing from history comes close to the hindsight bound. The bigger limit is the spread: even with hindsight, the top vendors cover only part of the year’s exploited vulnerabilities. The rest is scattered over dozens of vendors, and every year a sizeable share comes from vendors that had never appeared in KEV before.

That tail is consistent with the adaptive attacker in Section 1, though KEV alone cannot show that attackers move because of defender priorities. A defender cannot prioritize everything, and whatever is left out is where the attacker can go.

Caveats. KEV records what CISA confirmed, which also reflects install base, researcher attention, CISA’s focus on US federal systems, and its rule that an entry needs a CVE ID and clear remediation guidance; a CVE count is not attack volume. A vendor is not a defensive allocation unit either: one vendor can mean dozens of products. The backtest shows how predictable attacker attention is in this data, not how to set a budget.

Data: CISA Known Exploited Vulnerabilities catalog (public domain).

7. Pitfalls#

Security is not a game against nature. Actuarial models treat losses as draws from a stationary distribution. In security, the distribution shifts because the attacker is watching your moves.

Fixed defenses are exploitable by adaptive adversaries. The attacker only needs to observe long enough to learn your pattern.

The defender’s optimal strategy is usually mixed. Randomization isn’t a sign of indecision – it’s a mathematically optimal response to an adaptive adversary. This applies to honeypot placement, audit schedules, and red team targeting.

Perfect information about your defenses can help the attacker more than it helps you. In adversarial settings, revealing your exact security posture is giving the opponent a roadmap. In this parameterization, ambiguity protects the under-resourced orgs that pool with strong ones. Whether it helps a given defender depends on its type, on attacker economics, and on whether others disclose.

Shared security creates free-rider problems that markets alone don’t solve. The Prisoner’s Dilemma in supply chain security is a structural incentive problem that requires external mechanisms: regulation, contracts, or insurance.

In zero-sum settings, the game-theoretic perspective doesn’t require knowing the attacker. The minimax strategy needs only your own attack surfaces and the relative value at risk on each. Outside zero-sum, you need at least a model of the attacker’s payoffs.

History predicts the head, not the tail. In KEV, the most-exploited vendors stay much the same from year to year, but even a perfect top-10 list covers only part of what gets exploited, and new vendors keep appearing.