C#  

C# 15 Memory Safety: Auditing Unsafe Code Contracts

C# has supported unsafe code since its earliest versions.

Pointers, native interop, fixed buffers, function pointers, and direct memory access can be valuable when managed abstractions are not sufficient. They are also areas where the compiler and runtime cannot provide the same level of safety guarantees as ordinary managed C#.

Traditionally, the unsafe keyword has been used to mark code containing pointer-related operations.

C# 15 begins a broader redesign of this model.

The goal is to make the actual memory-safety boundary visible to developers, reviewers, and security auditors rather than treating the mere existence of pointer syntax as the complete definition of unsafe behavior. Microsoft describes this as a multi-release effort, with the new model available in preview alongside .NET 11 development.

This makes C# 15 particularly interesting for teams maintaining:

  • Native interop

  • High-performance libraries

  • Game engines

  • Device drivers and hardware integrations

  • Image and video processing

  • Cryptographic implementations

  • Serialization libraries

  • Custom memory allocators

  • Performance-sensitive infrastructure

The important question for existing applications is not simply whether they compile.

It is:

Where are the real memory-safety obligations in the codebase, and can reviewers identify them reliably?

What Is Unsafe Code?

Most C# code is managed and verifiably safe.

For example:

int[] values = [10, 20, 30];

Console.WriteLine(values[1]);

The runtime manages the array's memory and performs the appropriate safety checks.

Unsafe code can directly interact with memory through pointers:

unsafe
{
    int value = 42;
    int* pointer = &value;

    Console.WriteLine(*pointer);
}

The compiler and runtime cannot provide the same verification guarantees for the pointer dereference.

Microsoft's C# documentation describes unsafe code as code whose safety cannot be verified by .NET tools. It can be useful for native interop and certain performance-sensitive scenarios, but it introduces additional security and stability risks.

The Traditional Unsafe Model

Historically, the existence of pointer syntax generally required an unsafe context.

For example:

unsafe
{
    int value = 42;
    int* pointer = &value;

    Console.WriteLine(*pointer);
}

The unsafe context covers the entire block.

A method could also be marked unsafe:

public static unsafe int ReadValue(int* pointer)
{
    return *pointer;
}

This makes the complete method an unsafe context.

The traditional model is relatively simple:

Pointer-related code
        |
        v
unsafe context

But it does not precisely distinguish between operations that merely describe a pointer and operations that actually access unmanaged memory.

C# 15 begins changing that distinction.

What Changes in C# 15?

The updated memory-safety model focuses on operations that actually access unmanaged memory.

Under the C# 15 preview model, some pointer-related operations no longer require an unsafe context.

These include:

  • Declaring a pointer type.

  • Taking an address with &.

  • Using fixed.

  • Converting stackalloc to a pointer.

  • Applying sizeof to an unmanaged type.

Operations that actually access pointed-to memory remain unsafe.

For example:

int value = 42;

int* pointer = &value;

unsafe
{
    Console.WriteLine(*pointer);
}

The pointer can be created outside the unsafe context, while dereferencing it remains an unsafe operation.

Microsoft explicitly describes this distinction as one of the first steps in the C# 15 memory-safety redesign.

Pointer Existence Is Not the Same as Memory Access

This is the central idea behind the redesign.

Consider:

int value = 42;

int* pointer = &value;

The pointer exists, but no unmanaged memory has been accessed through it yet.

Now consider:

int result = *pointer;

This operation actually accesses the memory represented by the pointer.

The distinction becomes:

Create pointer
     |
     v
Potentially unsafe representation
     |
     v
Dereference pointer
     |
     v
Actual unmanaged memory access

The second operation deserves closer scrutiny during a security review.

Why This Matters for Code Audits

Consider a large codebase containing:

Application
 |
 +-- NativeInterop
 +-- ImageProcessing
 +-- Compression
 +-- Serialization
 +-- Performance
 +-- Hardware

A security reviewer searching for:

unsafe

may find many declarations.

But the important questions are:

  • Where is memory actually dereferenced?

  • Where are native functions invoked?

  • Where can an invalid pointer be created?

  • Where is a buffer length trusted?

  • Where can a pointer escape its intended lifetime?

  • Where does managed memory interact with native code?

