<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>File Upload | FirewallSync</title><link>https://firewallsync.com/tags/file-upload/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Fri, 30 Oct 2026 04:30:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/file-upload/index.xml" rel="self" type="application/rss+xml"/><item><title>Secure File Uploads in Node.js: A Tested Express Setup</title><link>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</link><guid>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</guid><pubDate>Fri, 30 Oct 2026 04:30:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Most upload bugs come from trusting something the client sent: the filename, the Content-Type, or the idea that a file sitting in a public folder won't be run. This guide builds an Express 5 and Multer 2 upload endpoint that limits size and count, checks magic bytes against an allowlist, renames and stores files outside the webroot, and serves them back safely. We ran ten attack requests against it, and one of them got through the type check, which is why the storage rules matter more.</description><enclosure url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" alt="Close-up of a weathered green roadside mailbox at the end of a row of mailboxes on wooden posts, with fallen leaves and a blurred road behind" width="1200" height="630"></p><p>A secure file upload accepts only the types you need, decides the filename and extension itself, stores the bytes somewhere nothing will execute or serve them directly, and checks authorization again on download. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a> says it plainly: &ldquo;There is no silver bullet in validating user content.&rdquo; No single check is enough, so you stack them.</p>
<p>This guide builds that stack in Node.js and tests it. Versions: Node.js 22.23.4, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 and sharp 0.35.5, installed from npm on 2026-10-30.</p>
<h2 id="why-uploads-keep-turning-into-breaches">Why uploads keep turning into breaches</h2>
<p>Unrestricted upload is <a href="https://cwe.mitre.org/data/definitions/434.html">CWE-434</a>. In the CISA KEV catalog (version 2026.10.29), 24 entries carry CWE-434, eight of them added in 2026. The count is a floor: not every KEV entry has a CWE assigned. The most recent is the Magento &ldquo;PolyShell&rdquo; bug we covered in <a href="/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/">Magento CVE-2026-48356: Patch and Hunt</a>, where the fix included making sure upload directories can&rsquo;t execute code.</p>
<p>The OWASP cheat sheet groups the risks into two kinds:</p>
<ul>
<li><strong>Malicious files</strong>: exploits for the parser that processes the file, phishing documents, zip and XML bombs, overwriting existing files, and active content such as HTML or SVG that attacks other users.</li>
<li><strong>Public retrieval</strong>: if uploads are publicly reachable, attackers can enumerate other users&rsquo; files, use them for DoS, or host illegal content on your domain.</li>
</ul>
<h2 id="the-endpoint-step-by-step">The endpoint, step by step</h2>
<p>Here is the full server. Each control is explained below.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Node.js 22, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 (package.json: &#34;type&#34;: &#34;module&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">express</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;express&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">multer</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;multer&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">fileTypeFromBuffer</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;file-type&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">randomUUID</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:crypto&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">writeFile</span>, <span style="color:#a6e22e">mkdir</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:fs/promises&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">path</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:path&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;/srv/app-uploads&#39;</span>; <span style="color:#75715e">// not under any static/public dir
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ALLOWED</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>([            <span style="color:#75715e">// detected type -&gt; extension we will store
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  [<span style="color:#e6db74">&#39;image/png&#39;</span>, <span style="color:#e6db74">&#39;png&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;image/jpeg&#39;</span>, <span style="color:#e6db74">&#39;jpg&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;application/pdf&#39;</span>, <span style="color:#e6db74">&#39;pdf&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">files</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>();             <span style="color:#75715e">// id -&gt; { owner, mime, ext }; use your database in production
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">upload</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">multer</span>({
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">storage</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">memoryStorage</span>(),   <span style="color:#75715e">// bytes stay in memory until they pass checks
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">limits</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">fileSize</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>, <span style="color:#a6e22e">files</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>, <span style="color:#a6e22e">fields</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span>, <span style="color:#a6e22e">parts</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">6</span> },
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Stand-in for your real auth middleware
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">requireUser</span>(<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;x-demo-user&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">user</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">401</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;login required&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> { <span style="color:#a6e22e">id</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">user</span> };
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">express</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">post</span>(<span style="color:#e6db74">&#39;/uploads&#39;</span>, <span style="color:#a6e22e">requireUser</span>, <span style="color:#a6e22e">upload</span>.<span style="color:#a6e22e">single</span>(<span style="color:#e6db74">&#39;file&#39;</span>), <span style="color:#66d9ef">async</span> (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">400</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;no file&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">detected</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fileTypeFromBuffer</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>);   <span style="color:#75715e">// magic bytes, not the client&#39;s Content-Type
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">detected</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">has</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>)) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">415</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;file type not allowed&#39;</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">id</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">randomUUID</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ext</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">mkdir</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, { <span style="color:#a6e22e">recursive</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o700</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Server-chosen name; &#39;wx&#39; fails instead of overwriting; 0o600 = no execute bit
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">writeFile</span>(<span style="color:#a6e22e">path</span>.<span style="color:#a6e22e">join</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>), <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">flag</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;wx&#39;</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o600</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">set</span>(<span style="color:#a6e22e">id</span>, { <span style="color:#a6e22e">owner</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">mime</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>, <span style="color:#a6e22e">ext</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">201</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">id</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UUID</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;/files/:id&#39;</span>, <span style="color:#a6e22e">requireUser</span>, (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">meta</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">UUID</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">meta</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">owner</span> <span style="color:#f92672">!==</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">404</span>).<span style="color:#a6e22e">end</span>();  <span style="color:#75715e">// authz on every download
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">set</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Type&#39;</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">mime</span>,                       <span style="color:#75715e">// the type we detected, never the client&#39;s
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#e6db74">&#39;Content-Disposition&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`attachment; filename=&#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;`</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;X-Content-Type-Options&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;nosniff&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Security-Policy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;default-src &#39;none&#39;; sandbox&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">sendFile</span>(<span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>, { <span style="color:#a6e22e">root</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">UPLOAD_DIR</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">use</span>((<span style="color:#a6e22e">err</span>, <span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">err</span> <span style="color:#66d9ef">instanceof</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">MulterError</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;LIMIT_FILE_SIZE&#39;</span> <span style="color:#f92672">?</span> <span style="color:#ae81ff">413</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">400</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#a6e22e">status</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">listen</span>(<span style="color:#ae81ff">3000</span>, () =&gt; <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;listening on :3000&#39;</span>));
</span></span></code></pre></div><p>The <code>x-demo-user</code> header and the in-memory <code>files</code> map exist only so the example runs on its own. Replace them with your session middleware and a database table.</p>
<h3 id="1-authenticate-before-parsing-and-only-on-the-routes-that-need-it">1. Authenticate before parsing, and only on the routes that need it</h3>
<p><code>requireUser</code> runs before <code>upload.single()</code>, so an anonymous request is rejected before Multer reads a byte of the body. Multer&rsquo;s README warns: &ldquo;Never add multer as a global middleware since a malicious user could upload files to a route that you didn&rsquo;t anticipate.&rdquo; Mount it per route, as above.</p>
<h3 id="2-set-every-limit">2. Set every limit</h3>
<p>Multer passes <code>limits</code> straight to busboy, and the defaults in the Multer 2.5.0 README are generous: <code>fileSize</code>, <code>files</code>, <code>parts</code> and <code>fields</code> all default to <code>Infinity</code>. The README also notes that &ldquo;Limits apply to the whole request; there is no per-file limit.&rdquo; Set them to what the feature needs. Map <code>LIMIT_FILE_SIZE</code> to 413 and other Multer errors to 400 in your error handler, as the example does.</p>
<p><code>memoryStorage()</code> holds the whole file in RAM, which is why <code>fileSize</code> matters. For large files, use disk storage into a private temp directory and run the same checks before moving the file into place. If you also accept compressed data, the limit has to apply after decompression; see <a href="/posts/nodejs-decompression-bomb-zlib-limits/">Node.js decompression bombs</a>.</p>
<h3 id="3-detect-the-type-from-content-against-an-allowlist">3. Detect the type from content, against an allowlist</h3>
<p>The <code>Content-Type</code> of each part is whatever the client says. OWASP: &ldquo;it is trivial to spoof.&rdquo; <code>fileTypeFromBuffer()</code> reads the file&rsquo;s magic number instead, and we compare the result to a short allowlist of types the feature actually needs.</p>
<p>Know the limits of this check. The file-type readme says detection &ldquo;is a best-effort hint. It does not guarantee the file is actually of that type or that the file is valid/not malformed.&rdquo; It also covers only binary formats, &ldquo;not text-based formats like <code>.txt</code>, <code>.csv</code>, <code>.svg</code>&rdquo;. If you need text formats, validate them with a real parser.</p>
<h3 id="4-choose-the-name-and-extension-yourself">4. Choose the name and extension yourself</h3>
<p>The stored name is a UUID plus the extension mapped from the detected type. The user&rsquo;s <code>originalname</code> is never used on disk, which removes the whole class of tricks OWASP lists for extensions (<code>.jpg.php</code>, <code>.php%00.jpg</code>, <code>.pHp</code>, <code>.phtml</code>, <code>shell.asp:.jpg</code>) and path traversal in filenames. If you need to show the original name, store it as data in your database and escape it on output.</p>
<p>The <code>wx</code> flag makes <code>writeFile</code> fail if the path exists rather than overwrite it. Mode <code>0o600</code> means no execute bit, and your process umask can only make it stricter.</p>
<h3 id="5-store-outside-anything-that-serves-or-executes-files">5. Store outside anything that serves or executes files</h3>
<p>OWASP ranks storage options in this order: a different host, then outside the webroot, then inside the webroot with write-only permissions. <code>UPLOAD_DIR</code> must not be inside <code>express.static()</code>, your nginx <code>root</code>, or a PHP document root. Private object storage (an S3 bucket with <a href="/posts/s3-block-public-access-what-the-four-settings-do-and-how-to-enforce-them/">Block Public Access</a> on, served by short-lived signed URLs) satisfies &ldquo;different host&rdquo;.</p>
<p>If files have to live under a web server&rsquo;s document root, also turn off per-directory overrides there. OWASP&rsquo;s example is Apache <code>AllowOverride None</code>, so an uploaded <code>.htaccess</code> can&rsquo;t remap <code>.png</code> to the PHP handler. For PHP stacks, our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini hardening guide</a> covers the upload-related settings.</p>
<h3 id="6-serve-through-a-handler-with-defensive-headers">6. Serve through a handler with defensive headers</h3>
<p>The download route checks ownership on every request. Without that, a guessable or leaked ID becomes an <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR</a>. It then sets four headers:</p>
<table>
  <thead>
      <tr>
          <th>Header</th>
          <th>What it does here</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Content-Type</code> from detection</td>
          <td>The browser gets the type you verified, not the one the uploader claimed</td>
      </tr>
      <tr>
          <td><code>Content-Disposition: attachment</code></td>
          <td>The browser downloads the file instead of rendering it on your origin</td>
      </tr>
      <tr>
          <td><code>X-Content-Type-Options: nosniff</code></td>
          <td>Stops the browser second-guessing the type; see <a href="/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/">HTTP security headers</a></td>
      </tr>
      <tr>
          <td><code>Content-Security-Policy: default-src 'none'; sandbox</code></td>
          <td>If the file is rendered anyway, it gets no scripts, no subresources and an opaque origin</td>
      </tr>
  </tbody>
</table>
<p>Serving user files from a separate domain (for example a dedicated <code>usercontent</code> domain) adds one more layer: active content never runs on the same origin as your session cookies.</p>
<h2 id="what-we-tested-and-what-got-through">What we tested, and what got through</h2>
<p>We started the server with <code>UPLOAD_DIR</code> set to a temp directory and sent ten requests with curl:</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>Request</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Valid 1x1 PNG</td>
          <td>201, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>2</td>
          <td>PHP script named <code>shell.jpg</code>, sent as <code>image/jpeg</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>3</td>
          <td>SVG containing <code>&lt;script&gt;</code>, sent as <code>image/svg+xml</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Valid PNG with a PHP payload appended, filename <code>../../shell.php</code></td>
          <td><strong>201</strong>, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>5</td>
          <td>6 MB file</td>
          <td>413, <code>LIMIT_FILE_SIZE</code></td>
      </tr>
      <tr>
          <td>6</td>
          <td>Two files in one request</td>
          <td>400, <code>LIMIT_FILE_COUNT</code></td>
      </tr>
      <tr>
          <td>7</td>
          <td>Upload without a user</td>
          <td>401</td>
      </tr>
      <tr>
          <td>8</td>
          <td>Owner downloads file from test 1</td>
          <td>200 with all four headers above</td>
      </tr>
      <tr>
          <td>9</td>
          <td>Another user requests the same ID</td>
          <td>404</td>
      </tr>
      <tr>
          <td>10</td>
          <td>ID of <code>../../etc/passwd</code> (URL-encoded)</td>
          <td>404</td>
      </tr>
  </tbody>
</table>
<p>Both stored files were <code>-rw-------</code>. Test 4 is the one to remember. A PNG with code appended is still a valid PNG, so magic-byte detection passed it. It did no harm because of what came after: the traversal filename was ignored, the stored name ended in <code>.png</code>, the directory isn&rsquo;t served or interpreted, and downloads go out as attachments with a sandbox CSP. Remove any one of those and the polyglot becomes more dangerous.</p>
<h2 id="re-encode-images-when-you-can">Re-encode images when you can</h2>
<p>For images, OWASP recommends decoding and re-encoding to strip anything that isn&rsquo;t pixels. With sharp, metadata is dropped by default (&ldquo;The default behaviour, when withMetadata is not used, is to strip all metadata&rdquo;, per its type definitions):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">sharp</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;sharp&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// limitInputPixels guards against decompression-style image bombs; sharp&#39;s default is 268402689 pixels
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">clean</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">sharp</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">limitInputPixels</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">40_000_000</span> }).<span style="color:#a6e22e">png</span>().<span style="color:#a6e22e">toBuffer</span>();
</span></span></code></pre></div><p>Run on the polyglot from test 4, the 97-byte input contained <code>&lt;?php</code>; the 90-byte output did not. OWASP adds a caveat worth keeping: the image library &ldquo;itself handles untrusted input&rdquo;, so keep it patched and, for high-volume or high-risk uploads, run processing in a separate worker or sandbox. The file-type readme makes the same point for its own parser, recommending a size limit and &ldquo;a worker thread with a timeout&rdquo; for untrusted files.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Authenticate before parsing; mount Multer only on upload routes.</li>
<li>Set <code>fileSize</code>, <code>files</code>, <code>fields</code> and <code>parts</code>.</li>
<li>Detect type from content; compare to a short allowlist; reject text formats you can&rsquo;t parse.</li>
<li>Generate the stored name and extension; never write <code>originalname</code> to disk.</li>
<li>Store outside the webroot or in private object storage, without execute permissions.</li>
<li>Serve via a handler that checks ownership and sets <code>attachment</code>, <code>nosniff</code> and a sandbox CSP.</li>
<li>Re-encode images; scan or sandbox documents if they will be opened by staff.</li>
<li>Protect the upload form from <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF</a> if it uses cookie auth.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We tested one stack (Express 5.3.0, Multer 2.5.0). Other frameworks have different defaults; check their docs for limits and filename handling.</li>
<li>We did not test antivirus scanning or Content Disarm and Reconstruction for Office or PDF files, which OWASP also recommends where applicable.</li>
<li>The in-memory metadata map and header-based &ldquo;auth&rdquo; are placeholders and are not production-ready.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Extension bypasses, Content-Type spoofing, storage order, AllowOverride None, image re-encoding caveat</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Multer limit defaults (Infinity), &ldquo;no per-file limit&rdquo;, never mount globally</td>
          <td>Multer 2.5.0 README, as installed from npm (<a href="https://github.com/expressjs/multer">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>file-type detection is &ldquo;a best-effort hint&rdquo;; binary formats only; worker-thread advice</td>
          <td>file-type 22.3.0 readme (<a href="https://github.com/sindresorhus/file-type">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>sharp strips metadata by default; limitInputPixels default 268402689</td>
          <td>sharp 0.35.5 <code>lib/index.d.ts</code> (<a href="https://github.com/lovell/sharp">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>24 KEV entries with CWE-434, 8 added in 2026</td>
          <td><a href="https://github.com/cisagov/kev-data/blob/main/known_exploited_vulnerabilities.json">CISA KEV catalog (kev-data)</a>, version 2026.10.29, counted with Python</td>
          <td>Verified (floor; CWE field not always set)</td>
      </tr>
      <tr>
          <td>Results of the ten test requests and the re-encode test</td>
          <td>Our run on Node.js 22.23.4, 2026-10-30</td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Magento CVE-2026-48356 (PolyShell): Patch and Hunt</title><link>https://firewallsync.com/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/</link><guid>https://firewallsync.com/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/</guid><pubDate>Thu, 29 Oct 2026 04:30:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Security Operations</category><description>CVE-2026-48356 is the PolyShell file-upload flaw in Adobe Commerce and Magento Open Source. CISA added it to the KEV catalog on October 27, 2026. Here are the fixed builds, why a store already on 2.4.9 may still need an update, and the checks that find an existing compromise.</description><enclosure url="https://firewallsync.com/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/cover_hu14701130590101558514.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/cover_hu14701130590101558514.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/cover_hu14701130590101558514.jpg" alt="Rows of empty grey metal shopping carts with orange handles, nested together against a white wall on a polished concrete floor" width="1200" height="630"></p><p>CVE-2026-48356 is the Magento file-upload flaw that Sansec calls <strong>PolyShell</strong>, and it is now on CISA&rsquo;s <a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-48356">Known Exploited Vulnerabilities catalog</a> (added October 27, 2026; federal due date November 10). Adobe shipped the fix in <a href="https://helpx.adobe.com/security/products/magento/apsb26-73.html">APSB26-73</a> on July 14, 2026. If your store is not on a <code>2026-jul</code> build, update now. If it was exposed for any time since March, hunt as well. Attackers have used the flaw to plant webshells and to inject JavaScript loaders into CMS blocks, and an update removes neither.</p>
<h2 id="what-is-affected-and-what-fixes-it">What is affected and what fixes it</h2>
<p>The table comes from Adobe&rsquo;s <a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/48xxx/CVE-2026-48356.json">CVE record</a> and the APSB26-73 solution table, which agree on the fixed builds.</p>
<table>
  <thead>
      <tr>
          <th>Product</th>
          <th>Affected (up to and including)</th>
          <th>Fixed build</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Adobe Commerce</td>
          <td>2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15, 2.4.5-p17, 2.4.4-p18</td>
          <td>2.4.9-2026-jul, 2.4.8-2026-jul, 2.4.7-2026-jul, 2.4.6-2026-jul, 2.4.5-2026-jul, 2.4.4-2026-jul</td>
      </tr>
      <tr>
          <td>Magento Open Source</td>
          <td>2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15</td>
          <td>2.4.9-2026-jul, 2.4.8-2026-jul, 2.4.7-2026-jul, 2.4.6-2026-jul</td>
      </tr>
      <tr>
          <td>Adobe Commerce B2B</td>
          <td>1.5.3, 1.5.2-p5, 1.4.2-p10, 1.3.4-p17, 1.3.3-p18</td>
          <td>1.5.3-2026-jul, 1.5.2-2026-jul, 1.4.2-2026-jul, 1.3.4-2026-jul, 1.3.3-2026-jul</td>
      </tr>
      <tr>
          <td>Adobe Commerce Events</td>
          <td>1.20.0 and earlier</td>
          <td>1.21.0</td>
      </tr>
  </tbody>
</table>
<p>Two details matter here.</p>
<p><strong>2.4.9 is listed as affected.</strong> Sansec&rsquo;s <a href="https://sansec.io/research/magento-polyshell">PolyShell write-up</a> says Adobe released the fix in 2.4.9 on May 12, 2026, and backported it to the supported release lines through APSB26-73 on July 14. Adobe&rsquo;s CVE record, however, lists 2.4.9 among the affected versions and 2.4.9-2026-jul as unaffected. We have not diffed the two releases, so we can&rsquo;t say which is right. The safe reading is Adobe&rsquo;s: move to 2.4.9-2026-jul.</p>
<p><strong>Older Open Source lines are not listed at all.</strong> Adobe lists 2.4.4 and 2.4.5 <code>2026-jul</code> builds only for Adobe Commerce. For Magento Open Source 2.4.5 and older, the record names neither an affected nor a fixed version. Don&rsquo;t read that as safe: Sansec says every version up to 2.4.9-alpha2 is affected. If you run one of those lines, plan an upgrade and treat the server-side mitigations below as essential in the meantime.</p>
<p>Check what you run with <code>bin/magento --version</code> or <code>composer show magento/product-community-edition</code> (<code>magento/product-enterprise-edition</code> for Commerce), and compare the result with the table.</p>
<h2 id="how-polyshell-works">How PolyShell works</h2>
<p>Sansec describes the flaw in the REST API&rsquo;s handling of file uploads for cart item custom options, where a product option has type &ldquo;file&rdquo;. The most exposed routes are the unauthenticated guest-cart ones:</p>
<ul>
<li><code>POST /V1/guest-carts/:cartId/items</code></li>
<li><code>PUT /V1/guest-carts/:cartId/items/:itemId</code></li>
</ul>
<p>Uploaded files are written to <code>pub/media/custom_options/quote/</code>. Sansec says the GraphQL mutations use a different code path and are not vulnerable.</p>
<p>What an upload leads to depends on the server. Sansec lists code execution through an uploaded PHP file for Apache before 2.3.5 without <code>php_flag engine 0</code>, for stock nginx configs on 2.0.0 to 2.2.x, and for any nginx config that passes every <code>.php</code> request to PHP-FPM. Stored XSS is possible on versions before 2.3.5 and on custom web server configurations.</p>
<p>Adobe describes the same CVE differently. The CVE record says exploitation &ldquo;requires user interaction in that a victim must visit a maliciously crafted URL or interact with a compromised web page&rdquo; and scores it CVSS 3.1 <strong>9.3</strong> (<code>AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N</code>). The regional copy of the bulletin we read shows <strong>9.6</strong> with <code>A:L</code> and labels the impact &ldquo;Privilege escalation&rdquo;. We report both; neither changes the action. A KEV listing outranks the <code>UI:R</code> in the vector. Our <a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS vs EPSS vs KEV guide</a> explains why.</p>
<h2 id="what-attackers-have-done-with-it">What attackers have done with it</h2>
<p>Sansec reported a mass wave on March 30, 2026: <a href="https://sansec.io/research/polyshell-mass-attack-wave">471 stores compromised in one hour</a>, with a backdoor named <code>accesson.php</code> dropped across multiple directories and loaders injected into CMS pages and static blocks. That was months before Adobe&rsquo;s July backport.</p>
<p>On October 26, <a href="https://thehackernews.com/2026/10/hackers-infect-953-online-stores-with.html">The Hacker News reported</a> Sansec&rsquo;s findings on a longer campaign: 953 stores infected since July 2025 with a JavaScript loader stored in Magento&rsquo;s <code>cms_block</code> table. Per that report, Sansec attributes 471 of those infections to CVE-2026-48356, and about 102 more to file uploads through PolyShell or CVE-2025-54236 (SessionReaper). One store on 2.4.7-p10 with no missing core patches was compromised through a rogue administrator account. The report also describes webshells disguised as PNG files in <code>pub/media/custom_options/quote/</code>. The loader&rsquo;s final payload is not known.</p>
<p>These figures are Sansec&rsquo;s, as reported by The Hacker News. CISA&rsquo;s catalog entry does not say who is exploiting the flaw, and lists known ransomware use as &ldquo;Unknown&rdquo;.</p>
<h2 id="step-1-install-the-fixed-build">Step 1: Install the fixed build</h2>
<p>Update to the <code>2026-jul</code> build for your release line, then redeploy static content and clear caches as your normal release process requires. If you can&rsquo;t update this week, do steps 2 and 3 now anyway: they limit what a successful upload can do and tell you whether one has already happened.</p>
<p>Sansec suggests a WAF rule that blocks the vulnerable API calls. That buys time, but it is a mitigation, not a fix.</p>
<h2 id="step-2-make-sure-uploads-cant-be-served-or-executed">Step 2: Make sure uploads can&rsquo;t be served or executed</h2>
<p>Magento ships deny rules for this directory. Check that your live config still has them.</p>
<p><strong>nginx.</strong> The stock <a href="https://github.com/magento/magento2/blob/2.4-develop/nginx.conf.sample"><code>nginx.conf.sample</code></a> in the 2.4-develop branch denies the directory and only hands named entry points to PHP. These are excerpts from that file, not a complete config:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/media/custom_options/</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">deny</span> <span style="color:#e6db74">all</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> ~ <span style="color:#e6db74">^/(index|get|static|errors/report|errors/404|errors/503|health_check)\.php$</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># ... fastcgi_pass to PHP-FPM ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Banned locations (only reached if the earlier PHP entry point regexes don&#39;t match)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">location</span> ~<span style="color:#e6db74">*</span> <span style="color:#e6db74">(\.php</span>$|\.phtml$|\.htaccess$|\.htpasswd$|\.git<span style="color:#e6db74">)</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">deny</span> <span style="color:#e6db74">all</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The risky pattern is a hand-written or hosting-panel config with a generic <code>location ~ \.php$ { fastcgi_pass ...; }</code> and no deny for <code>/media/custom_options/</code>. With that, a PHP file uploaded to the directory is executed. Sansec recommends a <code>deny all</code> location for <code>custom_options</code> placed before any <code>\.php$</code> block.</p>
<p><strong>Apache.</strong> The repository ships <code>pub/media/custom_options/.htaccess</code> containing <code>Require all denied</code> (Apache 2.4) or <code>deny from all</code> (older). Confirm it exists on disk and that <code>AllowOverride</code> lets Apache read it. Sansec also recommends <code>php_flag engine 0</code> there.</p>
<p>Neither rule stops the upload. Both stop the uploaded file from being fetched or run through the web server.</p>
<h2 id="step-3-hunt-for-an-existing-compromise">Step 3: Hunt for an existing compromise</h2>
<p>Run these from the Magento root, or adjust the paths. Sansec published the first checks; we added the database queries.</p>
<p><strong>Files in the upload directory.</strong> Customer uploads can be legitimate, but any script file there is a strong sign of compromise:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Everything uploaded through custom options, newest first</span>
</span></span><span style="display:flex;"><span>find pub/media/custom_options/ -type f ! -name <span style="color:#e6db74">&#39;.htaccess&#39;</span> -printf <span style="color:#e6db74">&#39;%TY-%Tm-%Td %TH:%TM  %s  %p\n&#39;</span> | sort -r | head -50
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Script extensions there are a red flag</span>
</span></span><span style="display:flex;"><span>find pub/media/custom_options/ -type f <span style="color:#ae81ff">\(</span> -iname <span style="color:#e6db74">&#39;*.php*&#39;</span> -o -iname <span style="color:#e6db74">&#39;*.phtml&#39;</span> -o -iname <span style="color:#e6db74">&#39;*.phar&#39;</span> <span style="color:#ae81ff">\)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># The backdoor name from the March wave, anywhere in the docroot tree</span>
</span></span><span style="display:flex;"><span>find /var/www -name <span style="color:#e6db74">&#39;accesson.php&#39;</span> -type f
</span></span></code></pre></div><p>The October report describes webshells disguised as <code>.png</code>. A PNG that starts with <code>&lt;?php</code> is not an image. Check with <code>file</code> or <code>head -c 64</code> on anything recent in that directory.</p>
<p><strong>Injected scripts in CMS content.</strong> The loader lives in the database, so file scans miss it. Run this against a replica or read-only connection (add your table prefix if you use one):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#e6db74">&#39;block&#39;</span> <span style="color:#66d9ef">AS</span> kind, block_id <span style="color:#66d9ef">AS</span> id, identifier, update_time
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> cms_block
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%&lt;script%&#39;</span>
</span></span><span style="display:flex;"><span>   <span style="color:#66d9ef">OR</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%deepveindrilling%&#39;</span>
</span></span><span style="display:flex;"><span>   <span style="color:#66d9ef">OR</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%lanhd6549tdhse%&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">UNION</span> <span style="color:#66d9ef">ALL</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#e6db74">&#39;page&#39;</span>, page_id, identifier, update_time
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> cms_page
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%&lt;script%&#39;</span>
</span></span><span style="display:flex;"><span>   <span style="color:#66d9ef">OR</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%deepveindrilling%&#39;</span>
</span></span><span style="display:flex;"><span>   <span style="color:#66d9ef">OR</span> content <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%lanhd6549tdhse%&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> update_time <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>Column names come from Magento&rsquo;s <code>Cms</code> module schema; we checked the query&rsquo;s syntax against a small SQLite copy of those tables, not against a live MySQL store. Many stores have legitimate scripts in CMS blocks, so review hits by <code>update_time</code> and by where the script loads from. The October report lists these loader domains: <code>manulfie[.]click</code>, <code>neswnsssn[.]click</code>, <code>lanhd6549tdhse[.]top</code>, <code>additionalver[.]com</code> and <code>deepveindrilling[.]com</code>. Sansec&rsquo;s March write-up adds <code>jslibrary[.]net</code> and <code>canevaslab[.]com</code>. Add any you want to the <code>LIKE</code> list.</p>
<p><strong>Admin accounts you didn&rsquo;t create.</strong> One store in the October report was compromised through a rogue admin:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> user_id, username, email, created, logdate, is_active
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> admin_user
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> created <span style="color:#f92672">&gt;=</span> <span style="color:#e6db74">&#39;2026-03-01&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> created <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p><strong>Web logs.</strong> Look for requests to the guest-cart item routes, which usually sit under <code>/rest/V1/</code> or <code>/rest/&lt;store_code&gt;/V1/</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>grep -E <span style="color:#e6db74">&#39;&#34;(POST|PUT) /rest/([a-z0-9_]+/)?V1/guest-carts/[^/ ]+/items&#39;</span> /var/log/nginx/access.log* | head
</span></span></code></pre></div><p>Headless storefronts call these routes legitimately, so a hit is not proof. Line up timestamps with new files in <code>pub/media/custom_options/quote/</code> and with <code>cms_block</code> updates.</p>
<p>If you find a webshell or an injected loader, treat the store as compromised. Remove the artifacts, rotate admin, database and integration credentials, check for other persistence (cron, other PHP files, modified <code>app/etc/env.php</code>), and consider your payment-card obligations, since these loaders run in shoppers&rsquo; browsers. Our <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell response guide</a> covers the server-side hunt for PHP webshells in more detail.</p>
<h2 id="hardening-after-the-patch">Hardening after the patch</h2>
<ul>
<li>Keep <code>pub/</code> as the document root and let PHP run only for Magento&rsquo;s named entry points. Sansec&rsquo;s RCE conditions are old releases or web server configs that execute PHP files in upload directories.</li>
<li>Turn off PHP execution in every writable media directory, not just this one. Our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini hardening guide</a> covers related settings.</li>
<li>If you don&rsquo;t use file-type custom options, Sansec&rsquo;s WAF suggestion is cheap: block file uploads to the guest-cart item routes.</li>
<li>Monitor <code>cms_block</code> and <code>cms_page</code> for changes. A loader injected through the database survives code deploys.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We did not reproduce the exploit or diff the patched releases. Affected and fixed versions come from Adobe&rsquo;s CVE record and bulletin. The upload mechanics, server conditions and campaign figures come from Sansec, the latter through The Hacker News. The <code>adobe.com/trust</code> copy of APSB26-73 could not be retrieved during research; we used Adobe&rsquo;s regional <code>helpx</code> copy, which shows &ldquo;Last updated on 14 July 2026&rdquo; and may not reflect later revisions. We ran the <code>find</code> and <code>grep</code> commands against a mock directory tree and sample log lines, and the SQL against SQLite, not against a production store.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>KEV addition 2026-10-27, due 2026-11-10, CWE-434, ransomware use &ldquo;Unknown&rdquo;</td>
          <td><a href="https://github.com/cisagov/kev-data/blob/main/known_exploited_vulnerabilities.json">CISA KEV catalog (kev-data)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Affected and unaffected versions; CVSS 3.1 9.3 vector; &ldquo;requires user interaction&rdquo;</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/48xxx/CVE-2026-48356.json">CVE-2026-48356 record</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CISA SSVC Exploitation &ldquo;active&rdquo;</td>
          <td>CISA ADP in the same CVE record</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Published July 14, 2026; priority 2; fixed builds; 9.6 score; &ldquo;not aware of any exploits&rdquo; at publication</td>
          <td><a href="https://helpx.adobe.com/security/products/magento/apsb26-73.html">Adobe APSB26-73</a> (regional helpx copy)</td>
          <td>Qualified: regional copy</td>
      </tr>
      <tr>
          <td>Guest-cart REST routes, upload path, RCE conditions, 2.4.9 fix and July backport</td>
          <td><a href="https://sansec.io/research/magento-polyshell">Sansec: PolyShell</a></td>
          <td>Qualified: researcher&rsquo;s account</td>
      </tr>
      <tr>
          <td>471 stores in one hour on March 30, 2026; accesson.php</td>
          <td><a href="https://sansec.io/research/polyshell-mass-attack-wave">Sansec: mass PolyShell wave</a></td>
          <td>Qualified: researcher&rsquo;s account</td>
      </tr>
      <tr>
          <td>953-store campaign; 471 via CVE-2026-48356; loader domains</td>
          <td><a href="https://thehackernews.com/2026/10/hackers-infect-953-online-stores-with.html">The Hacker News, Oct 26, 2026</a> citing Sansec</td>
          <td>Qualified: press report</td>
      </tr>
      <tr>
          <td>Stock nginx deny rules; custom_options .htaccess; cms_block / admin_user columns</td>
          <td><a href="https://github.com/magento/magento2/tree/2.4-develop">magento/magento2 2.4-develop</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>