Skip to content
← Blog

Technical explainer

NuGet supply chain security: install scripts, MSBuild injection, and dotnet restore

3 min read

.NET developers tend to assume the supply-chain conversation is an npm and PyPI problem. NuGet has its own install-time execution surface, its own dependency-confusion history, and — in dotnet restore — its own routine command that pulls and trusts code from a public registry.

This post covers the NuGet install-time surface, the V3 protocol that makes gating possible, and what packages.lock.json does for integrity.

Where NuGet runs code at install/build time

NuGet has two execution vectors, one legacy and one current:

  • install.ps1 / tools/install.ps1 — the legacy PowerShell install hooks. In classic packages.config projects, NuGet runs these scripts when a package is installed. They're deprecated for SDK-style projects but still recognized in older toolchains, and they're straightforward code execution at install time.
  • MSBuild .targets / .props injection — the modern vector. A package can ship build/<id>.targets files that MSBuild imports into your build automatically. That's how legitimate packages hook compilation — and how a malicious package runs a task during dotnet build. No install.ps1 required.

Either way, restoring and building a project with an untrusted dependency can execute attacker-controlled code.

Dependency confusion is a first-class NuGet risk

NuGet's dependency-confusion exposure is well documented: when a project is configured with both a private feed and nuget.org, a public package with the same name as an internal one — at a higher version — can win resolution. Locking sources and package-source mapping mitigate it, but misconfiguration is common, which is exactly why install-time interception is valuable.

packages.lock.json and contentHash

NuGet's lockfile, packages.lock.json (enabled with RestorePackagesWithLockFile), pins every resolved package and records a contentHash — a base64-encoded SHA-512 of the .nupkg. On restore, NuGet verifies the downloaded package against it. That's reproducibility plus tamper-evidence. As always, it proves the bytes are the locked bytes, not that they're safe.

The V3 protocol is what makes gating practical

NuGet's V3 protocol starts from a service index (/v3/index.json) that lists resource URLs: the package base address (the flat container that serves .nupkg files), the registration base (version metadata), and search. For a dotnet restore, the relevant resources — flat container and registration — both live on api.nuget.org.

That means a gate can be single-upstream: front api.nuget.org, and rewrite the service index so the flat-container and registration URLs point back at the gate. The client then fetches .nupkg files through the gate, where they can be scored. (The repository-signatures resource is deliberately left on HTTPS — NuGet requires it — and search/autocomplete are package-discovery endpoints, not artifact downloads, so they're out of scope.)

How install-time gating fits

With a generated nuget.config pointing the package source at the gate:

  • OSV lookup against the NuGet ecosystem.
  • Threat-feed match for known-malicious packages.
  • SHA-512 verification of the downloaded .nupkg against the packages.lock.json contentHash.

And because .targets injection and legacy install scripts run code during restore/build, the gate pairs with an OS sandbox that contains the build and restricts its network to the gate.

With Veln, veln safe dotnet restore writes a project-local nuget.config pointing at a gate that fronts api.nuget.org and rewrites the V3 service index, scores every .nupkg, and runs the restore/build inside an OS sandbox. veln verify reads packages.lock.json for CI and pre-commit gating.

The practical takeaway

Enable packages.lock.json for reproducibility and integrity, and use package-source mapping to close dependency confusion. But neither stops a malicious .targets file from running in your build, and neither tells you a package is malicious in the first place. OSV and threat-feed answer "is this known-bad," and a contained restore answers "what can it do if it tries."