Ofuscar miembros virtuales y de interfaz: cadenas de override, implementaciones explícitas y la tabla MethodImpl que los une
Renombrar un método privado es una edición local. Renombrar un método virtual no lo es, porque su nombre forma parte de cómo el CLR cablea el despacho: un override reutiliza el slot de su base emparejando nombre y firma al cargar el tipo, una implementación implícita de interfaz se encuentra de la misma manera, y solo una implementación explícita se vincula mediante una fila MethodImpl que lleva tokens en lugar de texto. Si te equivocas en la agrupación, o bien obtienes una TypeLoadException en el momento en que se carga el tipo — o, peor, nada en absoluto, mientras el override se desconecta en silencio y se ejecuta en su lugar la implementación base. Este artículo lee el IL de cada vinculación, muestra cuáles son por nombre y cuáles por tabla, recorre las tres formas en que un renombrador ingenuo las rompe, y explica cómo Nebula agrupa un método virtual con cada override e implementación a los que está conectado — a través de tipos e interfaces — para que todo el slot se renombre a un único nombre o se mantenga intacto cuando cualquier miembro de él cruza a un ensamblado que no controlas.
Renombra un helper privado y habrás editado una cadena en el heap #Strings; a nada más en el ensamblado le importa. Renombra un método virtual y habrás metido la mano en el mecanismo que usa el runtime para decidir en qué método aterriza realmente una llamada. Ese mecanismo es en parte por nombre y en parte por tabla, y la diferencia entre ambos decide si un renombrado es gratis, fatal o — el peor resultado — silenciosamente erróneo.
Este artículo lee lo que el CLR hace realmente con un método virtual, un override, una implementación implícita de interfaz y una explícita; muestra cuáles de esas vinculaciones puede tocar un renombrador; y explica cómo Nebula las agrupa para que todo el slot se mueva junto o no se mueva en absoluto.
Tres formas en que un método acaba en un slot
Empieza con una pequeña jerarquía que ejercita las tres vinculaciones:
public interface IJob
{
void Run();
string Describe();
}
public abstract class JobBase : IJob
{
public virtual void Run() { } // implicit interface implementation, and a base slot
string IJob.Describe() => GetType().Name; // explicit interface implementation
}
public sealed class ExportJob : JobBase
{
public override void Run() { /* ... */ } // implicit override of JobBase.Run
}
Compílala y mira las cabeceras de método que imprime ILDASM, porque los flags y las directivas son toda la historia.
// JobBase
.method public hidebysig newslot virtual instance void Run() cil managed
.method private hidebysig newslot virtual final instance string
'IJob.Describe'() cil managed
{
.override Acme.Jobs.IJob::Describe
...
}
// ExportJob
.method public hidebysig virtual instance void Run() cil managed // note: no newslot
Aquí se ven tres cosas.
JobBase.Run es newslot virtual: introduce un slot de vtable. También implementa IJob.Run, pero nada en sus metadatos lo dice — no hay directiva, ni atributo, ni referencia a IJob en el método. El CLR lo deduce al cargar el tipo recorriendo los métodos de la interfaz y buscando, en la clase, un método virtual público con el mismo nombre y firma. Eso es una implementación implícita de interfaz, y se vincula por nombre.
ExportJob.Run es virtual sin newslot. Ese flag — o más bien su ausencia, «reutilizar slot» — le dice al cargador «busca un método virtual en un tipo base con mi nombre y firma, y ocupa su slot». Eso es un override implícito, y también se vincula por nombre. Si el cargador no encuentra coincidencia no falla; asigna en silencio un slot nuevo, exactamente como si el método hubiera dicho newslot.
IJob.Describe en JobBase es de otra naturaleza. Es privado, newslot virtual final, nombrado con la cadena con punto 'IJob.Describe' — y lleva una directiva .override. Esa directiva es una fila en la tabla de metadatos MethodImpl con dos columnas: MethodBody (el token de este método) y MethodDeclaration (el token de IJob::Describe). El cargador lee la fila y cablea el slot a partir de los tokens. El nombre del método nunca se consulta; podría llamarse x y el override se resolvería igual. Eso es una implementación explícita, vinculada por tabla.
Qué significa eso para un renombrador
Pon las tres vinculaciones una junto a otra y la regla sale sola.
Las implementaciones explícitas son libres. La fila MethodImpl lleva tokens. Renombra 'IJob.Describe' a a y la fila sigue diciendo «el cuerpo 0x06000012 implementa la declaración 0x06000003»; el cargador nunca mira la cadena. Un decompilador mostrará string IJob.a() o, si el método de interfaz también se renombró, string b.a() — pero nada del despacho cambió. (Lo mismo vale para un override de C# que el compilador decidió emitir con un .override explícito, cosa que hace en algunos casos de retorno covariante y varianza genérica.)
Las vinculaciones implícitas soportan carga, en ambas direcciones. ExportJob.Run encuentra JobBase.Run por nombre. JobBase.Run es encontrado por IJob.Run por nombre. Renombra cualquiera de los tres y la cadena se rompe por ese eslabón. Así que no pueden renombrarse individualmente — solo juntos, al mismo nombre nuevo. Y «juntos» es transitivo: si JobBase.Run resultara implementar también IRunnable.Run de una segunda interfaz, el método de esa interfaz se une al conjunto, y también lo hace cualquier otra clase que implemente IRunnable.Run implícitamente, y cada override de esas. La unidad de renombrado no es un método; es la componente conexa de métodos enlazados por overrides e implementaciones.
Si cualquier miembro de la componente es externo, toda la componente queda congelada. Supón que JobBase también sobrescribe object.ToString(). Ese override está vinculado por nombre a un método de System.Private.CoreLib, que no estás renombrando. Renombra el override y se desconecta; así que debe conservar su nombre — y, por la transitividad anterior, también cualquier cosa enlazada a él. Lo mismo se aplica a IDisposable.Dispose, Stream.Read, una interfaz de un paquete NuGet que no es entrada de la ejecución, o una interfaz pública de una biblioteca cuya API estás preservando. La frontera del ensamblado (o de la ejecución multiensamblado) es la frontera de lo que puede moverse.
Los tres modos de fallo, en concreto
La razón por la que esto requiere cuidado es que las tres formas de equivocarse fallan de maneras muy distintas, y la más silenciosa es la más peligrosa.
Renombraste la interfaz, no la implementación → TypeLoadException. Renombra IJob.Run a a pero deja JobBase.Run en paz. Cuando algo toca JobBase por primera vez, el cargador construye su mapa de interfaces, busca un método que coincida con a() con la firma correcta, no encuentra ninguno y se niega a cargar el tipo:
System.TypeLoadException: Method 'a' in type 'Acme.Jobs.JobBase' from assembly
'Acme.Jobs, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' does not have an implementation.
Ruidoso, inmediato y fácil de atribuir — este es el fallo bueno. Fíjate en que lo lanza el cargador, no una llamada, así que se dispara aunque Run nunca se invoque, y se lleva por delante todo el tipo, no un método.
Renombraste el override, no la base → desconexión silenciosa. Renombra ExportJob.Run a a pero deja JobBase.Run. ExportJob.a sigue siendo virtual sin newslot, así que el cargador busca un virtual base llamado a con esa firma. No hay ninguno. El cargador no lanza nada; la especificación dice que un virtual de reutilización de slot sin base coincidente simplemente obtiene un slot nuevo. ExportJob carga sin problemas. Y cada llamada a job.Run() a través de una referencia IJob o JobBase despacha ahora a JobBase.Run — el cuerpo base vacío — porque eso es lo que ocupa el slot heredado. El override se ha convertido en un método sin relación al que nada llama. No hay excepción, ni aviso, ni línea de log; la exportación simplemente deja de ocurrir en silencio. Este es el modo de fallo que llega a producción.
Renombraste una implementación de una interfaz externa → TypeLoadException, posiblemente lejos del renombrado. Renombra Dispose en una clase que implementa IDisposable implícitamente, y obtienes el fallo del cargador anterior — pero el método «que falta» es System.IDisposable.Dispose, definido en un ensamblado que nunca tocaste, y el tipo que falla puede ser un diminuto helper internal que no está ni cerca del código en el que estabas pensando. La variante que muerde en la práctica es la interfaz de un paquete: tu tipo implementa IHostedService, IValueConverter o IEqualityComparer<T>, el ensamblado del paquete no es una entrada de la ejecución de ofuscación, y la implementación implícita se renombra porque nada dentro del módulo la fijó.
Cómo agrupa Nebula los slots
El renombrador de Nebula no renombra métodos; renombra grupos. Antes de asignar ningún nombre, un análisis de grupos de métodos construye un union-find sobre cada método del módulo usando exactamente las tres relaciones anteriores:
- Overrides explícitos — cada registro
.overrideenlaza el cuerpo con la declaración que implementa, si esa declaración vive en este módulo. - Overrides implícitos de base — cada virtual de reutilización de slot se enlaza con el virtual del tipo base con el mismo nombre y firma, subiendo por la cadena de bases.
- Implementaciones implícitas de interfaz — para cada interfaz que el tipo implementa (siguiendo la herencia de interfaces y la cadena de bases), cada método de interfaz se enlaza con el virtual coincidente de la clase.
El resultado es un representante por componente conexa. Después se aplica la regla de frontera: un grupo se marca como preservado cuando cualquier miembro suyo sobrescribe o implementa algo fuera del módulo — la cadena de bases que llega a System.Object, un .override cuya declaración resuelve a otro ensamblado, una interfaz que el resolutor puede ver pero no posee. La preservación también se propaga en el otro sentido: si un solo miembro de un grupo debe conservar su nombre por cualquier razón — es público bajo preservePublicApi, está listado en exclude, la autodetección encontró reflexión sobre él — todo el grupo conserva su nombre, porque un slot renombrado a medias es el fallo de desconexión silenciosa de arriba.
Cada grupo que sobrevive se renombra a un nombre. Todos los IJob.Run, JobBase.Run y ExportJob.Run pasan a ser, digamos, a; el emparejamiento por nombre del cargador encuentra a en cada tipo exactamente igual que encontraba Run, y el explícito 'IJob.Describe' se renombra en el mismo grupo que IJob.Describe, puesto que su fila .override los enlaza.
Hay un caso más que merece la pena conocer porque produce una línea de log. Si un tipo implementa una interfaz que no puede resolverse en el momento de la ofuscación — el ensamblado de referencia no está junto a la entrada, o es un paquete que Nebula no puede localizar — Nebula no puede ver los miembros de esa interfaz, así que no puede saber cuáles de los virtuales del tipo la implementan implícitamente. La respuesta segura es preservar todos los métodos virtuales de ese tipo, y decirlo:
warn: preserved 4 virtual member(s) of 'Acme.Jobs.ExportJob' (implements an unresolved interface; names may be part of that contract).
Si ves ese aviso, el arreglo suele ser hacer que el ensamblado referenciado sea resoluble (mantener intacta la carpeta de salida de la compilación, o apuntar Nebula hacia ella), tras lo cual la interfaz se resuelve como externa, solo se fijan los métodos que realmente la implementan, y el resto del tipo se renombra con normalidad.
Métodos de interfaz por defecto, genéricos y varianza
Unas cuantas características más recientes del lenguaje se apoyan en la misma maquinaria y heredan sus reglas.
Un método de interfaz por defecto (C# 8) es simplemente un método virtual con cuerpo declarado en la interfaz. Una clase que no lo sobrescribe obtiene la implementación de la interfaz a través del mapa de interfaces; una clase que sí lo sobrescribe lo hace con la misma vinculación implícita por nombre o con un .override explícito. En cualquier caso el método pertenece al grupo del método de interfaz, y el renombrado sigue al grupo.
Una interfaz genérica como IRepository<T> no añade nada nuevo: implementar IRepository<Customer> enlaza el Load de la clase con IRepository<T>.Load por nombre y firma con T sustituido, y el método del genérico abierto es el miembro del grupo. Ofuscar genéricos explica por qué los propios parámetros de tipo son libres.
Los retornos covariantes (C# 9) y algunos casos de varianza hacen que el compilador emita un .override incluso para lo que escribiste como un override ordinario, más un atributo [PreserveBaseOverrides] — que no es más que la vinculación explícita por token otra vez. Nebula lee las filas MethodImpl, así que estos casos se tratan de forma idéntica.
La excepción de la reflexión
Todo lo anterior trata de lo que lee el cargador. Hay un segundo lector de nombres de miembros de interfaz, y es tu propio código. Una biblioteca de proxies dinámicos, un decorador de DI, un framework de intercepción — cualquier cosa que construya tipos en tiempo de ejecución o resuelva miembros con GetMethod("Run") o GetInterfaceMap y luego compare nombres — depende de que los nombres sobrevivan:
// A decorator that forwards by name: breaks if IJob.Run is renamed.
MethodInfo target = typeof(IJob).GetMethod("Run")!;
La autodetección de Nebula captura los patrones comunes (literales de cadena que coinciden con nombres de miembros en tipos sobre los que se hace reflexión, convenciones de serialización y de DI), y como la preservación se propaga por el grupo, fijar IJob.Run fija automáticamente cada implementación suya. Para los casos que no puede ver — un nombre ensamblado en tiempo de ejecución, una convención en una biblioteca de terceros — excluye la interfaz explícitamente y el grupo la sigue.
La configuración
Para una biblioteca cuyas interfaces son su contrato público, preservePublicApi conserva por sí solo las interfaces públicas y las implementaciones públicas, y la agrupación se asegura de que ningún override interno de un virtual público se renombre por debajo de él. Para una aplicación, lo renombras todo y excluyes solo los tipos de contrato a los que la reflexión o los plugins externos se vinculan por nombre:
{
"inputs": ["out/Acme.Jobs.dll", "out/Acme.Host.dll"],
"outputDirectory": "out/obf",
"preservePublicApi": false,
"crossAssemblyRename": true,
"exclude": [
"Acme.Jobs.IPlugin"
],
"controlFlowObfuscation": true,
"encryptStrings": true
}
Dos notas sobre eso. crossAssemblyRename con ambos ensamblados como entradas es lo que permite que un slot virtual que abarca Acme.Jobs y Acme.Host — una clase base en uno, overrides en el otro — se renombre como un único grupo en lugar de quedar congelado en la frontera del ensamblado; con solo uno de ellos como entrada, el slot es externo a la ejecución y se queda como está. Y excluir IPlugin por nombre de tipo preserva sus miembros, lo que por propagación preserva cada clase que los implementa, sin necesidad de listar los implementadores.
Decompila el resultado y la jerarquía se lee como quieres: interface a { void b(); string c(); }, abstract class d : a { public virtual void b() {} string a.c() ... }, sealed class e : d { public override void b() ... } — cada slot intacto, cada nombre desaparecido, y ToString() y Dispose() siguen en pie exactamente donde el runtime los espera.
La regla, en una línea
El nombre de un método virtual se comparte con cada override e implementación a los que está conectado, así que se renombra como grupo o no se renombra en absoluto — y el grupo queda congelado en el momento en que cualquier miembro suyo se vincula a algo fuera de los ensamblados que estás renombrando. Las implementaciones explícitas se vinculan mediante la tabla MethodImpl y renombrarlas no cuesta nada; las implícitas se vinculan por nombre y cuesta todo renombrarlas por separado. El trabajo del ofuscador es saber cuál es cuál, y no dejar nunca un slot renombrado a medias.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.