Encrypted flags
A replacement can encrypt or decrypt the text it matched. With a pair of rules, the service only ever stores flags encrypted, and they are decrypted again only on the route where users read their own data:
"8080":
protocol: http
rules:
- name: encrypt stored flags
replace: { '{flag}': { encrypt: flags } }
- name: decrypt where users read their own notes
dir: out
match: { path: '^/note/' }
replace: { '{flag}': { decrypt: flags } }{flag} is the game's flag format. In FAUST CTF, the checker stores FAUST_Q2hlY2tlcnMgYXJlIGZ1bi8rK0FCQ0RF; the service sees and stores something like ENCRYPTED_FLAG_20Ygs+3eNl6ETWWAeICwnEzfiWYOuPQfWvfh9+Xx. When it is read back through /note/, vulnboxd turns it into the original again.
What is stored is not a flag
Serving strings that look like flags but are not is against the rules of most games, and can get a team banned: whoever steals one wastes a submission on it, and the gameserver wastes the check. So a stored flag does not keep the flag's own fixed text. It is written as three parts:
ENCRYPTED_FLAG_ 20Ygs+3e Nl6ETWWAeICwnEzfiWYOuPQfWvfh9+Xx
└ marker ──────┘ └ tag ──┘ └ the flag, encrypted ─────────┘The marker says plainly what the string is, so nobody mistakes it for a flag, in your database or in someone else's capture. The tag is keyed with the box's secret: it is what tells a flag vulnboxd stored from one somebody made up. Without it, anyone who knows the marker could store a string of their own, read it back through the decrypting route, and have vulnboxd hand them something flag-shaped — which is the very thing being avoided. A string whose tag does not check out is passed through exactly as it is.
The stored flag is longer than the flag
A flag that was 38 characters is stored as 55. A service that checks the format of what it is given will reject it, and you lose the SLA you were trying to protect. Try it against the service before the game, and if it is strict, use a shorter marker:
flag:
pattern: 'FAUST_[A-Za-z0-9/+]{32}'
stored_prefix: 'ENC_'There is no marker that is both obvious and the same length as the flag, so this is a trade between a stored flag you can recognise at a glance and one the service cannot tell from what it expected.
A flag format with nothing fixed in it, such as a bare [A-Za-z0-9]{32}, cannot be made safe this way: the stored form would still read as a flag whatever marker went in front. vulnboxd refuses such a rule rather than store one, and does the same for a marker that happens to end in the flag's own fixed text.
Write it as {flag}
Only {flag} knows which part of the pattern is fixed. A rule that spells the game's flags out itself, such as replace: { 'FAUST_(\w+)': { encrypt: flags } }, keeps FAUST_ in what it stores, which is exactly the problem. vulnboxd warns when it sees one.
What it is for
Encryption does not make stolen flags useless. An exploit that reads flags through /note/, say by guessing another user's note id, gets them decrypted like any other client.
What it does is decide where flags can leave in plaintext: only through the decrypting route, as ordinary responses, where your other rules and your traffic captures see them. Every other way out yields ciphertext:
- An injection that dumps the table, or a file read of the database, returns encrypted flags.
- Code execution on the service can read the data, encode it, compress it, or send it from the box to a server of the attacker's own. All it has to send is ciphertext.
- A flag the service is tricked into returning base64-encoded, split up or on some other route never passes the decrypt rule. The attacker decodes it and is left with ciphertext, where without encryption that flag would have slipped past your outbound flag filters.
That turns every exfiltration trick into one of two outcomes: the attacker gets ciphertext, or the flag goes out through the one route you are already watching with outbound flag rules and distinct.
Your game's flag format
{flag} takes care of it. vulnboxd works out from the game's flag format which part varies and which characters it uses, and encrypts exactly that: FAUST_[A-Za-z0-9/+]{32} keeps FAUST_ and encrypts the 32 characters after it, / and + included.
For a pattern of your own, give the part to encrypt as the first capture group, and list the characters it uses as alphabet. It is a literal list, ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 by default, and characters outside it are left as they are:
# hex flags
replace: { 'flag_([0-9a-f]{32})': { encrypt: flags, alphabet: 0123456789abcdef } }encrypt and decrypt must use the same key name and the same alphabet to undo each other.
How it works
The encryption is format-preserving: a keyed Feistel network over the characters of an alphabet.
- Only the first capture group is transformed, or the whole match if there is none. That keeps a prefix such as
FLAG{and the}around it intact. - Only characters listed in
alphabetchange, and each is replaced by another from the same list, so the encrypted part is the same length as the flag's own and holds nothing the service would not expect. The marker and the tag in front of it are what make the whole string longer. - Encryption is deterministic: the same flag always encrypts to the same ciphertext. A checker that later sends the flag again, for example to look something up by it, is encrypted to the value that was stored.
Keys
flags in the examples is the key's name. Keys are derived from that name and a random per-box secret in /var/lib/vulnboxd/secret, created on first start. The secret never enters the service repositories.
Keep the secret
If you lose the secret, the flags stored so far cannot be decrypted any more, and every one of them fails its check until it expires. Back it up if you might reinstall the box during a game.
Pitfalls
Decrypt only where users read their own data. A decrypt rule without a path decrypts every flag in every response, including those an exploit got the service to print somewhere else. Scope it to the route the service's normal flow uses to show a flag to its owner.
Match the flag as it is on the wire. A URL-encoded form body turns ECSC{...} into ECSC%7B...%7D, and / and + in a FAUST flag into %2F and %2B. {flag} matches the flag as the game writes it, so where a service takes flags URL-encoded, write the pattern for what actually arrives:
replace: { 'ECSC(?:\{|%7B)([A-Za-z0-9_-]{32})(?:\}|%7D)': { encrypt: flags, alphabet: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_- } }Services that transform flags break the round trip. If the service hashes the flag, or encodes it and later decodes it for display, the decrypt rule may never see the ciphertext it needs. Test the round trip against the service before the game depends on it.