The C# 15 model is designed to make these boundaries more explicit.

Microsoft's stated goal is to make safety assumptions visible and reviewable rather than leaving them implied by convention.

Auditing Existing Unsafe Code

Start with an inventory.

Search the solution for:

unsafe
*
&
fixed
stackalloc
sizeof
delegate*
DllImport
LibraryImport
Marshal
NativeMemory

Do not assume every match represents the same risk.

Classify each usage.

CategoryExampleReview Priority
Pointer declarationint* pMedium
Pointer dereference*pHigh
Pointer indexingp[i]High
Function pointer calldelegate* invocationHigh
Native interopLibraryImportHigh
stackallocTemporary stack memoryMedium/High
Explicit layoutNative-compatible structuresHigh
Managed wrapperValidated API boundaryMedium

The priority should reflect the actual application and threat model.

Example: Pointer Dereference

Consider:

public static unsafe int ReadInt(int* pointer)
{
    return *pointer;
}

The method makes a clear safety boundary.

A reviewer immediately knows:

ReadInt
   |
   +-- Requires pointer
   |
   +-- Dereferences pointer
   |
   +-- Caller must provide valid memory

The important contract is not simply that the method uses a pointer.

The contract is that the caller must ensure the pointer is valid for the operation.

Make Safety Assumptions Explicit

A useful code-review question is:

What must be true for this operation to be safe?

For:

return *pointer;

possible assumptions include:

  • pointer is non-null.

  • The referenced memory is valid.

  • The memory is readable.

  • The memory remains valid for the operation.

  • The pointer refers to the expected type.

  • The memory is sufficiently aligned where required.

  • The pointer is not stale.

The compiler cannot infer all of these application-level invariants.

They should therefore be documented and enforced where possible.

Wrap Unsafe Operations Behind Safe APIs

One of the strongest patterns is to isolate unsafe implementation details.

Instead of exposing:

public static unsafe int ReadValue(int* pointer)
{
    return *pointer;
}

to the entire application, create a validated boundary.

For example:

public static int ReadValue(
    ReadOnlySpan<byte> buffer)
{
    if (buffer.Length < sizeof(int))
    {
        throw new ArgumentException(
            "Buffer is too small.",
            nameof(buffer));
    }

    return BitConverter.ToInt32(
        buffer[..sizeof(int)]);
}

This allows most callers to remain in safe managed code.

The architectural boundary becomes:

Application
     |
     v
Safe API
     |
     v
Validated Input
     |
     v
Unsafe Implementation
     |
     v
Native / Raw Memory

The less code that needs to reason about raw memory, the easier the application is to audit.

Native Interop Is an Important Boundary

Unsafe code often exists because an application calls native APIs.

For example:

[DllImport("native-library")]
private static extern int ProcessBuffer(
    IntPtr buffer,
    int length);

The danger is not necessarily the declaration itself.

The security-critical assumptions are around:

buffer
length
lifetime
ownership
encoding
native implementation

A safer design validates these properties before making the call.

Modern .NET applications can also use source-generated LibraryImport APIs where appropriate.

The important principle remains:

Treat native boundaries as trust boundaries.

Audit Buffer Lengths

One of the most common sources of memory-safety bugs is incorrect buffer length handling.

Consider:

unsafe
{
    fixed (byte* p = buffer)
    {
        NativeProcess(p, buffer.Length);
    }
}

The correctness of this operation depends on the native function honoring the supplied length.

A dangerous implementation might assume a larger buffer.

Therefore, document the native contract:

NativeProcess
Input:
  Pointer -> readable buffer
  Length  -> number of valid bytes

Requirements:
  Pointer must remain valid during call.
  Length must not exceed allocated buffer.

Then test the boundary.

Validate Before Entering Unsafe Code

A useful pattern is:

public static void Process(
    ReadOnlySpan<byte> data)
{
    if (data.IsEmpty)
    {
        return;
    }

    if (data.Length > MaxBufferSize)
    {
        throw new ArgumentOutOfRangeException(
            nameof(data));
    }

    ProcessUnsafe(data);
}

