How I Tracked and Retired Out-of-Support .NET Runtimes Across Legacy IIS Servers

Written by mukhtarpeace | Published 2025/11/27
Tech Story Tags: .net-core-security | asp.net-core-vulnerabilities | iis-runtime-mapping | powershell-automation | out-of-support-.net | windows-server-maintenance | cve-remediation | runtimeconfig.json

TLDRA security alert exposed multiple out-of-support .NET Core runtimes across legacy IIS apps. This article recounts the full audit—scanning runtimeconfig files, mapping them to IIS sites, prioritizing upgrades or retirements, and safely removing outdated runtimes without breaking production.via the TL;DR App

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:

  • .NET Core / ASP.NET Core 2.2.x – out of support
  • A cluster of high-severity vulnerabilities that all said the same thing in different words:

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:

  • .NET Core / ASP.NET Core runtimes have lifecycles.
  • When a runtime goes out of support, it stops receiving security patches.
  • If you’re still running it in production, you’re effectively accepting every new vulnerability for free.


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:

  • x64 and x86 runtimes installed side-by-side
  • Apps deployed as both framework-dependent and self-contained
  • Old copies of sites living in dusty backup folders

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:

  • framework.name tells you whether it’s Microsoft.NETCore.App, Microsoft.AspNetCore.App, etc.
  • framework.version tells you exactly which version the app expects.

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:

  • I limited the search to the folders where IIS sites actually live (C:\inetpub\wwwroot, E:\contentstore, etc.).
  • For each runtimeconfig.json, I parsed the JSON and captured the framework name and version.
  • I exported everything to CSV so I could slice and dice it in Excel later.


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

FilePathFrameworkVersion
C:\inetpub\wwwroot\SomeApp\SomeApp.runtimeconfig.jsonMicrosoft.AspNetCore.App2.2.3
E:\contentstore\AnotherApp\AnotherApp.runtimeconfig.jsonMicrosoft.NETCore.App6.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:

  • “Which public sites are using out-of-support runtimes?”
  • “Which services residents rely on still depend on 2.2?”
  • “If we remove 2.2 from Server X, which URLs break?”

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:

SiteNameSiteStateFrameworkVersion
BinDayStartedMicrosoft.AspNetCore.App2.2.3
MissedBinStartedMicrosoft.NETCore.App6.0.36
Old-IntranetStoppedMicrosoft.AspNetCore.App2.1.30

From here it was easy to filter:

  • All Started sites using 2.2.x or 2.1.x
  • All sites that were Stopped but still had runtimeconfig files (often just leftovers)

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:

  • Site name
  • Business owner (if we could find one)
  • Framework and version
  • Public or internal
  • Usage (from logs or analytics if available)

…I grouped them into three buckets:

Keep and upgrade

  • Public-facing
  • High traffic or important journey
  • Still actively used by a team
  • Short-term plan: upgrade runtime / app to LTS version.

Keep for now, schedule rebuild

  • Used, but clearly held together with duct tape
  • Candidate for migration to a new stack or service in the medium term
  • Plan: keep it running safely while you design the replacement.

Retire gracefully

  • No clear owner
  • Analytics show near-zero traffic
  • Nobody screams when you email “does anyone still need this?”
  • Plan: announce decommissioning, archive, then remove.

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

  • Translate “out of support .NET runtime” into plain language risk.
  • Show security and managers a prioritised list instead of 50 random entries.
  • Give product owners clear options with timelines, not just “we must update everything now”.

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

  • Exported IIS configuration (appcmd add backup or via the IIS GUI).
  • Took filesystem backups/snapshots of site folders.
  • Copied the installers for old runtimes into a backup folder on each server (in case we had to temporarily reinstall).

Write a removal plan per server

For each machine:

  • List runtimes currently installed (dotnet --list-runtimes and Control Panel).
  • Mark which ones were in use by mapped apps and which ones looked unused.
  • Decide an order: typically remove the least risky first (e.g. x86 runtime nobody is mapped to).

3. Define a post-removal checklist

After removing a runtime I would:

  • Hit a set of smoke test URLs for each mapped site.
  • Check Windows Event Viewer and IIS logs for obvious errors.
  • Ask someone in the owning team (if possible) to click through a few key journeys.

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:

  • “Apps & Features” (or Programs & Features on older versions)
  • Or via the relevant dotnet-core-uninstall utility / MSI removal


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

  • HTTP Error 500.31 - ANCM Failed to Find Native Dependencies
  • Or “This site can’t be reached” from the browser, depending on how broken things were.


This is where the earlier mapping paid off.

Because we knew:

  • Which sites were expecting Microsoft.AspNetCore.App 2.2.x
  • Which servers they lived on
  • Who owned them

…it was obvious what broke and why.


In most cases, the options were:

Retarget the app to a supported runtime

  • If we had the code and it was still maintained, we moved it to 6.0/8.0 and redeployed.

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

  • If there was a small version jump we could make safely (e.g. 2.1.28 → 2.1.30), we did that as a stopgap.
  • This isn’t a long-term solution, but sometimes buys time.

Accept retirement and archive

  • If an app broke and nobody could justify fixing it, we took that as confirmation it belonged in the “retire” bucket.

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:

  • Scans configured roots for *.runtimeconfig.json
  • Parses framework name & version
  • Marks each entry as OutOfSupport based on simple rules
  • Attempts to map the file to an IIS site


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:

  • Keep a central record of all web apps and the framework version they target.
  • Scan servers for runtimeconfig.json on a schedule and compare versions against policy.

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

When you show owners:

  • their app,
  • the runtime it uses, and
  • the risk of staying put,

…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:

  • backups,
  • a mapping from app → runtime → owner,
  • and a rollback plan,

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

Make it repeatable

Here’s the minimal checklist I’d recommend:

Inventory

  • Scan for runtimeconfig.json and map to IIS sites.
  • Export version and support status.

Classify

  • Bucket apps into upgrade / rebuild / retire.
  • Get sign-off from owners.

Plan

  • Back up IIS + site folders + installers.
  • Write removal plan per server.

Execute

  • Remove runtimes in a controlled order.
  • Run smoke tests after each change.

Maintain

  • Re-run the inventory periodically.
  • Don’t allow new apps to go live on out-of-support runtimes.


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.



Written by mukhtarpeace | Curious builder of digital services for real people, usually found fixing old systems and making them a bit less painful
Published by HackerNoon on 2025/11/27