Skip to content

Latest commit

 

History

History
594 lines (433 loc) · 35.4 KB

File metadata and controls

594 lines (433 loc) · 35.4 KB

Unsafe Evolution

Champion issue: #9704

Summary

We update the definition of unsafe in C# from referring to locations where pointer types are used, to be locations where memory unmanaged by the runtime is dereferenced. These locations are where memory unsafety occurs, and are responsible for the bulk of CVEs (Common Vulnerabilities and Exposures) categorized as memory safety issues.

// Under the proposed rules:
void M()
{
    int i = 1;
    int* ptr = &i; // Allowed: creating a pointer is not itself unsafe
    unsafe
    {
        Console.WriteLine(*ptr); // Dereference of memory not managed by the runtime. This is unsafe.
        ref int intRef = Unsafe.AsRef(ptr); // Conversion of memory not managed by the runtime to a `ref`. This is unsafe.
    }
}

namespace System.Runtime.CompilerServices
{
    public static class Unsafe
    {
        [RequiresUnsafe] // APIs annotated with this attribute need an unsafe context.
        public static ref T AsRef<T>(void* source) { /* ... */ }
    }
}

Motivation

Background for this feature can also be found in https://github.com/dotnet/designs/blob/main/accepted/2025/memory-safety/caller-unsafe.md, which tracks the broader ecosystem changes that will be needed as part of this proposal. These include BCL updates to properly annotate methods as being unsafe, as well as tooling updates for better understanding of where memory unsafety occurs. For C# specifically, we want to make sure that memory unsafety is properly tracked by the language; today, it can be difficult to look at a program holistically and understand all locations where memory unsafety occurs. This is because various helpers such as the System.Runtime.CompilerServices.Unsafe, System.Runtime.InteropServices.Marshal, and others do not express that they violate memory safety and need special consideration. Methods that then use these helpers aren't immediately obvious, and when auditing code for memory safety issues (either ahead of time when doing review, or when trying to determine the cause of a vulnerability that is being reported) it can be difficult to pinpoint the locations that could be contributing to issues.

Historically, unsafe in C# has referred to a specific memory-safety hole: the existence of pointer types. The moment that a pointer type is no longer involved, C# is perfectly happy to let memory unsafety lie latent in code. It is this issue that we are looking to address with this evolution of unsafe in C# and the .NET ecosystem, labeling areas where memory unsafety could potentially occur, making it easier for reviewers and auditors to understand the boundaries of potential memory unsafety in a program. Importantly, this means that we will be changing the meaning of unsafe, not just augmenting it. The existence of a pointer is not itself unsafe; the unsafe action is dereferencing the pointer. This extends further to types themselves; types cannot be inherently unsafe. It is only the action of using a type that could be unsafe, not the existence of that type.