The unsafe implementation can then assume validated invariants:

private static unsafe void ProcessUnsafe(
    ReadOnlySpan<byte> data)
{
    fixed (byte* pointer = data)
    {
        NativeProcess(pointer, data.Length);
    }
}

This creates a clear separation:

Validation
    |
    v
Safety Invariants
    |
    v
Unsafe Operation

fixed and Pointer Lifetime

Pinning managed memory prevents the garbage collector from moving the object while the pointer is being used.

For example:

unsafe
{
    fixed (byte* pointer = buffer)
    {
        NativeProcess(pointer, buffer.Length);
    }
}

The pointer should not escape the lifetime of the fixed statement.

Do not store the pointer for later use unless the memory lifetime and ownership model explicitly support it.

This becomes particularly important around asynchronous code.

Never Carry Stack Pointers Across await

Unsafe code and asynchronous suspension require careful separation.

Consider the problematic pattern:

unsafe
{
    int value = 42;
    int* pointer = &value;

    await Task.Delay(10);

    Console.WriteLine(*pointer);
}

The local variable's lifetime and storage assumptions do not safely map onto an asynchronous state machine.

The C# compiler specifically prevents await expressions inside an unsafe context and warns against taking addresses of locals or parameters in async methods under relevant scenarios.

The safer architecture is to isolate the unsafe operation:

public static async Task<int> ProcessAsync()
{
    int result = ReadValue();

    await Task.Delay(10);

    return result;
}

private static unsafe int ReadValue()
{
    int value = 42;
    int* pointer = &value;

    return *pointer;
}

The pointer operation completes before the asynchronous suspension.

stackalloc Requires Careful Review

stackalloc allocates memory on the stack.

For example:

Span<byte> buffer = stackalloc byte[256];

buffer.Clear();

This can be efficient for small temporary buffers.

But developers should understand its lifetime.

The memory is associated with the current stack frame and should not escape that scope.

A useful audit question is:

Can this stack-backed memory outlive the method or scope that created it?

Also review variable sizes carefully.

Avoid blindly allocating large dynamic buffers on the stack:

Span<byte> buffer =
    stackalloc byte[userControlledSize];

If the size can be controlled by external input, validate it.

For example:

if (size <= 0 || size > 4096)
{
    throw new ArgumentOutOfRangeException(
        nameof(size));
}

Span<byte> buffer =
    stackalloc byte[size];

The limit should be chosen based on the actual application's requirements.

Function Pointers

C# supports function pointers using delegate*.

For example:

unsafe
{
    delegate* managed<int, int> operation =
        &Square;

    int result = operation(5);
}

Function pointer invocation is explicitly one of the operations that remains unsafe under the updated C# 15 model.

Reviewers should therefore treat function pointer calls as explicit native or low-level execution boundaries.

Questions to ask include:

  • Where did the function pointer originate?

  • Is the signature correct?

  • Is the target still valid?

  • Can untrusted data influence the target?

  • Is the calling convention correct?

  • Can the function pointer escape its intended scope?

The New unsafe Contract Model

The broader C# 15 design changes the meaning of unsafe on a member.

Under the proposed updated model, marking a member unsafe can mean that callers inherit a safety obligation.

Conceptually:

Unsafe implementation
        |
        v
unsafe member
        |
        v
Caller must acknowledge
safety obligation

This is different from the historical interpretation where unsafe primarily established an unsafe context inside the declaration.

Microsoft describes this as making the audit obligation flow to callers.

safe as the Counterpart

The redesigned model also introduces a safe contextual keyword for declarations where the compiler cannot automatically determine safety.

The concept is:

unsafe
   |
   +-- Caller must acknowledge safety obligation

safe
   |
   +-- Declaration explicitly attests safety

This is particularly relevant to extern declarations and certain explicit-layout fields.

However, developers should be careful when copying proposed syntax from feature specifications.

The C# 15 memory-safety feature is still evolving in preview, and Microsoft notes that different preview releases implement different parts of the design.

Assembly-Level Opt-In

The complete model also introduces an assembly-level opt-in mechanism.

