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.
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.
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.
| Piece | Where it lives | Compiled into | Why it matters |
|---|---|---|---|
| Markup | Orders.aspx | A generated class, at first request | Inherits from your code-behind; data-binding expressions here decide whether output is encoded |
| Code-behind | Orders.aspx.cs | Your assembly in /bin | Event handlers, query building, authorization checks |
| Base page | BasePage.cs | Your assembly | Runs for every form that inherits it: one place to get things right, or wrong |
| Shared helpers | App_Code or a class folder | Your assembly (or App_Code.dll) | Data access, file handling, error display: one flaw, every caller |
| Handlers | Download.ashx | Your assembly | Not pages at all; page-level protections such as view state do not cover them |
| Libraries | NuGet packages or copied files | Separate DLLs in /bin | Same process, same permissions, their own vulnerabilities |
| Configuration | web.config | Read at startup | Error pages, debug, tracing, keys and cookie flags for the whole application |
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.
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.
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.
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.
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.
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.
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 expression | HTML-encodes? | Note |
|---|---|---|
| Label.Text | No | Renders the string as markup. The most common Web Forms XSS sink. |
| Literal (default) | No | Mode defaults to PassThrough. |
| Literal Mode="Encode" | Yes | The safe way to drop plain text into a page. |
| <%= value %> | No | Writes raw output. |
| <%: value %> | Yes | Encoded output, added in ASP.NET 4. |
| <%# Eval("X") %> | No | Raw data binding in templates. |
| <%#: Item.X %> | Yes | Encoded data binding, added in ASP.NET 4.5. |
| BoundField | Yes, by default | HtmlEncode is true unless someone turns it off. |
| TextBox.Text | Yes | Encoded into the value attribute. |
| HyperLink.NavigateUrl | Not the scheme | Check 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.
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.Request.QueryString, Request.Form, control values and cookies to those sinks. Group its output by sink to see root causes.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.
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.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.
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.
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.
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.
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.
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.
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.
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.
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.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: 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.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).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 + ".");
}
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.
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.
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.
/bin itself, so hand-copied DLLs appear too. Record each file's name, version and hash.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.
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./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.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.
| Weakness | CWE | OWASP 2025 | Usually lives in | Fix it once at | Detect with |
|---|---|---|---|---|---|
| SQL injection | 89 | A05 | Data helper taking SQL text | Parameter-only helper signature | Taint analysis grouped by sink |
| Cross-site scripting | 79 | A05 | Message helper, Label.Text, <%# Eval %> | Encoding inside the helper; <%: %> | Markup and code search; analyzers |
| View state deserialization | 502 | A08 | machineKey in web.config | Unique, protected key; MAC on | Config audit; MAC-failure events |
| Cross-site request forgery | 352 | A01 | BasePage, handlers | ViewStateUserKey in BasePage; tokens in handlers | Tests that replay a postback cross-user |
| Insecure direct object reference | 639 | A01 | Record loader | Owner check in the query | Two-user tests; ID-walk alerts |
| Unrestricted upload | 434 | A06 | FileStore.Save | Allow-list, server names, store off-site | File integrity monitoring |
| Path traversal | 22 | A01 | Download.ashx | IDs instead of paths; containment check | Code search for Path.Combine on input |
| Verbose errors, debug, trace | 209, 489, 215 | A10, A02 | web.config, ErrorPanel | customErrors On; retail mode; central handler | Config audit; response checks |
| Vulnerable library | 1104 | A03 | /bin | Upgrade the package | SBOM plus dependency scanning |
| DLL search order | 427 | A08 | Native loads, writable folders | Full paths; locked folders | Image-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.
SqlDataSource controls use parameters?Label.Text or Literal.Text without encoding? Do templates use <%#: rather than <%# Eval?enableViewStateMac untouched?ViewStateUserKey, and do handlers and page methods check an anti-forgery token and refuse state changes on GET?customErrors On or RemoteOnly, debug false, tracing off, and is retail mode set on production servers?/bin, including hand-copied DLLs, and is it scanned on a schedule?/bin? It should not.