Configuration
vulnboxd reads /etc/vulnboxd/config.yml, or the file given with -c. The file is optional: without it, every setting has its default. Unknown keys are errors, so a typo is reported rather than ignored.
Durations are written like 500ms, 10s, 2m or 1h 30m. Sizes here are plain byte counts, except pcap.buffer_mb, which is megabytes. Sizes in a rules file take K, M and G — see larger_than.
Complete example
These are the defaults, with every key spelled out:
services_dir: /srv
exclude: []
rules_file: vulnboxd-rules.yml
state_dir: /var/lib/vulnboxd
listen: "::"
backend_port_base: 20000
poll_interval: 10s
metrics: 127.0.0.1:9330
bypass: {}
webhook_url: ""
game:
mode: GENERIC
team: null
gameserver: ""
poll: 15s
services: {}
flag: null
tick:
start: 1970-01-01T00:00:00Z
interval: 60s
window: 5s
deploy:
stop_timeout: 1s
grace: 15s
health_timeout: 30s
rollback:
enabled: true
consecutive: 1
when: now
services: {}
monitor:
interval: 10s
failures: 3
pcap:
enabled: false
listen: 127.0.0.1:4242
interface: any
ports: null
filter: ""
command: ""
buffer_mb: 64
minecraft:
enabled: false
listen: 127.0.0.1:25565
password: ""
max_players: 64
motd: Welcome. The yard is yours to build in.
scoreboard: ""
upgrade:
repo: aarondewes/vulnboxd
token: ""
drain: 30s
timeouts:
idle: 2m
http_request: 30s
stall: 1m
limits:
buffered_body: 8388608
stalled: 256
init:
github_owner: ""
github_token: ""
repo_prefix: service-
ssh_dir: /root/.ssh/vulnboxd
max_file_size: 524288000
vulnbox_ip: ""
capture_dir: .
webhook_url: ""Services
services_dir
- Default:
/srv
The directory holding one directory per service, each with a compose file. Can be overridden on the command line with --services-dir.
exclude
- Default:
[]
Names of service directories to leave alone entirely.
rules_file
- Default:
vulnboxd-rules.yml
The name of the rules file inside each service directory.
state_dir
- Default:
/var/lib/vulnboxd
Where vulnboxd keeps its state: the generated compose overrides (<state_dir>/<service>/compose.override.yml), the backend port assignments, each service's rollback history (<service>/history.json), the lock that makes one daemon the manager (manager.lock), the upgrade request (upgrade-pending) while one is waiting, and the secret that encrypted flags are keyed with.
Network
listen
- Default:
"::"
The address the proxies listen on. :: is every address; on Linux it accepts ipv4 connections too. Set 0.0.0.0 for ipv4 only, or a specific address to listen on one interface.
backend_port_base
- Default:
20000
Intercepted containers are republished on loopback ports counting up from here. Assignments are remembered in state_dir, so a service keeps its ports across restarts.
Keep this below the kernel's ephemeral range, cat /proc/sys/net/ipv4/ip_local_port_range, usually from 32768. A port inside it can be handed to an outgoing connection as its source port, and docker then cannot publish the container there at all: the deploy fails with "address already in use", which SO_REUSEADDR does not help with. vulnboxd asks the kernel to keep any backend port that does fall inside the range out of it, through ip_local_reserved_ports, and drops a connection that is holding one when a deploy needs it. Staying below the range needs neither.
A box already running keeps the assignments it has, so changing this does not move a service that is up. Deleting backends.json from state_dir reassigns every port from the new base, at the cost of one redeploy of each service in the next window.
metrics
- Default:
127.0.0.1:9330
Where Prometheus metrics are served, and what vulnboxctl top reads. null turns them off.
DANGER
Keep this on localhost. The numbers say which rules are firing, which is exactly what an attacker would like to know.
bypass
- Default:
{}
Headers with exact values, as {header: value}, that let an http request skip every rule of every service. The headers are removed before the request reaches the service, and values need at least 12 characters. A service's rules file can set its own bypass as well; a request that matches either one is let through. See testing your own exploits.
bypass:
x-team-bypass: Qm8vT3kX9rWz4LpAwebhook_url
- Default: empty
A Discord-compatible webhook that alerts are posted to: ports that stop answering, checker verdicts, failed builds and deploys, rollbacks, broken rules files. Empty sends none.
The game
game
- Default:
mode: GENERIC
The game being played. A game mode fills in the flag format, the timing and the vulnbox address, and with team set, reads the checker's verdicts from the gameserver.
| key | default | |
|---|---|---|
mode | GENERIC | GENERIC, FAUST_2026 or ECSC_2026 |
team | your team's number on the gameserver | |
gameserver | the mode's | where the gameserver is |
poll | 15s | how often the verdicts are read |
services | {} | service directory to scoreboard name, where they are not alike |
flag
- Default: the game mode's, or none
The game's flag format, which rules write as {flag}. Written as the game states it; anchors and slashes are dropped, since flags are found inside traffic:
flag: 'FAUST_[A-Za-z0-9/+]{32}'vulnboxd works out what encryption needs from it: the part between the fixed text at either end is what gets encrypted, with the characters of its class as the alphabet. To say so yourself, give a capture group around the part to encrypt, an alphabet, and the marker an encrypted flag is stored behind:
flag:
pattern: 'flag_([0-9a-f]{32})'
alphabet: 0123456789abcdef
stored_prefix: ENCRYPTED_FLAG_| key | default | |
|---|---|---|
pattern | the game's flag format, as the game states it | |
alphabet | the characters of the varying part | which characters encryption may use |
stored_prefix | ENCRYPTED_FLAG_ | what a stored flag is written behind, so it cannot be handed in as a flag. A shorter one keeps what the service holds closer to the length it expects |
Timing
poll_interval
- Default:
10s
How often each service's git repository is fetched for new commits.
tick.start
- Default:
1970-01-01T00:00:00Z, or the game mode's
The start of any tick, as an RFC 3339 timestamp. All ticks are aligned to it, so set it to when the game began. See lining up with the game.
tick.interval
- Default:
60s, or the game mode's
How long a tick lasts.
tick.window
- Default:
5s
The end of each tick in which vulnboxd restarts services and end_of_tick applies. Must be shorter than the tick. This is vulnboxd's own window; if your game defines one, as the ECSC does, set it to match.
Deploys
deploy.stop_timeout
- Default:
1s
How long a container being replaced gets to shut down before it is killed. Most services run their app as pid 1, where SIGTERM is ignored, so waiting longer usually only wastes the window. See services that stop slowly.
deploy.grace
- Default:
15s
How long the proxy keeps retrying a service that is not listening, so a deploy that runs over its window shows up as a slow request rather than a failed one.
deploy.health_timeout
- Default:
30s
How long a deployed service gets to have its containers running and every tcp port answering. A new version that does not is rolled back.
Rollback
rollback
- Default:
enabled: true,consecutive: 1,when: now
When vulnboxd undoes a change by itself, with a pushed commit, because the service stopped working after it. See Rollback for what each setting does and when to change it.
rollback:
enabled: true
consecutive: 1
when: now
services:
notes: { consecutive: 3 }
legacy: { enabled: false }Monitoring
monitor.interval
- Default:
10s
How often every tcp port of every service is checked for answering. See port checks.
monitor.failures
- Default:
3
Checks in a row a port has to miss before it counts as down and is alerted.
Capture
pcap
- Default: off
The capture broker: captures every packet the box sees and serves it over PCAP-over-IP, so tools like tulip and shovel read it live. It runs as its own service, vulnboxd-pcap.
pcap:
enabled: true
listen: 127.0.0.1:4242 # readers connect here; keep it off the public internet
interface: any # which interface to capture
ports: 9000,9100-9200 # only these ports; empty captures every one
filter: not port 22 # a pcap filter for what to leave out
command: "" # the whole capture command, when the above is not enough
buffer_mb: 64 # how far behind one reader may fall before it is disconnected| key | default | |
|---|---|---|
pcap.enabled | false | run it at all. vulnboxctl install writes the unit when this is set |
pcap.listen | 127.0.0.1:4242 | where readers connect |
pcap.interface | any | which interface to capture |
pcap.ports | the game mode's | single ports and ranges in any mix: 9000, 9000-9999, 9000,9100-9200,8000. Empty captures every port |
pcap.filter | a pcap filter for what to leave out | |
pcap.command | the whole capture command, which must write classic pcap to its output | |
pcap.buffer_mb | 64 | how far behind one reader may fall before it is disconnected |
What you set for ports wins over the game mode's. The broker's own listen address is always excluded from the capture, whatever is set here; a command of your own replaces all of it and has to keep its own traffic out itself. See what ends up in the capture.
minecraft
- Default: off
The status world: a Minecraft server where each service is a pillar, lit while the checker says it works. A toy, its own service (vulnboxd-mc), and only in a build made with the minecraft feature — the release archive ships one. The setup asks about it; turning it off here and running the setup again stops the service and removes its unit.
minecraft:
enabled: true
listen: 127.0.0.1:25565 # keep it off the public internet
password: 'a word' # empty leaves the gated plot shut to everyone
max_players: 64
motd: Welcome. The yard is yours to build in.
scoreboard: '' # empty takes the game's gameserver; `none` leaves the screen blank| key | default | |
|---|---|---|
minecraft.enabled | false | run it at all. The setup writes the unit when this is set, and removes it when it is not |
minecraft.listen | 127.0.0.1:25565 | where players connect. The password crosses the wire in the clear, so keep this on loopback or your team's vpn |
minecraft.password | said in chat to be let in to what the rules are doing. Empty keeps that shut to everyone, which is the safe way round | |
minecraft.max_players | 64 | how many at once |
minecraft.motd | a welcome | said to everyone who joins, so it gives nothing away |
minecraft.scoreboard | the game's | where to read the game's scoreboard from. none leaves the screen blank |
Upgrades
upgrade
- Default:
repo: aarondewes/vulnboxd
Where vulnboxctl upgrade fetches releases from. A private repository needs token, one that can read it; VULNBOXD_UPGRADE_TOKEN in the environment takes precedence. A config file holding a token is written readable by root only.
upgrade:
repo: TeamGeramy/vulnboxd
token: github_pat_...
drain: 30s| key | default | |
|---|---|---|
upgrade.repo | aarondewes/vulnboxd | owner/name releases are fetched from |
upgrade.token | a token that can read it, for a private repository | |
upgrade.drain | 30s | how long the daemon being replaced stays alive to finish the connections it already had open, while the new one serves everything else. 0 replaces it in place instead, which closes them |
A drain needs a unit the daemon can tell systemd about; see the unit has to allow it.
Timeouts and limits
timeouts.idle
- Default:
2m
tcp, ftp, http and WebSocket connections with no traffic in either direction for this long are closed.
timeouts.http_request
- Default:
30s
The time a client gets to send its request headers, also between requests on a keep-alive connection, and then again for the body. A request that runs out is answered with 408.
timeouts.stall
- Default:
1m
How long a stall rule holds a connection before dropping it. Longer wastes more of an attacker's time; it does not raise the worst-case cost to vulnboxd, which limits.stalled bounds, but it does mean the tarpit fills sooner.
limits.buffered_body
- Default:
8388608(8 MiB)
The largest http body held in memory, for rules that need a whole body at once (body, set_body) and for mirroring. Larger messages are refused rather than passed through unchecked. Rules that can stream never buffer more than this. See HTTP bodies.
limits.stalled
- Default:
256
How many connections one port holds open at a time for a stall rule. Past it a stall rule blocks instead, so a flood of matching connections cannot pile up on vulnboxd. Each held connection costs one socket and one task. vulnboxd_connections_stalled says how many are held.
Setup
These are only used by vulnboxctl init and setup-interactive.
init.github_owner
- Default: empty
The organisation or user the service repositories are created in. Empty skips everything GitHub-related.
init.github_token
- Default: empty
A token with repo access. The GITHUB_TOKEN environment variable takes precedence, and is the better place for it: the setup wizard never writes the token to the config file.
init.repo_prefix
- Default:
service-
Put in front of each service's repository name on GitHub, so a service directory called web becomes service-web. It may hold letters, digits, dots, underscores and dashes, and may be empty for a repository named after the service alone.
If the name it wants belongs to something else, vulnboxd uses the next free one: service-web-2, service-web-3, and so on, up to twenty. A repository vulnboxd made for that service before is used again rather than duplicated, which is how a second run stays idempotent. To put a service in a repository of your own naming, set its origin yourself: a service that already pushes somewhere keeps that repository whatever its name is.
init.ssh_dir
- Default:
/root/.ssh/vulnboxd
Where the per-service deploy keys are kept, one key pair per service, along with the known_hosts file holding GitHub's host keys.
init.max_file_size
- Default:
524288000(500 MiB)
Files this size or larger are added to .gitignore instead of being committed, since GitHub would refuse the push.
init.vulnbox_ip
- Default: the game mode's, or empty
This box's address, written into the capture tools' configuration and named in alerts. A game mode works it out from the team number, or asks the gameserver during setup. Empty writes 0.0.0.0.
init.capture_dir
- Default:
.(the current directory)
Where tulip.json (for Tulip) and shovel.yaml (for Shovel) are written, listing every published service port.
init.services_file
- Default: empty, which writes none
Also write a plain list of every service listener to this file: its name, this box's address and the port, one entry each. For the tools that want to be told where the services are rather than work it out — exploit runners, flag submitters, a dashboard's own configuration.
init:
services_file: /root/services.yml# Written by vulnboxctl: one entry per service listener.
services:
notes-notes:
host: "10.60.13.2"
port: 9100
shop-web:
host: "10.60.13.2"
port: 9000The names are the ones shovel.yaml uses — the service directory and the compose service, which are the same word when a service has one container. A compose service that publishes more than one port gets one entry per port, with the port on the end of the name, since two entries cannot share a key.
init.webhook_url
- Default: empty, which uses the top-level
webhook_url
If set, a JSON message {"content": "..."} is posted here when a setup run starts and when it finishes, with any failures. That is the format Discord webhooks expect.