<?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>Php | FirewallSync</title><link>https://firewallsync.com/tags/php/</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>Wed, 04 Nov 2026 04:40:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/php/index.xml" rel="self" type="application/rss+xml"/><item><title>WordPress CVE-2026-119977: Patch the Upgrade Race RCE</title><link>https://firewallsync.com/posts/wordpress-cve-2026-119977-upgrade-race-rce-kev/</link><guid>https://firewallsync.com/posts/wordpress-cve-2026-119977-upgrade-race-rce-kev/</guid><pubDate>Wed, 04 Nov 2026 04:40:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>CISA added CVE-2026-119977 to the KEV catalog on 3 November 2026. In WordPress 4.7 through 7.1.3, a full-package core update stages a copy of WordPress, installer included, in a web-served folder under wp-content/upgrade, and an unauthenticated attacker who reaches it during the update window can run code. Here are the fixed versions per branch, what the 7.1.4 code change does, why the update itself is a risky moment, and what to check afterwards.</description><enclosure url="https://firewallsync.com/posts/wordpress-cve-2026-119977-upgrade-race-rce-kev/cover_hu7098877180882857123.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/wordpress-cve-2026-119977-upgrade-race-rce-kev/cover_hu7098877180882857123.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/wordpress-cve-2026-119977-upgrade-race-rce-kev/cover_hu7098877180882857123.jpg" alt="Looking up at steel scaffolding tubes, clamps and wooden boards fixed to the side of a brick building under construction" width="1200" height="630"></p><p><strong>Scope.</strong> This post is based on the CVE record published by Wordfence (the CNA), the CISA KEV entry and CISA&rsquo;s SSVC assessment, the WordPress 7.1.4 release notes, and a diff we ran of the WordPress 7.1.3 and 7.1.4 source files. The exploitation telemetry is Wordfence&rsquo;s, as reported by The Hacker News. We did not reproduce the attack.</p>
<h2 id="what-the-vulnerability-is">What the vulnerability is</h2>
<p>The CVE record&rsquo;s description says: &ldquo;WordPress Core is vulnerable to Remote Code Execution in versions 4.7 through 7.1.3. This is due to predictable, web-accessible staging paths used during full-package core upgrades. This makes it possible for unauthenticated attackers to execute code on the server during the upgrade window.&rdquo;</p>
<table>
  <thead>
      <tr>
          <th>Field</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CVE</td>
          <td>CVE-2026-119977</td>
      </tr>
      <tr>
          <td>CNA</td>
          <td>Wordfence (published 31 October 2026)</td>
      </tr>
      <tr>
          <td>Weakness</td>
          <td>CWE-367, Time-of-check Time-of-use (TOCTOU) race condition</td>
      </tr>
      <tr>
          <td>CVSS 3.1</td>
          <td>8.1 High, <code>AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H</code></td>
      </tr>
      <tr>
          <td>Reporter</td>
          <td>Umut Bayram</td>
      </tr>
      <tr>
          <td>KEV</td>
          <td>Added 3 November 2026; due 6 November 2026; ransomware use Unknown; forensic triage: Yes</td>
      </tr>
      <tr>
          <td>CISA SSVC</td>
          <td>Exploitation active, Automatable no, Technical Impact total</td>
      </tr>
  </tbody>
</table>
<p>The attack complexity is High for a reason: the attacker has to hit files that exist only while a core update runs. According to The Hacker News&rsquo;s summary of Wordfence&rsquo;s analysis, exploitation needs a full-package update that includes the installer files, public web and PHP access to the staging tree, a predictable or discovered staging path, and enough time before WordPress deletes the temporary files. The same report says attackers have been repeatedly polling paths such as <code>setup-config.php</code>.</p>
<p>&ldquo;Automatable: no&rdquo; in the SSVC entry does not make this low priority. It reflects the narrow timing window, not the impact. Combined with active exploitation and total technical impact, it still puts the CVE near the top of a queue; <a href="/posts/cisa-vulnrichment-how-to-read-ssvc-values-in-a-cve-record-and-turn-them-into-a-patch-decision/">our SSVC guide</a> shows how your own mission and exposure values turn those three into a decision, and every public WordPress site is internet-facing.</p>
<h2 id="fixed-versions">Fixed versions</h2>
<p>WordPress released 7.1.4 on 28 October 2026 and says: &ldquo;Because this is a security release, it is recommended that you update your sites immediately.&rdquo; The release fixes this issue (&ldquo;A race condition during core updates that could lead to remote code execution reported by Umut Bayram&rdquo;) along with 21 other listed items.</p>
<p>The CVE record lists 25 affected branches, each with an inclusive upper limit (<code>lessThanOrEqual</code>). We checked each against the backport list in the release notes; in every branch the fixed release is the next point release.</p>
<table>
  <thead>
      <tr>
          <th>Branch</th>
          <th>Last affected</th>
          <th>Fixed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>7.1</td>
          <td>7.1.3</td>
          <td><strong>7.1.4</strong></td>
      </tr>
      <tr>
          <td>7.0</td>
          <td>7.0.7</td>
          <td>7.0.8</td>
      </tr>
      <tr>
          <td>6.9</td>
          <td>6.9.10</td>
          <td>6.9.11</td>
      </tr>
      <tr>
          <td>6.8</td>
          <td>6.8.11</td>
          <td>6.8.12</td>
      </tr>
      <tr>
          <td>6.7</td>
          <td>6.7.10</td>
          <td>6.7.11</td>
      </tr>
      <tr>
          <td>6.6</td>
          <td>6.6.10</td>
          <td>6.6.11</td>
      </tr>
      <tr>
          <td>6.5</td>
          <td>6.5.13</td>
          <td>6.5.14</td>
      </tr>
      <tr>
          <td>6.4</td>
          <td>6.4.13</td>
          <td>6.4.14</td>
      </tr>
      <tr>
          <td>6.3</td>
          <td>6.3.13</td>
          <td>6.3.14</td>
      </tr>
      <tr>
          <td>6.2</td>
          <td>6.2.14</td>
          <td>6.2.15</td>
      </tr>
      <tr>
          <td>6.1</td>
          <td>6.1.15</td>
          <td>6.1.16</td>
      </tr>
      <tr>
          <td>6.0</td>
          <td>6.0.17</td>
          <td>6.0.18</td>
      </tr>
      <tr>
          <td>5.0 to 5.9</td>
          <td>5.0.30 &hellip; 5.9.19</td>
          <td>5.0.31, 5.1.28, 5.2.30, 5.3.27, 5.4.25, 5.5.24, 5.6.23, 5.7.21, 5.8.19, 5.9.20</td>
      </tr>
      <tr>
          <td>4.7 to 4.9</td>
          <td>4.7.38, 4.8.33, 4.9.34</td>
          <td>4.7.39, 4.8.34, 4.9.35</td>
      </tr>
  </tbody>
