Challenge

You are given a pattern like H??T and have to supply a word that fits, for example HOST. The pattern (ptrn) and the word (word) both come from the POST body, and I had the server source.

Approach

The pattern is turned into a regex and then used to validate the word:

1
2
3
4
5
6
7
8
9
@app.route("/", methods=["POST"])
def ask():
    ptrn = request.form.get("ptrn")
    word = request.form.get("word")
    regex = re.compile(re.sub("[^A-Za-z.]","",ptrn.replace("?", ".")).upper())
    if not regex.match(word.upper()):
        return render_template("index.html", pattern=randPtrn(), message=f"{word.upper()} ISNT {ptrn}")
    if checkWd(word.upper()):
        ...

Since I control ptrn, I can send a run of ? characters that becomes . wildcards matching whatever length my word is. That bypasses the word validator, so any payload reaches checkWd.

checkWd builds its query by f-string:

1
2
3
4
def checkWd(word):
    query = f"SELECT word FROM words WHERE word = '{word}'"
    ls = db.execute(query).fetchall()
    return len(ls) > 0

That is the injection point. The flag table name is dynamic, so it cannot be brute forced, but the query is built with string formatting and a {flagtable} placeholder is reachable, so the idea was:

1
' UNION SELECT * FROM {flagtable}

There is a second injectable query in getAns:

1
2
3
4
5
6
def getAns(ptrn):
    query = f"SELECT word FROM words WHERE word LIKE '{ptrn.replace('?','_')}' ORDER BY nr DESC"
    res = db.execute(query).fetchall()
    if res:
        return res[0][0]
    return None

getAns does not return the value directly, but the response tells you which character of your guess is wrong, which is enough to leak the answer one character at a time.