Ofuscar código que usa dynamic: el binder en tiempo de ejecución busca los miembros por nombre, así que una propiedad renombrada es una RuntimeBinderException
Una llamada estática sobrevive al renombrado porque se resuelve por token de metadatos y el ofuscador reescribe el token junto con el miembro. Una llamada dynamic no sobrevive a nada: el compilador de C# rebaja settings.Timeout a un call site cuyo binder recibe la cadena "Timeout", y en tiempo de ejecución Microsoft.CSharp recorre el tipo real del receptor buscando un miembro con exactamente ese nombre. Renombra Timeout a a y el sitio sigue pidiendo "Timeout", la búsqueda falla, y la app lanza RuntimeBinderException en una línea que compiló bien y pasó todas las pruebas en la build sin proteger. Esta entrada lee el IL de un sitio dynamic para mostrar dónde vive el nombre (un ldstr que alimenta Binder.GetMember y Binder.InvokeMember dentro de un contenedor oculto <>o__), explica por qué el cifrado de cadenas ni ayuda ni perjudica, por qué ningún renombrador puede arreglarlo automáticamente con seguridad (el tipo del receptor no se conoce hasta que la llamada se ejecuta), qué operaciones dynamic se vinculan por nombre y cuáles no (indexadores vía Item, operadores vía nombres op_, claves de ExpandoObject nunca), el patrón de doble despacho por dynamic que rompe silenciosamente un visitor entero, y las tres soluciones por orden de preferencia: sustituir dynamic por una interfaz, fijar solo los miembros que un sitio alcanza con [Obfuscation(Exclude = true)], o una entrada exclude en nebula.config.json — más la única prueba que lo detecta en la build protegida.
Aquí hay un método que funciona en todas las builds excepto en la que entregas:
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";
}
En la build de depuración, Configure(new Settings()) hace lo que dice. En la build ofuscada lanza:
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'a' does not contain a definition for 'Timeout'
No hay nada mal en el ofuscador. El tipo se renombró a a, su propiedad se renombró, y el sitio dynamic — que nunca supo de ninguno de los dos — siguió pidiendo Timeout por nombre. Para ver por qué eso es inevitable, y qué hacer al respecto, lee el IL que el compilador emitió para esa única línea.
Dónde vive el nombre
Una lectura de propiedad normal, int timeout = settings.Timeout; con settings tipado como Settings, compila a un callvirt contra un token de metadatos:
callvirt instance int32 Acme.Settings::get_Timeout()
El token apunta a una fila de la tabla de métodos. Cuando Nebula renombra get_Timeout a a, reescribe la fila y todo token que la referencia — el sitio de llamada y el miembro se mueven juntos, y la cadena Timeout deja de existir en cualquier parte del ensamblado. Por eso el renombrado es invisible para el código ordinario.
La versión dynamic compila a algo completamente distinto. El compilador genera una clase anidada oculta para guardar un call site en caché, y el sitio se inicializa con un binder al que se le entrega el nombre del miembro como cadena:
// 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)
No hay ningún token para Timeout en toda esa secuencia. La única mención del miembro es ldstr "Timeout", un literal de cadena pasado a Binder.GetMember. En tiempo de ejecución, el binder de C# toma el tipo real del objeto que llega en settings, lo busca por un miembro llamado Timeout — la misma búsqueda que hace la reflexión — construye una regla para ese tipo, la guarda en caché en el sitio, y la ejecuta. La asignación compila de la misma forma a través de Binder.SetMember(..., "Endpoint", ...); una llamada a método a través de Binder.InvokeMember(..., "Save", ...).
Tras el renombrado, el tipo tiene un miembro a y el sitio sigue diciendo "Timeout". El binder busca, no encuentra nada, y lanza. El enlace era un nombre, y el renombrador, que solo mueve tokens, no tenía motivo para saberlo.
Por qué el cifrado de cadenas no viene al caso
El reflejo, al ver ldstr "Timeout", es pensar que el cifrado de cadenas de Nebula se encargará. Lo cifrará — y el valor descifrado que se entrega a Binder.GetMember en tiempo de ejecución es el mismo "Timeout", que sigue sin existir en el tipo renombrado. El cifrado no cambia nada de la vinculación.
Lo que cambia es la fuga. En una build sin cifrar, cada sitio dynamic es un cartel en texto plano en el heap de cadenas de usuario: un lector que abre el ensamblado ve "Timeout" pasado a un binder justo al lado de un tipo cuyos miembros ahora son a, b, c, y ha recuperado un nombre original gratis. Este es el coste más silencioso de dynamic en un ensamblado protegido — donde no se rompe, sigue narrando. Con encryptStrings activado, el cartel desaparece de la vista estática; la búsqueda está exactamente igual de rota que antes.
Qué operaciones se vinculan por nombre
No toda operación dynamic es una búsqueda por nombre, y saber cuáles lo son te dice dónde mirar:
- Acceso a miembro, asignación, invocación —
s.Timeout,s.Endpoint = …,s.Save()— pasan porGetMember,SetMember,InvokeMembercon el nombre del miembro como cadena. Se rompen cuando el miembro se renombra. - Indexación —
s[0]— pasa porGetIndex/SetIndex, que resuelve el miembro por defecto del tipo ([DefaultMember("Item")], la propiedadItemdel indexador). Renombra el indexador y la indexación dynamic se rompe igual. - Operadores y conversiones —
a + b,(int)d— pasan porBinaryOperation/Convert, que resuelven los operadores definidos por el usuario por sus nombres especiales,op_Addition,op_Implicit. Son miembrosspecialnamede los que depende el propio runtime, y Nebula no los renombra, así que la aritmética dynamic sobre tus tipos sigue funcionando. - Invocación de un valor delegado —
d(x)dondedcontiene un delegado — pasa porInvoke, sin nombre, sin problema. ExpandoObjecty cualquierIDynamicMetaObjectProvider— los «miembros» son claves de diccionario suministradas en tiempo de ejecución, no miembros CLR. El renombrado nunca toca datos, así queexpando.Timeoutsigue funcionando sea como sea. Lo mismo vale para undynamicsobre un proxy COMIDispatch: los nombres los resuelve el servidor COM, no tus metadatos.
El caso peligroso es el primer punto aplicado a tipos que posees, y hay un patrón que lo esconde especialmente bien.
El patrón que rompe un visitor entero de golpe
El doble despacho por dynamic es un atajo popular para un visitor sin el código repetitivo de Accept:
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;
}
Esto compila a Binder.InvokeMember(..., "Compute", typeof(Area), ...) con el binder en tiempo de ejecución seleccionando la sobrecarga por el tipo real del argumento. Renombra Compute a b y todas las formas fallan en la primera llamada — no una propiedad, el despacho entero — con 'Area' does not contain a definition for 'Compute'. Como las sobrecargas son private, nada más en el ensamblado las referencia por otra cosa que esta única cadena, así que no hay rastro en tiempo de compilación de que se alcancen en absoluto. El conjunto de pruebas sin proteger está en verde; la build protegida no puede calcular ni un área.
Soluciones, por orden de preferencia
1. Sustituye dynamic por una interfaz o un virtual. Esta es la respuesta correcta mucho más a menudo de lo que parece. El duck typing entre tipos que posees es para lo que existen las interfaces, y la llamada a interfaz es un callvirt vinculado por token que sobrevive al renombrado, se ejecuta sin binder, y lo comprueba el compilador:
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
El visitor se convierte en un Accept virtual ordinario o en un IHasArea; el lector de ajustes recibe Settings o un ISettings. Si el dynamic existía solo para evitar escribir un tipo, escribir el tipo es la solución.
2. Fija solo los miembros que un sitio alcanza. Donde dynamic es genuinamente necesario — un host de scripting que llama a tus objetos, un runtime de lenguaje dinámico, una frontera de plugin que no controlas — el conjunto de miembros que toca es pequeño y conocido. Marca exactamente esos con el atributo estándar [Obfuscation], que Nebula respeta mediante 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 en un miembro conserva el nombre de ese miembro y nada más; el tipo sigue renombrándose, los demás miembros siguen renombrándose. O mantenlo fuera del código fuente por completo con la lista exclude de nebula.config.json, que acepta Namespace.Type.Member para un solo miembro:
{
"inputs": ["bin/Release/net8.0/Acme.Studio.dll"],
"encryptStrings": true,
"controlFlowObfuscation": true,
"exclude": [
"Acme.Settings.Timeout",
"Acme.Settings.Endpoint",
"Acme.Area.Compute"
]
}
Lo que no debes hacer es excluir Acme.Settings en bloque. Eso conserva todos los miembros — los que alcanza el binder y la docena que nunca alcanzará — para proteger dos propiedades, y es el tipo que un atacante más quiere leer.
3. Encuentra todos los sitios antes de entregar. Los sitios dynamic se anuncian en el IL: una referencia a Microsoft.CSharp.RuntimeBinder.Binder, contenedores ocultos <>o__, y literales ldstr que los alimentan. nebula inspect --strings lista todos los literales del ensamblado, y los que además son nombres de miembros de tus tipos son los que hay que mirar — Timeout, Endpoint, Compute destacan de inmediato junto a cadenas genuinas de cara al usuario. Contrástalo con una búsqueda de dynamic en el código fuente y tienes la lista completa de miembros a fijar.
La prueba que lo detecta
Elijas lo que elijas, afírmalo en la build protegida, porque el fallo solo existe ahí. Un sitio dynamic lanza en el primer uso, así que una prueba que ejercite cada sitio es una prueba que falla a gritos cuando falta una fijación:
[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 }));
}
Ejecútala contra la salida del paso de ofuscación en CI — el mismo lugar donde va una prueba de ida y vuelta de serialización — y una RuntimeBinderException se convierte en una build en rojo en lugar del informe de fallo de un cliente.
El principio es el mismo que rige la reflexión y la serialización: un nombre que se resuelve en tiempo de ejecución es un contrato, y un renombrador no puede ver un contrato escrito como cadena. dynamic es la característica de C# que convierte un acceso a miembro ordinario en exactamente ese tipo de cadena. Nebula.NET renombrará todo lo que le permitas; tu trabajo es asegurarte de que el único nombre que un binder pedirá por texto o bien ya no se pide, o bien se conserva deliberadamente. Para el mapa más amplio de lo que rompen las búsquedas de nombres en tiempo de ejecución, consulta qué rompe la ofuscación.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.