Cybersecurity Tabletop Exercise Tool: Wargame Presenter

Cybersecurity Tabletop Exercise Tool — header banner on SecurityScriptographer

Wargame Presenter is a cybersecurity tabletop exercise tool I wrote to run incident response exercises from one laptop. You build a scenario from injects and decisions, put it on the projector, and every choice the room makes moves values such as containment, operations and reputation. It is a single Python file with no dependencies, it runs offline on 127.0.0.1, and this post covers how a scenario is built, how a session runs, and two security gaps I found and fixed in the local server.

Key Takeaways

  • A cybersecurity tabletop exercise tool does not need a cloud platform; this one is a Python file, static HTML and JavaScript, and plain JSON files.
  • Decisions change attributes you define yourself, and classification levels turn the numbers into labels such as "Spreading" or "Under control".
  • Risky options roll a 1–100 die against a success chance, and conditional pages let a ransomware tabletop exercise branch on earlier choices or values.
  • Hidden attributes, such as how much data really left the network, stay off the projector until the after-action review.
  • The first release was open to DNS rebinding and cross-site writes from the same browser; the server now checks the Host header, the Origin and the content type.

Environment

  • Wargame Presenter, first release from November 2026, MIT license.
  • Python 3.8 or newer, standard library only. I ran it with Python 3.11.9 on Windows 11 Pro.
  • Microsoft Edge or any browser from 2023 on, because the app needs CSS container queries and BroadcastChannel.
  • A laptop with the projector as second screen.
  • The bundled sample Operation Nightfall: ransomware at the fictional Hamburg logistics company NordWerk Logistik GmbH, with 18 pages, 6 decisions and 7 attributes.

The Problem

Most tabletop exercises run from a slide deck. The deck holds the injects, and the consequences of each decision live in the facilitator's head or in a spreadsheet next to it. Branching means hidden slides and hyperlinks. When the room picks an option nobody planned for, the deck has no answer, and the debrief depends on whoever took notes.

The material is not the problem. CISA publishes ready-made Tabletop Exercise Packages, and NIST SP 800-84 describes how to plan and evaluate exercises. Neither gives you something to present with. Dedicated platforms exist, but they are usually hosted services, so the scenario ends up on someone else's server. A useful scenario is built around the organisation's own weak spots, such as a provider account without MFA or an untested backup. I would rather not upload that.

In Germany the room is also often mixed, and a German plus an English deck means making every change twice. I wanted one laptop, nothing to install beyond Python, and every text in both languages, switchable during the exercise.

The Solution: An Offline Cybersecurity Tabletop Exercise Tool

Step 1 — Start the Local Server with the Python Standard Library

git clone https://github.com/RAdosSantosJanuario/wargame-presenter.git
cd wargame-presenter
python server.py

On first start it imports the sample and opens the browser:

  imported sample: operation-nightfall.json

  Wargame Presenter
  running at  http://127.0.0.1:8765/
  data folder C:\tools\wargame-presenter\data
  stop with   Ctrl+C

On Windows, start.bat does the same with a double-click. The server sets MIME types itself, because the Windows registry can map .js to text/plain, which breaks ES modules.

Step 2 — Define Attributes and Classification Levels

Attributes are what decisions change; the sample has seven, from containment to compliance risk. Each one has a range, a start value and a direction, which decides whether a change shows as a green or a red arrow.

{
  "id": "containment",
  "name": {"en": "Containment", "de": "Eindämmung"},
  "unit": "%", "min": 0, "max": 100, "start": 10,
  "better": "higher",
  "hidden": false,
  "levels": [
    {"upTo": 25,  "severity": "critical", "label": {"en": "Out of control",   "de": "Außer Kontrolle"}},
    {"upTo": 50,  "severity": "bad",      "label": {"en": "Spreading",        "de": "Breitet sich aus"}},
    {"upTo": 75,  "severity": "warning",  "label": {"en": "Partly contained", "de": "Teilweise eingedämmt"}},
    {"upTo": 100, "severity": "good",     "label": {"en": "Under control",    "de": "Unter Kontrolle"}}
  ]
}
Attribute editor defining classification levels from critical to good for a tabletop exercise attribute

Classification levels in the attribute editor.

