<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
	<channel>
		<title>Waut Lornoy / posts</title>
		<link>https://waut.lornoy.org</link>
		<description>Writeup posts and offensive-security research notes on exploitation, web security, reverse engineering, and network security, published as the work happens.</description>
		<language>en</language>
		<lastBuildDate>Sun, 02 Aug 2026 00:00:00 GMT</lastBuildDate>
		<atom:link href="https://waut.lornoy.org/rss.xml" rel="self" type="application/rss+xml" />
		<item>
			<title>Archonyx: A CSS Side Channel to Root</title>
			<link>https://waut.lornoy.org/blog/htb-archonyx-writeup</link>
			<guid isPermaLink="true">https://waut.lornoy.org/blog/htb-archonyx-writeup</guid>
			<pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate>
			<category>ctf</category>
			<category>cyber-apocalypse-2026</category>
			<category>web-exploitation</category>
			<description>A writeup for Archonyx, a source-available web challenge from Cyber Apocalypse CTF 2026. A CSS-only side channel leaks a bot&apos;s session key past a strict CSP, a symlink race in a zip extractor exposes the app&apos;s JWT secret, and a mismatch between two zip parsers plants code that Less loads and runs as root.</description>
			<content:encoded><![CDATA[<p>Archonyx is a web challenge from Cyber Apocalypse CTF 2026. It shipped as full server-side source rather than a black-box instance, a Node/Express app plus a headless-Chromium report bot, so the first move was reading code. The goal is <code>/readflag</code>, a setuid-root binary that prints <code>/flag.txt</code>.</p>
<p>The finished chain runs five phases, all the way to root:</p>
<ol>
<li><strong>Leak the bot's relay key</strong> with a CSS-only side channel that gets around the page's Content-Security-Policy, read back through <code>window.length</code>.</li>
<li><strong>Read <code>/app/.env</code></strong> through a symlink planted via <code>decompress</code>, dereferenced by the app's own file reader.</li>
<li><strong>Forge a <code>ledgermaster</code> JWT</strong> using the secret from step 2, against a verifier that only checks the signature.</li>
<li><strong>Plant a JavaScript file at a known public path</strong> by exploiting a mismatch between the zip validator and the zip extractor.</li>
<li><strong>Run it</strong> through Less's <code>@plugin</code> directive, which loads the file as a Node module.</li>
</ol>
<p>None of this was obvious on the first read. What follows is how it actually came together, including the parts that didn't work.</p>
<hr>
<h2 id="mapping-the-attack-surface">Mapping the attack surface<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#mapping-the-attack-surface">#</a></h2>
<p>Reading through the route definitions gave a quick picture of what needs what:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Route group</th>
<th>Gate</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/api/*</code></td>
<td><code>resolveAuth</code>: session cookie, or <code>x-api-key</code> belonging to a <strong>verified</strong> user</td>
</tr>
<tr>
<td><code>/ledgermaster/*</code></td>
<td><code>requireRole('ledgermaster')</code>, which reads the role straight out of the JWT</td>
</tr>
<tr>
<td><code>/broker/me</code>, <code>/widget/*</code>, <code>/file</code>, <code>/consignments</code></td>
<td><code>requireSession</code></td>
</tr>
<tr>
<td><code>POST /report</code>, <code>GET /api/convoys</code>, <code>POST /api/convoys</code>, <code>GET /api/feed/:file.json</code></td>
<td><strong>nothing</strong> (our entry point)</td>
</tr>
</tbody>
</table></div>
<p>Registration works, but login refuses unverified accounts, and only a <code>ledgermaster</code> can verify anyone. So an account can be created and never used, which ruled out the obvious "just register and go" path early.</p>
<p>The interesting sink is <code>POST /ledgermaster/render</code>, which compiles attacker-supplied Less. Less's <code>@plugin</code> directive loads its target as a Node module and runs it, which is code execution the moment I can reach it. That route needs the <code>ledgermaster</code> role.</p>
<p>Roles come from a JWT signed with <code>JWT_SECRET</code>, and a seed script writes that secret to <code>/app/.env</code> when the container starts. Reading an arbitrary file needs the archive-extraction endpoints below, and those need an authenticated API caller. Which gave me a chain with a hole in the middle:</p>
<pre><code>flag &#x3C;- /readflag &#x3C;- RCE &#x3C;- ledgermaster JWT &#x3C;- JWT_SECRET &#x3C;- /app/.env &#x3C;- authenticated API &#x3C;- ???
</code></pre>
<p>The only authenticated party anywhere in the system is the report bot itself. It's seeded with a <code>warden</code> session and a relay key at container start. Nothing in the app exposes that key to a normal request. Everything below exists to get one string out of a browser I don't control.</p>
<hr>
<h2 id="phase-1-leaking-the-bots-relay-key">Phase 1: leaking the bot's relay key<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#phase-1-leaking-the-bots-relay-key">#</a></h2>
<p>This took the longest by a wide margin, and most of it was wrong turns.</p>
<h3 id="an-early-plan-csrf-the-bot-into-uploading">An early plan: CSRF the bot into uploading<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#an-early-plan-csrf-the-bot-into-uploading">#</a></h3>
<p>My first idea skipped the relay key entirely. If I could get the bot's browser to submit an authenticated request on my behalf, I wouldn't need to know any of its secrets. The session cookie has no <code>SameSite</code> protection, so a same-origin <code>POST</code> from the bot's browser would ride along automatically.</p>
<p>That sent me down a real rabbit hole: Chrome's two-minute grace period for <code>SameSite</code>-less cookies on a fresh navigation, top-level popups instead of iframes (since <code>Lax</code> only forgives top-level navigation, not framed requests), and <code>DataTransfer</code> tricks to fake a multipart file part so the forged request would look like a file upload. This proved a custom file could land on the server, but it never became the way in. the final chain authenticates every later phase with the relay key directly, and phase 1 itself only needs a plain top-level <code>GET</code>, which <code>Lax</code> permits without any of this. I kept the CSRF work only long enough to confirm the upload endpoint was reachable at all.</p>
<h3 id="a-gadget-that-leaks-json-into-an-attribute">A gadget that leaks JSON into an attribute<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#a-gadget-that-leaks-json-into-an-attribute">#</a></h3>
<p>Looking for anywhere client-side JS makes its own fetch, the widget iframe that renders the transmission log stood out:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> current</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> URLSearchParams</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">location</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">search</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">get</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">record</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">held</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">fetch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/api/feed/</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">.json</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> credentials</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">include</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">then</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">r</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">json</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">then</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">res</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> value</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    ...</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">parent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">getElementById</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">info-panel</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setAttribute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">data-content</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span></code></pre>
<p>This is a credentialed, arbitrary same-origin <code>GET</code> whose JSON response gets written straight into the parent frame. <code>record</code> has no server-side validation at all. Setting it to <code>../relay-key?</code> turns the fetch target into:</p>
<pre><code>/api/feed/ + ../relay-key? + .json   ->   /api/feed/../relay-key?.json   ->   /api/relay-key
</code></pre>
<p>The browser's URL normalization collapses <code>..</code> the same way a filesystem path would, and the trailing <code>?</code> turns the leftover <code>.json</code> into a query string instead of a path segment. The response comes back as <code>{"data":"&#x3C;12 hex chars>"}</code>, and that lands here:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">div</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">info-panel</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> data-content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">""</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">div</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span></code></pre>
<p>That <code>&#x3C;div></code> is dead markup. Nothing else in the app reads it. It exists only to receive this value, which is the clearest sign that leaking data through an HTML attribute is the intended route rather than a coincidence.</p>
<p>My first attempt at reading <code>/app/.env</code> reused this exact gadget, pointing <code>record</code> at <code>../../.env?</code> instead. It failed for a boring reason: the gadget calls <code>r.json()</code> before it does anything else, and <code>.env</code> is <code>JWT_SECRET=...</code>, not JSON. The fetch throws before <code>res.data</code> is ever set. Whatever read <code>.env</code> was going to need its own bug, which turned out to be true, and is where phase 2 starts.</p>
<h3 id="getting-past-qs-and-urlsearchparams-disagreeing">Getting past <code>qs</code> and <code>URLSearchParams</code> disagreeing<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#getting-past-qs-and-urlsearchparams-disagreeing">#</a></h3>
<p>Getting a value into <code>data-content</code> is only half of it: nothing reads that attribute either. The partner injection sits in the same page:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> params</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> URLSearchParams</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">location</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">search</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> params</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">get</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">theme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">getElementById</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">theme-display</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">innerHTML</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span></code></pre>
<p>guarded server-side like this:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> req</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">query</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">theme</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">allowedThemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">includes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">theme</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">400</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Invalid theme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre>
<p>Two different parsers read the same query string. The app sets <code>app.set('query parser', 'extended')</code>, which routes <code>req.query</code> through <code>qs</code>. <code>qs</code> defaults to a 1000-parameter limit and silently drops anything past it instead of throwing. The browser's own <code>URLSearchParams</code>, the one running in the page, has no such limit. So I padded the query string:</p>
<pre><code>/ledger?record=..%2Frelay-key%3F&#x26;z&#x26;z&#x26;z...(1010 times)...&#x26;theme=&#x3C;payload>
</code></pre>
<p><code>record</code> stays parameter 1, so the server still parses it correctly and renders it into the iframe's <code>src</code>. <code>theme</code> becomes parameter 1012, past <code>qs</code>'s limit, so <code>req.query.theme</code> is <code>undefined</code>, the allowlist check gets skipped, and the page renders anyway. The browser's <code>URLSearchParams</code>, with no parameter limit, still finds <code>theme</code> and passes it straight to <code>innerHTML</code>.</p>
<p>While I had <code>qs</code> open, I also checked whether <code>allowPrototypes</code> was still its old permissive self, since a prototype pollution primitive on top of this would have been a much shorter writeup. It wasn't: modern <code>qs</code> guards <code>__proto__</code> inside <code>parseObject</code>, and <code>constructor[prototype][x]</code> only ever sets an own property through <code>utils.merge</code>. Dead end, but a five-minute one.</p>
<h3 id="the-csp-wall-and-the-dead-ends-it-caused">The CSP wall, and the dead ends it caused<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-csp-wall-and-the-dead-ends-it-caused">#</a></h3>
<p>At this point I had two working primitives, an attribute write and an <code>innerHTML</code> sink, and a CSP that made both nearly worthless:</p>
<pre><code>Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-&#x3C;16 random bytes, per response>';
  style-src 'unsafe-inline' 'self' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  frame-ancestors 'self'; form-action 'self'; base-uri 'self'
</code></pre>
<ul>
<li><strong>No script.</strong> <code>script-src</code> is nonce-only, the nonce is <code>crypto.randomBytes(16)</code> per response, and scripts inserted via <code>innerHTML</code> never execute regardless.</li>
<li><strong>No egress.</strong> <code>img-src</code>, <code>connect-src</code>, <code>media-src</code>, and <code>frame-src</code> all fall back to <code>default-src 'self'</code>. The classic <code>background-image: url(https://attacker/?leak=...)</code> is dead on arrival.</li>
<li><strong>No framing.</strong> <code>frame-ancestors 'self'</code> stops an attacker page from embedding the target and watching it.</li>
<li><strong>No form exfil.</strong> <code>form-action 'self'</code>, and nothing clicks anyway.</li>
<li><strong>No base hijack.</strong> <code>base-uri 'self'</code>, and every script tag uses an absolute path, so a same-origin <code>&#x3C;base></code> changes nothing.</li>
</ul>
<p>I spent real time here on things that were never going to work. <code>style-src</code> and <code>font-src</code> both carve out <code>fonts.googleapis.com</code> and <code>fonts.gstatic.com</code>, so I tried routing an exfil request through a Google font URL. That's allowed by the policy, but the request lands on Google's servers, which I don't control and can't read. I also tried <code>POST /api/convoys</code>, which is unauthenticated for both reads and writes and satisfies <code>form-action 'self'</code>, as a place to write leaked data to and read it back from. That fails because nothing on the page ever clicks a form and no script runs to submit one automatically. And early on, before any of this, I tried hosting the attacker page on webhook.site, which turned out to serve its own <code>script-src 'none'</code>, killing script tags, inline handlers, and <code>srcdoc</code> alike before I'd even gotten to the target's CSP.</p>
<p>Uploading a file to <code>/uploads</code> and opening it directly doesn't help either: <code>app.use(security)</code> runs before <code>express.static</code>, so even planted HTML gets the same nonce CSP.</p>
<h3 id="css-as-a-signal-depart-as-the-effect">CSS as a signal, <code>/depart</code> as the effect<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#css-as-a-signal-depart-as-the-effect">#</a></h3>
<p>CSP allows inline styles, and a CSS attribute selector can act as a conditional: it only fires its declared network request when the attribute matches.</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">#</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">info-panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-content</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">a1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> background-image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">/depart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre>
<p>If the attribute doesn't start with <code>a1</code>, the browser never issues the request at all. <code>default-src 'self'</code> forces that request back to the app itself, which is normally a dead end, until I noticed the logout route:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">exports</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">logout</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">req</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">clearCookie</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">token</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">redirect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/enter</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">};</span></span></code></pre>
<p>A plain <code>GET</code> with a side effect that persists: logging the bot out. The browser doesn't care that the request was triggered by a CSS background image. The server responds with <code>Set-Cookie: token=; Expires=Thu, 01 Jan 1970</code>, and the session is gone. The stylesheet effectively reads:</p>
<blockquote>
<p><em>If the relay key starts with <code>a1</code>, destroy the bot's session.</em></p>
</blockquote>
<p>Two details make this reliable. <code>#info-panel</code> is an empty <code>&#x3C;div></code> with no styling, so it paints no box and Chrome skips the background-image fetch entirely unless the injected rule gives it a size. And the selector re-evaluates whenever the attribute changes, so ordering takes care of itself: the injected <code>&#x3C;style></code> sits in the page from parse time, and the fetch fires the instant the widget iframe writes <code>data-content</code>.</p>
<h3 id="reading-the-answer-through-windowlength">Reading the answer through <code>window.length</code><a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#reading-the-answer-through-windowlength">#</a></h3>
<p>The bot visits the attacker page first, and that page carries no CSP of its own. From there it opens the target in a popup window (the bot launches Chromium with <code>--disable-popup-blocking</code>) and keeps a handle to it. A popup, not an iframe, since <code>frame-ancestors</code> blocks framing.</p>
<p>A cross-origin <code>WindowProxy</code> exposes almost nothing, but <code>window.length</code>, the number of nested browsing contexts inside it, is one of the few properties still readable across origins:</p>
<ul>
<li><code>/ledger</code> <strong>with</strong> a session renders two iframes (<code>frame-dispatch</code>, <code>frame-log</code>).</li>
<li><code>/ledger</code> <strong>without</strong> one hits <code>if (!req.user) return res.redirect('/enter')</code> and lands on a page with zero iframes.</li>
</ul>
<p>Setting <code>location</code> on a cross-origin window is also allowed, so a page can send a window somewhere without ever reading what's inside it. The attacker page navigates the popup to <code>/ledger</code> and reads the frame count:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th><code>w.length</code></th>
<th>meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>2</code></td>
<td>session alive -> selector did not match -> guess wrong</td>
</tr>
<tr>
<td><code>0</code></td>
<td>session dead -> selector matched -> guess right</td>
</tr>
</tbody>
</table></div>
<p>The bot's own cookie rides along automatically. It's a top-level <code>GET</code> navigation, which <code>SameSite=Lax</code> allows with no extra trickery needed, and none of the CSRF machinery from earlier.</p>
<h3 id="cost-controls-and-one-probes-timeline">Cost, controls, and one probe's timeline<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#cost-controls-and-one-probes-timeline">#</a></h3>
<p>The cookie jar is per browser profile, and each <code>bot.visit()</code> launches a fresh browser, so a match destroys the session for the rest of that visit. One yes/no answer per bot visit.</p>
<p>The key is <code>crypto.randomBytes(6).toString('hex')</code>, 12 characters over a 16-symbol alphabet. Rather than 16 questions per character, each probe tests half the remaining alphabet at once by listing eight selectors:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">#</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">info-panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-content</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">a0</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> #</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">info-panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-content</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">a1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ... </span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> background-image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">/depart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre>
<p>16 -> 8 -> 4 -> 2 -> 1 is four probes per character, 48 probes for the whole key, plus two controls. About 15 to 20 minutes at roughly 12 seconds of page time each. I drove the whole search from a small local server: it hands the bot's browser the current guess, collects the answer back, and refuses to start the real search until two control probes disagree the way they should:</p>
<ul>
<li><code>#info-panel[data-content]</code>, matching any element with the attribute at all, <strong>must</strong> return <code>0</code>.</li>
<li><code>#info-panel[data-content^="zzz"]</code>, since the key is hex, <strong>must</strong> return <code>2</code>.</li>
</ul>
<p>That always-match result carries a lot of weight. A single <code>0</code> there proves the <code>record</code> traversal reached <code>/api/relay-key</code>, the bot's cookie was attached, the response reached <code>data-content</code>, <code>theme</code> survived <code>qs</code> truncation, the CSS applied, <code>/depart</code> fired, and <code>window.length</code> reads correctly. Without it the search runs on noise.</p>
<p>One probe, end to end:</p>
<ol>
<li><code>POST /report</code>. The app launches Chromium, sets the bot cookie, and navigates it to the attacker page.</li>
<li>The attacker page fetches its probe from its own origin (unrestricted) and opens the popup at the padded <code>/ledger</code> URL.</li>
<li><code>/ledger</code> renders, the widget iframe fetches <code>/api/relay-key</code>, and writes <code>data-content</code> on the parent.</li>
<li>The injected CSS matches or it doesn't, so <code>/depart</code> fires or it doesn't.</li>
<li>After 6 seconds: <code>w.location = '.../ledger'</code>.</li>
<li>Two samples of <code>w.length</code>, two seconds apart, because a navigation still in flight shows the old count and would read as a false negative. Disagreement gets reported as <code>unstable</code> and re-run.</li>
<li>The result posts back to the attacker's own server.</li>
</ol>
<hr>
<h2 id="phase-2-reading-appenv">Phase 2: reading <code>/app/.env</code><a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#phase-2-reading-appenv">#</a></h2>
<p>With the relay key in hand as an <code>x-api-key</code>, the browser is no longer needed at all. <code>resolveAuth</code> accepts that header from any verified user, and the bot is verified.</p>
<h3 id="two-extractors-and-only-one-lets-you-create-a-symlink">Two extractors, and only one lets you create a symlink<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#two-extractors-and-only-one-lets-you-create-a-symlink">#</a></h3>
<p>Archonyx has two separate zip-extraction endpoints. <code>/api/manifest</code> runs through <code>unzipper</code>, and I tried the symlink idea there first: upload a symlink entry pointing at <code>/app/.env</code> and read it back through whatever the app serves. It doesn't work, structurally. <code>unzipper</code> derives <code>entry.type</code> only from "size 0 and ends in <code>/</code>", and the symlink bit lives in <code>externalFileAttributes</code>, which exists only in the central directory. A streaming parser never reads that far, so <code>Extract</code> cannot create a link no matter what the archive contains.</p>
<p>The app's other extraction endpoint downloads and unpacks what it calls a "mirror station bundle", through <code>decompress@4.2.1</code> instead of <code>unzipper</code>. That version already contains the standard zip-slip fix, and it's thorough about writes: <code>safeMakeDir</code> resolves the real path of every parent directory before creating it, and <code>preventWritingThroughSymlink</code> refuses to write to a destination that's already a symlink. Neither guard covers creating a symlink in the first place. A symlink entry is handled by <code>fs.symlink(x.linkname, dest)</code>, and <code>linkname</code> comes straight from the archive with no validation. The destination path stays inside the output directory, so both guards pass, but the link itself can point anywhere on the filesystem.</p>
<h3 id="why-the-unknown-uuid-doesnt-matter">Why the unknown UUID doesn't matter<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#why-the-unknown-uuid-doesnt-matter">#</a></h3>
<p>The link lands in <code>/app/uploads/imports/&#x3C;uuid>/</code>, and no endpoint exposes that UUID. That looked fatal until I found the function that turns a filename into a path on disk:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> resolveFilePath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">caller</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> filename</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">filename</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> path</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">basename</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">filename</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> filename</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">caller</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">drawsId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> path</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">uploadsDir</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">imports</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> caller</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">drawsId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> filename</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre>
<p>The server supplies the UUID from the caller's own record, and <code>fs.readFile</code> follows symlinks. A request for <code>env</code> makes the server resolve the directory and dereference the link on my behalf. I never need to know the UUID at all.</p>
<h3 id="racing-the-cleanup-loop">Racing the cleanup loop<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#racing-the-cleanup-loop">#</a></h3>
<p><code>validateExtractedFiles</code> reads the symlink, sees <code>.env</code> isn't an image, and deletes it. My first plan was to pad the archive with thousands of valid PNGs, so the cleanup loop, which runs sequentially and does one <code>readFile</code> plus one magic-byte check per entry, would take several seconds to reach the symlink and I'd have a window to read it first.</p>
<p>That reasoning was wrong, which is why a naive upload-then-read only won about half the time. The loop iterates <code>fs.readdirSync(dir)</code>, and on ext4, <code>readdir</code> returns entries in filename-hash order, not creation order. Padding puts the symlink at a random position in that order, and adding more padding only shifts the average, not the outcome.</p>
<p>The real fix is about timing, not padding: start reading while the archive is still being extracted, well before the response comes back or cleanup begins. The order in which the endpoint does its work makes this possible:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> uploadService</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">downloadAndExtract</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> extractDir</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // link exists partway through</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">res</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">json</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Mirror station bundle fetched and lodged</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> });</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setImmediate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> uploadService</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">validateExtractedFiles</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">extractDir</span><span style="--shiki-light:#999999;--shiki-dark:#666666">));</span></span></code></pre>
<p><code>decompress</code> extracts all entries concurrently, and the symlink is first in the archive, so it exists within milliseconds of extraction starting, while the 3,000 padding files are still being written, well before the response is sent and well before cleanup begins. So I stopped waiting for the response entirely: eight reader threads start hammering <code>/api/cargo/env/raw</code> before the upload request is even issued, which turns a coin-flip into a near-certainty.</p>
<p>A second improvement widens the window further. <code>readFile</code> follows symlinks, so the padding entries can themselves be symlinks to a large existing file (<code>/usr/local/bin/node</code>, about 110 MB) instead of real PNGs. Each one then costs a full multi-megabyte read before <code>file-type</code> rejects it, stretching the cleanup loop to tens of seconds without writing any real data to disk.</p>
<hr>
<h2 id="phase-3-forging-the-token">Phase 3: forging the token<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#phase-3-forging-the-token">#</a></h2>
<p><code>JWT_SECRET</code> is all that's needed, since the app trusts a token's contents once the signature checks out. There's no <code>expiresIn</code> at signing, <code>jwt.verify</code> checks nothing but the signature, and authorization reads the role straight out of the payload instead of re-reading the user from the database the way <code>resolveAuth</code> does. So a token payload of <code>{"username":"admin","role":"ledgermaster"}</code>, signed with HS256, is indistinguishable from a genuine admin session. Forging one took twenty lines and no dependencies: base64url-encode a header and that payload, HMAC-SHA256 the two together with the leaked secret, and concatenate the three parts with dots.</p>
<hr>
<h2 id="phase-4-planting-the-payload">Phase 4: planting the payload<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#phase-4-planting-the-payload">#</a></h2>
<h3 id="two-ways-to-read-the-same-zip">Two ways to read the same zip<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#two-ways-to-read-the-same-zip">#</a></h3>
<p>A ZIP describes its contents twice:</p>
<pre><code>[ Local header "../bot_x/evil.js" ][ payload bytes ]   &#x3C;- streaming readers start here
[ Local header "ok.png"           ][ PNG bytes     ]
[ Central directory: "ok.png" -> offset of the PNG local header ]
[ End of central directory ]
</code></pre>
<p>Each file is preceded by a local file header (<code>PK\x03\x04</code>) holding its name and size. At the end sits the central directory (<code>PK\x01\x02</code>), an index of every file's name plus the byte offset of its local header. Random-access readers seek to the end and read the central directory, which is how a zip tool lists contents instantly. Streaming readers can't seek backwards, so they parse forward from byte 0 using local headers. Nothing in the format requires the two descriptions to agree, and Archonyx splits exactly on that line:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> directory</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> unzipper</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Open</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">buffer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">buffer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // random access -> central directory</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">...</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">stream</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">pipe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unzipper</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Extract</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> path</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> extractDir</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }))</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">     // streaming    -> local headers</span></span></code></pre>
<p><code>validateArchive</code> iterates <code>directory.files</code> and applies three checks: an extension allowlist, a <code>..</code> substring check, and magic-byte sniffing via <code>entry.buffer()</code>. That last call seeks to the offset the central directory declares, so it reads the genuine PNG and passes. <code>extractArchive</code> then re-parses the same bytes streaming, hits the local header at offset 0 naming <code>../bot_x/evil.js</code>, and writes it. The validator never saw that entry exist.</p>
<h3 id="why-the-traversal-guard-doesnt-catch-it">Why the traversal guard doesn't catch it<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#why-the-traversal-guard-doesnt-catch-it">#</a></h3>
<p><code>unzipper.Extract</code> does guard against zip-slip, but with a string comparison:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> extractPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> path</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">outPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> entry</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">path</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">extractPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">indexOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">outPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> entry</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">autodrain</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span></span></code></pre>
<p>With <code>outPath = /app/uploads/workspace/bot</code>, an entry named <code>../bot_x/evil.js</code> joins to <code>/app/uploads/workspace/bot_x/evil.js</code>. That string <em>starts with</em> <code>/app/uploads/workspace/bot</code>, so <code>indexOf</code> returns 0 and the check passes, even though <code>bot_x</code> is a different directory. The comparison matches string prefixes where it should match path components. A correct check would compare against <code>outPath + path.sep</code>, or use <code>path.relative()</code> and reject results starting with <code>..</code>.</p>
<p>Real traversal (<code>../../../views/enter.ejs</code>) <em>is</em> caught, because <code>path.join</code> normalises it to <code>/app/views/enter.ejs</code>, which shares no prefix with <code>outPath</code>. So the escape is confined to sibling directories whose names happen to extend the output path. I tried harder anyway: if writes could reach <code>/app/views</code> at all, <code>views/enter.ejs</code> would have been a much shorter path to root. The Dockerfile never sets <code>NODE_ENV=production</code>, so Express's view cache stays off and EJS templates get re-read from disk on every render. An <code>.ejs</code> file is server-side JavaScript, so overwriting one is code execution with no admin role needed. It doesn't work, because both extractors' prefix checks confine writes to paths that start with their own output directory, and neither <code>/app/views</code> nor <code>/app/data</code> starts with <code>/app/uploads</code>.</p>
<h3 id="why-the-file-survives-and-why-the-url-is-predictable">Why the file survives, and why the URL is predictable<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#why-the-file-survives-and-why-the-url-is-predictable">#</a></h3>
<p><code>validateExtractedFiles</code> deletes anything that isn't a PNG or JPEG, but it only scans the directory it was handed, <code>workspace/bot</code>. A file sitting in <code>workspace/bot_x</code> is never enumerated and never deleted. Combined with <code>/uploads</code> being served statically with no authentication, that leaves an arbitrary file, with an arbitrary extension, at a predictable public URL. There's no UUID involved: the relay key belongs to the user <code>bot</code>, and <code>getExtractDir</code> keys off the username.</p>
<hr>
<h2 id="phase-5-getting-code-execution">Phase 5: getting code execution<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#phase-5-getting-code-execution">#</a></h2>
<p>With the forged token from phase 3 in hand, one authenticated request against the instance's base URL (<code>$PUB</code> below) points the render endpoint at the planted file:</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -X</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> POST</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> $PUB</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/ledgermaster/render</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -b</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">token=$JWT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">     -H</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Content-Type: application/json</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">     -d</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">{"css":"@plugin \"/app/uploads/workspace/bot_x/evil.js\";"}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span></code></pre>
<p>Less's plugin loader reads the file and evaluates it with</p>
<pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Function</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">module</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">require</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">registerPlugin</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">functions</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tree</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">fileInfo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">less</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">pluginManager</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> contents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span></code></pre>
<p>A real Node <code>require</code> gets passed in as the second argument, so this is arbitrary JavaScript running as the user <code>ctf</code>. From there, <code>/readflag</code> is setuid root.</p>
<p>There's a guard meant to stop plugins from loading remote URLs: it installs a file manager whose <code>supports()</code> is <code>/^[a-z][a-z0-9+\-.]*:\/\//i</code>, so it only claims absolute URLs, and any filesystem path falls through to the default loader. Relative paths resolve from <code>process.cwd()</code> (<code>/app</code>). I used the absolute form because it skips Less's search-path logic entirely, removing one variable from an already ambiguous failure mode.</p>
<h3 id="a-response-that-tells-you-nothing">A response that tells you nothing<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#a-response-that-tells-you-nothing">#</a></h3>
<p>Worth writing down, since it cost real debugging time. I first read the response <code>{"error":"Seal casting failed"}</code> as proof the plugin had run and Less had merely rejected its output, in other words, success. That response is actually ambiguous: <code>localModule.exports</code> starts as <code>{}</code>, which is truthy, so Less can accept the file as a valid empty plugin and return <code>{"data":"Seal cast"}</code> <em>after</em> running it, but a plain 500 equally means the file was never found in the first place. Both outcomes can produce either response.</p>
<p>The fix was to stage the payload with markers and drop the <code>try/catch</code> that was swallowing the only real evidence. Each stage writes a file under <code>/uploads/workspace/bot_x/</code>, so whichever markers show up localizes the failure: no markers means Less never loaded the file at all, <code>a_ran</code> alone means <code>require</code> failed, and <code>a</code> plus <code>b</code> without <code>d</code> means <code>/readflag</code> itself is the problem. Once <code>d</code> appears, <code>curl $PUB/uploads/workspace/bot_x/d_flag.txt</code> returns the flag.</p>
<hr>
<h2 id="bugs-and-fixes">Bugs and fixes<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#bugs-and-fixes">#</a></h2>
<div class="table-scroll"><table>
<thead>
<tr>
<th>#</th>
<th>Bug</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>The zip validator reads the central directory, while the extractor streams local file headers, so the two see different archives.</td>
<td>Validate and extract from a single parse, or reconcile the central directory against local headers before trusting either.</td>
</tr>
<tr>
<td>2</td>
<td>Zip-slip guard is a string-prefix check (<code>extractPath.indexOf(outPath) != 0</code>) rather than a path check.</td>
<td>Compare against <code>outPath + path.sep</code>, or use <code>path.relative(outPath, extractPath)</code> and reject results starting with <code>..</code>.</td>
</tr>
<tr>
<td>3</td>
<td><code>decompress@4.2.1</code> blocks writing <em>through</em> a symlink but does not restrict <em>creating</em> one. <code>linkname</code> comes straight from the archive.</td>
<td>Reject symlink and hardlink entries outright, or resolve <code>linkname</code> and require it to stay inside the output directory.</td>
</tr>
<tr>
<td>4</td>
<td>The raw-file endpoint builds its path from the caller's own record server-side, and <code>fs.readFile</code> follows symlinks.</td>
<td><code>fs.readFile</code> with <code>O_NOFOLLOW</code>, or <code>lstat</code> the resolved path and refuse symlinks.</td>
</tr>
<tr>
<td>5</td>
<td><code>/uploads</code> is served statically with no authentication.</td>
<td>Authenticate <code>/uploads</code>, or serve user content from a separate origin.</td>
</tr>
<tr>
<td>6</td>
<td><code>record</code> is concatenated into a credentialed same-origin fetch path, and the result is written into the parent frame's DOM.</td>
<td>Validate <code>record</code> against an allowlist, and stop writing across frame boundaries.</td>
</tr>
<tr>
<td>7</td>
<td><code>theme</code> goes into <code>innerHTML</code>. The server validates with <code>qs</code>, the client sink reads with <code>URLSearchParams</code>.</td>
<td>Parse the query string once. If the server validates with <code>qs</code>, the client must not re-read with <code>URLSearchParams</code>. Use <code>textContent</code> instead of <code>innerHTML</code>.</td>
</tr>
<tr>
<td>8</td>
<td>Logging out is a <code>GET</code> that deletes the session cookie, a state-changing <code>GET</code>.</td>
<td>Make logout a <code>POST</code> with a CSRF token. A <code>GET</code> that mutates state is a side channel by construction.</td>
</tr>
<tr>
<td>9</td>
<td>Authorization reads the role from the token payload instead of re-reading the user. No <code>expiresIn</code> at signing.</td>
<td>Re-read the user from the database on every authorization check, exactly as session auth does. Set <code>expiresIn</code>, and pin <code>algorithms: ['HS256']</code> in <code>verify</code>.</td>
</tr>
<tr>
<td>10</td>
<td><code>less.render</code> runs on user input. The guard only rejects <code>scheme://</code> filenames.</td>
<td>Don't compile untrusted Less. If you must, strip <code>@plugin</code> before parsing, since a file-manager guard can't stop it: <code>@plugin</code> doesn't go through URL resolution.</td>
</tr>
</tbody>
</table></div>
<p>Also worth doing regardless of any single bug: set <code>sameSite: 'strict'</code> on the session cookie, and <code>NODE_ENV=production</code> so views are cached rather than re-read from disk on every request.</p>
<hr>
<h2 id="conclusion">Conclusion<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#conclusion">#</a></h2>
<p>The five phases run cleanly start to finish now, but very little of the actual work went in that order. The CSRF plan that opens phase 1 produced a real, working file upload, and it felt like progress for the length of time it took me to build. It wasn't: the final chain authenticates every phase with the relay key directly, and a plain top-level <code>GET</code> gets past <code>SameSite=Lax</code> without any of that machinery. The same pattern shows up in phase 2. Padding the symlink archive with more files felt like it should widen a race window, and it "worked" often enough, about half the time, that the reasoning behind it went unquestioned for a while. It was wrong: <code>readdir</code> on ext4 orders by filename hash, not by write order, so padding just moves the odds around a fixed average instead of buying time. And in phase 5, <code>{"error":"Seal casting failed"}</code> read as confirmation that the plugin had loaded and merely been rejected, when it was equally consistent with the file never having been found at all.</p>
<p>In each case the fix was the same: stop trusting a response that was consistent with success and go verify the mechanism directly instead. Reading <code>readdir</code>'s actual documented order settled the padding question in a few minutes. Staging the payload with per-step marker files turned an ambiguous JSON body into an unambiguous filesystem trail. Neither experiment was hard to run. They just weren't the first thing I reached for, and the gap between them cost more time across the chain than any single phase's exploit did.</p>]]></content:encoded>
		</item>
	</channel>
</rss>
