You turned on renaming, rebuilt, and opened the result in a decompiler to admire the wall of a, b, and c where your types used to be. Then you found them anyway — OrderService, CustomerName, ValidateCoupon — sitting in plain string literals, handed to you for free in the one place renaming never touched. Nothing is broken. This is the compiler's doing, not the obfuscator's, and understanding why is the difference between a protected build and one that quietly publishes its own rename map.
Two C# features put your original names into the assembly as data before renaming ever runs: nameof, which bakes a name into a literal at compile time, and [CallerMemberName], which freezes the calling member's name into a default parameter value at each call site. Renaming walks type and member references — a bare string is neither, so the old name stays in the string heap. This post is exactly where those literals come from in IL, why renaming cannot touch them on its own, when rewriting them is right and when it would break something, and how Nebula.NET closes the leak with string encryption and rename-aware handling.
The source
A class that uses both features the way real code does — logging categories, argument validation, and INotifyPropertyChanged:
public sealed class OrderService : INotifyPropertyChanged
{
private string _customerName = "";
public string CustomerName
{
get => _customerName;
set { _customerName = value; OnPropertyChanged(); } // [CallerMemberName] fills in "CustomerName"
}
public void ValidateCoupon(string code)
{
if (code is null)
throw new ArgumentNullException(nameof(code)); // nameof -> literal "code"
_logger.BeginScope(nameof(OrderService)); // nameof -> literal "OrderService"
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Where the names actually are in IL
nameof(OrderService) does not reference the type in the emitted code. The compiler replaces it, during compilation, with a plain string literal — a single ldstr:
// ValidateCoupon body — the names are DATA, not references:
ldstr "code" // nameof(code) -> literal
newobj instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
...
ldstr "OrderService" // nameof(OrderService) -> literal
callvirt instance class ... Microsoft.Extensions.Logging.ILogger::BeginScope<...>(...)
[CallerMemberName] is the same trick on a parameter. OnPropertyChanged() is called with no argument in source, but the compiler fills the optional parameter's default at the call site with the caller's name as a literal:
// CustomerName setter — the compiler injected the name at the CALL SITE:
ldarg.0
ldstr "CustomerName" // injected by [CallerMemberName] at compile time
call instance void OrderService::OnPropertyChanged(string)
Both names are now ldstr operands pointing into the assembly's #US (user string) heap. They are plain data with no metadata link back to the type or property they were derived from.
Why renaming cannot touch them on its own
Renaming rewrites references: a TypeRef to OrderService, a MethodDef for ValidateCoupon, every call and ldfld that names a member. It deliberately leaves string contents alone, because a string is not a reference and changing string semantics by default would break every program that compares, serializes, or displays one. So when the renamer turns OrderService into b.c, the ldstr "OrderService" is not swept along — there is nothing connecting the literal to the type. The result is a renamed binary with a plaintext index of its own original names.
Encrypt the value — the confidentiality fix
The leak is first of all an information leak: the original names are readable. String encryption closes that. Nebula replaces each ldstr with a call to a decrypting accessor, so the plaintext name is no longer in the heap and a decompiler sees a call to an obfuscated routine, not your type's old name:
{
"inputs": ["bin/Release/net8.0/OrderService.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"renameIdentifiers": true,
"encryptStrings": true
}
After this pass the ldstr "OrderService" becomes something like ldstr <cipher> / call string <decrypt>(...), and the string heap no longer advertises OrderService, CustomerName, or ValidateCoupon. For the common case — nameof used for a log category or an exception argument name — this is the whole fix: the value still decrypts to the right string at run time, and nothing depended on the name being readable.
Rewrite the value — the correctness fix, when it applies
Encryption hides the literal; it does not change what it decrypts to. That matters in two directions, and they pull opposite ways:
-
The literal drives reflection against a renamed member.
typeof(T).GetProperty(nameof(SomeProp))expects the runtime name. IfSomePropwas renamed, the decrypted literal still saysSomePropand the lookup fails. Here the right fix is to make the literal track the rename, or to exclude that member from renaming so the name stays stable. -
The literal is an external contract. A serialized property name, a
[CallerMemberName]feedingINotifyPropertyChanged, a data-binding path XAML also names — the string on the other side is not being renamed, so rewriting your literal would break the match. Here you must not rewrite, and often must not rename the member either.
Nebula's safe default is to encrypt (hide) rather than rewrite (re-mean), because the IL alone cannot always tell a reflective use from a contractual one. You mark the reflective cases explicitly — exclude the members whose names are looked up by string, so renaming leaves them stable and the nameof literal keeps matching:
{
"inputs": ["bin/Release/net8.0/OrderService.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"renameIdentifiers": true,
"encryptStrings": true,
"excludeMembers": ["OrderService.CustomerName"] // bound by name in XAML + INPC — keep it stable
}
The INotifyPropertyChanged case is the textbook trap. OnPropertyChanged() passes "CustomerName" by [CallerMemberName], and the WPF/XAML binding resolves the property by that name at run time. Encrypt the literal and the binding still works (it decrypts to "CustomerName"). Rename the property to b without excluding it, and now the literal says CustomerName while the property is b — the binding silently stops updating. The fix is to exclude bound properties from renaming, exactly as in the config above, so the name the binding needs stays the name the property has.
Verify the leak is closed
After protecting, confirm the names are gone from the string heap — the direct check that nameof/[CallerMemberName] no longer disclose them:
# Dump the user-string heap of the protected assembly; your type names must not appear.
ikdasm /metadata=US obf/OrderService.dll | grep -i "OrderService\|CustomerName\|ValidateCoupon" || echo "clean"
A clean result means the literals were encrypted; a hit means a string survived a pass — usually one excluded from encryption, which is worth a second look if it also carries a name you meant to hide.
What to take away
Renaming protects references; it does not protect the names the compiler already turned into data. nameof and [CallerMemberName] freeze your original type and member names into string literals at compile time, so a renamed binary can still hand a reader its own map. Close the information leak with string encryption so the plaintext names leave the heap. Then handle the two cases encryption cannot: where a literal drives reflection against a renamed member, keep that member's name stable by excluding it from renaming; where a literal is an external contract like an INotifyPropertyChanged binding, exclude the member so the name the other side expects does not move. Encrypt for confidentiality, exclude for correctness, and verify the heap is clean — and your renamed build stops publishing the names you renamed.
Top comments (0)