A value belongs to the first level whose upTo it does not exceed, so 40 % containment reads as "Spreading", and the levels color the attribute bar and every chart. The seventh attribute, data exposure, has "hidden": true. Decisions change it like any other value, but the room only sees it in the debrief, which matches how little anyone knows about data theft on the first day of an incident.

Step 3 — Write Injects and Decisions for a Ransomware Tabletop Exercise

Injects are what the room receives: an EDR alert at 06:12, a call from a warehouse, the ransom note by e-mail. Each is a slide element styled as an e-mail, call, chat, SMS, news article, social media post, SIEM/EDR alert or memo, revealed one at a time. This is one option from Decision 3, the ransom demand:

{
  "id": "c-negotiate",
  "label": {"en": "Negotiate to buy time", "de": "Verhandeln, um Zeit zu gewinnen"},
  "effects": {"containment": 10, "effort": 10, "resources": -5},
  "outcome": {"en": "The gang extends the deadline and sends a file list as proof. You gain 48 valuable hours."},
  "chance": 60,
  "fail": {
    "effects": {"exposure": 25, "reputation": -10, "effort": 10},
    "outcome": {"en": "The gang sees through the stalling and publishes a first sample on its leak site: payroll data of 200 employees."}
  }
}
Cybersecurity tabletop exercise tool: ransomware decision page with four options, a 60 percent dice-roll chance and the live attribute bar

Decision 3 on the projector.

An option with a chance below 100 rolls a number from 1 to 100 on confirmation, and the success branch applies if the roll is at or below the chance. Effects are clamped to each attribute's range. Decisions can also be multiple choice: in Decision 4, legal can finish only two of four filings that night, among them the NIS2 early warning to the BSI.

The editor ships a library of 34 common incident response options with suggested effects. One of them, rebuilding Active Directory from scratch, becomes the central question once an attacker has worked through the Active Directory kill chain.

Step 4 — Branch the Scenario with Conditional Pages and Network States

An option can jump to a page: paying the ransom goes straight to "The decryptor", a page that exists only on that path. Any page can also carry a condition. The leak-site page appears if the room refused to pay, monitored quietly in Decision 1, or let data exposure climb above 25:

{"mode": "any", "rules": [
  {"type": "choice", "choice": "c-refuse-restore",  "op": "chosen"},
  {"type": "choice", "choice": "c-monitor-quietly", "op": "chosen"},
  {"type": "attr",   "attr": "exposure", "op": ">", "value": 25}
]}

The same rules drive the network map. Every system and connection has a default status plus an ordered list of states, and the first state whose condition matches wins. At Monday 12:00 the Bremen warehouse shows as encrypted, unless the room chose "Isolate affected segments", in which case it shows as isolated and its link as blocked.

Network map in the tabletop exercise editor switching from the start state to the state after the isolation decision, with Bremen isolated and its link blocked

The map before and after isolating affected segments.

Building that map in the editor (1.5x speed).

Step 5 — Run the Exercise: Projector View and Presenter Console

Press Present, move the window to the projector and press F. P opens the presenter console with the current and next slide, facilitator notes, the countdown, the decision controls and all values, hidden ones included. The two windows talk over the browser's BroadcastChannel API, so there is no server round trip, but both have to be open in the same browser.

Presenter console with the current and next slide, countdown timer, facilitator notes, decision controls and attribute values

The presenter console during Decision 3.

Going back works as an undo: every page entry stores a snapshot of the values, and leaving a page backwards restores it. L switches the language for everyone. Every change is saved to data/runs/<runId>.json with an atomic write (temp file, then os.replace), so after a browser crash the session continues where it stopped.

Confirmed decision in the light theme with outcome text and attribute changes

The light theme after a confirmed decision.

Step 6 — Debrief with the After-Action Review

Debrief opens the after-action review. The one below is from a complete run of Operation Nightfall: six decisions, 1:17:59 from start to finish. Containment went from 10 % to 60 % and reputation from 80 to 95. Team strain went from 10 to 80 ("Burnout risk"), the only one of seven attributes that ended in a bad or critical range. Data exposure, hidden the whole time, ended at 30.

After-action review of a ransomware tabletop exercise with final attribute values, a start-versus-end radar chart and the decision log

