osec.one case analysis cover: PyTorch nightly 2022, a package name on the public index that beat the private one

Between 25 and 30 December 2022, anyone who installed a nightly build of PyTorch on Linux with pip got a package called torchtriton that PyTorch never built. It came from PyPI, the public Python index, it was version 3.0.0, and the first time anything imported it, a binary inside read the machine's SSH keys, .gitconfig, environment variables and the first 1,000 files in the home folder, and sent them out as DNS queries.

Nothing of PyTorch's was broken into. No account, no build server, no signing key. PyTorch hosted torchtriton on its own index, download.pytorch.org; nobody had registered the name on PyPI, so a stranger did. pip was told to look at both indexes, saw 2.0.0 on one and 3.0.0 on the other, and picked the higher number. This is called dependency confusion, and any team that installs from a private index plus a public one can set itself up for it in one line of config.

Attack chain: torchtriton exists only on PyTorch's own index, a stranger uploads torchtriton 3.0.0 to PyPI on 25 December, pip merges both indexes and takes the higher version, Linux nightly installs pull it in, importing triton runs a binary that reads SSH keys and home files, data leaves over DNS queries to h4ck.cfd
Six steps, none of which needed access to PyTorch.

The timeline

text
PyTorch nightly / torchtriton (2022, times GMT/UTC)

Feb 2021     Alex Birsan publishes "dependency confusion": public packages named after
             internal ones run inside 35+ companies, over $130,000 in bug bounties
