Back to glossary

Backdoor

A backdoor is hidden code or access that bypasses normal authentication, giving an attacker covert, repeatable control of a system.

Also known as back door, backdoor attack, backdoored package, software backdoor, supply chain backdoor

A backdoor, also written back door, is a hidden mechanism that bypasses a system's normal authentication so an attacker can return and run commands at will. In the open source supply chain, a backdoor usually ships inside a dependency: code that runs when the package installs, builds, loads, or is called, then waits for attacker instructions.

The defining property of a package backdoor is that, once installed, it needs no exploit and no stolen password to run. Your own build, import, or function call starts it, with your process's privileges, credentials, and network access. As our analysis of the shopsprint/decimal typosquat put it, "the persistence mechanism is the dependency itself."

Our position: you find a backdoor by analyzing what the published artifact actually does, not by reading its repository, waiting for a CVE, or checking its signature. The xz Utils backdoor shipped in release tarballs signed by a project maintainer. The boltdb-go repository showed clean code while the Go module proxy served a backdoor. Blocking install scripts, as npm v12 and pnpm 10 now do by default, closes one trigger point out of four.

What is a backdoor attack?

#

A backdoor attack is any compromise that plants a second, undocumented way into a system alongside the normal one. NIST's glossary defines a backdoor as "an undocumented way of gaining access to a computer system."

Every access path has three parts: a way in, a way to receive instructions, and a check on who may use it. A login prompt has all three: a port, a protocol, and a password check. A backdoor reimplements the same three parts out of sight.

  • The trigger is where the code first runs. In a package, that is an install hook, a build script, an import, or a function call.
  • The channel carries instructions to the backdoor. Real examples use raw TCP sockets, HTTPS polling, DNS TXT records, a blockchain canister, and an SSH key appended to ~/.ssh/authorized_keys. That channel is the backdoor's command and control (C2) link.
  • The key decides who can use the channel. Some backdoors check a cryptographic signature. Many check nothing.

A backdoor is a role, not a file type. It is one kind of malware, defined by what it gives the attacker: repeatable access. A trojan is a delivery disguise, and a backdoor can arrive inside one. A remote access trojan (RAT) is a full-featured backdoor program with a control panel. An infostealer takes what it can find once and leaves. Many real payloads combine them. The Laravel Lang compromise payload, covered below, fetched a credential stealer through a backdoor that ran on every PHP request.

Backdoors are not always malicious in origin. Hardcoded maintenance accounts and debug interfaces left enabled in production are backdoors too. The ones this entry covers were planted deliberately, through the software supply chain.

Where does a backdoor hide in a dependency?

#

A backdoor hides at whichever point in the dependency lifecycle executes third-party code without asking. Package managers create four such points by design, and each one exists for a legitimate reason.

Trigger pointWhy it existsReal backdoor that used itBlocked by npm v12 defaults?
InstallCompile native addons, download platform binariesCanisterWorm: a postinstall hook installed a pgmon systemd user serviceYes, for npm preinstall, install, postinstall and implicit node-gyp builds
BuildGenerate code, link C librariesproc-macro1: a malicious build.rs ran during cargo build; xz Utils: a modified build-to-host.m4 ran during ./configureNo. npm settings do not govern Cargo or autotools
LoadRegister helpers, set package stateshopsprint/decimal: a Go init(); Laravel Lang: a PHP file listed in autoload.filesNo
CallThe library's actual jobboltdb-go/bolt: backdoor in Open(); node-telegram-utils: backdoor in the class constructorNo

A backdoor at any of the four points can also write persistence outside the package: an SSH key in authorized_keys, a systemd user service, a macOS LaunchAgent, or a Windows Run key. That persistence survives uninstalling the package and the registry deleting it.

Install time is the trigger point most people know. npm runs a dependency's postinstall scripts by default in every version before 12. In the CanisterWorm campaign, a postinstall hook wrote a Python implant to ~/.local/share/pgmon/service.py and registered it as a systemd --user service with Restart=always. The implant polled an Internet Computer Protocol canister for the URL of its next payload. As of October 2026, Socket's campaign page counts 141 affected artifacts across 66 packages.