The after-action review of that run.

That run handled the incident well on paper and wore out the team doing it, which is the kind of finding an exercise should produce. The review lists every decision with the options not chosen, dice rolls and before/after values, and it prints to PDF or exports as JSON.

Step 7 — Security Notes for a Local-Only Web App

The threat model is one laptop in one room: the server binds to 127.0.0.1 and has no login, so --host 0.0.0.0 lets anyone on the network change every wargame. I checked the controls against a running instance:

$ curl -s -o /dev/null -w "%{http_code}\n" --path-as-is "http://127.0.0.1:8765/../server.py"
403
$ curl -s -w " %{http_code}\n" --path-as-is "http://127.0.0.1:8765/media/x/..%2fserver.py"
{"error": "not found"} 404
$ curl -s -w " %{http_code}\n" -X POST -H "Content-Type: application/json" --data '{"name":"x.html","data":"data:text/html;base64,PGgxPmhpPC9oMT4="}' http://127.0.0.1:8765/api/wargames/661f87450a/media
{"error": "unsupported image type: text/html"} 415

Path traversal fails, and uploads and imports accept only image types. Slides render images with <img>, so a script inside an uploaded SVG does not run there. Responses send X-Content-Type-Options: nosniff. Two gaps were left in the first release, and both came through the browser:

$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: attacker.example:8765" http://127.0.0.1:8765/api/wargames
200
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Content-Type: text/plain" --data '{"pages":[]}' http://127.0.0.1:8765/api/import
201
  • The server did not check the Host header, the precondition for DNS rebinding: a malicious page whose domain later resolves to 127.0.0.1 could read and change wargames through the facilitator's browser.
  • The API parsed every body as JSON, whatever the Content-Type said. A text/plain POST is a CORS "simple request" sent without a preflight, so any web page in the same browser could import a wargame.

The fix is a check before every request. The Host must be localhost, an IP address, or a name passed with the new --allow-host option; rebinding needs a domain name, so it now fails. A write whose Origin header does not match the Host is rejected, and POST and PUT must be application/json, which a foreign page can only send after a preflight that the server never answers. The same probes now return:

$ curl -s -w " %{http_code}
" -H "Host: attacker.example:8765" http://127.0.0.1:8765/api/wargames
{"error": "host not allowed"} 403
$ curl -s -w " %{http_code}
" -X POST -H "Content-Type: text/plain" --data '{"pages":[]}' http://127.0.0.1:8765/api/import
{"error": "expected Content-Type: application/json"} 415
$ curl -s -w " %{http_code}
" -X POST -H "Content-Type: application/json" -H "Origin: https://attacker.example" --data '{"pages":[]}' http://127.0.0.1:8765/api/import
{"error": "cross-origin request rejected"} 403

I repeated the attack in a real browser from a page on localhost, a different origin than 127.0.0.1. The browser sent the forged import, the server logged POST /api/import -> 403, and the number of wargames did not change.

Frequently Asked Questions

What is a cybersecurity tabletop exercise?

A discussion-based exercise in which a team works through a simulated incident, such as ransomware, and makes the decisions it would make for real. No production system is touched; the point is to test plans, roles and communication.

Does Wargame Presenter need an internet connection?

No. It runs on 127.0.0.1 with the Python standard library, loads no CDN scripts or web fonts, and keeps everything as JSON in the data folder.

Can I use it for scenarios other than ransomware?

Yes. Attributes, levels and options are free-form, so the same engine works for a business e-mail compromise, a cloud outage or a crisis exercise outside IT.

Conclusion

Wargame Presenter does one job: it puts a branching incident scenario on a projector and keeps score while the room argues. It has no multi-team mode, no audience voting and no access control.

The part I find most useful is the hidden attribute. A crisis team that contained the incident well still learns in the debrief how much data left while it was deciding, which is closer to a real ransomware case than a score that is visible the whole time.

Related Posts

Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.

Incident Response Python Sysadmin Tabletop Exercises Tutorials
SecurityScriptographer author

About the author

SecurityScriptographer is written and maintained by one person — a defender who builds and tests the detections, scripts, and Microsoft 365 workflows here before publishing them. More about me · @twi_nox

0 comments:

Post a Comment