Chapter 14 · Part III · Software & the web

One DLL, many forms, many holes

An ASP.NET Web Forms site looks like a folder of separate pages. It is not. Build it, and the code behind every form, plus every shared helper, is compiled into one assembly in the /bin folder. That is why a single careless line in a base page or a data-access class can show up as forty findings on a scanner report, and why one good change can close all forty. This chapter opens that DLL, tours the weaknesses that tend to gather inside it, and shows how to fix each one at the source.

7 of 25weaknesses in MITRE's 2025 CWE Top 25 that this chapter finds living in one Web Forms DLL; XSS, SQL injection and CSRF rank 1, 2 and 3
1 assemblywhat a Web Application Project compiles all its code-behind and class files into, in the Bin folder (Microsoft's documentation)
Sept 28, 2010Microsoft's out-of-band fix MS10-070 for the ASP.NET padding oracle, which could expose view state and, on newer versions, web.config

Common mix-up: a report with forty SQL injection findings is rarely forty bugs. In a Web Forms application it is often one helper, called forty times. Count root causes, not findings. The reverse also holds: patching one page closes one finding and leaves the bug alive in every other page, and in the next one somebody writes.

Before you start: this chapter builds on two earlier ones and does not repeat them. Injection: SQL, commands & XSS explains why gluing strings breaks parsers, and Sessions, cookies, CSRF & CORS explains cookies and forged requests. If the request and response cycle itself is hazy, TCP & UDP on networks.jelia.nyc sets the scene.

How the pieces fit

Forty forms, one assembly

Web Forms, Microsoft's original ASP.NET page model, was designed to make the web feel like a desktop program. Each page is an .aspx file of markup with server controls in it (a TextBox, a Button, a GridView), and next to it a code-behind file, such as Orders.aspx.cs, holding a C# or Visual Basic class with event handlers like btnSave_Click. When the user clicks a button, the browser posts the whole form back to the same page, the server rebuilds the page's controls, replays their state and runs the handler. This round trip is called a postback.

What matters for security is what happens at build time. In a Web Application Project, the kind most business applications use, the compiler takes every code-behind class and every standalone class in the project and produces a single assembly, for example bin/Shop.Web.dll. Microsoft's documentation says so plainly: all code-behind and class files are compiled into a single assembly, placed in the project's Bin folder. The .aspx markup is compiled later, on the server, the first time each page is requested, into small generated classes that inherit from your code-behind classes.

The older Web Site Project model compiles on the server instead. ASP.NET builds an assembly for each application folder, such as App_Code where shared classes live, and one for the main folder; precompiling the site produces similar sets of App_Web_*.dll files. Either way, the shared code is shared for real. There is one compiled copy of the data helper, one compiled copy of the base page, and every form calls into the same bytes.

Alongside your own assembly, /bin also holds every third-party library the site uses: an Excel exporter, a PDF generator, a logging framework, a JSON parser. The web server loads them all into the same process, with the same permissions. The DLL you wrote is the center of the picture, but it does not stand alone.

Many source files, one compiled unit. The libraries beside it run in the same process with the same rights.

PieceWhere it livesCompiled intoWhy it matters
MarkupOrders.aspxA generated class, at first requestInherits from your code-behind; data-binding expressions here decide whether output is encoded
Code-behindOrders.aspx.csYour assembly in /binEvent handlers, query building, authorization checks
Base pageBasePage.csYour assemblyRuns for every form that inherits it: one place to get things right, or wrong
Shared helpersApp_Code or a class folderYour assembly (or App_Code.dll)Data access, file handling, error display: one flaw, every caller
HandlersDownload.ashxYour assemblyNot pages at all; page-level protections such as view state do not cover them
LibrariesNuGet packages or copied filesSeparate DLLs in /binSame process, same permissions, their own vulnerabilities
Configurationweb.configRead at startupError pages, debug, tracing, keys and cookie flags for the whole application
Go deeper: the inheritance chain, and why the DLL is not a safe

Follow one request to Orders.aspx. The class that actually runs is generated from the markup, and it inherits from Orders, your code-behind class. If your team uses a common base, Orders in turn inherits from BasePage, which inherits from System.Web.UI.Page. Anything BasePage does in its OnInit or OnLoad runs for every form beneath it. That is the most powerful line in the application: a single place to set an anti-forgery key, demand authentication or install an error handler for all forms at once.

Compiled .NET code is intermediate language, not machine code, and it carries names of classes, methods and string constants. Free decompilers turn it back into readable C# in seconds. So anything compiled into the DLL is readable by anyone who obtains the file: a connection string with a password, an API key, a hidden admin page's address, a hard-coded "backdoor" account (CWE-798). The DLL can leak through a backup left in the web root, a misconfigured file share or a path traversal bug like the one later in this chapter. Treat the assembly as public and keep secrets in protected configuration or a secret store.

The same property helps defenders. You can decompile your own release build and read exactly what shipped, which is the honest way to review an application whose source has drifted from what is running.

The multiplier

One flaw, many doors

Teams write helpers to avoid repeating themselves, which is good engineering. A data class with a Query method saves every page from opening its own connection. A base page saves every form from repeating its setup. An ErrorPanel gives every page the same look when something fails. Reuse is a multiplier, though, and multipliers do not care what they multiply.

Suppose Db.Query accepts a finished string of SQL. It cannot parameterize anything, because by the time it sees the query, the values are already glued in. So every page that calls it builds its query by concatenation, and every one of those pages inherits SQL injection. Suppose Ui.ShowMessage puts text into a Label without encoding it. Every form that shows a message through it can be made to render markup. Suppose BasePage never sets an anti-forgery key. Every form that inherits it accepts forged postbacks. None of these pages contains a visible bug. The bug is in the shared code they trust.

This changes how you read a scanner report and how you plan the fix. Group findings by the sink, the method where the dangerous thing actually happens. Twelve findings that all end in Db.Query are one root cause. Fix the helper so the unsafe call is impossible, by giving it a parameterized signature and deleting the string-only version, and the compiler itself will point at every caller that still needs changing. Patch the twelve pages one at a time instead, and you are relying on nobody ever writing a thirteenth.

findings = Σhelpers flawed(h) × callers(h) · fixes needed = Σhelpers flawed(h)
The number a scanner reports grows with how often each flawed helper is called. The number of changes needed to fix the root causes grows only with how many helpers are flawed. Fixing at the helper turns a product into a count.

The worst case is the opposite of a helper: copy-paste. When each page carries its own copy of the same concatenated query, you get the large finding count and the large fix count together, and the copies drift until no two are the same. The first step is often to pull them into one helper, then fix the helper.

Instrument 1

The form map

Here is a made-up shop application: twelve forms and handlers in one DLL, wired to seven shared pieces of code, each with one flaw. Pick a helper to light up every form that inherits its flaw. Patch it and watch them all turn green at once. Pick a form to see everything it depends on. Then add a new page and notice that it is born with whatever state its helpers are in.

Or pick a form
Open form × flaw pairs–
Forms with an open flaw–
Root causes left–
Edits if fixed page by page–

Key: helpers sit on the top row, forms below. A ✕ and purple outline means at least one open flaw; ✓ and green means clean. The highlighted wires are the selected helper's callers. Notice Download.ashx: it is a handler, not a page, so it does not inherit BasePage and none of the page-level protections reach it.

Go deeper: what the map is counting

Each form has a list of the helpers it uses. A pair (form, helper) is open when the helper is unpatched. "Open pairs" is what a scanner that reports per page would show you. "Root causes left" counts unpatched helpers that at least one form still calls. "Edits if fixed page by page" equals the open pairs, because each would need its own change and its own retest. The new pages you add inherit BasePage and call the data, message and error helpers, which is what a typical new form does.

Real applications are messier. Some pages call a helper and also contain their own inline copy of the flaw, and some helpers are safe in one overload and unsafe in another. That is why the fix in this chapter is always two steps: make the helper safe, then make the unsafe path unavailable, so that the compiler or a code-analysis rule catches stragglers.

CWE-89 · CWE-79 · OWASP A05:2025 Injection

Data meets interpreter, Web Forms style

The injection chapter explains the root cause: text glued into something a parser will read. In a Web Forms DLL it takes two familiar shapes. The first is the data helper that takes finished SQL.

// App_Code/Db.cs — one helper, called from many pages
public static DataTable Query(string sql)
{
    using (var conn = new SqlConnection(ConnStr))
    // whatever text arrives is parsed as SQL
    using (var da = new SqlDataAdapter(sql, conn))
    { var dt = new DataTable(); da.Fill(dt); return dt; }
}

// Search.aspx.cs — and eleven pages like it
var dt = Db.Query("SELECT Id, Title FROM Products WHERE Title LIKE '%" + txtSearch.Text + "%'");
// The helper now only accepts SQL plus separate parameters; the string-only overload is deleted
public static DataTable Query(string sql, params SqlParameter[] args)
{
    using (var conn = new SqlConnection(ConnStr))
    using (var cmd = new SqlCommand(sql, conn))
    {
        cmd.Parameters.AddRange(args);
        using (var da = new SqlDataAdapter(cmd)) { var dt = new DataTable(); da.Fill(dt); return dt; }
    }
}

var dt = Db.Query("SELECT Id, Title FROM Products WHERE Title LIKE @pat",
    new SqlParameter("@pat", SqlDbType.NVarChar, 120) { Value = "%" + txtSearch.Text + "%" });

Deleting the old overload is the part people skip. While Query(string) exists, someone will call it. Once it is gone, every unconverted caller becomes a compile error, a to-do list the build hands you for free. The same applies to SqlDataSource controls in markup: their SelectCommand should use @name parameters bound from controls, never text assembled in code-behind. The database account in the connection string should have only the rights the site needs, so one missed query cannot drop tables or read other applications' data.

The second shape is output. Web Forms controls differ in whether they encode what you give them, and the differences are not obvious from their names.

Control or expressionHTML-encodes?Note
Label.TextNoRenders the string as markup. The most common Web Forms XSS sink.
Literal (default)NoMode defaults to PassThrough.
Literal Mode="Encode"YesThe safe way to drop plain text into a page.
<%= value %>NoWrites raw output.
<%: value %>YesEncoded output, added in ASP.NET 4.
<%# Eval("X") %>NoRaw data binding in templates.
<%#: Item.X %>YesEncoded data binding, added in ASP.NET 4.5.
BoundFieldYes, by defaultHtmlEncode is true unless someone turns it off.
TextBox.TextYesEncoded into the value attribute.
HyperLink.NavigateUrlNot the schemeCheck that user-supplied links start with https: or http:.
// Ui.cs — shared message helper
public static void ShowMessage(Label target, string text) { target.Text = text; }

// Profile.aspx.cs
Ui.ShowMessage(lblHello, "Welcome back, " + Request.QueryString["name"]);
// Encode inside the helper, so no caller can forget
public static void ShowMessage(Label target, string text) { target.Text = HttpUtility.HtmlEncode(text); }

// or in markup: <asp:Literal ID="litHello" runat="server" Mode="Encode" />  and  <%#: Item.DisplayName %>

For values that land somewhere other than plain HTML text, such as inside a JavaScript block, a URL or an attribute, use the matching encoder: HttpUtility.JavaScriptStringEncode, HttpUtility.UrlEncode, or the AntiXssEncoder methods built into .NET 4.5 and later. Better still, pass data to scripts through a data- attribute or JSON and keep script blocks constant, as the injection chapter's encoder shows.

ASP.NET's request validation, which rejects requests that contain markup-like text, is a safety net. Microsoft describes it as designed to help prevent some script-injection attacks, and it has well-known gaps: it does not look at every place data comes from, it does not understand JavaScript or URL contexts, and it is often turned off for a page that legitimately accepts HTML. Leave it on, but treat encoding as the fix.

Go deeper: detecting these in a Web Forms DLL
  • Search the source and the markup together. Concatenation into SqlCommand, SqlDataAdapter, ExecuteQuery or a SelectCommand; assignments to Label.Text and Literal.Text of anything that is not a constant; <%= and <%# Eval in .aspx and .ascx files; HtmlEncode="false" on BoundFields; ValidateRequest="false" on pages.
  • Static analysis with taint tracking follows Request.QueryString, Request.Form, control values and cookies to those sinks. Group its output by sink to see root causes.
  • Roslyn analyzers in the build (the .NET compiler's code-analysis rules include ones for SQL built from strings) catch new instances at the moment they are typed.
  • At runtime, database errors in logs that mention syntax near unexpected characters are the fingerprints of a concatenated query meeting unusual input, whether a customer called O'Brien or someone probing.
CWE-502 · OWASP A08:2025 Software or Data Integrity Failures

ViewState, the machine key and trust

Web Forms remembers the state of a page between postbacks by sending it to the browser. Control values, list contents and anything the code puts in the ViewState dictionary are serialized into a hidden form field called __VIEWSTATE, and the browser posts it back with the next click. The server then turns that text back into objects. That last step, deserialization, is the dangerous part: rebuilding objects from data that has spent time in someone else's hands. MITRE files it as CWE-502, and in .NET, deserializing an object graph chosen by an outsider can lead all the way to running code on the server.

ASP.NET guards the round trip with a message authentication code (MAC). Before sending view state, the server computes a keyed hash of it using the validationKey from its machine key, and attaches it. On postback it recomputes the hash and compares. Anyone can read or edit the field, but without the key nobody can produce a matching seal, so any change is refused before a single object is rebuilt. The machine key can also encrypt view state, using a separate decryptionKey. Encryption hides the contents; the MAC is what stops tampering.

So the whole defense rests on two conditions. The MAC must be on, and the key must be secret.

  • MAC on. Old applications sometimes set enableViewStateMac="false" to silence errors on web farms. Microsoft's advisory 2905247 of December 10, 2013 warned that this was dangerous, and with the security updates of September 2014 every ASP.NET runtime from 1.1 to 4.5.2 refuses to honor the setting. On an up-to-date server you cannot turn it off; on one missing those updates, you can.
  • Key secret. The default, AutoGenerate,IsolateApps, makes a random key per application on each server, which nobody needs to see. Web farms need every server to share one key, so teams set it explicitly in web.config, and that is where it goes wrong: keys copied from a blog post, a sample project or another application; keys committed to a public repository; keys left in a web.config that leaks through a backup or a path traversal bug. Microsoft's own guidance warns that anybody who has these keys could end up with full administrative control of the web application.
<!-- web.config: every one of these is a finding -->
<!-- refused by patched runtimes, critical on unpatched ones -->
<pages enableViewStateMac="false" />
<!-- a key other people have is not a key -->
<machineKey validationKey="(pasted from a tutorial)"
            decryptionKey="(pasted from a tutorial)" validation="SHA1" />
<!-- Generated for this application only, stored with protected configuration, never in source control -->
<machineKey validationKey="(64 random bytes generated for this app)"
            decryptionKey="(32 random bytes generated for this app)"
            validation="HMACSHA256" decryption="AES" />

// And in code: keep view state small, and never trust it for decisions
// bad: the price comes back from the browser
ViewState["Price"] = 49.00m;
// good: look it up again on the server
decimal price = Catalog.PriceFor(productId);

Generate a fresh key for each application with a cryptographic random generator (Microsoft's support article KB 2915218 has a script for it), store it encrypted using ASP.NET protected configuration, and share it only among the servers of that one farm. If a key has ever appeared anywhere it should not have, including a support ticket, a backup or a repository, treat it as burned: generate a new one, deploy it, and investigate. Separately, keep view state lean. It was never meant to carry prices, roles or permissions, and anything that decides what a user may do should be looked up again on the server.

Go deeper: MS10-070, the padding oracle of 2010

On September 17, 2010, Microsoft published security advisory 2416728 about a publicly disclosed weakness in how ASP.NET handled encrypted data, and on September 28 shipped an out-of-band fix, bulletin MS10-070 (CVE-2010-3332). The bulletin describes it as improper error handling during encryption padding verification. By sending many altered values and watching whether the server's response differed, an outsider could learn, a little at a time, whether a decrypted value had valid padding. That is enough to decrypt data such as view state and to forge encrypted values, and on .NET Framework 3.5 SP1 and later the bulletin says it could be used to retrieve files from the application, including web.config, with its keys.

Two lessons outlived the patch. First, error behavior is a side channel: Microsoft's interim workaround was to make every error return the same custom error page, so the responses could not be told apart. Second, check integrity before you decrypt: verify a MAC over the ciphertext first, with a comparison that takes the same time whether the first or the last byte differs, and never act on a decryption result until the MAC has passed. Modern authenticated-encryption modes build that order in.

Instrument 2

Break the seal

This is a real HMAC-SHA-256, computed in this page, guarding a toy view state. The server renders a page and seals its state with its key. You play the browser: edit the hidden field, then post it back and see whether the server accepts it. Then switch the server to a key copied from a public sample and see what happens when someone else also has the key. The state here is plain text so you can read it; real view state is a binary serialized object graph, which is exactly why accepting a forged one is so dangerous.

Server's machine key
Hidden field contents (you can edit this)

      

Seal in the field–
Seal the server computes–
Result–

Seals are shown as the first 16 hex digits of the 64-digit HMAC. The secret key is 32 random bytes made by your browser when the page loaded; it is never displayed. Nothing here is sent anywhere.

Go deeper: what the instrument computes

HMAC, defined in RFC 2104, hashes the message twice with the key mixed in: H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m)), where H is SHA-256 and ipad and opad are fixed byte patterns. Change one character of m and about half the output bits flip, with no way to predict which without K. The instrument runs a full SHA-256 written in plain JavaScript, so it is the same arithmetic a server does, minus the serialization. When the server's key is the published one, a re-sealed field verifies perfectly: the MAC is doing its job, but the job only protects you from people who do not know the key.

CWE-352 · CWE-639 · OWASP A01:2025 Broken Access Control

Forged postbacks and other people's records

A browser attaches a site's cookies to requests for that site even when another site triggered them. That is the root of cross-site request forgery, covered in detail in Sessions, cookies, CSRF & CORS. Web Forms has its own lever for it: Page.ViewStateUserKey. Set it to something tied to the current user, and ASP.NET mixes it into the view state MAC. A postback carrying view state that was issued to a different user, or to nobody, fails validation. Set it in the base page and every form that inherits it is covered at once.

public class BasePage : System.Web.UI.Page
{
    // nothing ties a postback to the user who received the form
}
public class BasePage : System.Web.UI.Page
{
    protected override void OnInit(EventArgs e)
    {
        base.OnInit(e);
        // Must be set during Init; setting it in Load throws. Use a per-user value that stays stable.
        if (User.Identity.IsAuthenticated) ViewStateUserKey = Session.SessionID;
    }
}

Three gaps remain, and the form map above shows the first one. Handlers are not pages. A .ashx handler, a web service or a page method does not inherit BasePage and has no view state, so it needs its own anti-forgery token check and should refuse to change anything on a GET. Session IDs can wobble. ASP.NET may issue a new session ID on each request until something is stored in the session, so a key based on it should only be relied on once the session is established; Visual Studio's Web Forms template uses an anti-forgery cookie token in the master page for this reason. Cookies should help. Setting SameSite on the session and authentication cookies stops most cross-site sending at the browser, as the sessions chapter explains.

The second weakness in this family needs no forgery at all. A page reads an ID from the address and loads the record: OrderDetail.aspx?id=1043. If the code checks that the user is signed in but not that order 1043 is theirs, changing the number shows someone else's order. This is an insecure direct object reference, filed as CWE-639, authorization bypass through a user-controlled key. In a shared DLL it usually lives in one loader method called from every detail page.

// Records.cs — used by OrderDetail, Invoice and Download.ashx
public static Order LoadOrder(string id) =>
    Db.QuerySingle<Order>("SELECT * FROM Orders WHERE Id = @id", new SqlParameter("@id", int.Parse(id)));
// The owner check lives in the query itself, so no caller can skip it
public static Order LoadOrderFor(int id, string userId) =>
    Db.QuerySingle<Order>("SELECT * FROM Orders WHERE Id = @id AND OwnerId = @owner",
        new SqlParameter("@id", id), new SqlParameter("@owner", userId));
// Null result: respond 404 Not Found, the same as for an ID that does not exist.

Note that the vulnerable version is properly parameterized. Parameterization stops injection; it does not decide who may see what. Random identifiers such as GUIDs make guessing harder but are not authorization, because IDs leak through links, logs and shared screens. Detection: write tests that sign in as two users and try each other's IDs on every detail page and handler, and alert when one account requests many different record IDs and gets many "not found" answers in a short time.

CWE-434 · CWE-22 · OWASP A06:2025 · A01:2025

Files in, files out

Web Forms makes uploads easy with the FileUpload control, and downloads easy with a small .ashx handler. Both tend to be written once, in a FileStore helper, and reused by every page that touches files, which makes them classic multipliers.

The upload risk, CWE-434, is accepting a file of a dangerous type and storing it where the server will treat it as code. On IIS, a file ending in .aspx or .ashx placed inside the application folder is a page; a DLL that lands in /bin is part of the application. The defense is a set of independent layers, so that any one of them failing is not fatal.

// FileStore.Save — the client chooses the name and the folder is inside the site
fuDoc.SaveAs(Server.MapPath("~/uploads/" + fuDoc.FileName));
// Server chooses the name; allow-listed type; checked size and content; stored outside the web root
var ext = Path.GetExtension(fuDoc.FileName).ToLowerInvariant();
if (!Allowed.Contains(ext) || fuDoc.PostedFile.ContentLength > MaxBytes || !LooksLike(ext, fuDoc.FileBytes))
    return Reject();
var id   = Guid.NewGuid().ToString("N");
// StoreRoot is not under the site, and cannot execute
var path = Path.Combine(StoreRoot, id + ext);
fuDoc.SaveAs(path);
// the original name is kept as data only
Uploads.Record(id, Path.GetFileName(fuDoc.FileName), CurrentUserId);

The download risk is path traversal, CWE-22, rank 6 in the 2025 Top 25. A handler that builds a file path from a request parameter can be steered out of its folder by path separators and parent-directory segments. There is also a .NET surprise: Path.Combine discards the first part entirely if the second part is an absolute path. Name-based checks are fragile; the robust fix is not to accept a path at all.

// Download.ashx — the request names the file
var file = context.Request.QueryString["file"];
context.Response.TransmitFile(Path.Combine(StoreRoot, file));
// Best: the request names a record; the server looks up the path and checks the owner
var doc = Uploads.FindFor(context.Request.QueryString["id"], CurrentUserId);
if (doc == null) { context.Response.StatusCode = 404; return; }
context.Response.TransmitFile(Path.Combine(StoreRoot, doc.StoredName));

// If a name must be accepted: resolve it, then prove it is still inside the root
var full = Path.GetFullPath(Path.Combine(StoreRoot, name));
if (!full.StartsWith(StoreRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { /* 404 */ }

Around both helpers, set the environment so a mistake cannot become code execution. The application pool's identity should have no write access to the site folder or /bin; the upload store should be outside the site, or at least in a folder where IIS will not execute scripts. For detection, watch the site folder and /bin with file integrity monitoring: a new .aspx, .ashx or .dll that did not arrive through a deployment is one of the clearest alarms a web server can raise.

CWE-209 · CWE-489 · CWE-215 · OWASP A10:2025 · A02:2025

Settings that talk too much

Some of the weaknesses in a Web Forms application are not in the DLL at all. They are in web.config, and they apply to every form at once, which makes the configuration file the biggest multiplier of all.

  • customErrors. With mode="Off", an unhandled exception sends the visitor the full ASP.NET error page: the exception, a stack trace, file paths, sometimes the failing line of source and the framework version. That is CWE-209, an error message containing sensitive information, and it hands a free map of the DLL to anyone who can make a page fail. The default, RemoteOnly, shows details only to requests from the server itself; On hides them from everyone.
  • debug="true". The compilation element's debug switch builds pages with debug information, turns off some optimizations and timeouts and makes error pages more detailed. MITRE has a specific entry for it, CWE-11, ASP.NET misconfiguration: creating debug binary, under the wider CWE-489, active debug code.
  • trace.axd. With tracing enabled, ASP.NET records recent requests and serves them at trace.axd: headers, cookies, form values, session contents and server variables. With localOnly="false" that viewer is open to anyone. Microsoft's own documentation says not to enable tracing on a live site. This is CWE-215, sensitive information in debugging code.
  • Cookie flags. httpOnlyCookies and requireSSL in the httpCookies element both default to false, so cookies your code creates can be read by page scripts and sent over plain HTTP unless you set them (CWE-1004, CWE-614).
  • deployment retail. A single line in the server's machine.config, <deployment retail="true" />, overrides every application on the machine: it disables trace output and debug capabilities and forces custom errors on. It is the closest thing to a production safety switch Web Forms has, and it can only be set at machine level.

The good pattern for errors is the same everywhere: catch exceptions centrally (Application_Error in Global.asax), log the full detail with a random reference number, and show the visitor a plain page with that number. Support can find the log entry; nobody else learns anything.

// ErrorPanel.Show, called from a dozen catch blocks
// stack trace, paths and SQL text, unencoded too
catch (Exception ex) { lblError.Text = ex.ToString(); }
catch (Exception ex)
{
    var reference = Guid.NewGuid().ToString("N").Substring(0, 8);
    Log.Error(ex, "Unhandled error {Ref} on {Page}", reference, Request.Path);
    lblError.Text = HttpUtility.HtmlEncode("Something went wrong. Reference " + reference + ".");
}
Instrument 3

A web.config auditor

Set each switch the way you would find it on a server and read the verdicts. The auditor computes the effective settings first, including the machine-wide retail switch and the web farm question, then checks each one against the risks above. Try the presets, then try turning on retail mode over the developer laptop preset.

High–
Medium–
Low–
Checks passed–
    The configuration you described

    Marked lines carry a finding; green marks are settings that close one. Key values shown in brackets are placeholders: never paste a real key anywhere public, including a page like this.

    CWE-1104 · CWE-427 · OWASP A03:2025 · A08:2025

    What else lives in /bin

    Open the /bin folder of a Web Forms application that has been in service for ten years and you will usually find dozens of DLLs. Some arrived as NuGet packages with a version in a packages.config file. Others were copied in by hand years ago, by someone who has since left, with no record of where they came from. All of them load into the IIS worker process with the same rights as your own code. A known vulnerability in any of them is a vulnerability in the site. MITRE calls this CWE-1104, use of unmaintained third-party components, and OWASP's 2025 list gives the whole area its own category, A03 Software Supply Chain Failures, explored in The software supply chain.

    • Know what you ship. Generate a software bill of materials (SBOM) for every release, from the package manifest and by scanning /bin itself, so hand-copied DLLs appear too. Record each file's name, version and hash.
    • Check it continuously. NuGet's package auditing and scanners such as OWASP Dependency-Check compare versions against public vulnerability databases. Run them in the build and on a schedule, because a library that was clean at release can be found vulnerable next month.
    • Upgrade once. Here the multiplier works for you: every form that uses the library picks up the fix in the same deployment.
    • Mind the loose ones. A DLL with no package record will never trigger an advisory. Identify it by hash, find its source, or replace it.

    A second, older risk concerns native DLLs, the unmanaged Windows libraries that some components load. When a program asks Windows to load a DLL by name only, the loader looks through a sequence of folders in order. If a folder early in that order is writable by someone who should not control it, a planted file with the expected name is loaded instead of the real one. This is CWE-427, uncontrolled search path element, often called DLL search-order hijacking, and OWASP files it under A08 Software or Data Integrity Failures.

    • Load by full path, or restrict the search with SetDefaultDllDirectories and LoadLibraryEx flags such as LOAD_LIBRARY_SEARCH_SYSTEM32, as Microsoft's DLL security guidance describes. Safe DLL search mode, on by default, also helps.
    • Lock the folders. The application folder and /bin should be writable only by the deployment process, never by the application pool identity or ordinary users. The same rule stops uploads from turning into code.
    • Watch the loads. Sysmon's image-loaded event (Event ID 7) records which modules each process loads, with hashes and signature details; an unsigned or unexpected DLL loading into the worker process deserves a look.
    Putting it together

    Finding every copy

    The tour comes down to one map: for each weakness, the shared place where it usually lives, which is also the one place to fix it.

    WeaknessCWEOWASP 2025Usually lives inFix it once atDetect with
    SQL injection89A05Data helper taking SQL textParameter-only helper signatureTaint analysis grouped by sink
    Cross-site scripting79A05Message helper, Label.Text, <%# Eval %>Encoding inside the helper; <%: %>Markup and code search; analyzers
    View state deserialization502A08machineKey in web.configUnique, protected key; MAC onConfig audit; MAC-failure events
    Cross-site request forgery352A01BasePage, handlersViewStateUserKey in BasePage; tokens in handlersTests that replay a postback cross-user
    Insecure direct object reference639A01Record loaderOwner check in the queryTwo-user tests; ID-walk alerts
    Unrestricted upload434A06FileStore.SaveAllow-list, server names, store off-siteFile integrity monitoring
    Path traversal22A01Download.ashxIDs instead of paths; containment checkCode search for Path.Combine on input
    Verbose errors, debug, trace209, 489, 215A10, A02web.config, ErrorPanelcustomErrors On; retail mode; central handlerConfig audit; response checks
    Vulnerable library1104A03/binUpgrade the packageSBOM plus dependency scanning
    DLL search order427A08Native loads, writable foldersFull paths; locked foldersImage-load monitoring

    A practical review of an inherited Web Forms application runs in this order. First, inventory the endpoints: every .aspx, .ashx, .asmx and .svc file is a door, and handlers are the ones most often forgotten. Second, read the shared code first: the base page, the data helper, the file helper, the error helper and web.config. An hour there covers more of the attack surface than a day of reading individual pages. Third, run the tools and group their output by sink, so you plan fixes by root cause. Fourth, make the fix structural: a safe signature, a deleted overload, a base class that does the right thing, a build rule that fails on the old pattern. Last, keep watching: dependency scans on a schedule, integrity monitoring on the site folders, and alerts on the error and access-denied patterns described above.

    Web Forms is not part of modern ASP.NET Core, and many of these applications will be maintained for years rather than rewritten. The good news is in the title of this chapter. The same structure that copies one flaw into forty forms lets one careful change protect all forty.

    A review checklist for a Web Forms DLL

    1. Does any data helper accept finished SQL text? Is the string-only overload deleted, and do SqlDataSource controls use parameters?
    2. Does any helper assign to Label.Text or Literal.Text without encoding? Do templates use <%#: rather than <%# Eval?
    3. Is the machine key unique to this application, generated, stored encrypted, absent from source control, and is enableViewStateMac untouched?
    4. Does the base page set ViewStateUserKey, and do handlers and page methods check an anti-forgery token and refuse state changes on GET?
    5. Does every record loader take the current user and filter by owner?
    6. Are uploads named by the server, type-checked, size-limited and stored where they cannot run? Do downloads take IDs, not paths?
    7. Is customErrors On or RemoteOnly, debug false, tracing off, and is retail mode set on production servers?
    8. Is there an SBOM for /bin, including hand-copied DLLs, and is it scanned on a schedule?
    9. Can the application pool identity write to the site folder or /bin? It should not.
    Cheat sheet

    Terms from this chapter

    Web Forms
    Microsoft's original ASP.NET page model: .aspx markup with server controls, code-behind event handlers and postbacks.
    Code-behind
    The C# or Visual Basic class beside an .aspx page that holds its event handlers; compiled into the site's assembly.
    Assembly
    A compiled .NET DLL; a Web Application Project compiles all its code-behind and classes into one, in /bin.
    Postback
    A Web Forms page posting its form back to itself so the server can replay control state and run an event handler.
    View state
    Page state serialized into the hidden __VIEWSTATE field, sent to the browser and read back on postback.
    Machine key
    The keys ASP.NET uses to sign (validationKey) and encrypt (decryptionKey) view state and other protected data.
    View state MAC
    A keyed hash over view state that lets the server reject any copy that was changed without the key.
    ViewStateUserKey
    A per-user value mixed into the view state MAC so a postback issued to one user fails for another; a CSRF defense.
    Sink
    The method where a dangerous action happens, such as running SQL or writing HTML; group findings by sink to find root causes.
    Insecure direct object reference
    Loading a record by a user-supplied ID without checking that the user may see it (CWE-639).
    Padding oracle
    A flaw where error responses reveal whether decrypted data had valid padding, letting an outsider decrypt or forge it (MS10-070).
    DLL search-order hijacking
    A planted DLL being loaded because Windows searched a writable folder before the real one (CWE-427).
    Where this comes from

    Sources