Back to all posts
Critical Research

binding.gyp:
the npm install-execution vector that skips postinstall

An attacker compromised four @immobiliarelabs Backstage plugins and hid the payload where almost no scanner looks: a node-gyp command expansion inside binding.gyp. No postinstall hook, no CVE, code execution on every npm install.

CyberXYZ Security Team Threat Intelligence
12 min read
If you run Backstage, audit now

Four @immobiliarelabs npm packages were trojanized across roughly two dozen versions: backstage-plugin-gitlab, backstage-plugin-gitlab-backend, backstage-plugin-ldap-auth, and backstage-plugin-ldap-auth-backend. Any environment that installed an affected version should treat developer, CI, and cloud credentials as compromised and rotate them immediately.

Summary

What happened

On June 26, 2026, an attacker pushed malicious code into four legitimate @immobiliarelabs packages, a vendor whose Backstage plugins are widely used by platform-engineering teams to wire GitLab and LDAP into their internal developer portals. This was not a typosquat, and there was no obvious account takeover notice. The malicious code was injected directly into real, trusted packages and republished across roughly 24 versions inside a single 30-second window. That burst pattern, many versions of several packages all stamped within seconds of each other, is the signature of an automated organization compromise: a stolen publish token or CI credential driving a script, rather than a human cutting a release.

The payload itself is an aggressive but otherwise conventional credential stealer. What makes this incident worth a full writeup is the delivery mechanism. The attacker chose an install-time execution path that sidesteps the single check almost every supply-chain tool depends on: the lifecycle script. Understanding why it works, and why it slipped past detectors broadly, is the useful part.

Affected packages and versions

The versions below were identified as compromised. Search your lockfiles and install logs for any of them.

PackageCompromised versions
@immobiliarelabs/backstage-plugin-gitlab1.0.1, 2.1.2, 3.0.3, 4.0.2, 5.2.1, 6.13.1, 7.0.2
@immobiliarelabs/backstage-plugin-gitlab-backend3.0.3, 4.0.2, 5.2.1, 6.13.1, 7.0.2
@immobiliarelabs/backstage-plugin-ldap-auth1.1.4, 2.0.5, 3.0.2, 4.3.2, 5.2.1
@immobiliarelabs/backstage-plugin-ldap-auth-backend1.1.3, 2.0.5, 3.0.2, 4.3.2, 5.2.1

The technique: execution hidden in binding.gyp

npm packages that ship native code use node-gyp to compile it, driven by a file named binding.gyp. Most engineers treat binding.gyp as inert build configuration, a manifest that lists source files and compiler flags. It is not inert. The GYP build system supports command expansions, written <!(command) and <!@(command), which node-gyp evaluates by running the command and substituting its standard output back into the build graph. Crucially, that evaluation happens while the package is being built, which on npm means during npm install. It is a general-purpose code-execution primitive that lives in a file nobody scans.

We pulled the published tarball from the npm registry and confirmed the carrier. Every trojanized version shipped this binding.gyp:

package/binding.gyp (verified from the published tarball){ "targets": [ { "target_name": "Setup", "type": "none", "sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"] } ] }

Read it plainly. When npm builds the package, node-gyp tries to resolve the sources list by running the command inside <!( ). That command is node index.js > /dev/null 2>&1 && echo stub.c: execute the bundled index.js, throw away its output, and if it exits cleanly, echo a throwaway stub.c so the build graph still receives a valid source file and the install does not error. The malicious index.js runs purely as a side effect of "resolving" the build. There is no install, preinstall, or postinstall entry anywhere in package.json. The lifecycle-script surface is completely clean.

The execution sits in the one file everyone trusts, invoked by a tool everyone trusts.

Why traditional scanners miss this

The mainstream of supply-chain detection, including most dedicated install-script scanners, keys on package.json lifecycle scripts. This package has none, so those checks return clean. SCA and SBOM tools add nothing here either: there is no CVE and no known-vulnerable dependency, just a trusted package at a new version. And binding.gyp is routinely excluded from content scanning altogether because it is treated as benign native-build tooling, while node-gyp itself sits on most engines' allowlists. The execution is parked in the one file everyone trusts, invoked by a tool everyone trusts.

The attack chain

The flow is compact and built to run once per host, quietly, at install time.

1
node-gyp triggers the payload

During npm install, the <!(node index.js ...) command expansion in binding.gyp executes the bundled index.js. No lifecycle script is involved, so install-hook detection never fires.

2
The loader unwraps itself

index.js is roughly 5 MB, wrapped in a ROT-2 outer transform, AES-128-GCM decryption with hardcoded keys, and obfuscator.io string-table rotation. The size and obfuscation are deliberate: hide intent, and choke static scanners that try to pattern-match the file.

3
A Bun runtime is fetched to run the stealer

The loader pulls the Bun JavaScript runtime from the official Bun GitHub release URL and uses it to run the decrypted payload, a living-off-the-land choice that routes the download through trusted infrastructure rather than attacker-controlled domains.

4
Credentials are harvested broadly

It reads GitHub tokens and GitHub Actions secrets from process memory via /proc/<pid>/mem; pulls AWS credentials from environment variables, IMDS, ECS metadata, SSM, and Secrets Manager; and collects GCP, Azure, Kubernetes and Vault tokens, registry tokens, password-manager vaults, and SSH keys.

