The email subject line was friendly enough:

"Security Updates for Microsoft .NET Core – Action Required."


The spreadsheet attached to it was not friendly at all.


Page after page listed CVE IDs, CVSS scores, and server names.


Two of the loudest offenders were:

This will not be patched. You need to get off this version.”


On paper, the guidance was simple:

“Update .NET Core, remove vulnerable packages and refer to vendor advisory.”


In reality, I was staring at multiple IIS servers, dozens of legacy web apps, and absolutely no reliable map of which application depended on which runtime.


Just uninstalling the old runtimes wasn’t an option. Some of these apps powered public-facing council services. If I broke the wrong site, residents would notice very quickly.



So I had two problems to solve:

  1. Find every .NET runtime installed anywhere on our web stack.
  2. Work out which apps actually used them, then remove the risky ones without blowing up production.


This article is the story of how I did that: the PowerShell I wrote, the mistakes I made, and the checklist I ended up with. If you’ve inherited a Windows/.NET zoo and a scary security report, I hope this saves you a few late nights.


What “out of support .NET runtime” really means

On Windows, it’s easy to treat .NET like a magical black box. Apps either work or they don’t, and as long as IIS is serving pages, life is good.


Then security drops a spreadsheet on your desk, and you remember:


There are two equally bad instincts at this point:

  1. It’s fine, we’ll risk it.Translation: We’ll wait until something exploitable appears in the news.
  2. Let’s just uninstall the old runtimes right now.Translation: We’re about to discover which mission-critical app silently depended on 2.2.3.


If you don’t know which apps use which runtimes, you don’t have a security problem; you have an inventory problem.

So I started by fixing that.


Step 1 – Discover every .NET runtime your apps think they need

The obvious first command on a Windows server is:

dotnet --list-runtimes
That’s useful, but it only tells you about shared runtimes that the global dotnet host can see on that machine.

In my case, that wasn’t enough. We had:

I needed something more thorough than “whatever dotnet feels like listing”.

Why I went hunting for runtimeconfig.json

Every .NET Core / ASP.NET Core app has a *.runtimeconfig.json file sitting next to its DLLs. It looks roughly like this:

{
  "runtimeOptions": {
    "framework": {
      "name": "Microsoft.AspNetCore.App",
      "version": "2.2.3"
    }
  }
}

That file is gold:

So instead of asking Windows what runtimes were installed, I decided to ask the applications themselves what they thought they needed.

The PowerShell scan

Here’s a simplified version of the script I used to scan a server:

$roots = @(
    "C:\inetpub\wwwroot",
    "E:\contentstore"
)

$results = @()

foreach ($root in $roots) {
    Write-Host "Scanning $root..."
    $configs = Get-ChildItem -Path $root -Recurse -Filter "*.runtimeconfig.json" -ErrorAction SilentlyContinue

    foreach ($config in $configs) {
        $json = Get-Content $config.FullName -Raw | ConvertFrom-Json
        $fw   = $json.runtimeOptions.framework

        $obj = [PSCustomObject]@{
            FilePath  = $config.FullName
            Framework = $fw.name
            Version   = $fw.version
        }

        $results += $obj
    }
}

$results | Sort-Object Framework, Version, FilePath |
    Export-Csv -Path "C:\Temp\dotnet-runtime-scan.csv" -NoTypeInformation

A few notes:


After running this on each web server, I had a spreadsheet that looked like:

FilePath Framework Version
C:\inetpub\wwwroot\SomeApp\SomeApp.runtimeconfig.json Microsoft.AspNetCore.App 2.2.3
E:\contentstore\AnotherApp\AnotherApp.runtimeconfig.json Microsoft.NETCore.App 6.0.36

This already told me two important things:

  1. Which versions were actually in use, not just installed.
  2. Where they were used on disk, which later made it easier to map to IIS sites.

Step 2 – Map runtimes to IIS sites and real users

A list of paths is nice, but security (and your management) doesn’t care that


C:\inetpub\wwwroot\random-old-site\whatever.runtimeconfig.json exists.

They care about questions like:

To answer that, I needed to map FilePath → IIS site.

Joining runtime data to IIS

On a modern Windows server with the WebAdministration module, you can get IIS sites like this:

Import-Module WebAdministration

$sites = Get-Website | Select-Object Name, PhysicalPath, State

The trick is to normalise the paths a little and see which FilePath starts with which PhysicalPath.

Here’s a simplified approach:

$runtimeData = Import-Csv "C:\Temp\dotnet-runtime-scan.csv"

Import-Module WebAdministration
$sites = Get-Website | Select-Object Name, PhysicalPath, State

