NuGet supply chain security: install scripts, MSBuild injection, and dotnet restore
.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 classicpackages.configprojects, 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/.propsinjection — the modern vector. A package can shipbuild/<id>.targetsfiles that MSBuild imports into your build automatically. That's how legitimate packages hook compilation — and how a malicious package runs a task duringdotnet build. Noinstall.ps1required.
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
NuGetecosystem. - Threat-feed match for known-malicious packages.
- SHA-512 verification of the downloaded
.nupkgagainst thepackages.lock.jsoncontentHash.
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."