</table>
<p>WordPress adds that backports are &ldquo;a courtesy&rdquo; and that &ldquo;only the most recent version of WordPress is actively supported.&rdquo; Sites on 4.6 or earlier get no fix at all.</p>
<h2 id="what-changed-in-714">What changed in 7.1.4</h2>
<p>We fetched <code>wp-admin/includes/class-wp-upgrader.php</code> and <code>wp-admin/includes/update-core.php</code> from the 7.1.3 and 7.1.4 tags of the WordPress/WordPress GitHub mirror and diffed them. Three changes stand out.</p>
<ol>
<li><strong>The staging folder name becomes unguessable.</strong> In 7.1.3, <code>unpack_package()</code> names the working directory after the downloaded package file: <code>$upgrade_folder . basename( basename( $package, '.tmp' ), '.zip' )</code>, inside <code>wp-content/upgrade/</code>. In 7.1.4 a new <code>get_working_dir_name()</code> appends <code>'-' . wp_generate_password( 16, false )</code>.</li>
<li><strong>The upgrade folder can no longer be listed.</strong> 7.1.3 deletes everything in <code>wp-content/upgrade/</code> before unpacking. 7.1.4 skips <code>index.php</code> during that cleanup and writes one if missing, with a comment that the directory &ldquo;is web-served&rdquo; and must not be listed &ldquo;so the name of the working directory created below cannot be discovered from a directory index.&rdquo;</li>
<li><strong>A staged wp-config.php is never copied into the live site.</strong> <code>update_core()</code> in 7.1.4 adds <code>wp-config.php</code> to the list of files it skips when copying the new release over the old one, with the comment &ldquo;Never copy a wp-config.php, which could have been written to the working directory.&rdquo;</li>
</ol>
<p>Read together, the fix closes both halves of the race: finding the staged copy, and turning a file written into it into something that persists after the update. That is our reading of the diff; the CNA&rsquo;s own advisory is the authority on the exploit chain.</p>
<h2 id="why-the-update-itself-is-the-risky-moment">Why the update itself is the risky moment</h2>
<p>The vulnerable step runs in the code that is already installed. When WordPress updates core, <code>Core_Upgrader::upgrade()</code> calls <code>unpack_package()</code> from the running version&rsquo;s <code>WP_Upgrader</code> class, and only then copies the new release&rsquo;s <code>update-core.php</code> into place and runs it. So updating a 7.1.3 site to 7.1.4 stages the 7.1.4 package with the 7.1.3 naming and cleanup logic. The new wp-config.php skip does apply during that update, because it lives in the new <code>update-core.php</code>, but the staging folder is still predictable and listable for that one run.</p>
<p>Two practical consequences:</p>
<ul>
<li><strong>Block the staging path before you update</strong>, not after. A web server rule that refuses every request under <code>/wp-content/upgrade/</code> removes the public half of the race. WordPress writes to that folder through the filesystem, not over HTTP, so it should not need web access to it.</li>
<li><strong>Do not rely on a rule file inside <code>wp-content/upgrade/</code>.</strong> Affected versions delete everything in that folder before unpacking, so a <code>.htaccess</code> placed there is gone when it matters.</li>
</ul>
<p>Illustrative configuration (we did not test these against a live site; adapt paths for subdirectory installs):</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:#75715e"># nginx, inside the WordPress server block
</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">/wp-content/upgrade/</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><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-apache" data-lang="apache"><span style="display:flex;"><span><span style="color:#75715e"># Apache, in the site&#39;s root .htaccess or vhost (mod_alias)</span>
</span></span><span style="display:flex;"><span>RedirectMatch <span style="color:#ae81ff">403</span> ^/wp-content/upgrade/
</span></span></code></pre></div><p>Also check whether automatic updates already did the work. In 7.1.4&rsquo;s <code>Core_Upgrader::should_update_to_version()</code>, the minor auto-update setting reads <code>get_site_option( 'auto_update_core_minor', 'enabled' )</code>, so minor (security) releases install automatically unless the <code>WP_AUTO_UPDATE_CORE</code> constant, a filter or that option turns them off. Many sites will have moved to their branch&rsquo;s fix on their own; managed hosts and fleets that disable auto-updates will not have.</p>
<h2 id="what-to-do-this-week">What to do this week</h2>
<ol>
<li><strong>Inventory versions.</strong> On each site, <code>wp core version</code> (WP-CLI) or <code>wp-includes/version.php</code> gives <code>$wp_version</code>. Anything at or below its branch&rsquo;s &ldquo;last affected&rdquo; row above needs the update. Anything on 4.6 or earlier needs a migration plan.</li>
<li><strong>Block <code>/wp-content/upgrade/</code> at the web server</strong>, reload, and confirm with a request that it returns 403.</li>
<li><strong>Update to 7.1.4</strong>, or to your branch&rsquo;s fixed release if you cannot move to 7.1 yet.</li>
<li><strong>Check for leftovers.</strong> CISA marks this entry for forensic triage. At minimum:</li>
</ol>
<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"># Run from the WordPress root. Illustrative; adapt the log path.</span>
</span></span><span style="display:flex;"><span>wp core verify-checksums                 <span style="color:#75715e"># core files that differ from the release</span>
</span></span><span style="display:flex;"><span>ls -la wp-content/upgrade/               <span style="color:#75715e"># should be empty apart from index.php after 7.1.4</span>
</span></span><span style="display:flex;"><span>find . -name <span style="color:#e6db74">&#39;wp-config.php&#39;</span> -not -path <span style="color:#e6db74">&#39;./wp-config.php&#39;</span>   <span style="color:#75715e"># unexpected copies</span>
</span></span><span style="display:flex;"><span>grep -E <span style="color:#e6db74">&#39;/wp-content/upgrade/&#39;</span> /var/log/nginx/access.log* | grep -v <span style="color:#e6db74">&#39; 403 &#39;</span>
</span></span></code></pre></div><p>Requests to <code>/wp-content/upgrade/.../setup-config.php</code> or <code>install.php</code> that did not get a 403, especially close to the time of a core update, are the lead to follow. Look for PHP files that <code>verify-checksums</code> does not explain, new administrator accounts, and changes to <code>wp-config.php</code> since the update.</p>
<ol start="5">
<li><strong>Record the decision.</strong> If you track patch decisions as tickets, this is a KEV entry with active exploitation and total technical impact on internet-facing hosts; our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a> shows how to carry those signals into the ticket, and <a href="/posts/cisa-kev-json-feed-what-each-field-means-and-how-to-match-it-against-a-scanner-export/">the KEV JSON feed guide</a> explains the <code>dueDate</code> and <code>forensicTriage</code> fields.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We did not reproduce exploitation. The preconditions and the 62,969-attack figure come from Wordfence via The Hacker News; we could not retrieve Wordfence&rsquo;s own advisory page in this run.</li>
<li>Which update package a given site downloads (full, no-content or partial) depends on what the WordPress update API offers it; we did not test which update paths stage the installer files.</li>
<li>The web server rules are illustrative and untested; test them on a staging site first.</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>Description, affected ranges, CVSS 8.1, CWE-367, reporter, published date</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/119xxx/CVE-2026-119977.json">CVE-2026-119977 record (cvelistV5)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>KEV added 3 Nov 2026, due 6 Nov 2026, forensic triage Yes</td>
          <td><a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog">CISA KEV catalog</a> (kev-data JSON, catalog 2026.11.03)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SSVC active / no / total</td>
          <td>CISA-ADP container in the CVE record</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>7.1.4 release date, fix description, backport versions, support statement</td>
          <td><a href="https://wordpress.org/documentation/?p=16371315">WordPress 7.1.4 release notes</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fixed release = next point release in all 25 branches</td>
          <td>CVE record vs release notes, our script</td>
          <td>Verified (computed)</td>
      </tr>
      <tr>
          <td>7.1.4 code changes; update runs old staging code</td>
          <td><a href="https://github.com/WordPress/WordPress/tree/7.1.3">WordPress 7.1.3</a> and <a href="https://github.com/WordPress/WordPress/tree/7.1.4">7.1.4</a> source, our diff</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Minor auto-updates on by default</td>
          <td><code>wp-admin/includes/class-core-upgrader.php</code> (7.1.4)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>62,969 blocked attacks in 24 hours; exploitation preconditions</td>
          <td><a href="https://thehackernews.com/2026/10/wordpress-rce-flaw-draws-nearly-63000.html">The Hacker News, 30 Oct 2026</a>, citing Wordfence</td>
          <td>Qualified (press report of vendor telemetry)</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><item><title>PHP session_regenerate_id: Stop Session Fixation Right</title><link>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</link><guid>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</guid><pubDate>Wed, 28 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Calling session_regenerate_id() at login is the start of PHP session security, not the whole of it. This guide shows the fixation attack strict mode does not stop, a tested helper for timestamp-based rotation, idle and absolute timeouts, and the grace-period shortcut that quietly undoes the fix.</description><enclosure url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" alt="Two antique brass skeleton keys on a white background, one pointing up and one down, each tied with a thin pale pink ribbon" width="1200" height="630"></p><p>To stop session fixation in PHP, call <code>session_regenerate_id()</code>, or the timestamp-based rotation shown below, whenever a user&rsquo;s privilege changes. Do it before you store the authenticated user in <code>$_SESSION</code>. Don&rsquo;t destroy the old session on the spot. Mark it with a timestamp and refuse it after a short grace period, and enforce your own idle and absolute timeouts, because PHP&rsquo;s garbage collector is not a timeout. That is what PHP&rsquo;s own manual asks for. Below is a helper that follows it, tested against a fixation attack, plus the one shortcut in the manual&rsquo;s example that brings the attack back if you use it at login.</p>
