SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
EVENT: Safaricom PwnZone 2.0 Qualifiers 2026CATEGORY: WebPOINTS: 550 PTS

Citrus Proof

AUTHORED BY:@bealthguy10/5/2026

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.