DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating readonly structs, in parameters, and ref returns: the modreqs and compiler attributes Nebula must not strip

There is a category of .NET metadata that looks like decoration and is actually load-bearing. A readonly struct, an in parameter, a ref readonly return — in C# source these read like modifiers you sprinkle for performance. In metadata they are a mix of custom attributes and required custom modifiers (modreqs) that the next compiler reads back to decide whether a call site makes a hidden copy, which overload binds, and whether a value may be passed by reference at all.

That makes them a trap for an obfuscator. A renamer that treats System.Runtime.CompilerServices.IsReadOnlyAttribute as just another type to rename, or a metadata cleaner that drops "unused" attributes, produces an assembly that loads fine and then behaves differently — extra copies, mis-bound overloads — or fails to bind against callers it does not control. This post reads the IL of each feature, separates the attribute pieces from the signature pieces, and shows how Nebula keeps the contract while still obfuscating your logic.

readonly struct: an attribute the next compiler reads

Start with the simplest marker:

public readonly struct Money
{
    public readonly decimal Amount;
    public Money(decimal amount) => Amount = amount;
    public Money Add(in Money other) => new(Amount + other.Amount);
}
Enter fullscreen mode Exit fullscreen mode

The readonly on the struct compiles to a custom attribute on the type:

.class public sequential ansi sealed beforefieldinit Money
       extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
    ...
}
Enter fullscreen mode Exit fullscreen mode

Nothing about the type token changes — it is still the same Money. The attribute is a flag the consuming compiler reads. When some other assembly writes void Charge(in Money m) and calls m.Add(...), the compiler checks Money for IsReadOnlyAttribute. If it is there, the compiler knows no member of Money can mutate the receiver, so it may invoke members directly on the in reference. If it is not there, the compiler must assume a member might mutate the value, and because in means "do not let it," it emits a defensive copy of the whole struct before every member access.

That is the quiet failure mode. Strip IsReadOnlyAttribute from Money and nothing crashes; every caller in every other assembly just silently starts copying Money on access, which is exactly the overhead readonly struct existed to remove. The semantics the author asked for are gone, and the only symptom is slower code. An obfuscator that discards "unused" attributes to shrink metadata will do precisely this.

in and ref readonly: the modreq in the signature

The parameter marker is a different animal, and a more dangerous one, because it lives inside the signature blob, not beside it. Look at Add(in Money other):

.method public hidebysig instance valuetype Money
        Add(valuetype Money& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) other) cil managed
{
    .param [1]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
    ...
}
Enter fullscreen mode Exit fullscreen mode

Two separate things encode in here. The parameter type is Money& — a managed reference — carrying modreq(InAttribute), and there is an IsReadOnlyAttribute on the parameter as well. The modreq is the load-bearing part: a required custom modifier is part of the type in the signature, so Add(in Money) and a hypothetical Add(ref Money) have genuinely different signatures. ref readonly returns use the same modreq(InAttribute) on the return type, and out uses modreq(OutAttribute).

This is why a modreq cannot be renamed or removed like an attribute:

  • Remove the modreq and the signature changes from Money& modreq(InAttribute) to plain Money& — now it reads as ref, not in. A caller compiled against the original expected in; the method's identity has shifted under it, and depending on what else is present you get a MissingMethodException at the call or a binding to the wrong overload.
  • Rename the referenced InAttribute type to a.b.C and the modreq now points at a modifier the consuming compiler does not recognize as ref readonly/in. It is still a valid modreq as far as the runtime is concerned, so the assembly loads, but C# no longer sees the parameter as in.

ref struct and [IsByRefLike]: a safety property, not a hint

A ref struct carries IsByRefLikeAttribute, and this one is a safety contract the runtime and compiler jointly enforce:

