Most hosted ASP.NET breaches do not start with a novel runtime bug. They start with an overpowered app pool and a SQL login that can read every database on the instance. If the site process can write outside its content root or run as a high-privilege SQL principal, one RCE or SQLi path becomes full lateral movement.
Least privilege is still the highest-ROI control on Windows Server 2025 + IIS 10.0. Lock the identity that runs your app, grant only the NTFS paths it needs, and give SQL Server a login that can touch one database with the minimum roles. The steps below are what we apply on production IIS boxes before an app ever sees traffic.
#Pick an app pool identity that is not a local admin
Prefer ApplicationPoolIdentity (the virtual account IIS creates per pool) or a dedicated domain/service account used only for that site. Never run production pools as LocalSystem, Administrator, or a shared “deploy” user that also owns CI agents. On shared or reseller Windows hosting the panel usually isolates pools for you; on a Windows VPS you own the choice—and the blast radius.
Create the pool explicitly so the identity is obvious in configuration reviews:
Import-Module WebAdministration
New-WebAppPool -Name "contoso-web"
Set-ItemProperty IIS:\AppPools\contoso-web -Name managedRuntimeVersion -Value ""
Set-ItemProperty IIS:\AppPools\contoso-web -Name startMode -Value AlwaysRunning
# ApplicationPoolIdentity is the default processModel.identityType
Set-ItemProperty IIS:\AppPools\contoso-web -Name processModel.identityType -Value ApplicationPoolIdentity
For ASP.NET Core 10 in-process hosting, leave managedRuntimeVersion empty (no CLR version) and keep the pool 64-bit unless a legacy native dependency forces otherwise. The identity string IIS uses on disk is IIS AppPool\contoso-web—grant rights to that name, not to IIS_IUSRS alone, when you need per-site isolation.
#NTFS: content root, not the whole volume
The pool needs read (and execute for binaries) under the site root. It needs write only where the app truly mutates disk: a dedicated App_Data or logs folder, Data Protection key ring path if you persist keys on disk, and any upload directory you cannot move to object storage. It does not need Modify on C:\, the Windows directory, other sites, or Web Deploy staging folders owned by your release account.
- Site root (e.g. D:\sites\contoso): Read & execute for IIS AppPool\contoso-web; no write.
- Writable subfolder only: Modify for the pool identity; deny execute on uploaded user content if the folder is web-reachable.
- Failed Request Tracing / stdout logs: write to a path outside the public root when possible.
- Remove inherited write ACLs from parent folders after you copy content in with an admin or deploy account.
Quick ACL sketch (run elevated; adjust paths):
$root = "D:\sites\contoso"
$data = Join-Path $root "App_Data"
$pool = "IIS AppPool\contoso-web"
icacls $root /grant "${pool}:(OI)(CI)RX" /T
icacls $data /grant "${pool}:(OI)(CI)M" /T
If you publish with Web Deploy, keep the deploy credential separate from the runtime pool identity. Deploy accounts may create files; runtime accounts should not need to replace their own binaries in production.
#SQL Server: one app, one database, no sysadmin
Connection strings that use sa, a shared “appuser” across tenants, or a login in db_owner “for convenience” turn every injection bug into a catalog-wide incident. Create a SQL login (or contained database user) used only by this application, map it to a single database, and grant the smallest fixed roles or explicit object permissions that match the code path.
Typical pattern for an EF Core 10 / ADO.NET app that migrates schema out-of-band (CI runs migrations with a different credential):
-- Run on the SQL instance as a privileged DBA — not from the web app
CREATE LOGIN contoso_web WITH PASSWORD = 'rotate-me-with-a-secret-store',
CHECK_POLICY = ON, CHECK_EXPIRATION = ON;
GO
USE ContosoDb;
CREATE USER contoso_web FOR LOGIN contoso_web;
-- Runtime: read/write data only
ALTER ROLE db_datareader ADD MEMBER contoso_web;
ALTER ROLE db_datawriter ADD MEMBER contoso_web;
-- Prefer explicit GRANTs on procs/tables instead of db_owner
-- GRANT EXECUTE ON SCHEMA::dbo TO contoso_web;
GO
Keep migration and reporting principals separate. Schema deployment can use a short-lived login in db_ddladmin (or a tighter set of GRANTs) during release windows, then disable it. Reporting tools should not share the web app password. Store the runtime secret in the host’s secret mechanism—IIS app pool environment variables, a locked-down appsettings production overlay excluded from the repo, or an encrypted configuration section on older ASP.NET Framework apps—not in source control or world-readable publish profiles.
On SQL Server 2025 instances you operate yourself, still apply the same least-privilege map; product version does not excuse a shared sysadmin string. On multi-tenant Windows shared hosting, you usually receive one database and one login already scoped—do not ask for instance-level rights “to make restore easier,” and do not embed those credentials in client-side config or mobile apps.
#Verify what the process can actually do
After deploy, confirm isolation with boring checks: recycle the pool, hit a health endpoint, and ensure the app can read config and write only its data folder. From a SQL session as contoso_web, try SELECT against another database—you want a permission error. From an elevated prompt, open a command window as the pool identity only if you have a controlled break-glass procedure; more often, failed access in Event Viewer and SQL error logs is enough signal that ACLs are working.
- Deny the pool interactive logon rights when using a custom service account.
- Turn off directory browsing; do not serve .bak, .mdf, .rdl, or publish profiles under the site root.
- Strip connection strings from client bundles and public /config endpoints.
- Document who may elevate for migrations so emergency “just use sa” does not become permanent.
#Practical takeaway
Before the next production push, name the app pool identity, list every filesystem path it can write, and open the SQL login’s effective permissions on one database only. If either side can reach another site’s files or another catalog, shrink it until a stolen process token is boring. On Windows hosting—shared, reseller, or your own Server 2025 VM—that single pass removes more realistic damage paths than another layer of framework trivia, and it survives framework upgrades from current .NET 10 LTS builds without rework.
Comments
No comments yet