Obfuscating code that uses dynamic: the runtime binder looks members up by name, so a renamed property is a RuntimeBinderException
A static call survives renaming because it is resolved by metadata token and the obfuscator rewrites the token with the member. A dynamic call survives nothing: the C# compiler lowers settings.Timeout into a call site whose binder is handed the string "Timeout", and at runtime Microsoft.CSharp walks the receiver's actual type looking for a member with exactly that name. Rename Timeout to a and the site still asks for "Timeout", the lookup fails, and the app throws RuntimeBinderException on a line that compiled fine and passed every test on the unprotected build. This post reads the IL of a dynamic site to show where the name lives (an ldstr feeding Binder.GetMember and Binder.InvokeMember inside a hidden <>o__ container), explains why string encryption neither helps nor hurts, why no renamer can safely fix it automatically (the receiver's type is not known until the call runs), which dynamic operations bind by name and which do not (indexers through Item, operators through op_ names, ExpandoObject keys never), the double-dispatch-by-dynamic pattern that silently breaks a whole visitor, and the three fixes in order of preference: replace dynamic with an interface, pin only the members a site reaches with [Obfuscation(Exclude = true)], or an exclude entry in nebula.config.json — plus the one test that catches it on the protected build.
Here is a method that works on every build except the one you ship:
public sealed class Settings
{
public int Timeout { get; set; } = 30;
public string Endpoint { get; set; } = "https://api.example";
}
static void Configure(dynamic settings)
{
int timeout = settings.Timeout; // compiles; passes tests; dies when obfuscated
settings.Endpoint = "https://api.example/v2";
}
On the debug build, Configure(new Settings()) does what it says. On the obfuscated build it throws:
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'a' does not contain a definition for 'Timeout'
Nothing is wrong with the obfuscator. The type was renamed to a, its property was renamed, and the dynamic site — which never knew about either — kept asking for Timeout by name. To see why that is unavoidable, and what to do about it, read the IL the compiler emitted for that one line.
Where the name lives
A normal property read, int timeout = settings.Timeout; with settings typed as Settings, compiles to a callvirt against a metadata token:
callvirt instance int32 Acme.Settings::get_Timeout()
The token points at a row in the method table. When Nebula renames get_Timeout to a, it rewrites the row and every token that references it — the call site and the member move together, and the string Timeout ceases to exist anywhere in the assembly. That is why renaming is invisible to ordinary code.
The dynamic version compiles to something else entirely. The compiler generates a hidden nested class to hold a cached call site, and the site is initialised with a binder that is handed the member name as a string:
// hidden container the compiler generates for Configure
.class nested private '<>o__0'
{
.field public static class [System.Runtime]System.Runtime.CompilerServices.CallSite`1<
class [System.Runtime]System.Func`3<CallSite, object, object>> '<>p__0'
}
// first use of the site: build the binder, with the member NAME as data
IL_0007: ldc.i4.0 // CSharpBinderFlags.None
IL_0008: ldstr "Timeout" // <-- the binding, as a string
IL_000d: ldtoken Acme.Program // calling context
IL_0012: call class Type Type::GetTypeFromHandle(RuntimeTypeHandle)
IL_0017: ldc.i4.1
IL_0018: newarr CSharpArgumentInfo
...
IL_002a: call CallSiteBinder Binder::GetMember(CSharpBinderFlags, string, Type, IEnumerable`1<CSharpArgumentInfo>)
IL_002f: call CallSite`1<...> CallSite`1<...>::Create(CallSiteBinder)
IL_0034: stsfld ... '<>o__0'::'<>p__0'
// every use: invoke the site's delegate with the receiver
IL_0039: ldsfld '<>o__0'::'<>p__0'
IL_003e: ldfld !0 CallSite`1<...>::Target
IL_0043: ldsfld '<>o__0'::'<>p__0'
IL_0048: ldarg.0 // settings (object)
IL_0049: callvirt instance !2 Func`3<CallSite, object, object>::Invoke(!0, !1)
There is no token for Timeout anywhere in that sequence. The only mention of the member is ldstr "Timeout", a string literal passed to Binder.GetMember. At runtime, the C# runtime binder takes the actual type of the object that arrives in settings, searches it for a member called Timeout — the same search reflection does — builds a rule for that type, caches it in the site, and executes it. The assignment compiles the same way through Binder.SetMember(..., "Endpoint", ...); a method call through Binder.InvokeMember(..., "Save", ...).
After renaming, the type has a member a and the site still says "Timeout". The binder searches, finds nothing, and throws. The link was a name, and the renamer, which only moves tokens, had no reason to know.
Why string encryption is beside the point
The reflex, on seeing ldstr "Timeout", is to think Nebula’s string encryption will take care of it. It will encrypt it — and the decrypted value handed to Binder.GetMember at runtime is the same "Timeout", which still does not exist on the renamed type. Encryption changes nothing about the binding.
What it changes is the leak. In an unencrypted build, every dynamic site is a plain-text signpost in the user-string heap: a reader opening the assembly sees "Timeout" passed to a binder right beside a type whose members are now a, b, c, and has recovered one original name for free. This is the quieter cost of dynamic in a protected assembly — where it does not break, it still narrates. With encryptStrings on, the signpost is gone from the static view; the lookup is exactly as broken as before.
Which operations bind by name
Not every dynamic operation is a name lookup, and knowing which are tells you where to look:
- Member access, assignment, invocation —
s.Timeout,s.Endpoint = …,s.Save()— go throughGetMember,SetMember,InvokeMemberwith the member name as a string. These break when the member is renamed. - Indexing —
s[0]— goes throughGetIndex/SetIndex, which resolves the type’s default member ([DefaultMember("Item")], the indexer’sItemproperty). Rename the indexer and the dynamic index breaks the same way. - Operators and conversions —
a + b,(int)d— go throughBinaryOperation/Convert, which resolve user-defined operators by their special names,op_Addition,op_Implicit. Those arespecialnamemembers the runtime itself relies on, and Nebula does not rename them, so dynamic arithmetic over your types keeps working. - Invocation of a delegate value —
d(x)wheredholds a delegate — goes throughInvoke, no name, no problem. ExpandoObjectand anyIDynamicMetaObjectProvider— the “members” are dictionary keys supplied at runtime, not CLR members. Renaming never touches data, soexpando.Timeoutkeeps working regardless. The same is true of adynamicover a COMIDispatchproxy: the names are resolved by the COM server, not by your metadata.
The dangerous case is the first bullet applied to types you own, and there is one pattern that hides it particularly well.
The pattern that breaks a whole visitor at once
Double dispatch by dynamic is a popular shortcut for a visitor without the Accept boilerplate:
public abstract class Shape { }
public sealed class Circle : Shape { public double R { get; init; } }
public sealed class Rect : Shape { public double W { get; init; } public double H { get; init; } }
public static class Area
{
public static double Of(Shape s) => Compute((dynamic)s); // picks the overload at runtime
private static double Compute(Circle c) => Math.PI * c.R * c.R;
private static double Compute(Rect r) => r.W * r.H;
}
This compiles to Binder.InvokeMember(..., "Compute", typeof(Area), ...) with the runtime binder selecting the overload by the argument’s actual type. Rename Compute to b and every shape fails on the first call — not one property, the entire dispatch — with 'Area' does not contain a definition for 'Compute'. Because the overloads are private, nothing else in the assembly references them by anything but this one string, so there is no compile-time trace that they are reached at all. The unprotected test suite is green; the protected build cannot compute a single area.
Fixes, in order of preference
1. Replace dynamic with an interface or a virtual. This is the right answer far more often than it feels. Duck typing between types you own is what interfaces are for, and the interface call is a token-bound callvirt that survives renaming, runs without a binder, and is checked by the compiler:
public interface IHasArea { double Area(); }
public sealed class Circle : Shape, IHasArea { … public double Area() => Math.PI * R * R; }
public static double Of(Shape s) => s is IHasArea a ? a.Area() : 0; // no binder, no name
The visitor becomes an ordinary virtual Accept or an IHasArea; the settings reader takes Settings or an ISettings. If the dynamic existed only to avoid writing a type, writing the type is the fix.
2. Pin only the members a site reaches. Where dynamic is genuinely required — a scripting host that calls into your objects, a dynamic-language runtime, a plugin boundary you do not control — the set of members it touches is small and known. Mark exactly those with the standard [Obfuscation] attribute, which Nebula honours through autoDetect:
public sealed class Settings
{
[Obfuscation(Exclude = true)] public int Timeout { get; set; } = 30;
[Obfuscation(Exclude = true)] public string Endpoint { get; set; } = "";
public string InternalCacheKey { get; set; } = ""; // still renames
private int _retries; // still renames
}
Exclude = true on a member keeps that member’s name and nothing else; the type still renames, the other members still rename. Or keep it out of the source entirely with the exclude list in nebula.config.json, which takes Namespace.Type.Member for a single member:
{
"inputs": ["bin/Release/net8.0/Acme.Studio.dll"],
"encryptStrings": true,
"controlFlowObfuscation": true,
"exclude": [
"Acme.Settings.Timeout",
"Acme.Settings.Endpoint",
"Acme.Area.Compute"
]
}
What you should not do is exclude Acme.Settings wholesale. That preserves every member — the ones the binder reaches and the dozen it never will — to protect two properties, and it is the type an attacker most wants to read.
3. Find every site before you ship. Dynamic sites announce themselves in the IL: a reference to Microsoft.CSharp.RuntimeBinder.Binder, hidden <>o__ containers, and ldstr literals feeding them. nebula inspect --strings lists every literal in the assembly, and the ones that are also member names on your types are the ones to look at — Timeout, Endpoint, Compute stand out immediately beside genuine user-facing strings. Cross-check against a search for dynamic in the source and you have the complete list of members to pin.
The test that catches it
Whatever you choose, assert it on the protected build, because the failure only exists there. A dynamic site throws at the first use, so a test that exercises each site is a test that fails loudly when a pin is missing:
[Fact]
public void Dynamic_sites_still_bind_on_the_protected_build()
{
var s = new Settings();
Configure(s); // GetMember "Timeout", SetMember "Endpoint"
Assert.Equal("https://api.example/v2", s.Endpoint);
Assert.Equal(Math.PI, Area.Of(new Circle { R = 1 }), precision: 10); // InvokeMember "Compute"
Assert.Equal(6.0, Area.Of(new Rect { W = 2, H = 3 }));
}
Run that against the output of the obfuscation step in CI — the same place a round-trip serialization test belongs — and a RuntimeBinderException becomes a red build instead of a customer’s crash report.
The principle is the same one that governs reflection and serialization: a name that is resolved at runtime is a contract, and a renamer cannot see a contract that is written as a string. dynamic is the C# feature that turns an ordinary member access into exactly that kind of string. Nebula.NET will rename everything you let it; your job is to make sure the one name a binder will ask for by text is either no longer asked for, or deliberately kept. For the wider map of what runtime name lookups break, see what obfuscation breaks.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.