21 Dec       h4ck.cfd registered (Namecheap; BleepingComputer)
25 Dec       torchtriton 3.0.0 on PyPI; Linux pip installs of torch nightly start pulling it
29 Dec       uploader says they reported it to Facebook (Meta) this day (their statement)
30 Dec 16:40 PyTorch learns of it
30 Dec 21:35 placeholder "pytorch-triton" 0.0.1 uploaded to PyPI (PyPI JSON, checked 23 Oct 2026)
30-31 Dec    nightlies depending on torchtriton pulled from download.pytorch.org;
             dependency renamed to pytorch-triton (pytorch/pytorch#91539)
31 Dec       PyTorch advisory: uninstall, purge the pip cache, use nightlies after 30 Dec
Jan 2023     malicious torchtriton removed from PyPI; the name returns 404 today

Download counts differ by who counted and when: 2,300+ after a week (BleepingComputer), 2,386 (Sophos), 2,717 (Snyk's tracking), about 2,800 (LWN). Each download is a machine or a CI job that pulled a nightly in the holiday week, mostly research and ML-engineering boxes with cloud keys and Git access.

Step 1: a package that lived only on a private index

PyTorch built its nightly Triton compiler as a separate wheel and served it from its own index. That index still lists the old files today, for example torchtriton-2.0.0+0d7e753227-cp310-cp310-linux_x86_64.whl: version 2.0.0 with a commit hash as a local label. The nightly torch wheel declared torchtriton as a dependency on Linux. Nobody owned the name on PyPI, which is the one condition this attack needs: a name your build depends on that you don't own on the public index.

Step 2: someone else registers the name

Anyone can create a PyPI project with an unused name in a minute, with no link to the private one. The uploader picked 3.0.0, higher than anything on PyTorch's index. The package's description said it was there to find dependency confusion bugs; a research probe sends back a hostname, not SSH keys.

Step 3: pip treats every index as one big list

The nightly install command looked like this:

bash
pip3 install --pre torch torchvision torchaudio \
  --extra-index-url https://download.pytorch.org/whl/nightly/cu117

--extra-index-url doesn't mean "look here if PyPI doesn't have it". pip collects candidates from all configured indexes and picks the best version that fits. There is no priority between indexes, and the order you list them in doesn't matter (pip issue #8606 is the long-standing request for one). We tested it with two local indexes and a dummy package, one index serving 2.0.0+0d7e753227, the other 3.0.0:

text
Our test, 23 Oct 2026: two local indexes, one package name
private index: democonf 2.0.0+0d7e753227    public index: democonf 3.0.0

Tool / settings                                              Installed
pip 25.3  --index-url public  --extra-index-url private      3.0.0  (public)
pip 25.3  --index-url private --extra-index-url public       3.0.0  (public)
pip 25.3  democonf==2.0.0+0d7e753227 (exact pin)              2.0.0+0d7e753227
pip 25.3  --require-hashes, private wheel's hash, no ==      refused: needs an == pin
uv 0.13.5 --index-url public  --extra-index-url private      2.0.0+0d7e753227  (private)
uv 0.13.5 --index-url private --extra-index-url public       3.0.0  (public)
uv 0.13.5 same, --index-strategy unsafe-best-match           3.0.0  (public)

Two things stand out. With pip, swapping the order changes nothing: the higher number wins either way. With uv, the default first-index strategy takes a package only from the first index that has it, and uv searches --extra-index-url before --index-url. So PyTorch's exact command, run through uv, would have installed PyTorch's own 2.0.0; the same command with the two URLs swapped would not. uv protects you only if your private index is the one it asks first.

Step 4: a nightly install pulled it in

Only Linux + pip + nightly was hit. Stable PyTorch didn't depend on torchtriton, and conda installs resolved from PyTorch's own channel. Anyone who ran the nightly command that week, by hand or from a CI job that tests against nightly, got 3.0.0.

Step 5: import ran the binary

The package put a binary at site-packages/triton/runtime/triton (SHA-256 2385b29489cd9e35f92c072780f903ae2e517ed422eae67246ae50a5cc738a0e) that ran when triton was imported. PyTorch's own code didn't import it by default, so a machine that installed it but never used Triton may not have run it. What it collected, from PyTorch's advisory:

text
Collected                              Limit
nameservers (/etc/resolv.conf)         -
hostname, username, working directory  -
all environment variables              -  (cloud keys and API tokens live here in CI)
/etc/hosts, /etc/passwd                -
$HOME/.gitconfig                       -
$HOME/.ssh/*                           every file under 99,999 bytes
first 1,000 files in $HOME             each under 99,999 bytes

Step 6: out over DNS

The data left as encrypted DNS queries to subdomains of h4ck[.]cfd, through the resolver wheezy[.]io. DNS is allowed out of almost every network and CI runner, and an egress rule that only watches HTTP doesn't see it. The uploader later said the data had been deleted and the package was research; PyTorch's advice was to treat affected machines as compromised either way.

What breaks each step

text
Step                             Control                                         Cost
1 name only on a private index   register the name on the public index too       5 minutes per name
                                 (PyTorch did this with pytorch-triton)
2 stranger registers it          use names you own: npm @org/ scope,             one-time rename
                                 a PyPI prefix you hold
3 resolver merges indexes        one index that proxies the public one, or       config change
                                 pin each private package to its index (uv)
4 install pulls a new version    lock file with exact versions and hashes        part of the build
5 import runs code               no long-lived keys on dev/CI boxes; OIDC         ongoing
6 data out over DNS              egress allow-list on CI runners; DNS logging    firewall rule

Python: scope the sources

1. Stop using --extra-index-url for private packages with pip. Use one index (--index-url) that serves your packages and proxies PyPI (devpi, a Nexus or Artifactory virtual repo, AWS CodeArtifact with an upstream), so one place decides which torchtriton exists.
2. With uv, pin private packages to their index and mark it explicit, so it is used only for those packages and they come from nowhere else:

toml
[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.example.com/simple"
explicit = true

[tool.uv.sources]
acme-billing = { index = "internal" }
acme-auth    = { index = "internal" }

3. Lock with hashes. uv lock records each file's hash and source; with pip, pip-compile --generate-hashes, then pip install --require-hashes -r requirements.txt. A hash belongs to one file, so a 3.0.0 from somewhere else can't match it, and as our test shows, --require-hashes refuses anything not pinned with ==.
4. Claim your names on PyPI. A placeholder 0.0.1 under your organisation's account costs nothing; PyTorch registered pytorch-triton within five hours of hearing about it.
5. Run nightly or pre-release tests in their own CI job with no deploy keys or cloud credentials in its environment.

npm: scopes do this for you

npm ties a scope to a registry, so @acme/... packages only come from the registry you set for @acme. Unscoped internal names (acme-billing) go to the public registry if it has them.

ini
# .npmrc in the repo
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
# everything else comes from the default registry

• Create the organisation on npmjs.com too, so nobody else can publish under @acme/ there.
• Commit package-lock.json and install with npm ci: each entry records resolved (the exact URL) and integrity (a SHA-512), and npm ci fails if they don't match.
• Check for unscoped internal names: npm view <name> on the public registry should return 404 or a package you own.

If you installed a nightly in that window (or something like it)

1. pip3 uninstall -y torch torchvision torchaudio torchtriton and pip3 cache purge, then reinstall a nightly built after 30 December 2022.
2. Run PyTorch's one-line check for the binary (it looks for triton/runtime/triton in the environment).
3. Rotate what it read: SSH keys (remove the old public keys from GitHub, servers and authorized_keys), tokens and cloud keys that were in environment variables, anything in the first 1,000 files of the home folder.
4. Search DNS logs for h4ck.cfd to see which machines ran it.

Why this keeps working

Dependency confusion needs no breach and no malware skill, only a name your resolver will look up somewhere you don't control. The same pattern of a public index serving a stranger's code within hours shows up in the Ultralytics 2024 release (a real project's PyPI release poisoned through its CI cache) and the Shai-Hulud npm worm (stolen maintainer tokens). Pin what you install, by version and by hash. For install scripts and binaries you download by URL, the osec Pinned download check gives you the SHA-256 and a verify-before-run snippet.

Paste the URL of an install script or binary your build downloads and get its SHA-256 plus a download-verify-run snippet for shell, GitHub Actions, GitLab CI and Dockerfiles. Free.

Pin a download

Sources

• PyTorch: Compromised PyTorch-nightly dependency chain between December 25th and December 30th, 2022 (31 Dec 2022): https://pytorch.org/blog/compromised-nightly-dependency/
• BleepingComputer: PyTorch discloses malicious dependency chain compromise over holidays (2,300+ downloads, uploader's statement, h4ck.cfd registration): https://www.bleepingcomputer.com/news/security/pytorch-discloses-malicious-dependency-chain-compromise-over-holidays/
• Sophos: PyTorch machine learning toolkit pwned from Christmas to New Year (3.0.0, 2,386 downloads): https://www.sophos.com/en-us/blog/pytorch-machine-learning-toolkit-pwned-from-christmas-to-new-year
• LWN: Malicious PyTorch dependency (about 2,800 downloads): https://lwn.net/Articles/919119/
• Sonatype: PyTorch namespace (dependency) confusion attack: https://www.sonatype.com/blog/pytorch-namespace-dependency-confusion-attack
• pytorch/pytorch#91539 (torchtriton renamed pytorch-triton): https://github.com/pytorch/pytorch/pull/91539
• pip issue #8606 (index priority): https://github.com/pypa/pip/issues/8606
• uv docs, package indexes (first-index strategy, explicit indexes, tool.uv.sources): https://docs.astral.sh/uv/concepts/indexes/
• npm docs, scopes and associated registries: https://docs.npmjs.com/cli/v10/using-npm/scope
• Alex Birsan: Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies (9 Feb 2021): https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610
• PyPI JSON for pytorch-triton and torchtriton, and download.pytorch.org/whl/nightly/torchtriton/, checked by us on 23 Oct 2026; pip 25.3 and uv 0.13.5 resolver test run by us the same day.