Obfuscating readonly structs, in parameters, and ref returns: the modreqs and compiler attributes Nebula must not strip
A readonly struct, an in parameter, and a ref readonly return are not just source conveniences — they are encoded in metadata as custom modifiers and compiler-emitted attributes that the C# compiler reads back to decide whether a call site makes a hidden defensive copy, which overload wins, and whether a value can be passed by reference at all. A modreq(InAttribute) on a parameter is part of its signature, so dropping it changes the method's identity and breaks binding; stripping IsReadOnlyAttribute from a struct makes every in-call to it start copying; removing IsByRefLike lets a ref struct be boxed. An obfuscator that treats all this as decorative metadata to be renamed or discarded produces assemblies that still load but behave differently, or that fail to bind against callers it does not control. This post reads the IL of each feature, shows exactly which pieces are signature and which are attribute, walks the three ways a careless renamer corrupts them, and explains how Nebula preserves the modreqs and the well-known compiler attributes verbatim while still renaming everything that is genuinely yours.
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);
}
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 )
...
}
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 )
...
}
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
modreqand the signature changes fromMoney& modreq(InAttribute)to plainMoney&— now it reads asref, notin. A caller compiled against the original expectedin; the method’s identity has shifted under it, and depending on what else is present you get aMissingMethodExceptionat the call or a binding to the wrong overload. - Rename the referenced
InAttributetype toa.b.Cand themodreqnow points at a modifier the consuming compiler does not recognize asref readonly/in. It is still a validmodreqas far as the runtime is concerned, so the assembly loads, but C# no longer sees the parameter asin.
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;
}
.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 )
...
}
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 )
...
}
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:
- 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 carryEmbeddedAttribute, which is itself on the list. - Treat
modreqs in a signature as immutable bytes. When Nebula rewrites a signature — because a user type in it was renamed — it copies themodreq/modopttokens through unchanged and never retargets theInAttribute/OutAttributethey reference. The modifier stays exactly where the compiler put it. - Keep the marker attributes on their declarations. The
IsReadOnlyAttributeon yourMoneystruct and on itsinparameter, theIsByRefLikeAttributeon yourBufferCursor— 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.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.