Build time runs code on the machine compiling your software. Cargo's documentation is explicit: a build.rs in a package root is compiled and executed "just before building the package." When the Rust crates arrayref, internment, and append-only-vec were compromised in August 2026, each malicious release only added a dependency on proc-macro1, a typosquat of proc-macro2. Its build.rs downloaded and launched a cross-platform backdoor. The application never had to import or call anything.

Load time is the trigger point most defenders underweight. Go runs every init() function in an imported package before main(), per the language specification. Composer includes every file listed under autoload.files "whenever vendor/autoload.php is included," per the Composer schema docs. The Laravel Lang compromise used exactly that field. Roughly 700 republished versions registered a malicious src/helpers.php. Composer loaded it on every request, and it fetched and ran a second-stage credential stealer once per host.

Call time hides the backdoor inside the library's real API. The boltdb-go/bolt typosquat opened its C2 connection when a caller invoked Open(), the first thing any BoltDB user does. The node-telegram-utils typosquats ran their backdoor from the bot class constructor.

Turning off install scripts by default was the right call, and npm v12 and pnpm 10 both made it. It does not touch the other three trigger points. We verified that with npm 12.2.1 and a two-file local package whose postinstall hook and top-level module code each write a marker file:

JSON
{ "name": "innocent-util", "version": "1.0.0", "main": "index.js",
  "scripts": { "postinstall": "node -e \"require('fs').writeFileSync('/tmp/bd-install','ran')\"" } }
JavaScript
// top-level code: runs on require(), not on install
require('fs').writeFileSync('/tmp/bd-load', `ran in pid ${process.pid}\n`)
exports.add = (a, b) => a + b
Bash
$ npm -v
12.2.1
$ npm install ../innocent-util

added 1 package, and audited 3 packages in 465ms

found 0 vulnerabilities
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   innocent-util@1.0.0 (postinstall: node -e "require('fs').writeFileSync('/tmp/bd-install','ran')")
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
$ ls /tmp/bd-install /tmp/bd-load
ls: cannot access '/tmp/bd-install': No such file or directory
ls: cannot access '/tmp/bd-load': No such file or directory
$ node -e "console.log(require('innocent-util').add(2,3))"
5
$ cat /tmp/bd-load
ran in pid 825

The install hook was blocked. The load-time code ran the moment the application imported the package, and the add() function still returned the right answer.

How the xz Utils backdoor worked

#

The xz Utils backdoor is, in our view, the most sophisticated open source backdoor documented publicly. It was planted in the compression library behind the xz tool, which most Linux distributions ship, and targeted the OpenSSH server. Andres Freund, a PostgreSQL developer, disclosed it on March 29, 2024, in a message to oss-security: "The upstream xz repository and the xz tarballs have been backdoored."

The timeline, reconstructed from Russ Cox's xz timeline and Freund's report:

  • 2021-10-29: The account "Jia Tan" sends a first, harmless patch to the xz-devel mailing list.
  • 2022-11-30: The README lists Lasse Collin and Jia Tan as the project maintainers.
  • 2023-07-07: A Jia Tan commit disables ifunc support in the project's oss-fuzz builds, "claiming ifunc is incompatible with address sanitizer," in Cox's words.
  • 2024-02-23: The obfuscated payload is committed as binary test files, tests/files/bad-3-corrupt_lzma2.xz and tests/files/good-large_compressed.lzma.
  • 2024-02-24: Jia Tan publishes xz 5.6.0. Version 5.6.1 follows on 2024-03-09.
  • 2024-03-29: Freund discloses the backdoor publicly, one day after notifying Debian and the distros mailing list.

The backdoored 5.6.0 release was public for 34 days. Disclosure came two years and five months after Jia Tan's first patch.