5
Persistence is planted in AI assistants

The payload rewrites .claude/settings.json, .cursor/rules/, .github/copilot-instructions.md, and .aider.conf.yml so a developer's AI tooling can re-introduce malicious behavior even after the package is removed. It weaponizes the assistant that now sits between the developer and their code.

6
The worm primes itself to spread

The code can publish modified packages to npm and PyPI using stolen registry credentials, the mechanism by which one compromised maintainer becomes many. Self-propagation is what turns a single bad release into a campaign.

What our engine recorded

CyberXYZ ingests every npm publish through a real-time firehose, so the malicious versions were in our data within about a minute of release. They were not flagged at first, for the same reason everyone else missed them: install-time detection was keyed on lifecycle scripts, and there are none here. Every malicious version declared no install, preinstall, or postinstall script, so the usual signal had nothing to fire on.

The right response to a missed technique is not a one-off rule, it is to close the class of evasion. We extended our analysis to read binding.gyp and treat a <!( ) command expansion that runs a bundled script (or chains and redirects) as install-time code execution, the same severity class as a malicious postinstall. Run against the live packages, it produces a clean verdict:

@immobiliarelabs/backstage-plugin-gitlab BLOCK
no hookNo install, preinstall, or postinstall script on any of the 24 versions. Lifecycle-based detection has nothing to fire on.
flaggedbinding.gyp runs a bundled script during the install build step: node index.js > /dev/null 2>&1 && echo stub.c. Classified as install-time code execution, critical severity.
correlated24 versions of four packages published in a 30-second burst. Clustered as a single coordinated campaign.

Legitimate native builds are not caught in the process. Real packages use command expansions to resolve include directories, for example <!(node -e "require('nan')") or a pkg-config lookup, and the analysis deliberately ignores those. It fires on a bundled script being executed (node index.js, a shell, a fetch piped to a shell) or on command chaining and redirection, the shapes that signal execution rather than a build-time query.

Indicators of compromise

TypeIndicatorContext
Execution directive <!(node index.js > /dev/null 2>&1 && echo stub.c) node-gyp command expansion inside binding.gyp
File index.js (~5 MB) Obfuscated payload (ROT-2 + AES-128-GCM + obfuscator.io)
File binding.gyp Carrier of the install-time execution
SHA-512 k7pGY+wScfqX51fpF412dOze6kSIytHYwZAXPhu6pDV+R7JWnD98Uc0nzGVHFead99nwWU4x56fkre/jH3Q7Xg== Payload hash
Network github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-<os>-<arch>.zip Bun runtime pulled to execute the loader
Memory access /proc/<pid>/mem Reads CI runner secrets from process memory
Persistence .claude/settings.json, .cursor/rules/, .github/copilot-instructions.md, .aider.conf.yml AI-assistant configuration tampering
Packages @immobiliarelabs/backstage-plugin-gitlab[-backend], @immobiliarelabs/backstage-plugin-ldap-auth[-backend] ~24 trojanized versions, one 30-second burst
How to check

Search your lockfiles and install logs for any version of the four packages above. Inspect any installed @immobiliarelabs Backstage plugin for a binding.gyp containing a <!(node ...) expansion and an oversized index.js. If found, assume install-time execution occurred: rotate every credential reachable from that machine or runner (cloud, registry, GitHub, SSH), revoke active sessions, and inspect .claude, .cursor, .aider, and Copilot config files for unexpected changes.

Supply-chain risk analysis

Mapped to MITRE ATT&CK, the chain spans initial access through exfiltration:

Initial AccessT1195.002Compromise Software Supply Chain
ExecutionT1059.007node index.js via the gyp build step
Defense EvasionT1027Obfuscation; hidden in binding.gyp
Credential AccessT1552.001Credentials in cloud / SSH files
Credential AccessT1528Steal access tokens
PersistenceT1546AI-assistant config poisoning
ExfiltrationT1567Exfiltration over web service

Watch this attack get caught at install time in the companion case study: the @immobiliarelabs Miasma compromise.

How CyberXYZ detects this

This technique defeated postinstall-based detection broadly, and that gap is exactly what our research focuses on. We treat the install-time surface as more than lifecycle scripts:

Recommended actions

Immediate. Identify any installed version of the four affected packages. On any host or runner that installed one, rotate all reachable credentials (cloud, GitHub, registry, SSH) and revoke active sessions. Review AI-assistant config files for tampering.

Short-term. Block install-time code execution in CI by disabling build scripts where native builds are not required (npm ci --ignore-scripts does not stop node-gyp, so gate native builds explicitly), and pin and review any dependency that ships a binding.gyp. Add detection for <!(cmd) expansions to your own tarball review.

Long-term. Treat the package manager as an enforcement point. An install-time firewall that vets and sandboxes packages before they reach a developer or CI, and that watches the full install-execution surface rather than just lifecycle scripts, is the durable answer to evasions like this one.


References

  1. StepSecurity, @immobiliarelabs npm Packages Compromised
  2. MITRE ATT&CK, T1195.002 Compromise Software Supply Chain
  3. node-gyp, node-gyp (native addon build tool)
👋

Let's Talk

Want to learn how CyberXYZ protects your supply chain? We'd love to hear from you.