In order for this information to flow through the system, we therefore need to have a way to mark methods themselves as unsafe. Applying an attribute (RequiresUnsafe) to a member will indicate that the member has memory safety concerns and any usages must be manually validated by the programmer using the member (the error will go away if the member is used inside an unsafe context). We are not going to use the unsafe modifier in signature to denote requires-unsafe members to avoid a breaking change (it won't even be required to allow pointers in signature as pointers are now safe; it will merely introduce an unsafe context).

Nevertheless, this is still a breaking change for particular segments of the C# user base. Our hope is that, for many of our users, this is effectively transparent, and updating to the new rules will be seamless. However, given that some large API surfaces like large parts of reflection may need to be marked unsafe, we do think it likely that there will need to be a decent on-ramp to the new rules to avoid entirely bifurcating the ecosystem.

Breaking changes

The following breaking changes can be observed when updating to a compiler implementing this language feature.

  • Specifying [RequiresUnsafe] attribute on unsupported symbol kinds is a compile-time error.
  • If the updated memory safety rules are enabled (which might be the default or even the only option in a future .NET version):
    • APIs marked with [RequiresUnsafe] or extern require an unsafe context when used.
    • stackalloc under certain conditions requires an unsafe context.
  • Under a new warnlevel:
    • Applying [RequiresUnsafe] warns if the updated memory safety rules are not enabled.

Detailed Design

Terminology: we call members requires-unsafe (previously known as caller-unsafe) if

Existing unsafe rules

The existing C# specification has a large section devoted to unsafe: §24 Unsafe code. It is defined as conditionally normative, as it is not required for a valid C# compiler to support the unsafe feature. Much of what is currently considered conditionally normative will no longer be so after this change, as most of the definition of pointers is no longer considered unsafe in itself. Pointer types, Fixed and moveable variables, all pointer expressions (except for pointer indirection, pointer member access, and pointer element access), and the fixed statement are all no longer considered unsafe, and exist in normal C# with no requirement to be used in an unsafe context. Similarly, declaring a fixed size buffer or an initialized stackalloc are also perfectly legal in safe C#. For all of these cases, it is only accessing the memory that is unsafe.

Given the extensive rewrite of both the unsafe code section and other parts C# specification inherent in this change, it would be unwieldy and likely not useful to provide a line-by-line diff of the existing rules of the specification. Instead, we will provide an overview of the change to make in a given section, as well as specific new rules for what is allowed in unsafe contexts.

Redefining expressions that require unsafe contexts

The following expressions require an unsafe context when used:

In addition to these expressions, expressions and statements can also conditionally require an unsafe context if they depend on any symbol that is marked as unsafe. For example, calling a method that is requires-unsafe will cause the invocation_expression to require an unsafe context. Statements with invocations embedded (such as usings, foreach, and similar) can also require an unsafe context when they use a requires-unsafe member.

When we say "requires an unsafe context" or similar in this document, it means emitting an error that the construct requires an unsafe context to be used.

Note

This section probably needs expansion to formally declare what each expression and statement must consider to require an unsafe context.

Pointer types

As mentioned, pointers become no longer inherently unsafe. Any references to unsafe contexts in §24.3 are deleted. Pointer types exist in normal C# and do not require unsafe to bring them into existence. The type definitions should be worked into §8.1 and its following sections, as other types.

Similarly, pointer conversions should be worked into §10, with references to unsafe contexts removed.

Similarly, pointer expressions, except for pointer indirection, pointer member access, and pointer element access, should be worked into §12, with references to unsafe contexts removed. No semantics change about the meaning of these expressions; the only change is that they no longer require an unsafe context to use.

For pointer indirection, pointer member access, and pointer element access, these operators remain unsafe, as these access memory that is not managed the runtime. They remain in §24, and continue to require an unsafe context to be used. Any use outside of an unsafe context is an error. No semantics about these operators change; they still continue to mean exactly the same thing that they do today. These expressions must always occur in an unsafe context.

The fixed statement moves to §13, with references to unsafe contexts removed.

Function pointers are not yet incorporated into the main C# specification, but they are similarly affected; everything but function pointer invocation is moved into the standard specification. A function pointer invocation expression must always occur in an unsafe context.

Fixed-size buffers

The story for fixed-size buffers is similar to pointers. The definition of a fixed-size buffer is not itself dangerous, and moves to §16.3. Accessing a fixed-size buffer in an expression is similarly safe, unless the expression occurs as the primary_expression of an element_access; these are evaluated as a pointer_element_access, which is unsafe, as per the rules above.

Stack allocation

Again, the story for stack allocation is very similar to pointers. Converting a stackalloc to a pointer is no longer unsafe; it is the deference of that pointer that is unsafe. We do add one new rule, however:

A stackalloc_expression is unsafe if all of the following statements are true:

  • The stackalloc_expression is being converted to a Span<T> or a ReadOnlySpan<T>.
  • The stackalloc_expression does not have a stackalloc_initializer.
  • The stackalloc_expression is used within a member that has SkipLocalsInitAttribute applied.

In these contexts, the resulting stack space could have unknown memory contents, and it is being converted to a type that provides a safe wrapper around unmanaged memory access. This violates the contract of Span<T> and ReadOnlySpan<T>, and so must be subject to extra scrutiny by the author and reviewers of such code.

Note

This means that assigning a stackalloc to a pointer is always safe, regardless of context.

sizeof

For certain predefined types, sizeof has always been constant and safe (§12.8.19) and that remains unchanged under the new rules. For other types, sizeof used to require unsafe context (§24.6.9) but it is now safe under the new memory safety rules.

Overriding, inheritance, and implementation

It is a memory safety error to add RequiresUnsafe at the member level in any override or implementation of a member that is not requires-unsafe originally, because callers may be using the base definition and not see any addition of RequiresUnsafe by a derived implementation.

Delegates and lambdas

It is a memory safety error to convert a requires-unsafe member to a delegate type outside the unsafe context. Delegate types and function types cannot be requires-unsafe. It is a compile-time error to apply RequiresUnsafe on a lambda symbol.

extern

Because extern methods are to native locations that cannot be guaranteed by the runtime, any extern method is automatically considered requires-unsafe if compiled under the updated memory safety rules (i.e., it gets the RequiresUnsafeAttribute). Even methods that only take unmanaged parameters by value cannot be safely called by C#, as the calling convention used for the method could be incorrectly specified by the user and must be manually verified by review.

extern methods from assemblies using the legacy memory safety rules are not considered implicitly unsafe because extern is considered implementation detail that is not part of public surface. extern is not guaranteed to be preserved in reference assemblies.

Note that this is different from the compat mode which applies to legacy-rules assemblies too because methods with pointers in signature would always need an unsafe context at the call site.

Unsafe modifiers and contexts

Today (and unchanged in this proposal), as covered by the unsafe context specification, unsafe behaves in a lexical manner, marking the entire textual body contained by the unsafe block as an unsafe context (except for iterator bodies), and also some surrounding contexts in case of declarations:

class A : Attribute
{
    [RequiresUnsafe] public A() { }
}
class C
{
    [A] void M1() { } // error: cannot use `A..ctor` in safe context
    [A] unsafe void M1() { } // ok: the `unsafe` context applies to the `A..ctor` usage
}

Since pointer types are now safe, an unsafe modifier on declarations without bodies does not have a meaning anymore. Hence unsafe on the following declarations will produce a warning:

  • delegate.

RequiresUnsafe on a member is not applied to any nested anonymous or local functions inside the member. To mark an anonymous or local function as requires-unsafe, it must manually be marked as RequiresUnsafe. The same goes for anonymous and local functions declared inside of an unsafe block.

When a member is partial, both parts must agree on the unsafe modifier, but only one can specify the RequiresUnsafe attribute, unchanged from C# rules today.

For properties, get and set/init accessors can be independently declared as RequiresUnsafe; marking the entire property as RequiresUnsafe means that both the get and set/init accessors are requires-unsafe. For events, add and remove accessors can be independently declared as RequiresUnsafe; marking the entire event as RequiresUnsafe means that both the add and remove accessors are requires-unsafe.

Attributes

When an assembly is compiled with the new memory safety rules, it gets marked with MemorySafetyRulesAttribute (detailed below), filled in with 15 as the language version. This is a signal to any downstream consumers that any members defined in the assembly will be properly attributed with RequiresUnsafeAttribute (detailed below) if an unsafe context is required to call them. Any member in such an assembly that is not marked with RequiresUnsafeAttribute does not require an unsafe context to be called, regardless of the types in the signature of the member.

It is an error to apply the MemorySafetyRulesAttribute to any symbol explicitly in source.

The compiler ignores RequiresUnsafeAttribute-marked members from assemblies that are using the legacy memory safety rules (instead, the compat mode is used there).

The compiler will emit a warning if RequiresUnsafe is used under the legacy memory safety rules and an error if it is applied to unsupported symbol kinds (note that this excludes symbol kinds that should be banned already by AttributeUsageAttribute on the attribute's definition):

  • destructors,
  • static constructors,
  • lambdas.

When a member under the new memory safety rules is extern, the compiler will implicitly apply the RequiresUnsafeAttribute to the member in metadata. When a user-facing requires-unsafe member generates hidden members, such as an auto-property's get/set methods, both the user-facing member and any hidden members generated by that user-facing member are all requires-unsafe, and RequiresUnsafeAttribute is applied to all of them.

The MemorySafetyRulesAttribute definition is synthesized by the compiler if necessary per standard well-known member rules. The RequiresUnsafeAttribute definition is not synthesized by the compiler, and a compilation error is reported if the expected attribute constructor cannot be resolved per standard well-known member rules.

namespace System.Runtime.CompilerServices
{
    /// <summary>Indicates the language version of the memory safety rules used when the module was compiled.</summary>
    [AttributeUsage(AttributeTargets.Module, Inherited = false)]
    public sealed class MemorySafetyRulesAttribute : Attribute
    {
        /// <summary>Initializes a new instance of the <see cref="MemorySafetyRulesAttribute"/> class.</summary>
        /// <param name="version">The language version of the memory safety rules used when the module was compiled.</param>
        public MemorySafetyRulesAttribute(int version) => Version = version;
 
        /// <summary>Gets the language version of the memory safety rules used when the module was compiled.</summary>
        public int Version { get; }
    }

    [AttributeUsage(AttributeTargets.Event | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
    public sealed class RequiresUnsafeAttribute : Attribute
    {
    }
}

Compat mode

For compat purposes, and to reduce the number of false negatives that occur when enabling the new rules, we have a fallback rule for modules that have not been updated to the new rules. For such modules, a member is considered requires-unsafe if it contains a pointer or function pointer type somewhere among its parameter types or return type (can be nested in a non-pointer type, e.g., int*[]). Note that this doesn't apply to pointers in constraint types (e.g., where T : I<int*[]>) as those wouldn't need unsafe context at the call sites previously either.

This does not include substituted generic parameters (e.g., method I<T>.M(T) when substituted T for int*[]) as there is no type-safe way for the target member to use that pointer type for anything anyway.

VB

We do not need to add support to Visual Basic for [RequiresUnsafe] members since there are no unsafe contexts in VB today and no way to work with pointers there either.

Alternatives

Use unsafe to denote requires-unsafe members

Instead of using RequiresUnsafeAttribute to denote requires-unsafe members, we could use the unsafe keyword on the member (and only use the attribute for metadata representation of requires-unsafe members). See a previous version of this speclet before the alternative was incorporated into it.

Advantages of unsafe:

  • similar to other languages and hence easier to understand,
  • more discoverable than an attribute.

Advantages of an attribute (or another keyword):

  • avoids breaking existing members marked as unsafe,
  • incremental adoption possible (member-by-member),
  • doesn't force marking the whole body as unsafe (even with unsafe keyword we could change unsafe to not have an effect on bodies though).

Open questions

Local functions/lambda safe contexts

Right now unsafe on a method body is lexically scoped. Any nested local functions or lambdas inherit this, and their bodies are in a memory unsafe context. Is this behavior that we want to keep in the language?

Delegate type unsafety

We could allow marking delegate types and lambdas (and function types) as requires-unsafe. This would require several additional rules (outside unsafe context):

  • disallow using requires-unsafe delegates as type arguments,
  • disallow converting those delegates to anything that's not requires-unsafe (Delegate,Expression, and object), Without this, there is a risk of forcing unsafe annotations in the wrong spot and having an area where the real area of unsafety isn't properly called out.

Lambda/method group conversion to safe delegate types

If we allow unsafe lambdas and delegates, should conversion of a requires-unsafe lambda or method group to a non-requires-unsafe delegate type permitted without warning or error in an unsafe context? If we don't do this, then it could be fairly painful for various parts of the ecosystem, particularly any enumerables that are passed through LINQ queries.

Lambda/method group natural types

Today, the only real impact on semantics and codegen (besides additional metadata) is changing the function_type of a lambda or method group when marked as RequiresUnsafe. If we were to avoid doing this, then there would be no real impact to either, which could give adopters more confidence that behavior has not subtly changed under the hood.

stackalloc as initialized

Today, the spec always considers stackalloc memory as uninitialized, and says that the contents are undefined unless manually cleared or assigned. Do we consider this a spec bug, or do we need to change what we consider unsafe for stackalloc purposes?

unsafe expressions

Other languages with more comprehensive unsafe features have added unsafe as an expression, to enable improved user ergonomics and allow authors to more precisely limit where unsafe is used. Is this something that we want to have in C#? Consider an inline call to an unsafe member that handles the safety directly: right now, the author would either need to wrap the entire statement in an unsafe block, expanding the scope of the unsafe context, or they would need to break out the inner function call into an intermediate variable.

extern int Add(int i1, int i2); // Some fancy extern addition function

// Code I want to write:
Console.WriteLine(unsafe(Add(1, 2)));

// Code I have to write option 1, unsafe context unnecessary includes the WriteLine call
unsafe
{
    Console.WriteLine(Add(1, 2));
}

// Code I have to write option 2, very verbose and harder to read:
int result;
unsafe
{
    result = Add(1, 2);
}
Console.WriteLine(result);

More unsafe contexts and relaxations

Before the change to use an attribute instead of a modifier to denote requires-unsafe members, we had allowed unsafe modifier on property accessors (we hadn't allowed it on event accessors since there were no modifiers allowed previously). We since reverted that. Should we still allow it even though it wouldn't denote requires-unsafe members anymore, just an unsafe context?

If we are allowing more unsafe contexts, should we relax more restrictions around unsafe and pointer parameters in iterators and async methods too? Especially allowing await UnsafeMethod() would be useful because now users have to rewrite that to Task t; unsafe { t = UnsafeMethod(); } await t;. See ref/unsafe in iterators/async for more details.

Should we also allow &UnsafeMethod in safe context? Today as the proposal stands, this requires unsafe context if the method is marked as [RequiresUnsafe]. But since we are just getting its address, which will need unsafe context when dereferenced/called, we could allow the address-of itself in a safe context.

unsafe on types

We could consider not automatically making the entire lexical scope of an unsafe type to be an unsafe context and warn for an unsafe on a type as it would have no meaning apart from edge cases like the following which we might not care about because they have no real-world use-cases:

class A : Attribute
{
    [RequiresUnsafe] public A() { }
}
[A] class C; // unavoidable error for using requires-unsafe A..ctor?
[A] unsafe class C; // if unsafe still introduces an unsafe context, this makes the error go away

More meaningless unsafe warnings

Should more declarations produce the meaningless unsafe warning? For example, fields without initializers (assuming we don't support requires-unsafe fields), methods with empty bodies (or extern), etc. We already have an IDE analyzer for unnecessary unsafe though.

Requires-unsafe fields

Today, no proposal is made around RequiresUnsafe on a field. We may need to add it though, such that any read from or write to a field marked as requires-unsafe must be in an unsafe context. This would enable us to better annotate the concerns around code such as:

class SafeWrapper
{
     internal byte* _p;

     public void DoStuff()
     {
            unsafe
            {
                  // ... validate that the object state is good ...
                  // ... perform operation with _p .... 
            }
     }

}

// Elsewhere in safe code:
void M(SafeWrapper w)
{
     w._p = stackalloc byte[10];
}

Should we also mark auto-property's backing field with [RequiresUnsafe]?

Taking the address of an uninitialized variable

Today, taking the address of a not definitely assigned variable can consider that variable definitely assigned, exposing uninitialized member. We have a couple of options to solving that:

  1. Require that variables be definitely assigned before allowing an address-of operator to be used on them.
  2. Make taking the address of an uninitialized variable unsafe.

Examples:

static void SkipInit<T>(out T value)  
{
    // value is considered definitely assigned after the address-of
    fixed (void* ptr = &value);
}
int i;
// i is considered definitely assigned after the address-of
_ = &i;
// Incrementing whatever was on the stack
i++;

Value of MemorySafetyRulesAttribute

What should be the "enabled"/"updated" memory safety rules version? 2? 15? 11? See also https://github.com/dotnet/designs/blob/main/accepted/2025/memory-safety/sdk-memory-safety-enforcement.md.

extern implicitly unsafe

This is currently the only place where RequiresUnsafeAttribute is implicitly applied by the compiler. Are we okay with this outlier?

Also, CoreLib exposes many extern methods (FCalls) as safe today. Treating extern methods as implicitly unsafe will require wrapping the implicitly unsafe extern methods with a safe wrapper. We may run into situations where adding the extra wrapper is difficult due to runtime implementation details.

RequiresUnsafe on partial members

It is required to have the unsafe modifier at both partial member parts by pre-existing C# rules. On the other hand, attributes may be specified only at one of those parts and even cannot be specified at both parts unless they have AllowMultiple, but then they are effectively present multiple times. We have changed the way to denote requires-unsafe members via an attribute instead of the unsafe modifier but haven't discussed this aspect of the change. Should we allow the attribute to be specified multiple times (via AllowMultiple or via special compiler behavior for this attribute and partial members only), or even require it (via special compiler checks for this attribute only)?

new() constraint

Do we want to support new() with requires-unsafe (something we currently don't seem to support in the compiler for other features, like Obsolete)?

M<C>(); // should be an error outside `unsafe` context since `M` calls the requires-unsafe `C..ctor`?

void M<T>() where T : new()
{
    _ = new T();
}

class C
{
    [RequiresUnsafe] public C() { }
}

new() constraint and usings

How should it behave in aliases and static usings?

  • Should it be an error at the using declaration, suppressable via the unsafe keyword we already support there, or
  • should it be an error normally at the use site like it would be if used directly without an alias or static using?

Note

In the second case, we would need to add "meaningless unsafe" warning for using aliases and static usings.

class C
{
    [RequiresUnsafe] public C() { }
}

class D<T> where T : new()
{
    public static void M() { _ = new T(); }
}
using X = D<C>;
using unsafe X = D<C>;

X.M();
using static D<C>;
using static unsafe D<C>;

M();

Note that other constraints behave like the former option today:

using X = D<C>; // error here

_ = new X(); // ok
_ = new D<C>(); // error here

class C
{
    public C(int x) { }
}

class D<T> where T : new();

Should more constructs be unsafe?

  • dynamic (probably should match what BCL decides for reflection APIs)

Answered questions

How breaking do we want to skew

Question text

The initial proposal is a maximally-breaking approach, mainly as a litmus test for how aggressive we want to be. It proposes no ability to opt in/out sections of the code, changes the meaning of unsafe on methods, prohibits the usage of unsafe on types, uses errors instead of warnings, and generally forces migration to occur all at once, at the time the compiler is upgraded (and then potentially repeatedly as dependencies update and add unsafe to members that were already in use). However, we have a wealth of experience in making changes like this that we can draw on to scope the size of the breaks down and allow incremental adoption. These options are covered below.

Opt in/out for code regions

This is not the first time that C# has redefined the "base" case of unannotated code. C# 8.0 introduced the nullable reference type feature, which in many ways can be seen as a blueprint for how the unsafe feature is shaping up. It had similar goals (prevent bugs that cost billions of dollars by redefining the way default C# is interpreted) and a similar general featureset (add new info to types to propagate states and avoid bugs). It was also heavily breaking, and needed a strong set of opt in and opt out functionality to allow the feature to be adopted over time by codebases. That functionality is the "nullable reference type context". This is a lexical scope that informs the compiler, for a given region in code, both how to interpret unannotated type references and what types of warnings to give to the user. We could use this as a model for unsafe as well, adding an "safety rules context" or similar to allow controlling whether these new rules are being applied or not.

One advantage that we have with the new unsafe features is that they are much less prevalent. While there are a decent number of unsafe calls in top libraries, our guesstimates on the percentage of top libraries that use unsafe is much lower than "every single line of C# code ever written". Hopefully this means that, while some ability to opt in/out is possibly needed, we don't need as complicated a mechanism as nullable has, with dedicated preprocessor switches and the like.

Warnings vs errors

The proposal currently states that memory safety requirements are currently enforced via a warning, rather than error. This is drawing from our experience working with the nullable feature, where warnings allowed code bases to incrementally adopt the new feature and not need to convert large swathes of code all at once. We expect a similar process will be needed for unsafe warnings: many codebases will simply be able to turn on the new rules globally and move on with their lives. But we expect the codebases we most care about adopting the new rules will have large amounts of code to annotate, and we want them to be able to move forward with the feature, rather than seeing a wall of errors and giving up immediately. By making the requirements warnings, we allow these codebases to fix warnings file-by-file or method-by-method as required, disabling the warnings everywhere else.

Method signature breaks

Right now, we propose that unsafe as a keyword on the method move from something that is lexically scoped without a semantic impact to something that has semantic impact, and isn't lexically scoped. We could limit this break by introducing a new keyword for when the caller of a method or member must be in an unsafe context; for example, callerunsafe as a modifier.

Defaults for source generators

For nullable, we force generator authors to explicitly opt-in to nullable regardless of whether the entire project has opted into the feature by default, so that generator output isn't broken by the user turning on nullable and warn as error. Should we do the same for source generators?

Conclusion

Answered in https://github.com/dotnet/csharplang/blob/main/meetings/2025/LDM-2025-11-05.md#unsafe-evolution. We will report errors for memory safety issues when the new rules are turned on, and no exceptions for source generators will be made.