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
# 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
$300Kto allocate across 3 battlefields: perimeter, detection, response.Attacker has
$300Kto 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.")
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%}")
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))
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.
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
Kvendors of the previous year;all history: the top
Kvendors over all previous years since 2022;hindsight: the top
Kvendors 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%}")
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.