CWE-401: Missing Release of Memory after Effective Lifetime - C#
Overview
Memory leaks in C# typically involve unmanaged resources (files, database connections, streams, unmanaged memory) rather than managed objects, since the garbage collector handles most memory management. Failing to dispose of IDisposable resources leaks connection pool entries and file handles until the pool or the process handle limit is exhausted and new work blocks.
Primary Defence: Use using statements or using declarations for every IDisposable resource, and a finally block where a using statement doesn't fit. Implement the IDisposable pattern in classes that manage unmanaged resources, unsubscribe from events once the subscriber is no longer needed, and keep static collections from growing unbounded.
Common Vulnerable Patterns
Undisposed Database Connections
// VULNERABLE - Undisposed Database Connections
public List<User> GetUsers() {
var conn = new SqlConnection(connectionString);
var cmd = new SqlCommand("SELECT * FROM users", conn);
conn.Open();
var reader = cmd.ExecuteReader();
var users = new List<User>();
while (reader.Read()) {
users.Add(new User(reader.GetString(0)));
}
return users;
// No Dispose() - connection, command, reader all leaked!
}
Why this is vulnerable: Database connections are backed by network sockets and connection pool entries. When Dispose() isn't called, the connection remains in the "in use" state in the pool, even though the method has returned and can't access it anymore. After 10-50 calls (typical pool size), the pool is exhausted and new requests block indefinitely waiting for available connections, causing application-wide denial of service. The garbage collector doesn't help: connection, command, and reader all hold unmanaged resources (native handles, memory buffers) that stay held until a finalizer runs, and finalization is non-deterministic - minutes away, or never, in a long-running process.
Undisposed File Streams
// VULNERABLE - Undisposed File Streams
public string ReadFile(string path) {
var stream = new FileStream(path, FileMode.Open);
var reader = new StreamReader(stream);
string content = reader.ReadToEnd();
return content;
// No Dispose() - file handle leaked!
}
// After many calls, file descriptors exhausted
for (int i = 0; i < 10000; i++) {
ReadFile($"data_{i}.txt");
// "Too many open files" error
}
Why this is vulnerable: Each FileStream opens a file handle from the operating system's limited pool (typically 1024-4096 per process). Without calling Dispose(), the handle remains open until the garbage collector finalizes the object - but finalization is non-deterministic and slow. A web application handling hundreds of requests per second can exhaust all file handles in seconds. Open file handles also prevent file deletion, modification, and can cause sharing violations when other processes try to access the files. The garbage collector manages memory only - unmanaged resources such as file handles, sockets, and database connections have to be released explicitly via Dispose().
Event Handler Leaks
public class SubscriberManager {
private List<Subscriber> subscribers = new List<Subscriber>();
public void AddSubscriber(Subscriber sub) {
subscribers.Add(sub);
// Subscribe to event
SomeStaticPublisher.DataReceived += sub.HandleData;
}
public void RemoveSubscriber(Subscriber sub) {
subscribers.Remove(sub);
// Event handler NOT unsubscribed - sub can't be GC'd!
}
}
// Usage in ASP.NET Core
public class MyController : Controller {
public MyController() {
// Subscribe to static event
GlobalEvents.OnUpdate += HandleUpdate;
// Controller instances never unsubscribe
// Each request creates new controller -> all leaked!
}
private void HandleUpdate(object sender, EventArgs e) { }
}
Why this is vulnerable: When an object subscribes to an event (especially on static/long-lived publishers), the publisher holds a delegate reference to the subscriber's method, preventing garbage collection of the subscriber. Even when RemoveSubscriber() removes the subscriber from the list, the event subscription keeps it alive. In ASP.NET applications where controllers are created per request, failing to unsubscribe means every controller instance ever created remains in memory indefinitely. After thousands of requests, memory is exhausted. The leak isn't obvious: the subscriber appears unused, and only the event delegate chain still points at it.
Secure Patterns
Using Statements
public async Task<string> ReadFileAsync(string path) {
// using statement: calls Dispose() automatically
using (var stream = new FileStream(path, FileMode.Open))
using (var reader = new StreamReader(stream)) {
return await reader.ReadToEndAsync();
}
// stream and reader disposed here, even on exception
}
// C# 8.0+ using declarations (simpler syntax)
public List<User> GetUsers() {
using var conn = new SqlConnection(connectionString);
using var cmd = new SqlCommand("SELECT * FROM users", conn);
conn.Open();
using var reader = cmd.ExecuteReader();
var users = new List<User>();
while (reader.Read()) {
users.Add(new User(reader.GetString(0)));
}
return users;
// All resources disposed at end of method scope
}
Why this works: The using statement automatically calls Dispose() on IDisposable objects when they go out of scope, whether the block exits normally or via exception. The compiler generates a try-finally block that guarantees disposal even if exceptions are thrown. That covers the usual source of resource leaks - a code path that skips disposal, error handlers most of all. Using declarations (C# 8.0+) dispose at the end of the enclosing scope rather than requiring nested braces, with the same guarantee. Any class implementing IDisposable works with this pattern, including streams, database connections, HTTP clients, and custom resources.
Implementing IDisposable Correctly
using System;
using System.Runtime.InteropServices;
public class ResourceHolder : IDisposable {
// Managed resource
private SqlConnection connection;
// Unmanaged resource
private IntPtr unmanagedBuffer;
private bool disposed = false;
public ResourceHolder(string connString) {
connection = new SqlConnection(connString);
unmanagedBuffer = Marshal.AllocHGlobal(1024);
}
// Public Dispose method
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this); // Prevent finalizer from running
}
// Protected virtual Dispose for derived classes
protected virtual void Dispose(bool disposing) {
if (!disposed) {
if (disposing) {
// Dispose managed resources
connection?.Dispose();
}
// Free unmanaged resources
if (unmanagedBuffer != IntPtr.Zero) {
Marshal.FreeHGlobal(unmanagedBuffer);
unmanagedBuffer = IntPtr.Zero;
}
disposed = true;
}
}
// Finalizer - only if class has unmanaged resources
~ResourceHolder() {
Dispose(false);
}
}
// Usage
using (var resource = new ResourceHolder(connString)) {
// Use resource
}
// Dispose() called automatically
Why this works: This is the standard IDisposable pattern. The public Dispose() method releases both managed and unmanaged resources and suppresses finalization, since the resources are already cleaned up. The protected virtual Dispose(bool) allows derived classes to extend cleanup. The disposing parameter indicates whether we're in a Dispose() call (true) or finalizer (false) - managed resources should only be accessed when disposing is true because they might already be finalized. The finalizer provides a safety net for unmanaged resources if Dispose() is never called, but it's only needed if the class directly holds unmanaged resources (most classes don't need a finalizer). The disposed flag prevents double-disposal. This pattern ensures resources are released deterministically via Dispose() while providing fallback cleanup via finalization.
Unsubscribing from Events
public class EventSubscriber : IDisposable {
private readonly EventHandler<DataEventArgs> handler;
public EventSubscriber() {
handler = HandleData;
GlobalEvents.DataReceived += handler;
}
private void HandleData(object sender, DataEventArgs e) {
// Process data
}
public void Dispose() {
// Unsubscribe from event
GlobalEvents.DataReceived -= handler;
}
}
// Usage
using (var subscriber = new EventSubscriber()) {
// Subscriber receives events
}
// Automatically unsubscribed and eligible for GC
// ASP.NET Core controller with proper cleanup
public class MyController : Controller, IDisposable {
public MyController() {
GlobalEvents.OnUpdate += HandleUpdate;
}
private void HandleUpdate(object sender, EventArgs e) { }
protected override void Dispose(bool disposing) {
if (disposing) {
GlobalEvents.OnUpdate -= HandleUpdate;
}
base.Dispose(disposing);
}
}
Why this works: Explicitly unsubscribing from events breaks the reference from the event publisher to the subscriber, allowing the subscriber to be garbage collected. Implementing IDisposable and unsubscribing in Dispose() ensures cleanup happens deterministically. In ASP.NET applications, controllers are automatically disposed at the end of each request, so overriding Dispose() to unsubscribe ensures each controller instance is eligible for GC. Subscribe with a named method rather than a lambda: whether you keep the delegate in a field, as EventSubscriber does, or pass the method group to both += and -=, as the controller does, you can name the handler again when it is time to remove it. A lambda you cannot - nothing you write at -= will match it. This pattern is essential for any object that subscribes to events on longer-lived publishers, especially static events or application-scoped services.
Weak Event Pattern
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
// SECURE - the weak reference is to the subscriber OBJECT; the MethodInfo is
// held strongly, because it is a metadata handle and not a retention path
public class WeakEventManager<TEventArgs> where TEventArgs : EventArgs {
private readonly List<(WeakReference Target, MethodInfo Method)> handlers = new();
public void AddHandler(EventHandler<TEventArgs> handler) {
if (handler.Target is null)
throw new ArgumentException(
"a static handler is never collected, so it does not need this manager",
nameof(handler));
handlers.Add((new WeakReference(handler.Target), handler.Method));
}
public void RemoveHandler(EventHandler<TEventArgs> handler) {
handlers.RemoveAll(h =>
!h.Target.IsAlive || // dead: drop it
(ReferenceEquals(h.Target.Target, handler.Target) && h.Method == handler.Method));
}
public void RaiseEvent(object sender, TEventArgs args) {
foreach (var (weakTarget, method) in handlers.ToList()) {
var target = weakTarget.Target; // read once: it can die mid-loop
if (target is not null) {
method.Invoke(target, new object[] { sender, args });
}
}
// Clean up entries whose subscriber has been collected
handlers.RemoveAll(h => !h.Target.IsAlive);
}
}
// Usage
public class Publisher {
private readonly WeakEventManager<DataEventArgs> eventManager
= new WeakEventManager<DataEventArgs>();
public event EventHandler<DataEventArgs> DataReceived {
add => eventManager.AddHandler(value);
remove => eventManager.RemoveHandler(value);
}
protected virtual void OnDataReceived(DataEventArgs e) {
eventManager.RaiseEvent(this, e);
}
}
// Subscribers can be GC'd even if they forget to unsubscribe
Why this works: The retention path that leaks is publisher -> delegate -> subscriber object. Breaking it means holding the subscriber weakly while keeping enough information to call back into it, which is what the (WeakReference, MethodInfo) pair does: MethodInfo describes the method rather than any instance, so keeping it costs nothing in retention. When the subscriber becomes unreachable, WeakReference.Target returns null, the invocation is skipped, and the entry is dropped on the next raise. Verified on .NET 10: a live subscriber kept receiving events across a forced GC.Collect(), and a subscriber that nothing else referenced stopped being invoked after one.
A weak reference to the delegate does not do this, and it fails closed. subscriber.OnData in a += allocates a fresh EventHandler<T> at the call site; once AddHandler returns, that delegate is unreachable from everywhere except the WeakReference holding it, so the first collection clears it - while the subscriber is still perfectly alive. Measured on .NET 10 with a strongly-referenced subscriber and a List<WeakReference<EventHandler<TEventArgs>>>: RaiseEvent fired 1 handler before GC.Collect() and 0 after it. Handlers stop firing at an arbitrary point with no exception, no log line and nothing to correlate against, which is a worse failure than the leak it was written to prevent.
Two things this pattern is not. It is not free - reflection invocation, a WeakReference allocation per subscription, and no thread safety unless you add it - so it is for framework and library code where subscriber cleanup is genuinely outside your control. And it is not the first answer: unsubscribing in Dispose, as shown above, is cheaper, deterministic and easier to reason about. Where a maintained implementation exists, prefer it - WPF ships System.Windows.WeakEventManager and CommunityToolkit.Mvvm ships a weak messenger, both of which have already been through the failure above.
Detecting Leaks
Undisposed resources rarely fail immediately; they surface as pool exhaustion or climbing handle counts after the process has been up for a while. Capture two heap snapshots around sustained load and compare them:
# Collect a dump on Linux or Windows without attaching a GUI profiler
dotnet-counters monitor --process-id <pid> System.Runtime
dotnet-gcdump collect --process-id <pid> -o before.gcdump
# apply load
dotnet-gcdump collect --process-id <pid> -o after.gcdump
Open both with dotnet-gcdump report, Visual Studio, dotMemory or ANTS, and
look for types whose instance count rises with load and does not fall. The
retention path names the holder - most often a static collection or an event
handler that was never unsubscribed.
Roslyn analyzers catch the simpler cases before the code runs, but neither
rule is on by default: CA2000 (dispose objects before losing scope) is not
enabled at all in the default .NET analyzer set, and CA1816 (call
GC.SuppressFinalize correctly) is enabled only at suggestion severity, not
as a warning. Enable both explicitly and raise them to error severity in
.editorconfig:
That turns a class of leak into a build failure instead of a suggestion nobody sees.