Back to glossary

Malicious Package

A malicious package is a registry library containing malicious code that steals data, opens a backdoor, or destroys files when installed, built, or imported.

Also known as malicious code, malicious dependency, malicious npm package, malicious PyPI package, package malware, trojanized package, open source malware, embedded malicious code, CWE-506

A malicious package, also called a malicious dependency, is a library published to a public registry such as npm or PyPI that contains malicious code: code written to steal credentials, open a backdoor, or destroy data on the machines that install, build, or import it. It runs with the installer's privileges, usually before anyone reads it.

A package manager is a code execution service that developers invoke on purpose. Installing one dependency can run its code at seven different points, from an npm postinstall hook to a Python .pth file that fires on every interpreter start, and most of them fire before your application executes a single line. The OpenSSF Malicious Packages dataset held 239,136 malicious-package reports as of October 15, 2026, 93% of them for npm, though one spam wave accounts for 59% of the total. Our position is that malicious code is not a kind of vulnerability. It rarely gets a CVE, reachability does not apply, and the decisive control is analyzing what a package actually contains before it is installed.

What is malicious code?

#

Malicious code is code whose purpose is to harm the system that runs it or the people who rely on that system. The difference from a bug is intent. A vulnerability is a mistake an attacker can exploit; malicious code is the attacker's own work, shipped to run. MITRE catalogs it as CWE-506, Embedded Malicious Code: "code that appears to be malicious in nature."

Malicious code and malware overlap but are not the same. Malware usually names a whole program: a trojan, a ransomware binary, a cryptominer. Malicious code also covers one injected line inside an otherwise legitimate library, which is what most software supply chain attacks look like. This page covers the general concept and its most common delivery vehicle today, the malicious package.

Intent is hard to observe from outside, so the working definition is behavioral. The OpenSSF Malicious Packages project, which maintains the largest open dataset, counts a package as malicious when installing or using it "would require some sort of incident response" because confidentiality, integrity, or availability was lost. That test settles most of the gray areas:

CaseMalicious under the OpenSSF definition?
Typosquat with no payloadNo, but reports are accepted because it is hard to tell apart from dependency confusion
Dependency confusion package, even an empty oneYes. OpenSSF treats it like an account takeover
Protestware that only prints a messageNo
Protestware that deletes files or degrades availabilityYes
Install-time telemetryNo, unless it exfiltrates sensitive data or provides remote access
Obfuscated codeNot by itself, but reports are accepted on registries that ban obfuscation, such as PyPI and crates.io
Offensive security toolNo, unless it executes a payload on install

We agree with the behavioral test, but we apply it earlier. OpenSSF asks whether a victim would need incident response. We ask whether the package's code can cause that outcome, and we ask before anyone installs it.

What is a malicious package?

#

A malicious package is malicious code packaged in the format a package manager already trusts: a name, a version, a manifest, a README, and files the installer will fetch and execute on request. Attackers get one onto a developer's machine in three ways. They publish a new package under a name people will install by mistake, they put code into a package people already use, or a maintainer turns on their own users.

New attacker-owned packages depend on naming. Typosquatting registers a name one typo away from a popular one. Dependency confusion publishes a public package with the same name as a company's internal one, so the resolver picks the public copy. Slopsquatting registers names that AI coding assistants hallucinate. All three start with no reputation, so they copy the real project's README, author field, and repository link to borrow one.

Compromised legitimate packages are more dangerous because they inherit trust and traffic. An attacker who completes a maintainer account takeover can publish a version that every caret range in the ecosystem accepts automatically. The axios compromise in March 2026 changed almost nothing in axios itself. It added one new dependency, plain-crypto-js, to a library with 100 million weekly downloads, so the payload arrived as a transitive dependency that no one had chosen. Shai-Hulud went one step further and turned each stolen npm token into the next compromise, which makes it a worm.

The third path needs no attacker at all. Protestware is code a legitimate maintainer adds to make a point, and some of it destroys data. Registries are also not the only channel. Editor extensions, MCP servers, and agent skills are packages too. The GlassWorm campaign delivered malware through Open VSX extensions that later declared other extensions as dependencies, so the editor installed the malicious ones automatically.

When does a malicious package's code run?

#

A malicious package's code can run at seven points between install and production, and the new install-script defaults cover two of them. Package managers were designed to make installation do work: compile native bindings, download platform binaries, generate files. Each of those conveniences is a hook where third-party code executes with the installer's privileges.

The figure lists the seven execution points with a real case for each. The same data, with the trigger and the default state as of October 2026:

#Execution pointTriggered byReal caseOff by default?
1npm lifecycle scripts (preinstall, install, postinstall)npm installplain-crypto-js 4.2.1, the axios compromise, March 2026Yes in npm 12 and pnpm 10
2Implicit native build from a binding.gyp filenpm installMiasma "Phantom Gyp" waves, 2026Yes in npm 12
3Python source distribution build (setup.py or build backend)pip install, pip download, pip install --dry-runcrytic-compilers 0.3.11, June 2024No. pip install --only-binary :all: avoids it
4Build scriptscargo build runs build.rsproc-macro1, pulled in by arrayref 0.3.10, August 2026No
5Interpreter startup hookAny python start reads *.pthMini Shai-Hulud PyPI wheels, June 2026No
6Import-time codeimport telnyx runs _client.pytelnyx 4.87.1 and 4.87.2, March 2026No
7Deferred runtime triggerA date, a call count, or a remote signalshanhai666 NuGet packages, armed for 2027 and 2028No

Install time is still where most attacks execute. When npm 12 shipped, we noted that "almost every worm and credential stealer that hit the registry since late 2025 ran at install time." npm 12 made dependency lifecycle scripts opt-in on July 8, 2026, including the implicit node-gyp rebuild that the Miasma "Phantom Gyp" waves used to run code with no script in package.json. pnpm 10 did the same for lifecycle scripts in January 2025. Default-on install scripts were a design mistake, and both changes were overdue. pnpm contributor Brandon Cheng put it plainly in the pull request: "It's nuts that a [pnpm|yarn|npm] install can run arbitrary code in the first place."

Attackers are already moving down the list. The telnyx compromise put its payload in the SDK's core HTTP client so it fired on import telnyx, and our analysis concluded that "by moving execution into the library's import path, the threat actor sidesteps that entire class of detection." The compromised Rust crates added a typosquatted dependency whose build.rs ran during any Cargo build that resolved it, so the application never had to call it. Blocking install scripts shrinks the attack surface. The malicious code itself is unchanged, and it waits for the next trigger.

A wheel that runs nothing at install and runs on every Python start

#

Python's site module executes every line in a .pth file that begins with import, each time an interpreter starts. Installing a wheel runs no package code, which makes wheels look safe. A .pth file inside the wheel moves execution to the next start of any Python process in that environment, for any program. The Mini Shai-Hulud campaign's PyPI wheels used exactly this in June 2026. We built a harmless wheel to show the behavior, using Python 3.13.16 and pip 26.2.1 on October 15, 2026:

Plain text
# innocent_utils-1.0.0-py3-none-any.whl
innocent_utils/__init__.py          def add(a, b): return a + b
innocent_utils-setup.pth            import os; open(f"/tmp/pth-ran-{os.getpid()}", "w").write("pwned\n")
innocent_utils-1.0.0.dist-info/     METADATA, WHEEL, RECORD
Bash
$ pip install innocent_utils-1.0.0-py3-none-any.whl
$ ls /tmp/pth-ran-*
ls: cannot access '/tmp/pth-ran-*': No such file or directory
$ python -c "pass"
$ ls /tmp/pth-ran-*
/tmp/pth-ran-530
$ python -I -c "pass"        # isolated mode still processes site-packages
$ ls /tmp/pth-ran-*
/tmp/pth-ran-530  /tmp/pth-ran-535
$ python -S -c "pass"        # only -S, which disables site, skips it

The install printed nothing and ran nothing. The first unrelated python command ran the payload. Isolated mode (-I) did not stop it.

Python 3.15 deprecates these import lines, and the site documentation says they will be silently ignored in Python 3.18 and 3.19. The replacement is a name.start entry point, and the same documentation states that executable startup entries "are always run at Python startup … regardless of whether a particular module is actually going to be used." The change makes startup code explicit and easier to audit. It does not remove startup code.

Source distributions are worse. The crytic-compilers typosquat hid its trojan launcher in setup.py, and pip runs that file earlier than most developers expect. pip builds metadata from an sdist by running its build backend, so downloading is not inspecting. Our test sdist's setup.py wrote a marker file under both of these commands, which never install anything:

Bash
$ pip download --no-deps innocent_sdist-1.0.0.tar.gz
$ pip install --dry-run --no-deps innocent_sdist-1.0.0.tar.gz
# each command left two marker files from setup.py, one per build-backend subprocess

How can malicious code do damage?

#

Malicious code does damage with the access its host already has: the filesystem, environment variables, the network, and the credentials stored in all three. Developer laptops and CI runners hold registry publish tokens, cloud keys, SSH keys, and signing material, which makes them better targets than most production servers. The payloads we analyze fall into six groups.