<p>Every behaviour here was run on PHP 8.3.6 CLI with the built-in web server (<code>php -S</code>) and curl, using the default <code>files</code> save handler. The cookie flags and <code>php.ini</code> settings that go with this code are covered in our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini security hardening guide</a>. This article is about the session lifecycle in your application code.</p>
<h2 id="the-attack-strict-mode-doesnt-stop">The attack strict mode doesn&rsquo;t stop</h2>
<p><code>session.use_strict_mode=1</code> makes PHP reject a session ID it never issued. That stops an attacker inventing an ID such as <code>PHPSESSID=123456</code>. But the <a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual</a> is explicit about the gap: &ldquo;an attacker can force users to use an initialized session ID which has been created by an attacker.&rdquo; The attacker visits your site, receives a real ID, and plants it in the victim&rsquo;s browser. The manual describes two routes: setting a cookie that the browser&rsquo;s precedence rules let override yours (for example from another subdomain), and JavaScript injection. If your login code keeps that ID, the attacker now holds an authenticated session.</p>
<p>We reproduced it with a minimal app whose login does only <code>$_SESSION['uid'] = 42;</code>, run with strict mode on:</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-console" data-lang="console"><span style="display:flex;"><span>$ PHPFLAGS<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;-d session.use_strict_mode=1&#34;</span> ./test.sh naive <span style="color:#ae81ff">8090</span>
</span></span><span style="display:flex;"><span>attacker ID: pmq468mni64u18nhukmh7vqe1v
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: unchanged
</span></span><span style="display:flex;"><span>attacker replays old ID: account page for uid 42
</span></span></code></pre></div><p>The test script fetches <code>/</code> to get a real ID, sends <code>/login</code> with that ID as the victim, then replays the ID as the attacker.</p>
<h2 id="what-phps-manual-requires">What PHP&rsquo;s manual requires</h2>
<p>The session security chapter is unusually prescriptive. The rules that matter for application code:</p>
<ul>
<li><strong>Rotate on privilege change, before writing auth state.</strong> &ldquo;Session IDs must be regenerated when user privileges are elevated, such as after authenticating. session_regenerate_id must be called prior to setting the authentication information to $_SESSION.&rdquo; Rotate again on any later privilege change, such as entering an admin area or completing step-up authentication. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a> makes the same point: renew the ID &ldquo;after any privilege level change&rdquo;.</li>
<li><strong>Don&rsquo;t delete active sessions immediately.</strong> &ldquo;session_regenerate_id(true); and session_destroy must never be called together for an active session.&rdquo; Deleting right away loses sessions when requests overlap or the new cookie never arrives. It also removes your chance to notice that someone used an old ID.</li>
<li><strong>Use timestamps instead.</strong> Set &ldquo;a short-term expiration time (timestamp) in $_SESSION&rdquo; on the old session and refuse it later: &ldquo;a few seconds later for stable networks&hellip; and a few minutes later for unstable networks such as cell phones or Wi-Fi.&rdquo;</li>
<li><strong>Don&rsquo;t rely on <code>session.gc_maxlifetime</code> for expiry.</strong> &ldquo;Attackers may access a victim&rsquo;s session ID periodically to prevent its expiration and keep exploiting it.&rdquo; The setting only controls when data &ldquo;will be seen as &lsquo;garbage&rsquo; and potentially cleaned up&rdquo; (default 1440 seconds).</li>
<li><strong>Treat obsolete-ID access as a signal.</strong> When an expired ID is used, the manual recommends removing &ldquo;authenticated status from all of the user&rsquo;s sessions&rdquo; and forcing re-authentication.</li>
</ul>
<p>The <a href="https://www.php.net/manual/en/function.session-regenerate-id.php"><code>session_regenerate_id()</code> page</a> has two example implementations. Both are labelled &ldquo;not fully working code, but an example!&rdquo;</p>
<h2 id="a-tested-rotation-helper">A tested rotation helper</h2>
<p><code>session_guard.php</code> follows the manual&rsquo;s pattern, with two corrections we found in testing (explained after the code). It targets PHP 8.1 or later (<code>session_create_id()</code> exists from 7.1, and the cookie-options array for <code>setcookie()</code> from 7.3).</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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// session_guard.php: timestamp-based session ID rotation, adapted from the
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// pattern in the PHP manual&#39;s session_regenerate_id() page.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">declare</span>(<span style="color:#a6e22e">strict_types</span><span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>)    <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>, <span style="color:#ae81ff">60</span>);     <span style="color:#75715e">// old ID usable after rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>)     <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>, <span style="color:#ae81ff">1800</span>);    <span style="color:#75715e">// idle timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>) <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>, <span style="color:#ae81ff">28800</span>); <span style="color:#75715e">// absolute timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>)   <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>, <span style="color:#ae81ff">900</span>);   <span style="color:#75715e">// periodic rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">RuntimeException</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_session_start</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    $now <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 1. Old ID after rotation: allow a short grace period, then refuse.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">&lt;</span> $now <span style="color:#f92672">-</span> <span style="color:#a6e22e">SESSION_GRACE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span>(<span style="color:#e6db74">&#39;obsolete session ID used&#39;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Cookie for the new ID was probably lost: move the client over.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#a6e22e">session_commit</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_id</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>]);
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><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">// 2. Idle and absolute timeouts, enforced in application code.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_IDLE</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">||</span> $now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ABSOLUTE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_logout</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> $now;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ROTATE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><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">// $forward: let a client still holding the old ID be moved to the new one
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// during the grace period (lost Set-Cookie on a flaky network). Never forward
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// on a privilege change: whoever holds the old ID would be handed the new one.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#a6e22e">bool</span> $forward)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $data <span style="color:#f92672">=</span> $_SESSION;                      <span style="color:#75715e">// session_start() below loads an empty session
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $new <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_create_id</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($forward) {
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>] <span style="color:#f92672">=</span> $new;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_commit</span>();                       <span style="color:#75715e">// old session keeps the markers
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_id</span>($new);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;0&#39;</span>); <span style="color:#75715e">// let session_start() accept $new
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> $data;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</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">function</span> <span style="color:#a6e22e">secure_login</span>(<span style="color:#a6e22e">int</span> $uid)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">false</span>);               <span style="color:#75715e">// rotate BEFORE marking authenticated
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>] <span style="color:#f92672">=</span> $uid;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">=</span> $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</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">function</span> <span style="color:#a6e22e">secure_logout</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>    $p <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_get_cookie_params</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">setcookie</span>(<span style="color:#a6e22e">session_name</span>(), <span style="color:#e6db74">&#39;&#39;</span>, [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;expires&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">time</span>() <span style="color:#f92672">-</span> <span style="color:#ae81ff">3600</span>, <span style="color:#e6db74">&#39;path&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;path&#39;</span>], <span style="color:#e6db74">&#39;domain&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;domain&#39;</span>],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;secure&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;secure&#39;</span>], <span style="color:#e6db74">&#39;httponly&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;httponly&#39;</span>], <span style="color:#e6db74">&#39;samesite&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;samesite&#39;</span>] <span style="color:#f92672">?:</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>    ]);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_destroy</span>();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Wire it in by calling <code>secure_session_start()</code> instead of <code>session_start()</code>, <code>secure_login($userId)</code> after you have verified the password and any second factor, and <code>secure_logout()</code> from your logout handler. On <code>ObsoleteSessionAccess</code>, return 401 and log the event.</p>
<h3 id="what-we-tested">What we tested</h3>
<p>Against the same attack, with the helper in place:</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-console" data-lang="console"><span style="display:flex;"><span>$ ./test.sh guard <span style="color:#ae81ff">8086</span>
</span></span><span style="display:flex;"><span>attacker ID: jt6upaht8b1i3hcuuk743hivm5
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: 24qohqtgd4trsgfuov1tjn20nm
</span></span><span style="display:flex;"><span>attacker replays old ID: anonymous
</span></span></code></pre></div><p>Periodic rotation, with rotation forced on every request and a 2-second grace period:</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-console" data-lang="console"><span style="display:flex;"><span>$ ./test2.sh
</span></span><span style="display:flex;"><span>account page for uid 42
</span></span><span style="display:flex;"><span>login ID 10d0f50p49pto2ocoffotrbsv3 rotated to r4428iae8369vin9533ubpb292
</span></span><span style="display:flex;"><span>old ID inside grace: account page for uid 42
</span></span><span style="display:flex;"><span>old ID after grace:  401 obsolete session
</span></span><span style="display:flex;"><span>logout with current ID: logged out
</span></span><span style="display:flex;"><span>current ID after logout: anonymous
</span></span></code></pre></div><p>With a 1-second idle timeout, a request made 3 seconds after login came back anonymous with a new session cookie, and the logged-in session&rsquo;s file was gone from <code>session.save_path</code>. The pre-login session stayed on disk, marked as destroyed, until garbage collection, as the manual intends.</p>
<h3 id="correction-1-never-forward-an-old-id-after-login">Correction 1: never forward an old ID after login</h3>
<p>The manual&rsquo;s second example deals with lost cookies: if the old ID comes back during the grace period, it switches the client to the new ID it stored in the old session. That is reasonable for periodic rotation, where the person holding the old ID is almost always the legitimate user. At login it is a hole. The attacker holds the old, pre-login ID, so forwarding hands them the new, authenticated one.</p>
<p>Our first draft forwarded in both cases, and the fixation test failed: the attacker&rsquo;s replay returned <code>account page for uid 42</code>. The manual hints at this in a comment (&ldquo;Do not try to set session ID again if you would like to remove authentication flag&rdquo;). The helper makes it explicit: <code>secure_regenerate(false)</code> at login, <code>secure_regenerate(true)</code> only for timed rotation. The trade-off is that a user whose login response is lost has to log in again.</p>
<h3 id="correction-2-carry-the-session-data-across">Correction 2: carry the session data across</h3>
<p>In the manual&rsquo;s example, the new session is started with <code>session_id($new); session_start();</code>. In our test on PHP 8.3.6, that started an empty <code>$_SESSION</code>: the authenticated user vanished on the first periodic rotation. The helper copies the data before committing the old session and restores it after starting the new one. At login this also keeps pre-login state, such as a cart.</p>
<h2 id="other-session-rules-worth-enforcing">Other session rules worth enforcing</h2>
<ul>
<li><strong>Timeouts.</strong> OWASP gives common idle timeouts of &ldquo;2-5 minutes for high-value applications and 15-30 minutes for low risk applications&rdquo;. Every session should also have an absolute timeout &ldquo;regardless of session activity&rdquo;. The helper&rsquo;s defaults (30 minutes idle, 8 hours absolute) are examples; set your own.</li>
<li><strong>Locking.</strong> PHP locks session data per request, which attackers can abuse to tie up workers. The manual suggests <code>session_start(['read_and_close' =&gt; true])</code> for requests that only read the session, and <code>session_commit()</code> as soon as you have finished writing.</li>
<li><strong>Remember-me is not a long session.</strong> The manual says not to &ldquo;use long life session IDs for auto-login&rdquo;. Use a separate, single-use random token in its own cookie, and replace it each time it is used.</li>
<li><strong>Track active sessions</strong> if you need &ldquo;log out everywhere&rdquo; or the manual&rsquo;s response to obsolete-ID access. PHP doesn&rsquo;t do this for you. The manual suggests a user-ID-prefixed ID from <code>session_create_id()</code>, which &ldquo;is vital&rdquo; to pair with strict mode. Don&rsquo;t use a confidential value as the prefix.</li>
<li><strong>Sessions don&rsquo;t stop CSRF.</strong> &ldquo;Sessions and authentication do not protect against CSRF attacks.&rdquo; See our <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF defences comparison</a>.</li>
<li><strong>Session files are an attack surface.</strong> October&rsquo;s PHP2Shell attempts tried to plant code through session files. See the <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell patch-and-hunt guide</a>.</li>
</ul>
<h2 id="common-mistakes">Common mistakes</h2>
<table>
  <thead>
      <tr>
          <th>Mistake</th>
          <th>Why it fails</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Setting <code>$_SESSION['uid']</code> before rotating</td>
          <td>The pre-login ID, which an attacker may hold, briefly carries the authenticated state. The manual says to rotate first</td>
      </tr>
      <tr>
          <td><code>session_regenerate_id(true)</code> on every request</td>
          <td>Concurrent requests lose their session, and you can no longer detect old-ID reuse</td>
      </tr>
      <tr>
          <td>Relying on <code>gc_maxlifetime</code> for logout</td>
          <td>Garbage collection is probabilistic, and activity on a stolen ID keeps it alive</td>
      </tr>
      <tr>
          <td>Forwarding old IDs after login</td>
          <td>Gives the attacker the new authenticated ID (our test)</td>
      </tr>
      <tr>
          <td>Rotating only at login</td>
          <td>A stolen post-login ID stays valid until logout or timeout. Add periodic rotation for sensitive apps</td>
      </tr>
  </tbody>
