# 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

Source: https://socket.dev/glossary/backdoor

Last updated: 2026-10-24

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](https://socket.dev/blog/popular-go-decimal-library-typosquat-dns-backdoor) [typosquat](https://socket.dev/glossary/typosquatting) 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](https://socket.dev/blog/how-to-use-socket-to-find-out-if-you-were-affected-by-the-backdoored-xz-package) shipped in release tarballs signed by a project maintainer. The [boltdb-go](https://socket.dev/blog/malicious-package-exploits-go-module-proxy-caching-for-persistence) repository showed clean code while the Go module proxy served a backdoor. Blocking install scripts, as [npm v12](https://socket.dev/blog/npm-12) and [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) 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)](https://socket.dev/glossary/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](https://socket.dev/glossary/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)](https://socket.dev/glossary/remote-access-trojans-rats) 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](https://socket.dev/supply-chain-attacks/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.

> Figure 1. Four trigger points, one real backdoor or more at each. Default-off install scripts close only the first.
>
> The four points in a dependency's lifecycle where a backdoor can run, with a real example of each, and which one npm v12 blocks by default

| Trigger point | Why it exists | Real backdoor that used it | Blocked by npm v12 defaults? |
| --- | --- | --- | --- |
| Install | Compile native addons, download platform binaries | CanisterWorm: a postinstall hook installed a pgmon systemd user service | Yes, for npm preinstall, install, postinstall and implicit node-gyp builds |
| Build | Generate code, link C libraries | proc-macro1: a malicious build.rs ran during cargo build; xz Utils: a modified build-to-host.m4 ran during ./configure | No. npm settings do not govern Cargo or autotools |
| Load | Register helpers, set package state | shopsprint/decimal: a Go init(); Laravel Lang: a PHP file listed in autoload.files | No |
| Call | The library's actual job | boltdb-go/bolt: backdoor in Open(); node-telegram-utils: backdoor in the class constructor | No |

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](https://socket.dev/glossary/postinstall-scripts) by default in every version before 12. In the [CanisterWorm](https://socket.dev/supply-chain-attacks/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](https://socket.dev/blog/popular-rust-crates-compromised) 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](https://go.dev/ref/spec#Package_initialization). Composer includes every file listed under `autoload.files` "whenever `vendor/autoload.php` is included," per the [Composer schema docs](https://getcomposer.org/doc/04-schema.md). 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](https://socket.dev/blog/npm-malware-targets-telegram-bot-developers) 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](https://seclists.org/oss-sec/2024/q1/274): "The upstream xz repository and the xz tarballs have been backdoored."

> Figure 2. Two years and five months from first patch to disclosure. The backdoored 5.6.0 release was public for 34 days.
>
> Timeline of the xz Utils backdoor from Jia Tan's first patch in October 2021 to public disclosure in March 2024

The timeline, reconstructed from [Russ Cox's xz timeline](https://research.swtch.com/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](https://tukaani.org/xz-backdoor/) 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](https://access.redhat.com/security/cve/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](https://github.com/amlweems/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:

| Backdoor | Channel | Who can issue commands |
| --- | --- | --- |
| xz Utils 5.6.0, 5.6.1 | SSH certificate during login | Holder of one Ed448 private key |
| node-telegram-utils | Two SSH public keys appended to authorized\_keys | Holders of the two matching private keys |
| shopsprint/decimal v1.3.3 | DNS TXT record on a free dynamic DNS subdomain | Whoever controls that DNS record |
| boltdb-go/bolt | Raw TCP to a fixed IP and port | Whoever controls that address |
| proc-macro1 stage 2 | HTTPS to a C2 address passed at launch, plus date-based fallback domains | Anyone 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](https://socket.dev/alerts/networkAccess), [Shell access](https://socket.dev/alerts/shellAccess), and Obfuscated code from the published package contents, and [Socket for GitHub](https://docs.socket.dev/docs/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](https://socket.dev/blog/vlt-launches-reproduce) 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](https://socket.dev/blog/hidden-blast-radius-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](https://socket.dev/glossary/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](https://socket.dev/glossary/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](https://socket.dev/glossary/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.