DamageMechanismDocumented case
Credential theftReads environment variables, config files, and browser stores, then exfiltrates themtelnyx 4.87.1 encrypted its loot with AES-256-CBC and RSA-4096 before upload
Remote accessInstalls a backdoor or remote access trojan that takes commandsplain-crypto-js dropped a macOS RAT that beaconed every 60 seconds
Self-propagationUses stolen publish tokens to inject itself into the victim's other packagesShai-Hulud, from September 2025
DestructionDeletes files or kills processes, often after a delayshanhai666 NuGet packages: 9,488 downloads, a 20% chance of killing the host process on each database query after the trigger date
Resource theftRuns a cryptominer@rspack/core 1.1.7 ran XMRig capped at 75% of CPU threads
Persistence and anti-forensicsSurvives reboots or erases its own tracesplain-crypto-js deleted its dropper and swapped in a clean package.json

Most of the campaigns on this page steal credentials, and an infostealer payload is the usual shape. The shanhai666 NuGet packages show the patient version of destruction: the SQL Server variant arms on August 8, 2027, long after anyone would connect a crash to the install that delivered it. The Rspack compromise shows the noisy version of resource theft, shipped in a package with roughly 370,000 weekly downloads.

Anti-forensics deserves its own sentence. After the plain-crypto-js postinstall hook ran, the installed directory looked like a normal copy of crypto-js, with no trace of the hook. Inspecting node_modules after the fact would have found nothing.

Malicious code also adapts to whatever inspects it. In June 2026 we analyzed an npm package that used prompt injection and 3.5 million tokens of padding to derail AI-based scanners. Our conclusion from that case: "The scanner is now part of the threat model."

Malicious package vs. vulnerable package

#

A malicious package and a vulnerable package both show up as alerts on a dependency, but they need opposite handling.

Vulnerable packageMalicious package
OriginA mistake by the authorCode written to cause harm
IdentifierCVE, mirrored in GHSA and OSVUsually no CVE. An OSV MAL- record, a GitHub malware advisory, CWE-506
When it hurtsWhen an attacker reaches the flawOn install, build, startup, or import
Does reachability matter?Yes. Most flagged CVEs are never reachedNo. The payload calls itself
FixUpgrade to a patched versionRemove it, treat the host as compromised, rotate secrets, rebuild
Patched versionUsually existsDoes not exist. "Fixed" means the last clean release
Evidence of exposureThe current dependency treePoint-in-time logs. Today's tree may look clean

Verdict: route malicious-package alerts to incident response, not the vulnerability backlog. Severity scores, reachability, and "no fix available" logic should never decide malware triage.

The CVE program has long treated malware as out of scope. When the CVE Board discussed malicious npm packages in 2018, CERT/CC's Art Manion summarized the case in one line: "Not a technical vulnerability, I accidentally installed malware." Exceptions exist, and they arrive late. The TanStack compromise pushed 84 malicious versions across 42 packages on May 11, 2026, and CVE-2026-45321 reached NVD on May 12. A scanner that only matches CVEs sees malware after the fact, if it sees it at all.

Malicious packages get their own records instead. This is the OSV entry for the axios dropper, trimmed:

JSON
{
  "id": "MAL-2026-2306",
  "published": "2026-03-31T02:07:58Z",
  "aliases": ["GHSA-2x9r-6wxq-hrr7"],
  "summary": "Malicious code in plain-crypto-js (npm)",
  "affected": [{
    "package": { "ecosystem": "npm", "name": "plain-crypto-js" },
    "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }] }],
    "database_specific": { "cwes": [{ "cweId": "CWE-506", "name": "Embedded Malicious Code" }] }
  }],
  "database_specific": {
    "iocs": { "domains": ["sfrclak.com"], "ips": ["142.11.206.73"] },
    "malicious-packages-origins": [
      { "source": "amazon-inspector", "versions": ["4.2.1"] },
      { "source": "ghsa-malware", "id": "GHSA-2x9r-6wxq-hrr7" },
      { "source": "google-open-source-security", "versions": ["4.2.0", "4.2.1"] }
    ]
  }
}

Three details matter. The summary uses the fixed form "Malicious code in <package> (<ecosystem>)" that the dataset uses for its records. The range starts at "introduced": "0" with no fix event, because the whole package is hostile and no version is safe. The record carries indicators of compromise, the domain and IP the dropper contacted, which is what responders need and what a CVE record has no field for. The merged GitHub advisory states the operational meaning directly: "Any computer that has this package installed or running should be considered fully compromised."

Detection speed vs. the public record

#

Public malware databases record attacks hours after they start, and the axios compromise shows the gap. We rebuilt the timeline from npm registry timestamps, the detection time in our original report, and the OSV record's metadata.