The attack has four stages, and each one hides behind a normal engineering practice.

  1. Hide the payload in test data. Compression libraries need corrupt and unusual archives as test fixtures. Two such files carried the encrypted backdoor object, and no reviewer reads binary fixtures.
  2. Trigger it only from the release tarball. The release tarballs contained a modified m4/build-to-host.m4 that is not in the git repository. Lasse Collin's incident page states that the test files "are harmless without the trigger code that was never included in the Git repository." Anyone auditing git saw nothing to run.
  3. Activate only on real targets. The injected build step ran only on x86-64 Linux with glibc, built with gcc and the GNU linker, as part of a Debian or RPM package build. At runtime it also checked the environment, such as TERM being unset.
  4. Reach sshd through a side door. OpenSSH does not use liblzma. Debian and several other distributions patch sshd to send systemd notifications through libsystemd, and libsystemd links liblzma. The backdoored liblzma redirected sshd's RSA_public_decrypt lookup to its own code.

Freund found it because public-key logins got slower. His measurements went from 0.299 seconds to 0.807 seconds of wall-clock time per SSH login, with no change in user CPU time. Red Hat scored CVE-2024-3094 at the maximum CVSS 3.1 base score of 10.0.

The xz case also broke the assumption that a signature settles trust. Collin's incident page says the 5.6.0 and 5.6.1 tarballs "were created and signed by Jia Tan." The signatures were valid. The signer was the attacker.

Who can use a backdoor once it ships?

#

A backdoor is usable by whoever can speak on its channel, and attackers make very different choices about that. The key is the least discussed part of a backdoor and the one that most changes the response.

The xz backdoor was locked. The xzbot research project shows the payload had to arrive in the CA signing key's N value of an SSH certificate, "encrypted and signed with the attacker's ED448 key." Only the holder of that private key could run commands, and researchers had to patch in their own key to demonstrate it. The intelligence community calls this design NOBUS, for "nobody but us."

Most package backdoors are not locked, or are locked badly:

BackdoorChannelWho can issue commands
xz Utils 5.6.0, 5.6.1SSH certificate during loginHolder of one Ed448 private key
node-telegram-utilsTwo SSH public keys appended to authorized_keysHolders of the two matching private keys
shopsprint/decimal v1.3.3DNS TXT record on a free dynamic DNS subdomainWhoever controls that DNS record
boltdb-go/boltRaw TCP to a fixed IP and portWhoever controls that address
proc-macro1 stage 2HTTPS to a C2 address passed at launch, plus date-based fallback domainsAnyone holding a sample, because every payload embeds the same RSA private key

The difference matters for responders. A locked backdoor protects the attacker's investment and blocks researchers from confirming impact by talking to it. An unlocked one is a liability for victims long after the original operator leaves, because a lapsed dynamic DNS name or a registered fallback domain hands the access to someone new. In the Rust case, Socket found that the same RSA private key is embedded across the recovered payloads and the fallback domains are predictable, which "creates additional opportunities for defensive sinkholing."

How do you detect a backdoor?

#

You detect a backdoor by comparing what an artifact does with what its purpose requires, using the artifact you actually install rather than its source repository. No single method catches every case, so each one below lists what it misses.

Capability diffing. A decimal arithmetic library has no reason to resolve DNS or spawn processes. The trojanized decimal.go in the shopsprint/decimal typosquat added three imports that never appear upstream: net, os/exec, and time. Its new init() looped forever, calling net.LookupTXT every five minutes and passing each answer to exec.Command. The legitimate shopspring/decimal module had 38,634 known importers on pkg.go.dev, per our write-up. The typosquat stayed weaponized for about 33 months, from August 19, 2023, until our May 2026 disclosure. Socket raises capability alerts such as Network access, Shell access, and Obfuscated code from the published package contents, and Socket for GitHub comments on a pull request when a newly added dependency carries that kind of risk. Misses: backdoors in packages that legitimately need the capability. An SSH client, an HTTP library, or a compression tool that shells out to xz looks the same with or without a backdoor at this level.

Artifact-to-source comparison. The xz trigger existed only in the tarball. The boltdb-go attacker let the Go module proxy cache a backdoored v1.3.1 in November 2021, then rewrote the git tag to point at clean code. Reviewers of the GitHub repository saw nothing for more than three years. Rebuilding the artifact from source and comparing bytes catches this class. Misses: most of the ecosystem, because few packages can be rebuilt. When vlt's reproduce tool tested 5,000 high-impact npm packages, only 5.78% could be rebuilt to match the published artifact, compared with 3.72% that had provenance attestations. Reproducibility also proves nothing when the backdoor is in the source, as it was in the xz test files.

