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: four
@immobiliarelabsBackstage packages were trojanized across ~24 versions in a single 30-second burst. - Why it is clever: the code runs from a
binding.gypnode-gyp command expansion, not apostinstallscript, so install-script scanners, SCA, and SBOM tools all see nothing. - What to do: if you installed an affected version, rotate every credential reachable from that machine or runner and inspect your AI-assistant config files.
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.
| Package | Compromised versions |
|---|---|
@immobiliarelabs/backstage-plugin-gitlab | 1.0.1, 2.1.2, 3.0.3, 4.0.2, 5.2.1, 6.13.1, 7.0.2 |
@immobiliarelabs/backstage-plugin-gitlab-backend | 3.0.3, 4.0.2, 5.2.1, 6.13.1, 7.0.2 |
@immobiliarelabs/backstage-plugin-ldap-auth | 1.1.4, 2.0.5, 3.0.2, 4.3.2, 5.2.1 |
@immobiliarelabs/backstage-plugin-ldap-auth-backend | 1.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:
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.
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.
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.
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.
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.
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.
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.
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:
install, preinstall, or postinstall script on any of the 24 versions. Lifecycle-based detection has nothing to fire on.binding.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.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
| Type | Indicator | Context |
|---|---|---|
| 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 |
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:
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:
- binding.gyp as an install hook. We parse
binding.gypand flag any<!(cmd)or<!@(cmd)command expansion that runs a bundled script or chains and redirects, as a high-severity install-execution finding in the same class as a maliciouspostinstall. Legitimate native-build queries such as<!(node -e "require('nan')")andpkg-configlookups are not flagged. - Version-burst prioritization. A package publishing many versions in a short window is the organization-compromise signature. Our scanner promotes those bursts to the front of the queue, so a low-traffic but highly targeted compromise cannot be buried under the tens of thousands of routine publishes that arrive every hour.
- Oversized obfuscated payloads. A multi-megabyte single source file is itself anomalous. We flag large, heavily-obfuscated files and bound the analysis spent on them, which also closes a denial-of-service path where a crafted blob stalls a scanner.
- AI-assistant persistence. We flag package code that writes to AI-assistant configuration paths (
.claude,.cursor, Copilot instructions,.aider), a persistence vector that, to our knowledge, no detection tooling watched before this campaign.
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
- StepSecurity, @immobiliarelabs npm Packages Compromised
- MITRE ATT&CK, T1195.002 Compromise Software Supply Chain
- node-gyp, node-gyp (native addon build tool)