Abandoned Package Hijack
- expired maintainer email domains
- expired domains and renamed accounts behind Maven groupIds
- revival hijack of deleted package names
- dormant packages that suddenly release
Scenario
A package is only as durable as the domain or account behind it. When the owner stops paying, an attacker can buy the identity and inherit the package. Here are three versions of the same story.
PyPI or npm. py-formatter-util is a small library that a popular data science toolkit depends on. It hasn't had a release in four years. The maintainer's email domain has lapsed. An attacker registers it, sets up a mailbox and requests a password reset. They copy the last release, add a few lines that send AWS_ environment variables to their server, and publish 1.2.2. The toolkit's loose version range pulls it into the next build.
Freed names. If the author had deleted the project instead, the attacker could simply register the freed name and publish a higher version. Anyone still installing it by name gets the new code.
Maven. An Android app depends on org.tinycsv:tinycsv:3.2.0. The maintainer let tinycsv.org expire in 2019. The app's Gradle build declares google(), mavenCentral() and JitPack, with no filters.
- The attacker buys
tinycsv.org. Maven Central won't grant a new claim on an existing namespace, so they go elsewhere. - JitPack accepts a custom groupId from anyone who controls the
git.DNS record for the domain. They pointgit.tinycsv.orgat their own GitHub repository and tag3.2.1, with a payload in a static initialiser. - Renovate auto-merges patch releases. Gradle searches every declared repository, finds
3.2.1on JitPack, and the payload ships.
The same applies to io.github.<user> groupIds when the GitHub user is renamed or deleted.
Real-world precedent: In May 2022 the PyPI package ctx was hijacked after its maintainer's email domain expired, and new releases stole environment variables (PyPI). A 2022 study found 2,818 npm maintainers using email addresses on expired domains, controlling 8,494 packages (The Record). In January 2024 Oversecured's MavenGate research found 6,170 of 33,938 domains and GitHub accounts behind Java groupIds could be bought or re-registered. In 2024 JFrog showed around 22,000 deleted PyPI names could be re-registered, a technique it called Revival Hijack.
Reconnaissance
Explanation
Attackers look for packages that are still installed a lot but no longer looked after: no recent releases, a single maintainer, an email or groupId on a personal domain, or a project deleted while others still depend on it. MavenGate needed no stolen credentials. It reversed each groupId to a domain and checked whether the domain was for sale.
Insight
Dormant packages are attractive because nobody watches them. A release after years of silence is unusual, yet most update bots treat it like any other patch.
Practical
Find the dormant packages in your own tree. Lockfiles carry names and versions but no maintainer data, so query the registry:
# Last upload time of the latest release for each pinned PyPI package
while read -r name; do
printf '%s ' "$name"
curl -s "https://pypi.org/pypi/$name/json" | jq -r '.urls[0].upload_time_iso_8601 // "missing"'
done < <(sed -E 's/[=<>!~ ;].*//' requirements.txt | grep -v '^#' | grep -v '^$')
A result of missing can mean the project was deleted. That name may be free for anyone to register.
For Java, list the distinct groupIds in your graph and check each reversed domain with whois. For io.github.* and com.github.* groupIds, check the GitHub account still exists under that name.
# Distinct groupIds from a CycloneDX SBOM
jq -r '.components[].group // empty' target/bom.json | sort -u
Tools/Techniques
- PyPI JSON API,
npm view <pkg> time maintainers, and deps.dev for release history. whoisanddigfor domain status.- OpenSSF Scorecard "Maintained" check.
Metrics/Signal
- Dependencies with no release in two years or more.
- Dependencies whose project no longer exists on the registry (target: zero).
- GroupIds in your graph whose domain has expired or changed hands.
Evaluate
Explanation
A dormant package is only dangerous if your builds would install a new version without anyone looking. Check three things: how loosely you pin, which repositories your builds search, and whether you verify artefacts against something the attacker can't reproduce.
Insight
PGP signatures look like the answer for Maven, and rarely are. Central requires a signature, but any key will do. Nothing binds a namespace to a key, consumers seldom check, and Google's Maven repository doesn't sign at all.
Practical
- Check whether requirements and lockfiles use exact pins with hashes, or open ranges.
- List the repositories each Gradle or Maven build declares, and whether each is filtered by group.
- Check whether you auto-merge upgrades for old, quiet libraries.
- For each dormant dependency, decide: replace it, vendor it, fork it under your control, or pin it with hashes. Remove anything unused.
Tools/Techniques
- pip-tools or uv for pinned, hashed requirements.
./gradlew buildEnvironmentandmvn help:effective-pom -Dverbosefor repository lists.- OSV-Scanner for malicious-package records.
Metrics/Signal
- Projects installing from hashed lockfiles.
- Builds that search more than one public repository without content filters.
- Dormant dependencies with a recorded decision.
Fortify
Explanation
Make sure a release from a hijacked identity cannot reach your builds by accident. Pin and verify, filter what each repository may serve, and pin the signing keys you trust.
Insight
Hash-pinned installs refuse anything you have not reviewed. Gradle's verification metadata adds the binding PGP lacks: you record which key signed org.tinycsv, and a new key fails the build.
Practical
- Python: generate hashed requirements and install in hash-checking mode.
uv pip compile requirements.in --generate-hashes -o requirements.txt
pip install --require-hashes -r requirements.txt
- npm: install from the lockfile with
npm ci, and add a release-age gate (pnpmminimumReleaseAge, RenovateminimumReleaseAge). With uv,--exclude-newerignores very new releases. - Gradle: restrict secondary repositories to the groups that need them, and turn on dependency verification.
// settings.gradle.kts
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
exclusiveContent {
forRepository { maven { url = uri("https://jitpack.io") } }
filter { includeGroup("com.github.some-org") }
}
}
}
./gradlew --write-verification-metadata sha256,pgp help
- In
gradle/verification-metadata.xml, pin trusted keys to groups:
<trusted-keys>
<trusted-key id="0123456789ABCDEF0123456789ABCDEF01234567" group="org.tinycsv"/>
</trusted-keys>
- Maven: use Maven Resolver trusted checksums, recorded once and committed, or pgpverify-maven-plugin with a key map.
- If you maintain packages: add a second verified email on a major provider, use trusted publishing, and archive projects instead of deleting them. PyPI has required 2FA since January 2024 and, since mid-2025, un-verifies emails whose domains expire.
Tools/Techniques
- pip hash-checking mode and uv.
- Gradle dependency verification and content filtering.
- PyPI trusted publishers and npm trusted publishing.
Metrics/Signal
- Builds installing from hashed lockfiles or committed verification metadata.
- Unfiltered public repositories across all Gradle builds. Target: one, Maven Central.
Limit
Explanation
A hijacked release runs in your build, on developer laptops, and sometimes in your shipped product. Limit what it can read in each place.
Insight
The ctx payload read environment variables. Builds and notebooks with no long-lived cloud keys give it nothing worth stealing. In an Android app, library code runs with every permission the app holds.
Practical
- Use short-lived cloud credentials (OIDC in CI, SSO sessions on laptops) instead of static
AWS_keys in the environment. - Keep publishing and signing credentials out of the jobs that resolve and test dependencies.
- Install through a repository manager you control, so you can block a version everywhere at once.
- Keep Android app permissions to what features need.
- Hold back auto-merge for long-dormant dependencies.
{
"packageRules": [
{
"matchDatasources": ["maven", "pypi", "npm"],
"minimumReleaseAge": "7 days"
}
]
}
Tools/Techniques
- devpi, Sonatype Nexus or JFrog Artifactory as a proxy.
- Renovate
minimumReleaseAgeandautomergerules.
Metrics/Signal
- Static cloud keys found in CI or developer environments.
- Time to block a package version across all builds.
Expose
Explanation
Detect the change of hands as well as the malicious code. A new signing key, a new source repository, a new uploader, a deleted project that reappears, or a sudden release from a long-quiet project are all signals.
Insight
These events are rare enough to review by hand. A library silent for five years that ships a patch release deserves a human look, whatever the scanner says.
Practical
- In update PRs, show the gap since the previous release. Treat gaps over a year as needing review.
- Check whether new releases carry provenance (PEP 740 attestations, npm provenance) from the expected repository.
- Treat Gradle verification failures for a changed key as a security event, and alert when a dependency resolves from a different repository than before.
- Diff the new release against the last one before merging.
# Wheels only: downloading an sdist can run its build code
pip download py-formatter-util==1.2.1 --no-deps --only-binary :all: -d old
pip download py-formatter-util==1.2.2 --no-deps --only-binary :all: -d new
unzip -q old/*.whl -d old/src && unzip -q new/*.whl -d new/src
diff -r old/src new/src
Tools/Techniques
- PyPI and npm package pages, which show attestations and the publishing workflow.
- Gradle verification failure reports.
- OSV, deps.dev, and malware feeds such as Socket that flag maintainer and publisher changes.
Metrics/Signal
- Dormant-package releases reviewed before merge.
- Signing-key changes on existing dependencies.
- Releases without attestations from projects that previously had them.
Exercise
Explanation
Practise spotting and handling a dormant package that suddenly releases, using private registries and a key you generate for the exercise.
Insight
Most teams have no rule for "this package was quiet for four years and just shipped". The demo that changes minds is the build that turns red because the signing key changed while the code looked identical.
Practical
- Run a local devpi index and a local Reposilite or Nexus instance.
- Upload a benign
1.2.2of an internal look-alike Python package that only writes a marker file. Let your update tooling propose it, and see whether hash pinning, release-age gates or review catches it. - Publish a benign copy of a real Java dependency's coordinates to the Maven instance with a higher version, signed with a throwaway
gpg --quick-generate-keykey. Add the repository to a sample Gradle project without filters and watch it resolve. - Add
exclusiveContentand verification metadata. Confirm the build fails, and read the error together. - List every real dependency with an expired domain or a long silence, and agree an owner for each.
Tools/Techniques
- devpi, Reposilite or Nexus Repository Community Edition.
- Your update bot pointed at the local registries.
Metrics/Signal
- Which control stopped each test release.
- Dormant or expired-domain dependencies with an agreed owner and plan.