Skip to content
← Blog

Attack post-mortem

ctx and phpass: how an expired domain led to coordinated PyPI and Packagist compromise

3 min read

In May 2022, a security researcher reported that two well-known open-source packages had been quietly modified to exfiltrate environment variables. The packages were ctx, a small Python utility on PyPI, and phpass, a widely cited PHP password-hashing library on Packagist. Different ecosystems, different maintainers, similar attack: the original maintainers' email domains had expired and been re-registered, allowing an attacker to reset the package-publishing accounts via standard "forgot password" flows.

This is a post-mortem of how it happened and why expired-domain takeover keeps working.

The mechanism

Most package registries — PyPI, npm, Packagist, RubyGems, crates.io — let an account holder reset their password via email. The reset link is sent to the email address on file. If that email address is on a domain the original owner no longer controls, anyone who registers that domain controls the email address — and therefore controls the password reset.

In the ctx and phpass cases, the original maintainers had used email addresses on personal or organizational domains that expired without renewal. The attacker registered the expired domains, reconstructed the relevant mailbox via standard MX records, and triggered password resets on each registry.

What the malicious versions did

The modified ctx versions on PyPI added code that read environment variables and POSTed them to a fixed remote URL. The intent was credential harvesting: cloud provider keys, secret tokens, anything that ends up in os.environ during a CI run or a developer's interactive session.

The modified phpass versions on Packagist did the same thing in PHP — getenv() calls, encoded payload, exfiltration POST.

Both packages had been stable for years. The modifications produced new versions with version numbers that looked routine.

Why expired-domain attacks work

Three structural reasons keep this attack viable.

First, domain expiry is silent. Registrars send renewal emails, but if the maintainer has moved on, the email goes nowhere. A domain quietly drops, becomes available, and the attacker's registration is invisible to the registry that still has the old email on file.

Second, registries have no way to detect domain handoffs. From the registry's perspective, the email is unchanged. The reset link is sent to the same address it has used for years. The registry cannot distinguish "the rightful owner used the link" from "an attacker who just registered the domain used the link."

Third, two-factor authentication is opt-in, not default. Where 2FA is enabled, the password reset alone does not grant access — the attacker would also need the second factor. But for older accounts created before 2FA was promoted by registries, the second factor is often not configured.

What registries have done

Both PyPI and npm have introduced mandatory 2FA for accounts that publish above certain thresholds (top packages, organizations). PyPI enforces 2FA on publish for all top-tier maintainers. npm enforces 2FA on publish for the top 100 packages and offers it for everyone.

These changes blunt the attack but do not eliminate it. Maintainers below the enforcement thresholds remain exposed. Attackers find packages that are widely used as transitive dependencies but whose owners are not in the top-100 enforcement bucket.

What helps as a consumer

Treat dependency updates from long-stable packages with extra suspicion. A package that has not changed in two years and suddenly publishes a patch is anomalous. The change might be legitimate; it might be the first version after an account takeover.

Use lockfiles and review the diff on update. If you upgrade ctx from a known-good pinned version to a new release, the diff is small and human-readable. Reviewing it costs minutes.

Consider publication-age cooling. A version published in the last few hours has not been reviewed by anyone. A short cooling period before auto-installing new releases buys time for the community to notice anomalies.

Takeaway

Expired domains are an open vulnerability surface across every package registry. The fix on the registry side is mandatory 2FA for everyone who can publish; that's incomplete but progressing. The fix on the consumer side is to stop treating "version is greater than pinned" as a sufficient reason to install — particularly for packages whose maintenance has been quiet for a long time.