Time (UTC)EventElapsed from malicious publish
Mar 30, 05:57:32plain-crypto-js 4.2.0 published, a clean copy of crypto-js18 hours before
Mar 30, 23:59:12plain-crypto-js 4.2.1 published with a postinstall dropper0
Mar 31, 00:05:41Socket flags plain-crypto-js as malware6m 29s
Mar 31, 00:21:58axios 1.14.1 published with the new dependency22m 46s
Mar 31, 01:00:57axios 0.30.4 published, poisoning the 0.x branch1h 01m 45s
Mar 31, 02:07:58OSV record MAL-2026-2306 published2h 08m 46s
Mar 31, 03:25:11npm replaces the package with 0.0.1-security3h 25m 59s
Mar 31, 04:26:59GitHub's malware advisory record last modified4h 27m 47s

A blocklist fed only by public databases would have let axios 1.14.1 install for 1 hour and 46 minutes before the first OSV record existed. The clean 4.2.0 release published 18 hours earlier also mattered: it meant the package was no longer brand new when the payload arrived. Databases are the record of an attack. They cannot be the gate.

Databases also make mistakes. In May 2026, OSV withdrew 157 malware reports after automated detections flagged legitimate releases, including FastAPI 0.136.3, and those records had already flowed into CI gates and policy engines.

Socket splits its verdicts along the same line. The Known Malware alert covers packages a researcher confirmed, or that contain files already confirmed as malware, and Socket's default policy blocks it. AI-Detected Potential Malware comes from static analysis plus an LLM review of the package's code, and it only warns by default, per the default security policy. When a researcher confirms the finding, it becomes Known Malware. The split trades speed against the false-positive failure OSV hit in May. A detection that is minutes old will occasionally be wrong, and a default block on it would break builds.

Counting malicious packages

#

The OpenSSF dataset counted 239,136 malicious-package reports across 11 ecosystems as of October 15, 2026, but the raw count measures reporting pipelines and spam floods more than risk.

EcosystemOSV malicious-package reports
npm222,181
PyPI11,803
RubyGems4,304
NuGet782
crates.io21
Go20
Open VSX and VS Code21
Maven, Packagist, Git4

Two monthly buckets dominate the npm number. November 2025 alone holds 142,163 npm reports, and August 2025 holds 35,099. Together they hold 80% of all npm reports in the dataset. The November spike matches a TEA token-farming spam wave. OpenSSF assigned MAL IDs to the packages, and up to 150,000 of them were taken down from npm. Those packages were garbage published to farm crypto rewards, and our analysis found they did not contain malicious code. They counted the same as a credential stealer.

We think the useful number is different: how long a malicious version of a package you already depend on stays installable before something stops it. For axios that window was minutes for an install-time check and hours for a database. That number, not the size of a dataset, decides whether a build gets hit.

Controls and what each one misses

#

No single control stops malicious packages, and each common one misses a specific case. Malware detection covers the analysis techniques in depth. The table below shows where each control sits and what it misses. Release-age delays are newly default: pnpm 11 set a 24-hour minimum release age in May 2026.

ControlStopsMisses
Install-script blocking (npm 12, pnpm 10)Execution point 1, plus point 2 in npm 12Build scripts, startup hooks, import-time and deferred payloads. Native modules need explicit approval
Minimum release age (pnpm 11 defaults to 24 hours)Short-lived malicious versions removed within the windowAnything still undetected when the window ends. Configuration can override it
Lockfile with npm ciSilent upgrades of existing dependenciesNew dependencies, update PRs, npx and global installs
Provenance and trusted publishingPublishes from outside the expected pipelineCompromised maintainers or CI publishing through the legitimate pipeline with valid provenance
CVE scanningKnown vulnerabilitiesNearly all malware
Registry takedowns and OSV feedsRepeat installs after detectionThe hours before detection
Pre-install analysis of package contentsNew malicious versions before they executeWrong verdicts in both directions, and second stages fetched later from a server

Verdict: combine a release-age delay with analysis of what each new version contains, enforced at install time. Treat install-script blocking as attack-surface reduction and lockfiles as reproducibility, not as detection. Lockfiles make existing installs deterministic, but they do not make new installs safe.

If you already installed one

#

If you installed a malicious package, establish exposure by time, not by today's state. Today's lockfile can be clean while a CI job ran npx against the poisoned version, and the dropper may have rewritten its own files. Treat every machine that installed or imported it as compromised. Rotate every secret it could read, from a different machine, starting with registry publish tokens, because those are what a worm uses next. Rebuild from a clean host, since removing the package does not remove a second stage.

The case no default catches

#

The hardest case remains the one the newest controls were not built for. An attacker with a real maintainer's credentials publishes through the real pipeline, with valid provenance, and ships code that runs at import time instead of install time. The new version satisfies every caret range in the ecosystem the moment it is published, and nothing in its metadata distinguishes it from the previous release. Only what the code does tells it apart, which is why that is what we read.