</table>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The helper is a tested example, not a drop-in library. It assumes PHP&rsquo;s default <code>files</code> handler. A custom or Redis save handler must implement session ID validation for strict mode to work, as the manual notes for user-defined handlers. It does not implement the cross-session &ldquo;remove authentication from all of the user&rsquo;s sessions&rdquo; response, which needs an active-session registry. If you use a framework, prefer its session layer to this helper, and check that it rotates the ID at login and enforces timeouts. Our tests ran on a single PHP process. We did not load-test concurrent requests during rotation.</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>Strict mode does not stop initialized attacker IDs; rotate before setting auth; timestamps instead of immediate deletion; gc_maxlifetime not to be relied on; locking; auto-login; CSRF</td>
          <td><a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual: Session Management Basics</a> (doc-en <code>reference/session/security.xml</code>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example rotation code, &ldquo;not fully working code&rdquo;, lost-cookie forwarding</td>
          <td><a href="https://www.php.net/manual/en/function.session-regenerate-id.php">PHP manual: session_regenerate_id</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>gc_maxlifetime definition and 1440 default</td>
          <td><a href="https://www.php.net/manual/en/session.configuration.php">PHP manual: session runtime configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Renew on privilege change; idle and absolute timeout ranges</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fixation succeeds without rotation (strict mode on); helper blocks it; forwarding at login reopens it; new session starts empty</td>
          <td>Our tests, PHP 8.3.6 CLI, <code>php -S</code></td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>php.ini Security Hardening: Production Settings That Matter</title><link>https://firewallsync.com/posts/php-ini-security-hardening-production-settings/</link><guid>https://firewallsync.com/posts/php-ini-security-hardening-production-settings/</guid><pubDate>Tue, 27 Oct 2026 04:55:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>PHP's own php.ini-production template still leaves several security settings at permissive defaults, including strict session mode, cookie flags and the X-Powered-By header. This guide lists the settings worth changing, shows what each one does with tested examples, explains where to set them so applications can't undo them, and is honest about the two directives that are not security boundaries.</description><enclosure url="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" alt="A vintage control panel labelled Radiation, with rows of analogue meters, trip and reset lamps, and rotary dials" width="1200" height="630"></p><p>To harden PHP for production, start from <code>php.ini-production</code>, then change six settings it leaves permissive: turn off <code>expose_php</code>, turn on <code>session.use_strict_mode</code>, set the session cookie&rsquo;s <code>HttpOnly</code>, <code>Secure</code> and <code>SameSite</code> flags, and turn off <code>allow_url_fopen</code> if your code never reads URLs as files. Apply them in <code>php.ini</code> or with <code>php_admin_value</code> in the PHP-FPM pool so applications cannot undo them, and do not treat <code>disable_functions</code> or <code>open_basedir</code> as a sandbox.</p>
<p>Everything below was checked against PHP 8.4.27&rsquo;s <code>php.ini-production</code> and the PHP manual, and every behaviour we describe was run on PHP 8.3 CLI (Ubuntu 24.04 package). Where a setting behaves differently under PHP-FPM or Apache, we say so.</p>
<h2 id="the-baseline">The baseline</h2>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Recommended</th>
          <th>Built-in default</th>
          <th>php.ini-production</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>expose_php</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td><strong>On</strong></td>
          <td>Removes the <code>X-Powered-By: PHP/x.y.z</code> header</td>
      </tr>
      <tr>
          <td><code>display_errors</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Errors can leak paths, queries and credentials</td>
      </tr>
      <tr>
          <td><code>display_startup_errors</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Same, for startup</td>
      </tr>
      <tr>
          <td><code>log_errors</code></td>
          <td><code>On</code></td>
          <td>Off</td>
          <td>On</td>
          <td>You still need the errors, in a log</td>
      </tr>
      <tr>
          <td><code>allow_url_include</code></td>
          <td><code>Off</code></td>
          <td>Off</td>
          <td>Off</td>
          <td>Stops <code>include 'http://...'</code>; deprecated since 7.4</td>
      </tr>
      <tr>
          <td><code>allow_url_fopen</code></td>
          <td><code>Off</code> if unused</td>
          <td>On</td>
          <td><strong>On</strong></td>
          <td>Limits URL reads via file functions</td>
      </tr>
      <tr>
          <td><code>enable_dl</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Stops runtime extension loading</td>
      </tr>
      <tr>
          <td><code>session.use_strict_mode</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>0</strong></td>
          <td>Rejects attacker-chosen session IDs</td>
      </tr>
      <tr>
          <td><code>session.use_only_cookies</code></td>
          <td><code>1</code></td>
          <td>1</td>
          <td>1</td>
          <td>No session IDs in URLs</td>
      </tr>
      <tr>
          <td><code>session.cookie_httponly</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>unset</strong></td>
          <td>Keeps the session cookie away from JavaScript</td>
      </tr>
      <tr>
          <td><code>session.cookie_secure</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>unset</strong></td>
          <td>Session cookie only over HTTPS</td>
      </tr>
      <tr>
          <td><code>session.cookie_samesite</code></td>
          <td><code>Lax</code></td>
          <td>unset</td>
          <td><strong>unset</strong></td>
          <td>Cookie not sent on most cross-site requests</td>
      </tr>
      <tr>
          <td><code>session.use_trans_sid</code></td>
          <td><code>0</code></td>
          <td>0</td>
          <td>0</td>
          <td>No transparent session IDs in links</td>
      </tr>
  </tbody>
</table>
<p>&ldquo;Built-in default&rdquo; is what PHP uses with no <code>php.ini</code> at all (<code>php -n</code>). Bold marks the six settings where the production template differs from the hardened value. We measured both columns with the audit script at the end of this article.</p>
<h2 id="where-to-set-them-so-they-stick">Where to set them so they stick</h2>
<p>PHP settings come from several layers, and the later ones can override the earlier ones for directives that allow it: <code>php.ini</code> and its scan directory, web server or FPM pool configuration, <code>.user.ini</code> files in the document root, and <code>ini_set()</code> in code. Each directive has a changeable mode. <code>INI_SYSTEM</code> settings such as <code>disable_functions</code>, <code>allow_url_fopen</code> and <code>allow_url_include</code> can only be set in <code>php.ini</code> or server configuration.</p>
<p>For PHP-FPM, use <code>php_admin_value</code> and <code>php_admin_flag</code> for anything security-relevant. The PHP-FPM documentation says: &ldquo;Settings defined with php_admin_value and php_admin_flag cannot be overridden with ini_set.&rdquo; Plain <code>php_value</code> and <code>php_flag</code> can be.</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-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; /etc/php/8.3/fpm/pool.d/app.conf (path varies by distribution)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[app]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">app</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">group</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">app</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/run/php/app.sock</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[expose_php]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[display_errors]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[log_errors]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[error_log]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/var/log/php/app-error.log</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[allow_url_fopen]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.use_strict_mode]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_httponly]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_secure]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_samesite]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">Lax</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; Only execute .php files (the default also allows .phar)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">security.limit_extensions</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">.php</span>
</span></span></code></pre></div><p>This is illustrative: we validated the directive names and values against the PHP-FPM documentation and our CLI tests, but did not run this pool file. Restart the pool after editing. For Apache with mod_php, the equivalent is <code>php_admin_value</code> and <code>php_admin_flag</code> in the virtual host.</p>
<p>You can see runtime locking in action. With <code>disable_functions</code> set in configuration, <code>ini_set('disable_functions', '')</code> returns <code>false</code> and changes nothing. With <code>open_basedir</code> set, an attempt to widen it to <code>/</code> also returns <code>false</code>.</p>
<h2 id="stop-advertising-your-php-version">Stop advertising your PHP version</h2>
<p><code>php.ini-production</code> ships with <code>expose_php = On</code>. Its comment says the header &ldquo;is no security threat in any way&rdquo;, and that is fair: hiding the version does not fix anything. But the header tells every scanner which PHP release you run, which turns mass scanning for a new CVE into a simple header match. We ran PHP&rsquo;s built-in server with each value:</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-console" data-lang="console"><span style="display:flex;"><span>$ curl -sI http://127.0.0.1:8099/index.php | grep -i x-powered   <span style="color:#75715e"># expose_php=1</span>
</span></span><span style="display:flex;"><span>X-Powered-By: PHP/8.3.6
</span></span><span style="display:flex;"><span>$ curl -sI http://127.0.0.1:8099/index.php | grep -i x-powered   <span style="color:#75715e"># expose_php=0</span>
</span></span><span style="display:flex;"><span>$
</span></span></code></pre></div><p>Frameworks and reverse proxies can add their own version headers, so check the response at the edge too. Our guide to <a href="/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/">HTTP security headers</a> covers the headers you do want to send.</p>
<h2 id="log-errors-never-display-them">Log errors, never display them</h2>
<p>The production template sets <code>display_errors = Off</code> and <code>log_errors = On</code>, and its comment explains why: displayed errors could leak &ldquo;sensitive information &hellip; such as database usernames and passwords or worse.&rdquo; The built-in defaults are the opposite, so a server that runs PHP without loading a <code>php.ini</code> (a misconfigured container is the usual case) will print errors to users. Point <code>error_log</code> at a file or <code>stderr</code> that your logging pipeline collects.</p>
<h2 id="turn-off-url-includes-and-if-you-can-url-file-reads">Turn off URL includes and, if you can, URL file reads</h2>
<p><code>allow_url_include</code> lets <code>include</code> and <code>require</code> load code from a URL. It is off by default and the PHP manual marks it &ldquo;Deprecated as of PHP 7.4.0&rdquo;; enabling it now prints a deprecation notice at startup. Keep it off.</p>
<p><code>allow_url_fopen</code> is broader: it lets <code>file_get_contents()</code>, <code>fopen()</code> and similar functions open <code>http://</code> and <code>ftp://</code> URLs. If your application only makes HTTP requests through cURL or an HTTP client library, turning it off removes one way for user input passed to a file function to become an outbound request. It does not stop server-side request forgery through your HTTP client; see our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF guide</a> for that. Test before you change it: some libraries and package managers rely on it.</p>
<h2 id="session-settings">Session settings</h2>
<p>PHP&rsquo;s session defaults favour compatibility. Four changes matter.</p>
<h3 id="strict-mode">Strict mode</h3>
<p>The PHP manual is blunt: &ldquo;Enabling session.use_strict_mode is mandatory for general session security. All sites are advised to enable this.&rdquo; Without it, PHP accepts any session ID the browser sends, even one the server never issued, which enables session fixation. We simulated a browser sending an attacker-chosen <code>PHPSESSID</code> cookie:</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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// strict.php: run with `php -d session.use_strict_mode=0|1 strict.php`
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.save_path&#39;</span>, <span style="color:#66d9ef">__DIR__</span>);
</span></span><span style="display:flex;"><span>$_COOKIE[<span style="color:#a6e22e">session_name</span>()] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;attackerchosenid123456&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;use_strict_mode=%s -&gt; session_id=%s</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>, <span style="color:#a6e22e">ini_get</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>), <span style="color:#a6e22e">session_id</span>());
</span></span></code></pre></div><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-console" data-lang="console"><span style="display:flex;"><span>$ php -d session.use_strict_mode<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span> strict.php
</span></span><span style="display:flex;"><span>use_strict_mode=0 -&gt; session_id=attackerchosenid123456
</span></span><span style="display:flex;"><span>$ php -d session.use_strict_mode<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> strict.php
</span></span><span style="display:flex;"><span>use_strict_mode=1 -&gt; session_id=3e4393b3059589b68078c6a9f5c4fe34
</span></span></code></pre></div><p>Strict mode does not replace calling <code>session_regenerate_id(true)</code> after login; do both.</p>
<h3 id="cookie-flags">Cookie flags</h3>
<ul>
<li><code>session.cookie_httponly = 1</code> makes the cookie inaccessible to JavaScript. The manual says it &ldquo;can effectively help to reduce identity theft through XSS attacks&rdquo;. It does not stop an XSS payload from making requests as the user.</li>
<li><code>session.cookie_secure = 1</code> sends the cookie only over HTTPS. The manual notes that with it on, &ldquo;sessions only work with HTTPS connections&rdquo;. If TLS terminates at a load balancer, that means the browser&rsquo;s connection: the flag is a cookie attribute the browser enforces, so it works as long as users reach you over HTTPS.</li>
<li><code>session.cookie_samesite = Lax</code> stops the browser sending the session cookie on most cross-site subrequests and form posts. It reduces CSRF exposure but does not remove the need for tokens; we compare the options in <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection: SameSite, tokens and Fetch Metadata</a>.</li>
</ul>
<p>These cookie protections address token theft through the browser. They do nothing about a cookie stolen from the endpoint itself, which our explainer on <a href="/posts/what-is-token-theft-and-why-mfa-doesnt-always-stop-it/">token theft and MFA</a> covers.</p>
<h2 id="request-size-and-resource-limits">Request size and resource limits</h2>
<p><code>post_max_size</code> (8M in both defaults), <code>upload_max_filesize</code> (2M), <code>max_input_vars</code> (1000), <code>memory_limit</code> (128M) and <code>max_execution_time</code> (30 seconds) are mainly availability controls. Set them to what your application needs, not higher, and enforce body size limits at the web server or proxy as well, because those apply before PHP parses anything.</p>
<p>These limits briefly became security-critical in October 2026. PHP&rsquo;s stopgap advice for <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell (CVE-2026-109618 and CVE-2026-109619)</a> included a low <code>post_max_size</code>, <code>memory_limit=-1</code> set non-overridably and <code>session.upload_progress.enabled=0</code>. Those were emergency measures for unpatched servers and PHP said they &ldquo;do not make an installation safe&rdquo;. Once patched, keep a finite <code>memory_limit</code> unless you have a container or cgroup limit doing that job. Note that <code>session.upload_progress.enabled</code> is on by default and in the production template; if your application does not show upload progress bars, turning it off removes a feature you do not use.</p>
<h2 id="what-not-to-rely-on">What not to rely on</h2>
<p>Two directives look like sandboxes and are not.</p>
<p><strong><code>disable_functions</code></strong> removes named internal functions. With <code>system</code>, <code>exec</code> and <code>shell_exec</code> disabled, calling <code>system()</code> raises <code>Error: Call to undefined function system()</code>, which defeats the laziest webshells. But the PHP manual says plainly: &ldquo;This directive can be circumvented and should not be considered a sufficient security measure for shared hosting environments.&rdquo; It only covers internal functions you thought to list, and in PHP 8 a disabled function&rsquo;s name can be redefined by userland code.</p>
<p><strong><code>open_basedir</code></strong> limits which paths PHP&rsquo;s file functions can open, and the manual says symlinks are resolved so they cannot be used to escape it. It is still not an isolation boundary. The same manual notes that &ldquo;With dynamic loading, it&rsquo;s possible to ignore all open_basedir restrictions&rdquo;, which is one reason to keep <code>enable_dl</code> off, and the restriction only applies to PHP&rsquo;s own file access, not to anything a spawned process does. It also disables PHP&rsquo;s realpath cache, which has a performance cost.</p>
<p>If you host more than one application, or code you do not fully trust, isolate them at the operating system level: a separate PHP-FPM pool per application running as its own Unix user, with file permissions that stop one user reading another&rsquo;s files, or separate containers. That is the boundary; the two directives above are defence in depth inside it.</p>
<h2 id="audit-what-is-actually-running">Audit what is actually running</h2>
<p>The settings that matter are the ones the serving process loaded, which may not be the <code>php.ini</code> you edited. The CLI and FPM usually read different files (on Debian and Ubuntu, <code>/etc/php/&lt;version&gt;/cli/</code> and <code>/etc/php/&lt;version&gt;/fpm/</code>). This script prints the effective values against the baseline above. Run it under the SAPI you care about: from the command line for the CLI, or temporarily as a web request for FPM or mod_php (then delete it, since it reveals configuration).</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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ini-audit.php: compare effective settings with a hardened baseline.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>$baseline <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;expose_php&#39;</span>               <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;display_errors&#39;</span>           <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;display_startup_errors&#39;</span>   <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;log_errors&#39;</span>               <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;allow_url_include&#39;</span>        <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;allow_url_fopen&#39;</span>          <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,   <span style="color:#75715e">// only if the app never opens URLs with file functions
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#e6db74">&#39;enable_dl&#39;</span>                <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_only_cookies&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_httponly&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_secure&#39;</span>    <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_samesite&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_trans_sid&#39;</span>    <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span>$norm <span style="color:#f92672">=</span> <span style="color:#a6e22e">fn</span>($v) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">match</span> (<span style="color:#a6e22e">strtolower</span>((<span style="color:#a6e22e">string</span>)$v)) {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;on&#39;</span>, <span style="color:#e6db74">&#39;true&#39;</span>, <span style="color:#e6db74">&#39;yes&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>, <span style="color:#e6db74">&#39;off&#39;</span>, <span style="color:#e6db74">&#39;false&#39;</span>, <span style="color:#e6db74">&#39;no&#39;</span>, <span style="color:#e6db74">&#39;&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>, <span style="color:#66d9ef">default</span> <span style="color:#f92672">=&gt;</span> (<span style="color:#a6e22e">string</span>)$v };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$fail <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($baseline <span style="color:#66d9ef">as</span> $key <span style="color:#f92672">=&gt;</span> $want) {
</span></span><span style="display:flex;"><span>    $have <span style="color:#f92672">=</span> <span style="color:#a6e22e">ini_get</span>($key);
</span></span><span style="display:flex;"><span>    $ok <span style="color:#f92672">=</span> ($key <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;session.cookie_samesite&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">?</span> <span style="color:#a6e22e">in_array</span>($have, [<span style="color:#e6db74">&#39;Lax&#39;</span>, <span style="color:#e6db74">&#39;Strict&#39;</span>], <span style="color:#66d9ef">true</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">:</span> $norm($have) <span style="color:#f92672">===</span> $want;
</span></span><span style="display:flex;"><span>    $fail <span style="color:#f92672">+=</span> $ok <span style="color:#f92672">?</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;%-26s %-6s want %-5s %s</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>, $key, <span style="color:#a6e22e">var_export</span>($have, <span style="color:#66d9ef">true</span>), $want, $ok <span style="color:#f92672">?</span> <span style="color:#e6db74">&#39;ok&#39;</span> <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;CHECK&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">%d setting(s) differ from the baseline (SAPI: %s, ini: %s)</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>    $fail, <span style="color:#a6e22e">PHP_SAPI</span>, <span style="color:#a6e22e">php_ini_loaded_file</span>() <span style="color:#f92672">?:</span> <span style="color:#e6db74">&#39;none&#39;</span>);
</span></span></code></pre></div><p>Run against PHP 8.4.27&rsquo;s <code>php.ini-production</code> with <code>php -c php.ini-production ini-audit.php</code> on PHP 8.3 CLI, it reported:</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-console" data-lang="console"><span style="display:flex;"><span>expose_php                 &#39;1&#39;    want 0     CHECK
</span></span><span style="display:flex;"><span>display_errors             &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>display_startup_errors     &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>log_errors                 &#39;1&#39;    want 1     ok
</span></span><span style="display:flex;"><span>allow_url_include          &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>allow_url_fopen            &#39;1&#39;    want 0     CHECK
</span></span><span style="display:flex;"><span>enable_dl                  &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>session.use_strict_mode    &#39;0&#39;    want 1     CHECK
</span></span><span style="display:flex;"><span>session.use_only_cookies   &#39;1&#39;    want 1     ok
</span></span><span style="display:flex;"><span>session.cookie_httponly    &#39;&#39;     want 1     CHECK
</span></span><span style="display:flex;"><span>session.cookie_secure      &#39;0&#39;    want 1     CHECK
</span></span><span style="display:flex;"><span>session.cookie_samesite    &#39;&#39;     want Lax   CHECK
</span></span><span style="display:flex;"><span>session.use_trans_sid      &#39;0&#39;    want 0     ok
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span>6 setting(s) differ from the baseline (SAPI: cli, ini: php.ini-production)
</span></span></code></pre></div><p>With no <code>php.ini</code> at all (<code>php -n</code>), 10 of the 13 differed. With the hardened values applied, none did.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>This covers PHP&rsquo;s own configuration, not your web server, framework or application code. We tested behaviour on PHP 8.3 CLI and read defaults from PHP 8.4.27&rsquo;s template; check the manual for your version, since defaults and deprecations change between releases. The PHP-FPM pool example was not run. The baseline is a starting point for a typical web application: turning off <code>allow_url_fopen</code> in particular can break code that depends on it, so test in staging first.</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>php.ini-production values (expose_php On, use_strict_mode 0, cookie flags unset, display_errors Off, log_errors On) and comments quoted</td>
          <td><a href="https://github.com/php/php-src/blob/php-8.4.27/php.ini-production">php.ini-production at php-8.4.27</a></td>
          <td>Verified (and measured with the audit script)</td>
      </tr>
      <tr>
          <td>Built-in defaults</td>
          <td><code>php -n</code> on PHP 8.3.6 (Ubuntu 24.04 package), October 27, 2026</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>use_strict_mode is &ldquo;mandatory for general session security&rdquo;; cookie_httponly and cookie_secure behaviour</td>
          <td><a href="https://www.php.net/manual/en/session.configuration.php">PHP manual: session runtime configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>disable_functions &ldquo;can be circumvented&rdquo;; PHP 8 redefinition; open_basedir symlink and dynamic-loading notes; realpath cache disabled</td>
          <td><a href="https://www.php.net/manual/en/ini.core.php">PHP manual: core php.ini directives</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>allow_url_include deprecated as of 7.4.0</td>
          <td><a href="https://www.php.net/manual/en/filesystem.configuration.php">PHP manual: filesystem runtime configuration</a></td>
          <td>Verified (and tested: deprecation notice)</td>
      </tr>
      <tr>
          <td>php_admin_value cannot be overridden with ini_set; security.limit_extensions default .php .phar</td>
          <td><a href="https://www.php.net/manual/en/install.fpm.configuration.php">PHP-FPM configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Strict mode rejects an attacker-chosen ID; disable_functions error; X-Powered-By header; ini_set refusing to loosen settings</td>
          <td>Our tests on PHP 8.3 CLI and built-in server</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>PHP2Shell stopgaps and &ldquo;do not make an installation safe&rdquo;</td>
          <td><a href="https://www.php.net/archive/2026.php">PHP security advisory, October 21, 2026</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>PHP2Shell CVE-2026-109618 and -109619: Patch, Then Hunt</title><link>https://firewallsync.com/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/</link><guid>https://firewallsync.com/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/</guid><pubDate>Tue, 27 Oct 2026 04:50:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>Two PHP core flaws, CVE-2026-109618 and CVE-2026-109619, allow unauthenticated remote code execution against PHP that serves web requests. CISA added both to the KEV catalog on October 26 with a three-day deadline. Here are the fixed versions, why php -v can mislead you on distribution builds, the stopgaps PHP itself recommends, and what to hunt for.</description><enclosure url="https://firewallsync.com/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/cover_hu12286854602341724834.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/cover_hu12286854602341724834.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/cover_hu12286854602341724834.jpg" alt="Close-up of yellow barrier tape printed with the word ATTENTION, stretched in front of a blurred orange road sign and greenery" width="1200" height="630"></p><p>CVE-2026-109618 and CVE-2026-109619, which the security community has nicknamed &ldquo;PHP2Shell&rdquo;, are two memory-safety flaws in PHP&rsquo;s core that can give an unauthenticated attacker remote code execution on a server running PHP for the web. The fixes are PHP <strong>8.2.35, 8.3.36, 8.4.27 and 8.5.12</strong>. CISA added both CVEs to its Known Exploited Vulnerabilities (KEV) catalog on October 26, 2026, and gave federal agencies until <strong>October 29</strong> to act.</p>
<p>These are bugs in the PHP interpreter, so they apply regardless of your framework or CMS. If you run PHP behind a web server, update the PHP package today, restart every PHP worker, then look for signs that someone got in first. Everything below comes from the PHP project&rsquo;s advisory and CVE records and CISA&rsquo;s KEV entry unless we say otherwise.</p>
<h2 id="are-you-affected">Are you affected?</h2>
<p>The CVE records list PHP 8.2 before 8.2.35, 8.3 before 8.3.36, 8.4 before 8.4.27 and 8.5 before 8.5.12 as affected. PHP&rsquo;s advisory adds who is at risk: &ldquo;all PHP installations serving web requests, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed.&rdquo; It rates mod_php highest, &ldquo;with a demonstrated exploit where an unauthenticated request can trigger the attack before any application code runs.&rdquo;</p>
<table>
  <thead>
      <tr>
          <th>Branch</th>
          <th>Vulnerable upstream</th>
          <th>Fixed upstream</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>8.5</td>
          <td>below 8.5.12</td>
          <td>8.5.12</td>
      </tr>
      <tr>
          <td>8.4</td>
          <td>below 8.4.27</td>
          <td>8.4.27</td>
      </tr>
      <tr>
          <td>8.3</td>
          <td>below 8.3.36</td>
          <td>8.3.36</td>
      </tr>
      <tr>
          <td>8.2</td>
          <td>below 8.2.35</td>
          <td>8.2.35</td>
      </tr>
      <tr>
          <td>8.1 and older</td>
          <td>not listed in the CVE records; no upstream fix</td>
          <td>vendor backport, PHP&rsquo;s source backport branches for 7.4/8.0/8.1, or upgrade</td>
      </tr>
  </tbody>
</table>
<p>The CVE records only list 8.2 to 8.5. Do not read that as &ldquo;8.1 is safe&rdquo;: PHP says 8.1 and older &ldquo;no longer receive updates&rdquo; and published source backports for 7.4, 8.0 and 8.1 because of the severity.</p>
<h3 id="why-php--v-can-mislead-you">Why <code>php -v</code> can mislead you</h3>
<p>Most production PHP comes from a distribution or a container base image, and those vendors backport fixes without changing the upstream version number. PHP says Linux distributions and LTS vendors &ldquo;received the patches in advance&rdquo;.</p>
<p>We checked an Ubuntu 24.04 host. <code>php -v</code> reports 8.3.6, which is far below 8.3.36, yet the package changelog shows the fix:</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-console" data-lang="console"><span style="display:flex;"><span>$ dpkg -l php8.3-cli | tail -1 | awk <span style="color:#e6db74">&#39;{print $2, $3}&#39;</span>
</span></span><span style="display:flex;"><span>php8.3-cli 8.3.6-0ubuntu0.24.04.13
</span></span><span style="display:flex;"><span>$ zcat /usr/share/doc/php8.3-cli/changelog.Debian.gz | head -4
</span></span><span style="display:flex;"><span>php8.3 (8.3.6-0ubuntu0.24.04.13) noble-security; urgency=medium
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span>  * SECURITY UPDATE: Remote RCE via state corruption issue
</span></span><span style="display:flex;"><span>    - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
</span></span></code></pre></div><p>So for a distribution build, search the package changelog for the CVE IDs. On Debian or Ubuntu, <code>zcat /usr/share/doc/php*-common/changelog.Debian.gz | grep -m1 CVE-2026-109618</code> works; on RPM-based systems the equivalent is <code>rpm -q --changelog &lt;php package&gt; | grep CVE-2026-109618</code> (illustrative; we did not run the RPM form). For official <code>php</code> Docker images and source builds, the upstream version is the one that counts.</p>
<p>Also check the binary that actually serves traffic. The CLI, <code>php-fpm</code> and <code>libapache2-mod-php</code> can come from different packages and be updated at different times.</p>
<h3 id="a-version-check-for-upstream-builds">A version check for upstream builds</h3>
<p>This script compares upstream version strings against the fixed releases. We ran it with PHP 8.3 CLI (<code>php php2shell-check.php 8.2.34 8.4.27 8.1.31</code>). It cannot see distribution backports, so a &ldquo;VULNERABLE&rdquo; result on a distro build means &ldquo;check the changelog&rdquo;, not &ldquo;definitely exposed&rdquo;.</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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Compares upstream PHP versions with the fixed releases for
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// CVE-2026-109618 / CVE-2026-109619. Does not detect vendor backports.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>$fixed <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;8.2&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;8.2.35&#39;</span>, <span style="color:#e6db74">&#39;8.3&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;8.3.36&#39;</span>, <span style="color:#e6db74">&#39;8.4&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;8.4.27&#39;</span>, <span style="color:#e6db74">&#39;8.5&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;8.5.12&#39;</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">status</span>(<span style="color:#a6e22e">string</span> $v, <span style="color:#66d9ef">array</span> $fixed)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> {
</span></span><span style="display:flex;"><span>    $branch <span style="color:#f92672">=</span> <span style="color:#a6e22e">implode</span>(<span style="color:#e6db74">&#39;.&#39;</span>, <span style="color:#a6e22e">array_slice</span>(<span style="color:#a6e22e">explode</span>(<span style="color:#e6db74">&#39;.&#39;</span>, $v), <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">isset</span>($fixed[$branch])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">version_compare</span>($v, <span style="color:#e6db74">&#39;8.2&#39;</span>, <span style="color:#e6db74">&#39;&lt;&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">?</span> <span style="color:#e6db74">&#39;END OF LIFE: no upstream fix; needs vendor backport or upgrade&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;UNKNOWN branch: check php.net&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">version_compare</span>($v, $fixed[$branch], <span style="color:#e6db74">&#39;&gt;=&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">?</span> <span style="color:#e6db74">&#34;fixed upstream (&gt;= </span><span style="color:#e6db74">{</span>$fixed[$branch]<span style="color:#e6db74">}</span><span style="color:#e6db74">)&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;VULNERABLE upstream (&lt; </span><span style="color:#e6db74">{</span>$fixed[$branch]<span style="color:#e6db74">}</span><span style="color:#e6db74">) unless your vendor backported the fix&#34;</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">foreach</span> (<span style="color:#a6e22e">array_slice</span>($argv, <span style="color:#ae81ff">1</span>) <span style="color:#f92672">?:</span> [<span style="color:#66d9ef">PHP_VERSION</span>] <span style="color:#66d9ef">as</span> $v) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;%-10s %s</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>, $v, <span style="color:#a6e22e">status</span>($v, $fixed));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><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-console" data-lang="console"><span style="display:flex;"><span>$ php php2shell-check.php 8.2.34 8.4.27 8.1.31
</span></span><span style="display:flex;"><span>8.2.34     VULNERABLE upstream (&lt; 8.2.35) unless your vendor backported the fix
</span></span><span style="display:flex;"><span>8.4.27     fixed upstream (&gt;= 8.4.27)
</span></span><span style="display:flex;"><span>8.1.31     END OF LIFE: no upstream fix; needs vendor backport or upgrade
</span></span></code></pre></div><h2 id="what-the-two-flaws-do">What the two flaws do</h2>
<p>Both bugs leave PHP&rsquo;s internal request state inconsistent and let later code trust it.</p>
<ul>
<li><strong>CVE-2026-109618, &ldquo;RCE via OOM corruption&rdquo;.</strong> The CVE record says PHP &ldquo;uses longjmp() for out-of-memory (OOM) errors to exit the current context&rdquo;. Some code paths &ldquo;leave request memory in an uninitialized state before performing an allocation&rdquo;, and &ldquo;the subsequent request cleanup procedure may read or branch on this memory.&rdquo; An attacker who can push a request into an out-of-memory failure at the right moment can trigger it.</li>
<li><strong>CVE-2026-109619, &ldquo;RCE via stale global state&rdquo;.</strong> Global state that &ldquo;temporarily points into request memory&rdquo; may not be reset, so it &ldquo;may refer to request memory even after it has been freed.&rdquo; PHP&rsquo;s advisory explains the consequence: &ldquo;A later request handled by the same worker may use that stale state before normal initialization resets it.&rdquo;</li>
</ul>
<p>Both carry a CVSS v4.0 base score of 9.2 (<code>AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H</code>): network-reachable, no privileges, no user interaction, but high attack complexity with attack requirements present. If you want to decode the vector, see our guide to <a href="/posts/how-to-read-a-cvss-v4-0-vector-string-metric-by-metric-with-real-cve-records/">reading a CVSS v4.0 vector string</a>. The fix shipped in all four branches as one change, described in PHP&rsquo;s NEWS file as &ldquo;Avoid state corruption via bailout&rdquo;.</p>
<p>Credits in the CVE records name Wiz, Ryan at Calif.io and OpenAI as reporters or finders.</p>
<h2 id="exploitation-status">Exploitation status</h2>
<p>The public picture is mixed, so here is what each source says:</p>
<table>
  <thead>
      <tr>
          <th>Date</th>
          <th>Source</th>
          <th>What it says</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Oct 17</td>
          <td>Amazon Threat Intelligence, as reported by Infosecurity Magazine</td>
          <td>An exploitation attempt against its MadPot honeypot &ldquo;dating October 17&rdquo;, four days before disclosure</td>
      </tr>
      <tr>
          <td>Oct 21</td>
          <td>PHP advisory</td>
          <td>A demonstrated exploit against mod_php; patches released</td>
      </tr>
      <tr>
          <td>Oct 21</td>
          <td>oss-security disclosure</td>
          <td>&ldquo;a complete FPM exploit has not been demonstrated&rdquo;, though source analysis suggests FPM is susceptible</td>
      </tr>
      <tr>
          <td>Oct 21</td>
          <td>VulnCheck</td>
          <td>Scanning with a <code>php2shell-scan/109618</code> user agent, and one attempt that &ldquo;tried to (unsuccessfully) drop a webshell via a server-side session file&rdquo;</td>
      </tr>
      <tr>
          <td>Oct 23</td>
          <td>Security Affairs, citing honeypot operators</td>
          <td>&ldquo;no successful remote code execution has been confirmed in real-world attacks&rdquo;</td>
      </tr>
      <tr>
          <td>Oct 23</td>
          <td>FBI IC3 public service announcement</td>
          <td>Updates &ldquo;should be applied immediately&rdquo;</td>
      </tr>
      <tr>
          <td>Oct 26</td>
          <td>CISA KEV</td>
          <td>Both CVEs added; due October 29; forensic triage required for federal agencies</td>
      </tr>
  </tbody>
</table>
<p>CISA&rsquo;s Vulnrichment SSVC entry in the CVE records, dated October 21, still says <code>Exploitation: none</code>. That predates the KEV listing. For how these signals usually relate, see <a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS vs EPSS vs KEV</a>.</p>
<p>Our reading: mass scanning started within hours, working exploits exist, and CISA has listed both as exploited. Whether specific attacks succeeded against production servers is not yet established in public sources. That uncertainty is a reason to hunt after patching, not a reason to wait.</p>
<h2 id="what-to-do-now">What to do now</h2>
<ol>
<li><strong>Inventory.</strong> Find every PHP runtime that serves HTTP: VMs, containers, base images, appliances with embedded PHP, and managed hosting. Container images need a rebuild and redeploy; patching the host does nothing for them.</li>
<li><strong>Update.</strong> Move to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or your vendor&rsquo;s patched package. For 8.1 and older, use a vendor backport or upgrade. PHP 8.2 itself reaches end of life on December 31, 2026, according to PHP&rsquo;s release notes, so plan that upgrade too.</li>
<li><strong>Restart every worker.</strong> CVE-2026-109619 is about state that persists across requests in the same worker. Restart PHP-FPM pools, Apache (for mod_php) and any long-running PHP servers after the package update.</li>
<li><strong>Prioritise internet-facing mod_php</strong>, then FPM behind public web servers, then internal applications.</li>
</ol>
<h3 id="if-you-cannot-update-today">If you cannot update today</h3>
<p>PHP lists stopgaps and is explicit that &ldquo;only an update is a complete solution&rdquo; and these &ldquo;do not make an installation safe&rdquo;:</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-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; php.ini or FPM pool config, applied before request parsing, then restart workers.</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; Illustrative: check each value against your application&#39;s needs.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; CVE-2026-109618 (OOM corruption)</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; In an FPM pool use php_admin_value[memory_limit] = -1 so applications can&#39;t override it.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">memory_limit</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">-1</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; 8M if the app takes no uploads, 32M if it needs them; keep upload_max_filesize below it</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">post_max_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">8M</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">upload_max_filesize</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">2M</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; CVE-2026-109619 (stale global state)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">session.upload_progress.enabled</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; PHP 8.5 only:</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; fatal_error_backtraces = 0</span>
</span></span></code></pre></div><p>The reasoning behind each, from the advisory:</p>
<ul>
<li><code>memory_limit=-1</code> &ldquo;removes the controllable PHP memory limit failure, but an underlying allocation failure can still reach the vulnerable path.&rdquo; With no PHP memory limit, a container or cgroup memory limit becomes your availability boundary. PHP warns: &ldquo;Do not substitute <code>RLIMIT_AS</code> or <code>RLIMIT_DATA</code>, as these can cause the vulnerable allocation failure.&rdquo;</li>
<li>Low <code>post_max_size</code>, plus limits at the web server or proxy on decoded request size, field count, multipart part count, header count and nesting depth. These &ldquo;must also cover requests without a usable <code>Content-Length</code>.&rdquo;</li>
<li><code>session.upload_progress.enabled=0</code> &ldquo;blocks the confirmed session paths but not other variants&rdquo;. Set it in <code>php.ini</code>, the FPM pool or server config; PHP says &ldquo;Do not rely on <code>ini_set()</code> or FPM <code>.user.ini</code>.&rdquo;</li>
<li>Hiding errors does not help: &ldquo;Hiding diagnostics with <code>error_reporting</code>, <code>display_errors</code> or <code>@</code> does not help.&rdquo;</li>
</ul>
<p><code>php_admin_value</code> matters here because, per the PHP-FPM documentation, &ldquo;Settings defined with php_admin_value and php_admin_flag cannot be overridden with ini_set.&rdquo;</p>
<h2 id="what-to-hunt-for-after-patching">What to hunt for after patching</h2>
<p>Patching removes the bug, not an attacker who already used it. Preserve web server and PHP logs before you rotate or rebuild anything. The indicators below come from VulnCheck&rsquo;s published honeypot observations. They describe scanners and one attempt; real attackers can change every one of them.</p>
<ul>
<li><strong>User agents:</strong> <code>php2shell-scan/109618</code>, <code>php2shell-verify/1.0</code>, <code>Mozilla/5.0 (php2shell-poc)</code>.</li>
<li><strong>Requests:</strong> multipart POSTs to <code>/index.php</code> that upload two files named <code>a</code> and <code>b</code> through the upload-progress path; follow-up <code>GET</code> requests using <code>php://filter</code> to read a session file.</li>
<li><strong>Session files containing PHP code.</strong> The attempt VulnCheck saw tried to plant a webshell through a session file, with a payload that prints <code>PWNED::</code>. A legitimate PHP session file contains serialized data, never <code>&lt;?php</code>.</li>
</ul>
<p>Two checks you can run on a Linux host. We tested both against sample files; adjust paths to your log format and to the <code>session.save_path</code> of the pool that serves traffic (<code>php -i</code> shows the CLI&rsquo;s value, not necessarily FPM&rsquo;s):</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"># Web logs: known scanner user agents and php://filter reads</span>
</span></span><span style="display:flex;"><span>grep -hE <span style="color:#e6db74">&#39;php2shell|php://filter&#39;</span> /var/log/nginx/access.log* /var/log/apache2/access.log* 2&gt;/dev/null
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Session files that contain PHP code or the test marker</span>
</span></span><span style="display:flex;"><span>grep -lE <span style="color:#e6db74">&#39;&lt;\?php|PWNED::&#39;</span> /var/lib/php/sessions/sess_* 2&gt;/dev/null
</span></span></code></pre></div><p>Then do the usual post-compromise checks for a web server: new or changed <code>.php</code> files under the document root since October 17, unexpected child processes of the PHP worker or web server user (shells, <code>curl</code>, <code>wget</code>), and outbound connections from web hosts. Our guide to <a href="/posts/logging-for-incidents-not-for-dashboards/">logging for incidents</a> covers which of these you will wish you had recorded.</p>
<p>Federal agencies have to follow CISA&rsquo;s forensic triage requirements for this KEV entry. If you want to borrow that process, our explainer on <a href="/posts/cisa-bod-26-04-explained-kev-deadlines-forensic-triage-and-what-private-teams-can-borrow/">CISA BOD 26-04</a> covers it.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We have not read the full GitHub security advisories, which PHP says contain the technical details; they were not reachable from our research environment. Exploitation reporting comes from vendors and press summaries we cite, not from our own telemetry. The hunting indicators come from one vendor&rsquo;s honeypots and will not catch a modified attack. Exposure counts published by vendors differ widely, so we have not repeated them.</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>Affected ranges 8.2 &lt; 8.2.35, 8.3 &lt; 8.3.36, 8.4 &lt; 8.4.27, 8.5 &lt; 8.5.12; mechanisms; CVSS v4.0 9.2 vector; CWEs; credits</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/109xxx/CVE-2026-109618.json">CVE-2026-109618</a> and <a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/109xxx/CVE-2026-109619.json">CVE-2026-109619</a> records (PHP CNA)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SAPIs at risk, mod_php highest, fixed releases, 8.1-and-older guidance, source backports, all stopgaps and their caveats</td>
          <td><a href="https://www.php.net/archive/2026.php">PHP security advisory, Oct 21, 2026</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fix shipped in 8.2.35, 8.3.36, 8.4.27, 8.5.12 as &ldquo;Avoid state corruption via bailout&rdquo;</td>
          <td>PHP NEWS files for <a href="https://github.com/php/php-src/blob/php-8.4.27/NEWS">8.4.27</a> and the other tags</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Full FPM exploit not demonstrated at disclosure</td>
          <td><a href="https://openwall.com/lists/oss-security/2026/10/21/6">oss-security, Oct 21, 2026</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>KEV addition Oct 26, due Oct 29, forensic triage flag</td>
          <td><a href="https://github.com/cisagov/kev-data">CISA KEV catalog data</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Ubuntu 24.04 package 8.3.6-0ubuntu0.24.04.13 includes the fix</td>
          <td>Package changelog on an Ubuntu 24.04 host, inspected October 27, 2026</td>
          <td>Verified (one package)</td>
      </tr>
      <tr>
          <td>php_admin_value cannot be overridden with ini_set</td>
          <td><a href="https://www.php.net/manual/en/install.fpm.configuration.php">PHP-FPM configuration docs</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Exploitation attempt from Oct 17</td>
          <td>Amazon, as reported by <a href="https://infosecurity-magazine.com/news/php2shell-exploitation-attempt">Infosecurity Magazine</a></td>
          <td>Qualified (secondary report)</td>
      </tr>
      <tr>
          <td>Scanner user agents, request pattern, session-file webshell attempt</td>
          <td><a href="https://www.vulncheck.com/blog/php2shell-vulnerabilities">VulnCheck, Oct 21, 2026</a></td>
          <td>Qualified (one vendor&rsquo;s honeypots)</td>
      </tr>
      <tr>
          <td>No confirmed successful RCE as of Oct 23</td>
          <td><a href="https://securityaffairs.com/?p=201538">Security Affairs</a></td>
          <td>Qualified (secondary report)</td>
      </tr>
      <tr>
          <td>FBI: apply updates immediately</td>
          <td><a href="https://www.ic3.gov/PSA/2026/PSA261023">FBI IC3 PSA I-102326-PSA</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>