Microsoft documents MemorySafetyRulesAttribute as the metadata marker associated with the updated safety rules.

This matters for large applications because memory-safety rules can become a repository-wide contract rather than a local compiler setting.

The intended progression is roughly:

Existing unsafe code
       |
       v
Audit boundaries
       |
       v
Move unsafe inward/outward
       |
       v
Document safety assumptions
       |
       v
Opt into updated rules
       |
       v
Compiler-enforced contracts

This incremental approach is important for large legacy codebases.

Do Not Rewrite All Unsafe Code Immediately

A common mistake is treating the new model as a reason to rewrite every unsafe method.

A better migration strategy is incremental.

Start with the highest-risk code:

Native Interop
      |
      v
Pointer Dereferences
      |
      v
Function Pointers
      |
      v
Manual Memory Management
      |
      v
Unsafe Buffer Operations

Then document the assumptions around each boundary.

The goal is not to eliminate every pointer.

The goal is to make the safety boundary explicit and reviewable.

Build an Unsafe Code Inventory

A practical inventory can contain:

LocationOperationRiskInvariantCaller ObligationTest
NativeInterop.csPointer callHighBuffer validValid bufferYes
ImageDecoder.csp[i]HighLength checkedValid dimensionsYes
Compression.csstackallocMediumSize boundedBounded inputYes
Math.csFunction pointerHighSignature validTrusted delegateYes

This transforms an informal code-review problem into an auditable engineering artifact.

Write Safety Contracts

For every unsafe boundary, document:

Purpose:
Why is unsafe code required?

Input:
What inputs are accepted?

Memory:
Which memory is accessed?

Lifetime:
How long must it remain valid?

Size:
How is the accessible range determined?

Ownership:
Who owns the memory?

Caller:
What must the caller guarantee?

Failure:
What happens when an invariant is violated?

For example:

Method: ProcessBuffer

Input:
Read-only byte buffer

Memory:
Reads only within buffer.Length

Lifetime:
Pointer used only during method execution

Size:
Maximum 1 MB

Ownership:
Caller retains ownership

Caller obligation:
Buffer must contain complete encoded record

This type of documentation is useful even before adopting the updated compiler model.

Test Memory-Safety Boundaries

Unsafe code should have stronger tests than ordinary business logic.

Test:

Empty buffer
Minimum buffer
Maximum buffer
Oversized buffer
Invalid pointer
Invalid length
Truncated input
Malformed native data
Concurrent access
Repeated execution

For example:

[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Rejects_Invalid_Size(int size)
{
    Assert.Throws<ArgumentOutOfRangeException>(
        () => Process(size));
}

For buffer-based APIs:

[Fact]
public void Rejects_Truncated_Buffer()
{
    var buffer = new byte[2];

    Assert.Throws<ArgumentException>(
        () => Process(buffer));
}

The exact exception type should match the API contract.

Fuzz Native and Unsafe Boundaries

Traditional unit tests may cover known inputs but miss unexpected combinations.

For parsers, decoders, and binary protocols, fuzzing can be particularly useful.

A simplified test concept is:

Random Input
     |
     v
Safe Validation
     |
     v
Unsafe Parser
     |
     v
No crash
No memory corruption
No unexpected process termination

The exact fuzzing technology depends on the project.

The important point is to make native and unsafe boundaries explicit fuzzing targets.

Common Mistakes

Assuming unsafe Means "Dangerous Everywhere"

The unsafe keyword identifies a region requiring additional safety reasoning.

It does not automatically mean the code is vulnerable.

A carefully validated native wrapper can be safer than poorly validated managed code.

Assuming Pointer Creation Is the Same as Pointer Dereference

C# 15 specifically separates these concepts in the updated model.

Returning Raw Pointers From Public APIs

This can spread the safety obligation across the application.

Prefer safe abstractions where possible.

Mixing Unsafe Operations With Async Lifetimes

Pointers to stack or managed memory require careful lifetime management.

Keep unsafe operations isolated from await boundaries.

Trusting Native Functions

A managed wrapper does not make the native library safe automatically.

Review the native API contract.

Using User-Controlled Sizes With stackalloc

Bound externally supplied sizes before allocating memory on the stack.

Assuming Preview Behavior Is Final

The C# 15 memory-safety model is still under development.

Microsoft explicitly states that the preview continues to evolve.

Troubleshooting C# 15 Unsafe-Code Errors

The Compiler Says an Operation Requires unsafe

Check whether the operation actually accesses unmanaged memory.

For example:

var value = *pointer;

still requires an unsafe context under the updated model.

unsafe Appears Unnecessary

C# 15's updated rules may allow some pointer operations without an unsafe context.

Microsoft documents diagnostics for unnecessary unsafe modifiers under the new model.

An Unsafe Member Cannot Be Called From Safe Code

Under the updated memory-safety contract model, a member marked unsafe can propagate a safety obligation to its caller.

Move the call behind a validated safe boundary where appropriate.

await Cannot Be Used in an Unsafe Context

Separate the unsafe operation into its own synchronous method and invoke it before or after the asynchronous portion.

A Practical Migration Strategy

For an existing application, use this sequence:

Inventory unsafe code
        |
        v
Classify memory operations
        |
        v
Identify actual dereferences
        |
        v
Document invariants
        |
        v
Add validation
        |
        v
Create safe wrappers
        |
        v
Add boundary tests
        |
        v
Separate unsafe + async code
        |
        v
Review native interop
        |
        v
Evaluate C# 15 preview rules

Do not begin by mechanically adding or removing unsafe.

Start by understanding the actual memory-safety contract.

Unsafe Code Review Checklist

Before approving unsafe code, ask:

  • Why is unsafe code necessary?

  • Can a managed API solve the same problem?

  • What memory is accessed?

  • How is the memory validated?

  • How is the buffer length validated?

  • Who owns the memory?

  • How long is the pointer valid?

  • Can the pointer escape?

  • Can untrusted input influence the address or length?

  • Is native code involved?

  • Is the operation separated from asynchronous suspension?

  • Are boundary tests present?

  • Are malformed and oversized inputs tested?

  • Is the unsafe surface area as small as practical?

Frequently Asked Questions

Is unsafe code being removed from C#?

No.

C# continues to support unsafe code.

The C# 15 effort changes how memory-safety boundaries are expressed and enforced rather than eliminating pointers or native interop.

Is C# 15 memory safety production-ready?

The updated memory-safety model is currently a preview feature associated with C# 15 and .NET 11 development. The implementation and complete contract model are still evolving.

Does creating a pointer still require unsafe?

Under the C# 15 preview model, pointer declaration and taking an address with & can be performed without an unsafe context. Pointer dereference and other direct memory-access operations still require one.

Should every unsafe method be rewritten?

No.

The better approach is to identify the actual safety boundaries, validate inputs, reduce the unsafe surface area, and document the assumptions that callers must satisfy.

Does the new model prevent memory corruption?

No language feature should be treated as a complete substitute for careful programming and testing.

The purpose of the new model is to make potentially unsafe operations and caller obligations more explicit and enforceable.

Conclusion

C# 15's memory-safety work represents an important change in how developers should think about unsafe code.

The traditional model largely associates the unsafe context with pointer syntax. The updated model moves toward a more precise distinction: the existence of a pointer is not necessarily the unsafe operation; accessing unmanaged memory through that pointer is.

For development teams, the practical benefit is an opportunity to audit unsafe code based on actual safety obligations.

A strong audit process looks like:

Find Unsafe Code
       |
       v
Identify Memory Access
       |
       v
Document Invariants
       |
       v
Validate Inputs
       |
       v
Isolate Unsafe Operations
       |
       v
Test Boundaries
       |
       v
Review Caller Obligations

The most important principle is:

Do not audit unsafe by keyword alone. Audit the memory-safety contract around every operation.

C# 15 is moving toward a model where those contracts become more visible to both the compiler and human reviewers. For existing .NET codebases, the best preparation is not a wholesale rewrite. It is a disciplined inventory of unsafe operations, explicit safety assumptions, narrow unsafe boundaries, and tests that prove those assumptions remain valid.