BISCUIT
Challenge
Hello world! italics bold
File given: biscuit.tar.zst.
Understanding the app
It's a little voting site (cake vs biscuit) gated behind login. Auth doesn't use plain session cookies, it uses Biscuit tokens, which encode a small Datalog program of facts and checks, signed with a keypair on the server. On signup or login the server mints one of these for you:
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()
Only the hardcoded "webmaster" account gets a role("admin") fact baked into its token. The /flag route checks for exactly that fact before showing anything:
def current_admin() -> str | None:
return _authorize('allow if user($u), role("admin");')
@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)
The vulnerability
The username gets dropped straight into the Datalog source with an f-string, with no validation and no escaping. They don't validate the input, it's just an empty f-string, so whatever you sign up with becomes literal Datalog syntax inside the token builder. A username like:
sakamotosan"); role("admin
turns the minted program into:
user("sakamotosan"); role("admin");
check if user($u), $u.length() > 0;
which closes the user(...) fact early and appends a role("admin") fact of your own choosing, the exact fact that's normally reserved for the webmaster account. The check clause still passes fine since user($u) is still true, so the token comes out signed and valid with the injected fact intact.
Solution
Sign up with that string as the username, then hit /flag with the resulting cookie. The exact payload used:
user("sakamotosan"); role("admin