$mapped = foreach ($row in $runtimeData) {
    $match = $sites | Where-Object {
        $row.FilePath.ToLower().StartsWith($_.PhysicalPath.ToLower())
    } | Select-Object -First 1

    [PSCustomObject]@{
        SiteName   = $match.Name
        SiteState  = $match.State
        PhysicalPath = $match.PhysicalPath
        FilePath   = $row.FilePath
        Framework  = $row.Framework
        Version    = $row.Version
    }
}

$mapped | Export-Csv "C:\Temp\dotnet-runtime-mapped.csv" -NoTypeInformation

Now my spreadsheet had meaningful rows like:

SiteName SiteState Framework Version
BinDay Started Microsoft.AspNetCore.App 2.2.3
MissedBin Started Microsoft.NETCore.App 6.0.36
Old-Intranet Stopped Microsoft.AspNetCore.App 2.1.30

From here it was easy to filter:

The interesting part was the conversations that followed.

Step 3 – Decide: upgrade, rebuild, or retire?

The biggest shock to people outside engineering is that not every running app automatically deserves to be rescued.

Once I had a list of:

…I grouped them into three buckets:

Keep and upgrade

Keep for now, schedule rebuild

Retire gracefully

Those decisions weren’t purely technical. I had to:

Only after that did we touch any actual runtimes.

Step 4 – Plan the clean-up (and rollback) like something will go wrong

Uninstalling runtimes from a live server is the kind of thing that feels fine… until it isn’t.

Before touching anything, I did three things:

Back up what I was about to break

Write a removal plan per server

For each machine:

3. Define a post-removal checklist

After removing a runtime I would:

Only when that was written down did we start the actual surgery.

Step 5 – Actually uninstalling runtimes (and handling the breaks)

On Windows, uninstalling .NET Core / ASP.NET Core runtimes is usually done via:


The first few removals went smoothly. Then we hit a machine where, right after removing 2.2, a couple of test URLs started returning:


This is where the earlier mapping paid off.

Because we knew:

…it was obvious what broke and why.


In most cases, the options were:

Retarget the app to a supported runtime

Quick-fix with compatible hosting bundle (short-term only)

Accept retirement and archive

The important thing is: we weren’t surprised. When something failed, we already knew it was a candidate.

The PowerShell I wish I’d had on day one

By the end of this process, I had glued together a single script that:


Here’s a condensed version you can adapt:

Import-Module WebAdministration

$roots = @(
    "C:\inetpub\wwwroot",
    "E:\contentstore"
)

function Get-OutOfSupport {
    param($framework, $version)

    # Very rough example – customise to your policy
    $v = [version]$version

    if ($v.Major -lt 6) { return $true }   # treat < 6.0 as out of support
    return $false
}

$sites = Get-Website | Select-Object Name, PhysicalPath, State
$results = @()

foreach ($root in $roots) {
    Write-Host "Scanning $root..."
    $configs = Get-ChildItem -Path $root -Recurse -Filter "*.runtimeconfig.json" -ErrorAction SilentlyContinue

    foreach ($config in $configs) {
        $json = Get-Content $config.FullName -Raw | ConvertFrom-Json
        $fw   = $json.runtimeOptions.framework

        $site = $sites | Where-Object {
            $config.FullName.ToLower().StartsWith($_.PhysicalPath.ToLower())
        } | Select-Object -First 1

        $outOfSupport = Get-OutOfSupport -framework $fw.name -version $fw.version

        $results += [PSCustomObject]@{
            SiteName     = $site.Name
            SiteState    = $site.State
            PhysicalPath = $site.PhysicalPath
            FilePath     = $config.FullName
            Framework    = $fw.name
            Version      = $fw.version
            OutOfSupport = $outOfSupport
        }
    }
}

$results |
  Sort-Object OutOfSupport -Descending, SiteName, Framework, Version |
  Export-Csv "C:\Temp\dotnet-runtime-inventory.csv" -NoTypeInformation

Is it perfect? No. But it’s far better than uninstalling runtimes and hoping for the best.

Lessons learned (and a checklist you can steal)

By the end of this .NET runtime hunt, I’d learned a few things the hard way.

You can’t secure what you can’t see

Runtime inventory should be a regular job, not a panic response to a security ticket. At minimum:

“Out of support” is a business problem, not just a dev problem

When you show owners:

…it’s much easier to have grown-up conversations about budget, timelines, and priorities.

Talking only in CVE numbers and CVSS scores just makes everyone’s eyes glaze over.

Plan as if something will break

Because something will.

If you have:

then a broken site is an annoyance, not a disaster.

Make it repeatable

Here’s the minimal checklist I’d recommend:

Inventory

Classify

Plan

Execute

Maintain


If you’re reading this because you just got your own scary .NET security report: breathe. You don’t have to fix everything today.

Start by finding out what you actually have. The rest becomes a series of informed decisions, not blind panic and broken sites.