Developers tend to think of a custom attribute as metadata that "describes" a type, and leave it at that. For obfuscation the useful mental model is sharper: a custom attribute application is a concrete row in a metadata table that points, by token, at a constructor, and carries a serialized blob of arguments. Once you see it that way, the question "does obfuscation break my attributes?" has a precise answer — it depends entirely on whether the thing that reads the attribute follows the token or reads a name.
The CustomAttribute table
When you write [FeatureGate(Tier = "pro")] on a method, the C# compiler does not store the text. It emits a row in the CustomAttribute metadata table (0x0C), and that row has three columns that matter:
CustomAttribute row
Parent -> the thing decorated (a MethodDef token here)
Type -> the attribute ctor (a MemberRef/MethodDef token, NOT a name)
Value -> a blob: prolog 0x0001, positional args, then named args
The critical word is token. The Type column does not say "FeatureGateAttribute"; it holds a numeric token that resolves to the constructor's row in the metadata. Rename the attribute class and the constructor's name changes, but its token — and the CustomAttribute row that points at it — does not. This is why renaming an attribute class is far safer than intuition suggests: everything that finds the attribute by walking the token keeps working, because you did not move the target, you only relabeled it.
The Value blob is where it gets interesting. Positional constructor arguments are serialized by position and type only — there are no names in that part of the blob, so renaming the constructor's parameters is invisible to it. Named arguments are the exception, and the source of nearly every attribute-related obfuscation bug.
Named arguments: the one thing you must rename in lockstep
A named argument — Tier = "pro" — is syntactic sugar for "construct the attribute, then set this field or property." The CLR cannot do the second half without knowing which member, so the blob stores the member's name as a UTF-8 string. That string is the hazard. If an obfuscator renames the Tier property on the attribute class to b but leaves the blob saying "Tier", the loader looks for a member called Tier that no longer exists and throws TypeLoadException the moment the attribute is materialized. Rename the blob string but not the property and you get the mirror image of the same crash.
public sealed class FeatureGateAttribute : Attribute
{
public FeatureGateAttribute(string feature) => Feature = feature;
public string Feature { get; } // positional — blob has no name for it
public string Tier { get; set; } = ""; // named — blob stores "Tier" as a string
}
[FeatureGate("export", Tier = "pro")] // "export" positional, Tier="pro" named
public void ExportReport() { /* ... */ }
A correct renamer treats the property and the named-argument string as a single unit: it either renames both — property Tier → b and the blob string "Tier" → "b" — or it renames neither. Nebula does the former by default, following the named-argument binding across the metadata so the two sides never drift apart. This is exactly the kind of cross-reference a naive rename-everything pass gets wrong, and why "it renamed my attribute and now the app won't load" is almost always a named-argument string left behind.
Which reflection survives
Now the practical payoff. Whether your own code still finds its attributes after obfuscation depends on how it looks for them — by token or by name.
// SURVIVES: generic overload resolves the type to a token.
// Rename FeatureGateAttribute -> a; the token is unchanged, so this still matches.
var gate = method.GetCustomAttribute<FeatureGateAttribute>();
if (gate is not null) Enforce(gate.Tier);
// SURVIVES: typeof(...) is a token reference too, resolved at compile time.
var attrs = method.GetCustomAttributes(typeof(FeatureGateAttribute), inherit: false);
// BREAKS: compares a runtime name string that renaming deleted.
var gate2 = method.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "FeatureGateAttribute"); // -> null after rename
// BREAKS: reads a named-argument value by reflecting a property name string.
var tier = (string)gateType.GetProperty("Tier")!.GetValue(gate)!; // "Tier" is gone
The rule falls out cleanly: find attributes by type, never by name. GetCustomAttribute<T>() and typeof(T) both compile to token references that the renamer rewrites consistently, so they keep pointing at the same (now-renamed) attribute. Any code path that reconstructs a decision from a string — GetType().Name, GetProperty("..."), Enum.Parse, a name compared against a config value — is reading a label that obfuscation is in the business of destroying, and it will quietly return null or nothing.
What you must preserve
Your own internal marker attributes — the ones you only ever read with GetCustomAttribute<T>() — can be renamed freely, and renaming them is a genuine security win: an attribute called LicenseCheckAttribute on a method is a signpost, and turning it into a removes the signpost. But some attributes are read by code you do not control, by name or by well-known type, and those must keep their names:
-
Serialization:
[JsonPropertyName],[DataMember],[XmlElement]— the serializer matches these by type and reads their named arguments; preserve the attribute and the member it decorates (see serialization pitfalls). -
Runtime-interpreted:
[Serializable],[DllImport],[MarshalAs],[StructLayout]— the CLR itself acts on these; renaming them changes runtime behavior, not just labels. -
Framework discovery: test attributes (
[Fact],[Theory]), ASP.NET Core routing and model-binding attributes, DI markers scanned by assembly — all found by scanning for a known type.
You draw that boundary in nebula.config.json, preserving the framework-facing attributes by namespace or type while leaving your internal markers to be renamed:
{
"schemaVersion": 1,
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "protected",
"preservePublicApi": true,
"rename": {
"preserveAttributes": [
"System.Text.Json.Serialization.*",
"System.Runtime.InteropServices.*",
"MyApp.Contracts.*"
]
}
}
Or inline, on the attribute class itself, with the same exclusion attribute Nebula honors elsewhere:
[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class WireContractAttribute : Attribute { /* read by the serializer */ }
The rule
A custom attribute lives in the metadata as a token-referenced constructor plus a value blob, and that structure tells you exactly what obfuscation can do. Tokens are rename-proof, so find attributes by type and they survive untouched; names in the blob are not, so a named argument must be renamed on both sides or neither, and anything a framework resolves by name must be preserved. Get those two facts right and you can strip the human-readable intent from your internal marker attributes — which is precisely the signal an attacker would navigate by — without breaking a single GetCustomAttribute<T>() call or confusing the serializer. The structural fact that an attribute is applied cannot be hidden; the meaning that made it worth reading can be.
Attribute handling is one slice of how Nebula.NET keeps a renamed assembly behaving identically to the original. For the broader failure mode it belongs to, read reflection and serialization pitfalls; to see why renaming is only the first layer, obfuscation beyond renaming. The free edition renames and verifies output on real assemblies so you can confirm your attributes behave before you ship.
Top comments (0)