Citrus Proof
550 points. The front page uses light colours and includes a small in-browser console for exercising the API. Two endpoints matter: one opens a visitor session, the other submits a proof with a layout. The token handling, not the layout, proved to be the real gate.
Opening a session always returns the same shape, a signed token carrying role visitor. There is no role selection and no visible expiry. The proof endpoint states the policy directly: no token or an invalid one returns a flat unavailable response, while a visitor token with any layout returns a forbidden-style failure. A visitor can reach the endpoint, but only a higher role receives a substantive answer.
The low-effort approaches were tried first. Unsigned tokens, empty signatures and routine role swaps were all rejected. Recovering the signing secret made no progress: it was absent from common wordlists and from the JWT-specific list, and themed guesses also failed. The verifier's trust assumptions were the productive line of inquiry.
The verifier trusted an embedded key. Supplying a header carrying an attacker-controlled octet key alongside a curator payload, signed with the matching private key, was accepted. The proof endpoint stopped returning errors and began echoing the submitted layout. That is the point of transition from the authentication layer to the injection layer.
Server-side template injection follows. {{7*7}} renders as 49, establishing that the
layout string is evaluated server-side. {{config}} discloses the Flask configuration, which
contains defaults but no flag. The filter rejects double underscores and square brackets
outright, so the direct routes to code execution fail with an identical generic message.
The bypass keeps the blocked characters out of the submitted string and reintroduces them
through the query string. Request data is visible to templates, so
{{range|attr(request.args.get('a'))}} with ?a=__class__ returns the class object while
the layout body contains no blocked text. The same technique yields globals from a helper
object, the os module from those globals, and from there a direct shell read.
A root shell is obtained on the first attempt:
uid=0(root) gid=0(root) groups=0(root)The environment contains the flag:
FLAG=safctf{83f575a861a0c69d675d700dcb658cd2}Flag:
safctf{83f575a861a0c69d675d700dcb658cd2}The password-list detour is unnecessary on a repeat run. Acceptance of an embedded key is the diagnostic signal: once unsigned tokens failed while the verifier still parsed extra header fields, embedded keys merit an attempt before any secret recovery effort.