Behavioral signals. Freund's half-second login delay, TXT lookups from a build agent to a free dynamic DNS provider, an unexpected line in authorized_keys, a new user-level systemd service. Misses: everything until the backdoor runs, and anything with a long sleep. The CanisterWorm implant waited 300 seconds before its first poll and then polled every 3,000 seconds.

Vulnerability scanning. CVE scanners match package versions against advisories. Misses: nearly every package backdoor. xz got a CVE because it lived in a legitimate, widely packaged project. A typosquat like shopsprint/decimal is reported as malware and removed, so there is no vulnerable version for a CVE scanner to match.

The common thread is that detection has to happen on the bytes the package manager will fetch, before they run. That is the argument for analyzing packages at the registry boundary rather than after deployment.

Removing the package does not remove the backdoor

#

A backdoor that writes persistence outside the package outlives the package. Uninstalling the dependency, updating the lockfile, or waiting for the registry to delete the version leaves that persistence in place.

The node-telegram-utils typosquat shows both halves of the problem. Its malicious code, published as version 0.64.0 on February 14, 2025, appended two attacker SSH keys to ~/.ssh/authorized_keys on Linux hosts. Today the registry serves a placeholder in its place:

Bash
$ curl -s https://registry.npmjs.org/node-telegram-utils \
    | jq '{description: .versions["0.0.1-security"].description, "dist-tags", time}'
{
  "description": "security holding package",
  "dist-tags": { "latest": "0.0.1-security" },
  "time": {
    "created": "2025-05-22T06:20:23.168Z",
    "modified": "2025-05-22T06:20:25.501Z",
    "0.64.0": "2025-02-14T08:17:47.696Z",
    "0.0.1-security": "2025-05-22T06:20:23.344Z"
  }
}

npm replaced the package on May 22, 2025, 34 days after our April 18 report. The malicious tarball is gone from the registry. On every Linux host that instantiated the bot class, the two keys remain in authorized_keys until someone removes them. The key comments come straight from the code in our write-up, so a check is one line:

Bash
$ grep -nE 'sleeper@DESKTOP-GM46AVB|administrator@Kakashi-PC' ~/.ssh/authorized_keys

The same logic applies to the other persistence locations real package backdoors use: ~/.config/systemd/user/ on Linux (CanisterWorm's pgmon.service), ~/Library/LaunchAgents on macOS, and HKCU\Software\Microsoft\Windows\CurrentVersion\Run on Windows (all three used by the proc-macro1 stage 2).

Treat any host that ran a backdoored version as compromised. Hunt for its persistence, rotate every credential the host could read, and rebuild from a known-good image. Do not treat a clean lockfile as proof of safety. As our analysis of the axios compromise showed, code fetched by npx, a global install, or a fresh CI image never appears in a project's lockfile.

Signatures prove who published, not what they published

#

Every control that verifies identity assumes the identity is trustworthy. Code signing, registry two-factor authentication, and package provenance answer "did this come from the expected publisher and pipeline?" They are necessary, and attackers have already routed around them.

There are two routes. The first is to become the publisher. Jia Tan spent more than two years building trust in xz Utils, then created and signed the backdoored releases as a maintainer. The second is to borrow the publisher. A maintainer account takeover or a stolen CI token publishes through the project's real pipeline. The Laravel Lang tags were published from the project's own GitHub organization. CanisterWorm republished every package its stolen npm tokens could publish.

Neither route changes what the code does. A supply chain attack that ships a backdoor still has to add a trigger, a channel, and a key, and those show up in the artifact as new capabilities, new files, and new network destinations. Content analysis catches what identity checks cannot, with one hard exception: a patient insider who adds a backdoor to a package that already needs every capability the backdoor uses. A compression library reaching into sshd through a binary test fixture is that case, and it remains the open problem. The xz Utils backdoor was caught because it added half a second to an SSH login. The next one may not be that slow.