Skip to content

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:

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

yaml
- name: debug route
  match: { path: '^/debug(/|\?|$)' }
  set_status: 404

Allow 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:

yaml
- name: unknown routes
  match: { method: 'GET|POST' }
  not: { path: '^/(|login|register|logout|notes(/[0-9a-f-]+)?|static/.*)(\?.*)?$' }
  set_status: 404

Build 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:

yaml
- name: no PUT on notes
  match: { method: PUT, path: '^/notes/' }
  set_status: 405

Path traversal, in the path and the query, raw and encoded:

yaml
- name: traversal
  match: { path: '\.\./|%2e%2e|%252e' }
  set_status: 404

Query parameters ​

Block a parameter wherever it appears. [?&] stops it matching inside another name.

yaml
- name: debug parameter
  match: { path: '[?&]debug=' }
  set_status: 404

Block a dangerous value rather than the parameter:

yaml
- name: file parameter escaping the directory
  match: { path: '[?&]file=[^&]*(\.\.|/etc/|%2f)' }
  set_status: 400

Injection in the query string:

yaml
- 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:

yaml
- name: api only takes json
  match: { path: '^/api/' }
  not: { headers_opt: { content-type: '^application/json' } }
  set_status: 415

Headers 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:

yaml
- 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:

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

yaml
- name: request too large
  match: { larger_than: 1M }
  set_status: 413

Cap 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:

yaml
- name: oversized search results
  dir: out
  match: { path: '^/search', larger_than: 256K }
  set_status: 502

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

yaml
- 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:

yaml
- 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:

yaml
- 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:

yaml
- 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:

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

yaml
- 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:

yaml
- 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:

yaml
- name: patched admin
  match: { path: '^/admin', method: 'GET|POST' }
  route: 19681

Try a rewrite against the real thing before routing to it:

yaml
- name: rewrite under test
  match: { path: '^/api/' }
  mirror: 19682

User agents ​

Block generic tools. Exploit scripts rarely use them, but it costs nothing:

yaml
- 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:

yaml
- name: only the checker's user agent on /notes
  match: { path: '^/notes/' }
  not: { headers: { user-agent: '^python-requests/2\.' } }
  set_status: 404

Requests without a user agent are blocked by this too, since headers requires the header to be there.

Raw tcp ​

yaml
- 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 ​

yaml
- name: data exfiltration over txt records
  match: { qtype: TXT }
- name: only the service's own zone
  not: { qname: '(^|\.)example\.ctf$' }

FTP ​

yaml
- 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:

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: '\.\./' }