public ref struct BufferCursor
{
    private ref readonly byte _current;
    public ref readonly byte Current => ref _current;
}
Enter fullscreen mode Exit fullscreen mode
.class public sequential ansi sealed beforefieldinit BufferCursor
       extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor() = ( 01 00 00 00 )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
            = ( 01 00 09 'RefStructs' 00 00 )
    ...
}
Enter fullscreen mode Exit fullscreen mode

IsByRefLikeAttribute is what tells a consuming compiler that BufferCursor may not be boxed, stored on the heap, captured by a lambda, or used as a generic type argument where it could escape — the whole set of rules that keeps a ref struct's interior references from outliving the stack frame they point into. Strip it and the compiler consuming your assembly will happily let someone box the cursor, defeating the guarantee and opening a use-after-free in safe-looking code. The paired CompilerFeatureRequiredAttribute is the compiler's own gate: a consumer that does not understand the RefStructs feature is told to refuse the type rather than mishandle it. Both are contracts; neither is yours to rename.

The Current property shows the return-side modreq in action: ref readonly byte Current compiles to a getter returning uint8& modreq(InAttribute), so the ref-return story and the in-parameter story share the exact same modifier type. One preserve entry covers both.

The self-embedded case, where renaming does the real damage

Here is the detail that catches obfuscators that do protect the framework attributes but by token or by assembly. When your assembly is the first in its closure to need one of these markers — you target a framework too old to define IsReadOnlyAttribute, say — the compiler synthesizes the attribute type into your own assembly:

.class private auto ansi sealed beforefieldinit
       System.Runtime.CompilerServices.IsReadOnlyAttribute
       extends [System.Runtime]System.Attribute
{
    .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
    ...
}
Enter fullscreen mode Exit fullscreen mode

This is a real type in your metadata, in a namespace you did not write, and its identity is matched by full name. The next compiler — including your own, next time you build against this as a reference — finds readonly structs by looking for a type literally named System.Runtime.CompilerServices.IsReadOnlyAttribute and reading its applications. Rename that embedded type to A.B and every readonly and in in the assembly quietly stops being recognized, because the marker the compiler hunts for is gone. The companion Microsoft.CodeAnalysis.EmbeddedAttribute marks these as compiler-internal and must survive by name too. A naive renamer sees a private attribute class with an odd name and renames it like any other internal type — and corrupts the ABI without touching a single line of your code.

How Nebula draws the line

Nebula's rule is to separate compiler contract from your logic, and it keeps a preserve list of the well-known modifier and marker family, matched by full name so it covers both the framework copies and the self-embedded ones:

Concretely, the pipeline does three things:

  1. Never rename the modifier/marker family by name. IsReadOnlyAttribute, IsByRefLikeAttribute, IsUnmanagedAttribute, RequiresLocationAttribute, InAttribute, OutAttribute, EmbeddedAttribute, CompilerFeatureRequiredAttribute — framework-defined or self-embedded — stay at their full names. The embedded copies are detected precisely because they carry EmbeddedAttribute, which is itself on the list.
  2. Treat modreqs in a signature as immutable bytes. When Nebula rewrites a signature — because a user type in it was renamed — it copies the modreq/modopt tokens through unchanged and never retargets the InAttribute/OutAttribute they reference. The modifier stays exactly where the compiler put it.
  3. Keep the marker attributes on their declarations. The IsReadOnlyAttribute on your Money struct and on its in parameter, the IsByRefLikeAttribute on your BufferCursor — these ride along with the (possibly renamed) entity rather than being swept as unused custom attributes.

What is left is the whole point: your Money becomes some renamed struct with obfuscated members and encrypted constants, your BufferCursor becomes a renamed ref struct with a scrambled body — and a consumer still sees a readonly struct with an in-taking method and a ref struct with a ref readonly property, because every byte the compiler reads to know that is still there. The protection lands on your logic. The ABI the next compiler depends on is untouched. That line — obfuscate the implementation, preserve the contract — is the one an obfuscator has to hold, and readonly/in/ref readonly is where holding it takes actual knowledge of the metadata rather than a rename table.

Top comments (0)