The NPM Worm Is Back And It's So Much Worse (TanStack Hacked)
BBetter Stack
Computing/SoftwareManagementInternet Technology
Transcript
00:00:00Shai Hulud is back for a fourth sequel.
00:00:02This time it's targeting packages like TanStack,
00:00:04literally hours after I published this video on Next.js,
00:00:07which was just brilliant timing by me.
00:00:08This one is actually a massive NPM supply chain attack
00:00:11that impacts more than just TanStack.
00:00:13It also got packages like UiPath, Mistral,
00:00:15and 160 other packages,
00:00:17even including PyPy packages like Guardrails.ai.
00:00:20What makes this one even more fun
00:00:22is it included a Deadman switch,
00:00:24so if it detected that you rotated your stolen keys,
00:00:26it would wipe your entire PC,
00:00:28and it even had some global politics built in too.
00:00:30So let's dive in.
00:00:36For this sequel, The Worm has the same goal,
00:00:39steal credentials from developer machines and CICD runners,
00:00:42then use those credentials to reach more packages.
00:00:44For TanStack, that meant publishing 84 malicious versions
00:00:47across 42 TanStack packages in just a few minutes.
00:00:51Now, I'll walk through how they managed
00:00:52to infect TanStack in the first place,
00:00:54but first let's see what the malware itself does
00:00:56if you were to install one of these impacted packages.
00:00:58Inside the malicious packages,
00:00:59you would find a new file called routerinit.js,
00:01:02as well as an injected optional dependency,
00:01:04which leads to what looks like
00:01:05a legitimate TanStack router GitHub link,
00:01:08but it's actually an orphan commit on the attacker's fork.
00:01:10This is just the way that GitHub handles fork links,
00:01:13so the URL can actually look like
00:01:14it belongs to the original project,
00:01:16even though the commit is actually from a fork.
00:01:18In that fork, there's a lifecycle script,
00:01:20prepare, that runs bun run task runner JS,
00:01:22and has exit one at the end.
00:01:24That's just a clever way to make the optional dependency fail
00:01:27after the payload's already run,
00:01:28so the install keeps going like normal,
00:01:30and it leaves less obvious traces in your install logs.
00:01:33Also, you may have noticed that this isn't running
00:01:35that routerinit.js file that I said was injected
00:01:37into the packages at the start,
00:01:38but for now, just think of these two files
00:01:40as playing the exact same role with different names.
00:01:42The TLDR is, when you install this,
00:01:44it's going to run this script.
00:01:46The first thing that script tries to do
00:01:47is decouple itself from the obvious install flow,
00:01:50so it will check whether it's already running
00:01:51in the background, and if it's not,
00:01:53it forks a detached copy of itself
00:01:54and exits the parent script cleanly.
00:01:57This way, your npm install logs
00:01:58don't show any of the script's output
00:02:00because the malware's now detached itself
00:02:01from that process and is running in the background.
00:02:04After this, it does something really clever.
00:02:06It writes copies of itself
00:02:07into your clawed code hooks directory,
00:02:08then configures your clawed settings
00:02:10to run this hook anytime you use clawed code
00:02:12in that project.
00:02:13This way, it can survive past the original install
00:02:16and keep re-triggering every time
00:02:17you open clawed code in that project.
00:02:20It actually does the same with VS Code's task runner,
00:02:22duplicating itself in there,
00:02:23so if you use VS Code's workspace auto-run features,
00:02:26you have the exact same problem.
00:02:28It even sets up an OS-level service
00:02:29called GitHub Token Monitor,
00:02:31but we'll come back to that one
00:02:32because that one is absolutely diabolical.
00:02:34It's also pretty diabolical
00:02:35that you haven't subscribed yet.
00:02:37The next thing the payload does
00:02:38is it gets to work stealing your credentials,
00:02:40and it tries everything.
00:02:41In GitHub Actions, it looks for credentials
00:02:43and secrets in the runner environment.
00:02:45More specifically, scraping the GitHub Actions
00:02:47runner worker process memory
00:02:48for your workflow secrets
00:02:50that include mask secrets,
00:02:52and it even inserts a fake code QL-looking GitHub workflow
00:02:55that serializes your repo secrets
00:02:57and exfiltrates them later.
00:02:58It also looks for AWS secrets,
00:03:00first going after your environment variables
00:03:02and local config files,
00:03:03but then it also goes after AWS metadata services
00:03:06like IMDS v2 and ECS task metadata.
00:03:09For Kubernetes, it steals service account tokens
00:03:11and certificates which allows it in-cluster API access
00:03:14to whatever role-based access control privileges
00:03:17that pod's service account had,
00:03:19which in badly configured clusters
00:03:21can be extremely broad,
00:03:22sometimes effectively admin.
00:03:24And to make that even worse,
00:03:25it also goes after HashiCorp Vault,
00:03:27gathering all your vault-related environment variables
00:03:29and tokens,
00:03:30then uses whatever Kubernetes access it has
00:03:32to retrieve all of your vault-managed secrets.
00:03:34And all of that's just what it does
00:03:35to your CI deployments.
00:03:37If it's on your workstation,
00:03:38it goes after all your SSH keys,
00:03:39your NPM credentials,
00:03:41your Git credentials,
00:03:42shell history,
00:03:43cloud provider credentials,
00:03:44crypto keys,
00:03:45signal,
00:03:45Slack,
00:03:45and Discord files.
00:03:46And on top of all of that,
00:03:47it extracts your clawed code session history.
00:03:49So if you've ever given clawed credentials
00:03:51or let it read files containing credentials,
00:03:53it has access to those too.
00:03:55So yeah, as I said,
00:03:56they were after absolutely everything
00:03:57that they could get their hands on,
00:03:58and then they would exfiltrate this data
00:04:00via the session messenger network.
00:04:02And as a backup,
00:04:02they also dead-dropped this stolen data
00:04:04into GitHub repos.
00:04:05And in theme with all of their attacks,
00:04:07these branches are named after Dune references.
00:04:09So it's got your credentials.
00:04:11It can't get any worse, right?
00:04:12Well, yes.
00:04:13Yes, it can.
00:04:14On top of all of that,
00:04:15if you remember that service
00:04:16that I said it sets up on your machine,
00:04:18well, that one monitors your GitHub tokens
00:04:19and it keeps re-exfiltrating them.
00:04:21But also every minute,
00:04:22it checks if the token is still valid.
00:04:24And if it's not,
00:04:25it runs RMRF on your user directory,
00:04:27wiping everything.
00:04:28It also tries to create an NPM token
00:04:30with your credentials,
00:04:31with the description,
00:04:32if you revoke this token,
00:04:33we will wipe the computer of the owner,
00:04:35implying that it does the same thing
00:04:36for NPM tokens too.
00:04:38So if you revoke these tokens
00:04:39before isolating your machine
00:04:40and removing that background process,
00:04:42the payload can self-destruct your PC,
00:04:44which is just absolutely diabolical.
00:04:46And as a side note here,
00:04:47the Python variant of this attack
00:04:48does roughly the same thing,
00:04:49but it also includes a check
00:04:51to see if your machine's language is Russian.
00:04:53If it is,
00:04:53it just stops.
00:04:54And if your machine appears to be
00:04:55from Israel or Iran,
00:04:56it generates a random number
00:04:58between 1 and 6.
00:04:59And if that number is 2,
00:05:00it runs a destructive wipe command
00:05:01and tries to full-volume play
00:05:03an MP3.
00:05:04Sadly,
00:05:05I couldn't find out
00:05:05what that MP3 is.
00:05:07Anyways,
00:05:07now it's done all that,
00:05:08the worst is still yet to come
00:05:09because that was only stage 1.
00:05:11Stage 2 is self-propagation
00:05:13and that's the most dangerous part
00:05:15of this attack.
00:05:16First,
00:05:16it will look on your machine
00:05:17for any valid NPM tokens
00:05:19where it can publish
00:05:19without two-factor authentication.
00:05:21And if it finds one,
00:05:22it will scan all of the packages
00:05:24that that account has access to,
00:05:26then use those credentials
00:05:26to add itself to those packages
00:05:28and publish new infected versions.
00:05:30Now that's obviously pretty bad,
00:05:32but you also probably shouldn't
00:05:33have published tokens
00:05:33sitting around
00:05:34that can bypass
00:05:35two-factor authentication.
00:05:36So the much scarier version of this
00:05:38is what happens
00:05:39when it runs inside your CI-CD.
00:05:41Because in CI,
00:05:42the attacker doesn't need
00:05:43a long-lived NPM token
00:05:44because good setups
00:05:45often rely on OIDC,
00:05:47which is meant to be safer.
00:05:48Essentially,
00:05:49instead of storing
00:05:50an NPM token as a secret,
00:05:51GitHub Actions proves to NPM,
00:05:53hey,
00:05:53I'm this repo
00:05:54running this workflow
00:05:55on this branch,
00:05:56and NPM then gives it
00:05:57a short-lived published token.
00:05:59The problem with this though
00:06:00is if the script gets access
00:06:01to a trusted GitHub Actions environment,
00:06:03it can stand in the same place
00:06:04as a legitimate publisher.
00:06:06So the malware can use
00:06:07the OIDC-related environment
00:06:08that GitHub exposes to the job
00:06:10to request an OIDC JWT token
00:06:12from GitHub's token endpoint,
00:06:14then it exchanges that JWT token
00:06:16with NPM
00:06:17for a short-lived published token
00:06:18through the NPM-trusted
00:06:19publishing system,
00:06:20and now it can publish
00:06:22without ever stealing
00:06:22a permanent NPM token
00:06:24and look completely legitimate.
00:06:26In this case,
00:06:26the malware bundles a copy
00:06:27of that router init.js file
00:06:29into the package's table,
00:06:30then adds the malicious
00:06:31optional dependency,
00:06:32then publishes it all
00:06:33as the latest tag
00:06:34for that package,
00:06:35so when someone
00:06:35or some CICD pipeline
00:06:37installs those packages,
00:06:38the loop starts over again,
00:06:40spreading as far
00:06:40as it possibly can.
00:06:42So that's all
00:06:42pretty insane, right?
00:06:43But now let's focus
00:06:44on patient zero,
00:06:46TanStack.
00:06:46How did they get infected
00:06:47in the first place?
00:06:48Well,
00:06:49according to their own post-mortem,
00:06:50the attacker abused
00:06:51that GitHub Actions pipeline.
00:06:53They started the day
00:06:53before the malicious packages
00:06:54were actually published,
00:06:56where they created a fork
00:06:57of TanStack router,
00:06:58but they actually renamed
00:06:59this to configuration
00:06:59to try and make it harder
00:07:01to find if you were searching
00:07:02through the obvious fork names.
00:07:04Then they added
00:07:04a malicious commit
00:07:05to this fork,
00:07:06which they fake-authored
00:07:07as Claude,
00:07:07and it had a commit message
00:07:08that was prefixed
00:07:09by skip CI,
00:07:10so it wouldn't immediately
00:07:11run CI on a push event.
00:07:13The next day,
00:07:13they then opened a PR
00:07:14against TanStack router
00:07:15called Work in Progress
00:07:16Simplify History Build.
00:07:18And this is where
00:07:18the actual attack happens.
00:07:20The TLDR is that
00:07:21TanStack had a bundle-sized
00:07:22GitHub Actions workflow
00:07:23that used pull request target,
00:07:25and that's notable
00:07:26because pull request target
00:07:27actually runs
00:07:28in the base repo
00:07:29security context,
00:07:30not the fork.
00:07:31That means that it has access
00:07:32to the base repo's cache scope
00:07:33and its GitHub token.
00:07:35So this workflow
00:07:35checked out the PR,
00:07:36installed its dependencies,
00:07:38and ran a benchmark build.
00:07:39The trouble is, though,
00:07:40that fork contained
00:07:40malicious code.
00:07:41In this case,
00:07:42it was a V setup script
00:07:43that poisoned the
00:07:44PMPM package store
00:07:45under the exact cache key
00:07:47that the release action
00:07:48would later use.
00:07:49They actually pre-computed
00:07:50this from the public
00:07:51PMPM lock file
00:07:52using the exact same formula
00:07:54that the workflow also uses.
00:07:56Once that poisoned cache
00:07:57was saved,
00:07:57they actually reset
00:07:58that branch back
00:07:59to match the current
00:07:59main branch,
00:08:00so the visible PR
00:08:01looked like a zero file
00:08:02no-op,
00:08:03and then they closed that PR
00:08:04and deleted
00:08:05the malicious branch.
00:08:06So from the outside,
00:08:07it looks like
00:08:07absolutely nothing
00:08:08has happened,
00:08:09but they've poisoned
00:08:10that GitHub action's cache.
00:08:11This means that
00:08:12eight hours later,
00:08:13when a normal maintainer
00:08:14merged an unrelated PR
00:08:15into main,
00:08:16it triggered Tanstack's
00:08:17release workflow,
00:08:18which restored the
00:08:19PMPM poisoned cache,
00:08:20and now attacker control code
00:08:22was running inside
00:08:23that release action.
00:08:24It then used the same logic
00:08:25with OIDC
00:08:26to get an NPM publishing token,
00:08:28and managed to publish
00:08:2984 versions of itself
00:08:30across 42 Tanstack packages,
00:08:32and it didn't even need
00:08:33to get to the
00:08:34published package step
00:08:35of the action.
00:08:36Funnily enough,
00:08:36the action actually failed
00:08:37because some tests failed,
00:08:39so it never reached that step,
00:08:40but the malicious code ran
00:08:41and published all of them
00:08:43regardless.
00:08:43So the attacker managed
00:08:44to chain three trust boundaries.
00:08:46First,
00:08:47the fork PR code
00:08:47got to poison
00:08:48the base repos cache,
00:08:49then that base repos cache
00:08:51got restored inside
00:08:52the real release workflow,
00:08:53then the real release workflow
00:08:54has OIDC permissions,
00:08:56which turn into
00:08:57NPM publish access,
00:08:58so they can publish
00:08:59what looks to be
00:08:59completely legitimate packages.
00:09:01And that's what I think
00:09:02is getting really scary
00:09:03about the supply chain attacks.
00:09:05They're moving away
00:09:05from stealing
00:09:06one maintainer's token
00:09:07to abusing
00:09:08the entire CI-CD system itself,
00:09:10and that means
00:09:11that all of our trust signals
00:09:12are starting to work
00:09:13for the attacker.
00:09:14This was a signed package
00:09:15with valid provenance
00:09:16published by a real workflow.
00:09:18So there we go,
00:09:19that is ShaiHalud4,
00:09:20and if you want to check
00:09:21if you've been compromised
00:09:21by any of these packages,
00:09:23I'll leave links
00:09:23to blog posts down below,
00:09:25which will cover
00:09:25how you can find out
00:09:26and what you can do
00:09:27if you have installed
00:09:28one of these.
00:09:29Let me know in the comments
00:09:30what you think
00:09:30about all of this
00:09:31and the NPM ecosystem,
00:09:33while you're down there,
00:09:33subscribe,
00:09:34and as always,
00:09:34see you in the next one.
Community Posts
No posts yet. Be the first to write about this video!
Write about this video