Cookbook
Rules for the situations that come up in a game, ready to copy into a service's vulnboxd-rules.yml. Each one goes under a port's rules:, or the service's top-level rules: for every port.
Log first
Paste a rule with action: log added, watch it for a tick or two, then let it block. See changing rules during a game.
Testing your own exploits
Your filters make it hard to tell whether a patch works or only the filter is holding. Two ways to see the service as it really is:
Give your team a bypass header. http requests that carry it skip every rule of the service, in both directions, and the header is removed before the request reaches the service. Set it at the top of a service's rules file, or for every service in /etc/vulnboxd/config.yml:
bypass:
x-team-bypass: Qm8vT3kX9rWz4LpA- Make the value long and random:
openssl rand -base64 12. Values shorter than 12 characters are refused. With several headers listed, a request needs all of them. - Keep it in your team's private repository and your own tooling, and out of anything that talks to other teams. Watch
vulnboxd_bypassed_total: if it climbs while nobody on your team is testing, the value has leaked, so change it. - A bypassed request skips encryption too: a flag read with the header comes back as it is stored, and one stored with it is stored in plaintext.
Or go around the proxy, which also works for tcp, udp, ftp and dns. On the vulnbox, every intercepted service also listens on the loopback port that vulnboxctl compose <service> ps shows: backend_port_base and up, so 20000 for the first one. Then ssh -L 8080:127.0.0.1:20000 vulnbox.
Routes
Block a route normal use never needs, such as a debug endpoint or a leftover backdoor. path covers path and query, so anchor it; '^/debug' alone would also match /debugger.
- name: debug route
match: { path: '^/debug(/|\?|$)' }
set_status: 404Allow only the routes the service has. Services often hide an endpoint the frontend never links to. List what the real client uses and answer everything else with a 404:
- name: unknown routes
match: { method: 'GET|POST' }
not: { path: '^/(|login|register|logout|notes(/[0-9a-f-]+)?|static/.*)(\?.*)?$' }
set_status: 404Build the list from the service's source and the frontend, not from what you saw in traffic: the checker may use a route only once in a while.
Block one method on one route, when the vulnerable code path is only reached that way and the service's normal flow never uses it:
- name: no PUT on notes
match: { method: PUT, path: '^/notes/' }
set_status: 405Path traversal, in the path and the query, raw and encoded:
- name: traversal
match: { path: '\.\./|%2e%2e|%252e' }
set_status: 404Query parameters
Block a parameter wherever it appears. [?&] stops it matching inside another name.
- name: debug parameter
match: { path: '[?&]debug=' }
set_status: 404Block a dangerous value rather than the parameter:
- name: file parameter escaping the directory
match: { path: '[?&]file=[^&]*(\.\.|/etc/|%2f)' }
set_status: 400Injection in the query string:
- name: sql injection in the query
match: { path: "(?i)[?&][^=&]+=[^&]*(union\\s+select|' or |%27\\s*or|sleep\\(|benchmark\\()" }Content types
Only take json on the api, when the service's own frontend only ever sends json. This shuts out exploits that rely on a different parser for form or xml bodies, while leaving requests without a body alone:
- name: api only takes json
match: { path: '^/api/' }
not: { headers_opt: { content-type: '^application/json' } }
set_status: 415Headers the service trusts
Strip headers attackers use to lie to the service. A service that trusts X-Forwarded-For or X-Real-IP for an "only from localhost" check, or X-Original-URL for routing, can be fooled by anyone who sends them. Nothing legitimate sends them to your vulnbox directly:
- name: no spoofed client headers
remove_headers: [X-Forwarded-For, X-Real-IP, X-Forwarded-Host, X-Original-URL, X-Rewrite-URL]Drop debug headers from responses, if the service puts internal data in them:
- name: no debug headers
dir: out
remove_headers: ['X-Debug-*']Sizes
Refuse large uploads before reading them. Put this first. Check what the service's normal use uploads before picking the limit: an image upload feature needs more than a notes app.
- name: request too large
match: { larger_than: 1M }
set_status: 413Cap a response, for a route that should never return much. A search that suddenly returns hundreds of kilobytes is usually a wildcard dumping every record:
- name: oversized search results
dir: out
match: { path: '^/search', larger_than: 256K }
set_status: 502Making an exploit wait
action: stall answers nothing and holds the connection instead of refusing it, so an exploit is stuck for a minute rather than failing fast and trying the next thing. Keep it to patterns normal use of the service cannot produce: a stalled checker costs far more than a blocked one.
- name: tarpit the traversal
action: stall
match: { path: '\.\./|%2e%2e|%252e' }The same for a raw tcp port, where blocking would just close the connection:
- name: tarpit shell payloads
action: stall
match: { regex: '/bin/(ba)?sh|/etc/passwd' }See stall for why a flood of them cannot be turned back on vulnboxd.
Payloads
Command injection anywhere in the request:
- name: command injection
match: { regex: '(?i)(;|\||`|\$\()\s*(cat|ls|id|whoami|curl|wget|nc|bash|sh)\b' }Template injection in one route's body. body needs the whole message, so keep it scoped:
- name: template injection on /render
match: { path: '^/render', body: '\{\{.*\}\}|\$\{.*\}' }Flags
{flag} stands for the game's flag format, which a game mode knows, or which the config sets as flag. See the game's flags.
Stop flags leaving where they have no business. Flags should only come back out of the route that shows a user their own data. Anywhere else, a flag in a response is an exploit at work:
- name: flag leak outside the flag route
dir: out
match: { regex: '{flag}' }
not: { path: '^/notes/' }
set_status: 404
set_body: "not found\n"Store flags encrypted, so that flags can only leave in plaintext through the route you decrypt on, where your other rules see them. A database dump, a file read, or data sent from the box by an exploit yields ciphertext. It does not stop an exploit that reads flags through that route; see Encrypted flags. Turn both rules on together.
- name: encrypt flags on the way in
replace: { '{flag}': { encrypt: flags } }
- name: decrypt flags where users read their own
dir: out
match: { path: '^/notes/' }
replace: { '{flag}': { decrypt: flags } }Brake bulk extraction. An exploit that walks every id over one keep-alive connection reads far more distinct flags than any legitimate client. Run it as action: log first to see whether normal traffic ever comes near the limit:
- name: mass flag extraction
dir: out
match: { distinct: { pattern: '{flag}', over: 5 } }
set_status: 403
set_body: "slow down\n"Hot-fixing one endpoint
Send it to a patched copy, when rebuilding the whole service would take too long or is risky:
- name: patched admin
match: { path: '^/admin', method: 'GET|POST' }
route: 19681Try a rewrite against the real thing before routing to it:
- name: rewrite under test
match: { path: '^/api/' }
mirror: 19682User agents
Block generic tools. Exploit scripts rarely use them, but it costs nothing:
- name: generic scanners
match: { headers: { user-agent: '(?i)sqlmap|nikto|nuclei|dirbuster|gobuster|feroxbuster' } }Allow only the checker's user agent, when it sends a recognisable one, such as an unmodified python-requests/2.x. Exploits that set their own or a browser's user agent are stopped; an attacker who copies the checker's gets through, so this raises the bar rather than closing the door. Confirm what the checker sends from your captures first, and scope the rule to the routes that matter so a change in the checker's user agent cannot take the whole service down:
- name: only the checker's user agent on /notes
match: { path: '^/notes/' }
not: { headers: { user-agent: '^python-requests/2\.' } }
set_status: 404Requests without a user agent are blocked by this too, since headers requires the header to be there.
Raw tcp
- name: shell payloads
match: { regex: '/bin/(ba)?sh|/etc/passwd|\bexecve\b' }
- name: format strings
match: { regex: '%[0-9]*\$?[nsxp]' }
- name: overflow padding # no backreferences: name the filler bytes you expect
match: { regex: '[A\x00\x90\x41]{512,}' }
- name: oversized upload # everything one side sent over the connection
match: { larger_than: 64K }
- name: mask flags
dir: out
replace: { '{flag}': { encrypt: tcp } }On a text protocol, check that %n-style patterns cannot occur in what the checker legitimately sends before you block on them.
DNS
- name: data exfiltration over txt records
match: { qtype: TXT }
- name: only the service's own zone
not: { qname: '(^|\.)example\.ctf$' }FTP
- name: no site commands
match: { regex: '(?i)^SITE ' }
- name: no escaping the root
match: { regex: '\.\./' }The whole file
All of the above as one rules file, from examples/rules.yml:
# SPDX-FileCopyrightText: 2026 Aaron Dewes <aaron@nirvati.org>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
# Worked examples for vulnboxd-rules.yml. Copy the pieces you need into a service's rules file.
#
# A rule fires when all of its `match` conditions hold, unless all of its `not` conditions hold too.
# Rules run in order, and a later rule sees what an earlier one rewrote. Anything you change here is
# picked up within a second, so you can tighten rules while the game is running.
#
# Start every new rule as `action: log`, watch its hits and the scoreboard for a tick, then switch it to
# a block. A rule that hits the checker costs SLA every tick until someone notices.
#
# {flag} stands for the game's flag format, from the game mode or `flag` in /etc/vulnboxd/config.yml.
intercept: true
end_of_tick: allow
pool_upstream: true
# http requests carrying this header, with exactly this value, skip every rule, so your team can check
# whether the service underneath is still vulnerable. The header never reaches the service. Generate the
# value (openssl rand -base64 12) and keep it out of anything that talks to other teams. It can also be
# set for every service in /etc/vulnboxd/config.yml.
bypass:
x-team-bypass: Qm8vT3kX9rWz4LpA
ports:
"8080":
protocol: http
rules:
# ---------------------------------------------------------------- routes
# Allow only the routes the service really has, from its source and its frontend. Hidden
# endpoints the frontend never links to get a 404.
- name: unknown routes
match: { method: 'GET|POST' }
not: { path: '^/(|login|register|logout|notes/.*|api/.*|search|render|admin|static/.*)(\?.*)?$' }
set_status: 404
# Block a route outright. `path` is a regex over path and query, so anchor it with ^.
# '^/debug' alone would also match /debugger.
- name: debug route
match: { path: '^/debug(/|\?|$)' }
set_status: 404
# Path traversal, in the path and the query, raw and encoded.
# Block one method on one route, when that is the only way to the vulnerable code and the
# service's normal flow never uses it.
- name: no PUT on notes
match: { method: PUT, path: '^/notes/' }
set_status: 405
# ---------------------------------------------------------------- query parameters
# Block one parameter wherever it appears. [?&] keeps it from matching inside another name.
- name: debug parameter
match: { path: '[?&]debug=' }
set_status: 404
# Block a dangerous value rather than the parameter itself.
- name: file parameter escaping the directory
match: { path: '[?&]file=[^&]*(\.\.|/etc/|%2f)' }
set_status: 400
# Typical injection payloads in the query string.
- name: sql injection in the query
match: { path: "(?i)[?&][^=&]+=[^&]*(union\\s+select|' or |%27\\s*or|sleep\\(|benchmark\\()" }
# ---------------------------------------------------------------- headers
# Only json on the api, when the service's frontend only ever sends json. `headers_opt` holds when
# the header is missing or matches, so requests without a body are left alone.
- name: api only takes json
match: { path: '^/api/' }
not: { headers_opt: { content-type: '^application/json' } }
set_status: 415
# Headers attackers use to make the service believe a request came from localhost, or to reroute
# it internally. Nothing legitimate sends them to a vulnbox directly.
- name: no spoofed client headers
remove_headers: [X-Forwarded-For, X-Real-IP, X-Forwarded-Host, X-Original-URL, X-Rewrite-URL]
# Debug headers that carry internal data out.
- name: no debug headers
dir: out
remove_headers: ['X-Debug-*']
# A crashing request still gets its 500, but no traceback with paths, config or signing keys.
- name: no stack traces
dir: out
match: { status: 500 }
action: replace
set_body: "internal error\n"
set_headers: { Content-Type: text/plain }
# ---------------------------------------------------------------- user agents
# Generic tools. Exploit scripts rarely use them, but blocking them costs nothing.
- name: generic scanners
match: { headers: { user-agent: '(?i)sqlmap|nikto|nuclei|dirbuster|gobuster|feroxbuster' } }
# When the checker sends a recognisable user agent, such as an unmodified python-requests one,
# let only that through on the routes that matter. Attackers who copy it still get in, so this
# raises the bar. Confirm what the checker sends from your captures first.
- name: only the checker's user agent on /notes
match: { path: '^/notes/' }
not: { headers: { user-agent: '^python-requests/2\.' } }
set_status: 404
# ---------------------------------------------------------------- sizes
# `larger_than` counts the body. When its length is known up front (Content-Length, or a request
# without a body) the head settles it, and the upload is refused before it is read. Chunked
# bodies are counted as they stream by and cut off once they pass the limit. Check what normal use
# uploads before picking the limit.
- name: request too large
match: { larger_than: 1M }
set_status: 413
# A search that suddenly returns hundreds of kilobytes is usually a wildcard dumping every record.
- name: oversized search results
dir: out
match: { path: '^/search', larger_than: 256K }
set_status: 502
# ---------------------------------------------------------------- making an exploit wait
# `stall` answers nothing and holds the connection for timeouts.stall instead of refusing it, so
# an exploit is stuck rather than failing fast and moving on. Keep it to patterns normal use
# cannot produce: a stalled checker costs far more than a blocked one. At most limits.stalled
# connections are held per port, so this cannot be turned into a flood against vulnboxd.
- name: tarpit the traversal
action: stall
match: { path: '\.\./|%2e%2e|%252e' }
# ---------------------------------------------------------------- payloads
# Match the whole request, head and body together.
- name: command injection
match: { regex: '(?i)(;|\||`|\$\()\s*(cat|ls|id|whoami|curl|wget|nc|bash|sh)\b' }
# `body` looks at the body alone. It needs the whole message, so keep such rules on the routes
# that need them; elsewhere bodies stream through without being held in memory.
- name: template injection on /render
match: { path: '^/render', body: '\{\{.*\}\}|\$\{.*\}' }
# ---------------------------------------------------------------- flags
# Flags should only come out of the route that shows users their own data. Anywhere else, a flag
# in a response is an exploit at work.
- name: flag leak outside the notes route
dir: out
match: { regex: '{flag}' }
not: { path: '^/notes/' }
set_status: 404
set_body: "not found\n"
# What is stored goes behind an ENCRYPTED_FLAG_ marker rather than looking like a flag, since
# serving flag-shaped strings that are not flags is against the rules of most games. It is longer
# than the flag, so check the service accepts it; flag.stored_prefix shortens it.
# Write the pattern as {flag} rather than spelling the format out, or the flag's own prefix stays.
# Store flags encrypted and decrypt them only where users read their own. This does not stop an
# exploit that reads flags through that route, but everything else (a database dump, a file read,
# data encoded or sent off the box by an exploit) yields ciphertext, so plaintext flags can only
# leave where the rules above see them. Turn both on at the same time: flags stored before are
# plaintext and would be garbled by the decrypt rule.
- name: encrypt flags on the way in
replace: { '{flag}': { encrypt: flags } }
- name: decrypt flags where users read their own
dir: out
match: { path: '^/notes/' }
replace: { '{flag}': { decrypt: flags } }
# Count how many different flags leave over one connection. An exploit walking every id over a
# keep-alive connection trips it and is cut off for the rest of that connection. Scripts that open
# a new connection per request (plain requests.get) get around it, so treat it as a brake. Run it
# as `action: log` first to see whether legitimate traffic comes near the limit.
- name: mass flag extraction
dir: out
match: { distinct: { pattern: '{flag}', over: 5 } }
set_status: 403
set_body: "slow down\n"
# ---------------------------------------------------------------- sending requests elsewhere
# Hand one endpoint to a patched copy running beside the service, and leave the rest alone.
# `route` takes host:port, or a bare port for something on this machine.
- name: patched admin
match: { path: '^/admin', method: 'GET|POST' }
route: 9001
# Send a copy of each request to a rewrite and compare the answers. The client always gets the
# real service's answer, so a mirror that is down or slow costs nothing; differences in status
# or body are logged as warnings. Both the request and the answer are held in memory for the
# comparison, so keep this on routes whose messages are small.
- name: rewrite under test
match: { path: '^/api/' }
mirror: 9002
# ---------------------------------------------------------------- watching
# `log` records hits without changing anything.
- name: watch logins
action: log
match: { method: POST, path: '^/login' }
# ------------------------------------------------------------------ raw tcp
"1337":
protocol: tcp
rules:
# Patterns are found wherever they land in the stream, however the data is split up.
- name: shell payloads
match: { regex: '/bin/(ba)?sh|/etc/passwd|\bexecve\b' }
# Check that the checker cannot legitimately send these before blocking on them.
- name: format strings
match: { regex: '%[0-9]*\$?[nsxp]' }
# Long runs of padding are usually a buffer overflow attempt rather than a real request.
# Backreferences do not exist here, so name the bytes you expect to be used as filler.
- name: overflow padding
match: { regex: '[A\x00\x90\x41]{512,}' }
# On tcp, the size is everything one side has sent over the connection.
- name: oversized upload
match: { larger_than: 64K }
# Mask flags the service would otherwise hand out on this port.
- name: mask flags
dir: out
replace: { '{flag}': { encrypt: tcp } }
# ------------------------------------------------------------------ dns
"53/udp":
protocol: dns
rules:
# Stop the service resolving names used to smuggle data out.
- name: data exfiltration over txt records
match: { qtype: TXT }
- name: only the service's own zone
not: { qname: '(^|\.)example\.ctf$' }
# ------------------------------------------------------------------ ftp
"2121":
protocol: ftp
rules:
- name: no site commands
match: { regex: '(?i)^SITE ' }
- name: no escaping the root
match: { regex: '\.\./' }