Ofuscar un ensamblado cuyas pruebas aún deben pasar: InternalsVisibleTo y ensamblados amigos
Renombrar los internos es donde la ofuscación se gana su sitio — pero [InternalsVisibleTo] convierte silenciosamente algunos de esos internos en un contrato. Un proyecto de pruebas, un ensamblado hermano o un mock generado accede a tus tipos internos por nombre, y ese nombre solo existe en tiempo de compilación. Ofusca el productor por sí solo y el amigo deja de compilar o, peor aún, un mock basado en reflexión falla en tiempo de ejecución. Aquí tienes exactamente qué hace [InternalsVisibleTo] en los metadatos, por qué Nebula.NET preserva el caso de un solo ensamblado por ti y cómo mantener en verde un ensamblado amigo genuino ofuscando el par en conjunto.
Lo más valioso que hace un ofuscador es renombrar lo que un consumidor no puede ver: tus tipos y miembros internal y private. Ahí es donde vive tu implementación real, y nada fuera del ensamblado debería referenciarlos por nombre, así que renombrar internal class PricingEngine a internal class a no te cuesta nada y le cuesta mucho a un atacante.
[InternalsVisibleTo] es el único atributo que rompe esa suposición. Permite que un ensamblado amigo con nombre vea tus internos por nombre, en tiempo de compilación — lo que asciende silenciosamente algunos de tus internos de “detalle de implementación” a “contrato”. El amigo más habitual es tu propio proyecto de pruebas unitarias, que entra para probar una clase que no querías hacer pública. Ofusca el productor por su cuenta y ese amigo puede dejar de compilar o, si enlaza por reflexión, fallar en tiempo de ejecución — sin ningún error del propio paso de ofuscación.
Qué es realmente [InternalsVisibleTo]
El atributo es un metadato a nivel de ensamblado. En el código fuente se lee así:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Acme.Pricing.Tests")]
En el módulo compilado es un único atributo personalizado sobre el ensamblado, que lleva el nombre del amigo (y, para un productor firmado, la clave pública del amigo):
.assembly Acme.Pricing
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.InternalsVisibleToAttribute::.ctor(string)
= ( 01 00 11 41 63 6D 65 2E 50 72 69 63 69 6E 67 // ...Acme.Pricing
2E 54 65 73 74 73 00 00 ) // .Tests
}
No hace nada en tiempo de ejecución — el CLR no lo consulta. Es una instrucción para el compilador del ensamblado amigo: “cuando compiles Acme.Pricing.Tests, déjale enlazar con los internos de Acme.Pricing”. Así que en el momento en que lo concedes, esto es legal en el amigo:
// In Acme.Pricing.Tests — only compiles because of InternalsVisibleTo
[Fact]
public void Discount_is_applied_once()
{
var engine = new PricingEngine(); // internal type
Assert.Equal(90m, engine.Apply(100m, 0.1)); // internal method
}
PricingEngine y Apply quedan ahora grabados en los metadatos del ensamblado de pruebas como referencias externas a Acme.Pricing. Esas referencias son por nombre.
Dónde se rompe — y dónde no
Esta es la trampa, dibujada. El productor y el amigo son dos ensamblados separados, y que un renombrado sea seguro depende por completo de si el amigo también se reescribe.
El caso de un solo ensamblado que Nebula mantiene funcionando. El atributo [InternalsVisibleTo] y la identidad del amigo que nombra se preservan en una ejecución — Nebula no corrompe el atributo ni la identidad propia del ensamblado, así que las relaciones de amistad siguen siendo estructuralmente válidas (esta es la garantía de ámbito de un solo ensamblado del motor). Lo que una ejecución sobre el productor por sí solo no puede hacer es entrar en un ensamblado amigo que nunca se le entregó y reescribir las referencias de ese amigo a los internos ahora renombrados del productor. Si Acme.Pricing.Tests no es una entrada, sigue conteniendo una referencia a PricingEngine, y después de que PricingEngine se convierta en a, esa referencia no resuelve a nada.
La variante en tiempo de ejecución es peor. Un framework de mocking o una prueba que hurga en los internos mediante reflexión — Type.GetType("Acme.Pricing.PricingEngine") o assembly.GetType("...") — compila sin problemas contra el productor sin ofuscar, y luego lanza null/MissingMethodException en tiempo de ejecución una vez renombrado el tipo, porque la cadena ya no coincide. Ningún error de compilación te avisa; solo lo hace la ejecución de las pruebas.
La solución: ofuscar el amigo junto con el productor
Un ensamblado amigo no es más que una referencia entre ensamblados que casualmente cruza la frontera de lo interno. Trátalo como cualquier otra ejecución multi-ensamblado: entrega a Nebula ambos ensamblados y deja que renombre los internos compartidos en ambos lados de forma coherente.
{
"inputs": [
"out/Acme.Pricing.dll",
"out/Acme.Pricing.Tests.dll"
],
"outputDirectory": "out/obf",
"preservePublicApi": true,
"crossAssemblyRename": true,
"encryptStrings": true
}
crossAssemblyRename es la línea clave: renombra los tipos y miembros compartidos entre los ensamblados de entrada y reescribe cada referencia entre ensamblados, de modo que cuando PricingEngine se convierte en a en el productor, el sitio de llamada en el ensamblado de pruebas se reescribe también a a. El atributo sigue nombrando un ensamblado real, la referencia resuelve y las pruebas se ejecutan contra los internos ofuscados — que es la prueba de aceptación ideal, porque ahora estás probando exactamente el IL que distribuyes.
Para que esto funcione, cada ensamblado que haga referencia a los internos renombrados debe ser una entrada. Esa es la regla general para crossAssemblyRename, y [InternalsVisibleTo] es simplemente el caso en que el ensamblado que referencia alcanza nombres internos en lugar de públicos.
Cuando no puedes incluir el amigo
A veces el amigo realmente no es tuyo para ofuscarlo — el ensamblado de un socio, o una herramienta que no controlas, a la que se concedió acceso interno. Entonces no renuncies a toda la superficie interna; preserva solo los internos exactos que toca. exclude fuerza la preservación de un tipo (y sus miembros) o de un único miembro por su nombre completo:
{
"inputs": ["out/Acme.Pricing.dll"],
"preservePublicApi": true,
"exclude": [
"Acme.Pricing.PricingEngine", // whole type the friend constructs
"Acme.Pricing.Internals.FeeTable.Lookup" // one member a partner tool calls
]
}
Todo lo demás interno se sigue renombrando, aplanando y cifrando sus cadenas — has pagado el acceso con dos tipos en lugar de entregarlo para todo el ensamblado. Descubre exactamente qué internos lee un amigo con nebula inspect antes de decidir; preservar un tipo que no necesitabas es protección que regalaste gratis.
Una nota sobre la firma: si tu productor tiene nombre seguro (strong name), [InternalsVisibleTo] lleva la clave pública del amigo, y una ofuscación que vuelve a firmar la salida (strongNameKeyFile) no cambia eso — la cláusula del amigo nombra la clave del amigo, no la del productor. Volver a firmar el productor es ortogonal; consulta volver a firmar un ensamblado ofuscado.
La regla general
[InternalsVisibleTo] convierte una porción de tu implementación en un contrato con un ensamblado concreto. La protección que quieres — renombrar los internos — es exactamente a lo que ese contrato es sensible, así que ambos tienen que reconciliarse, no evitarse:
- Por defecto: ofusca el amigo (normalmente tu proyecto de pruebas) junto con el producto en CI, con
crossAssemblyRenameactivado. Los internos se renombran en ambos lados y tus pruebas enlazan con el IL distribuido. - Cuando el amigo no es tuyo: usa
excludesolo con los tipos o miembros internos concretos que alcanza, nunca con toda la superficie interna. - Nunca: desactives el renombrado de internos porque una prueba lee una clase. Eso cambia la mayor parte de tu protección por un problema que tiene una solución precisa.
La garantía de un solo ensamblado significa que Nebula no corromperá silenciosamente una relación de amistad; mantener al amigo en verde a través de un renombrado es lo único que te corresponde a ti, e incluirlo en la ejecución es la forma de hacerlo. Consulta Múltiples ensamblados para el modelo entre ensamblados y Mantener tu aplicación funcionando para la lista de comprobación más amplia de “qué se resuelve por nombre”.
Descarga Nebula.NET gratis y ejecuta tu suite de pruebas contra la compilación ofuscada.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.