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:
- Find every .NET runtime installed anywhere on our web stack.
- 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:
- “It’s fine, we’ll risk it.” Translation: We’ll wait until something exploitable appears in the news.
- “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.nametells you whether it’sMicrosoft.NETCore.App,Microsoft.AspNetCore.App, etc.framework.versiontells 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:
| 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:
- Which versions were actually in use, not just installed.
- 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:
| 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:
- 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 backupor 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-runtimesand 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-uninstallutility / 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.jsonon 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.jsonand 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.
