Ofuscar un ensamblado multi-target: una sola configuración para net48 y net8.0
Una biblioteca que apunta a net48;net8.0 se compila en dos ensamblados distintos, con dos conjuntos de referencias distintos y a veces dos conjuntos de IL distintos. Proteger solo uno de ellos es distribuir un build sin ofuscar. Así es como Nebula.NET trata cada target framework como su propia ejecución de ofuscación compartiendo una única política, y las trampas por TFM que muerden si no lo haces.
Añade un segundo target framework a un .csproj y habrás duplicado silenciosamente tu superficie de distribución. <TargetFrameworks>net48;net8.0</TargetFrameworks> le dice al compilador que construya el proyecto dos veces, en dos carpetas separadas, como dos ensamblados distintos. Comparten nombre y versión, pero no son el mismo binario: enlazan contra ensamblados de referencia distintos, y si tu código tiene un solo #if NET8_0_OR_GREATER, ni siquiera contienen el mismo IL. Un cliente que instala el build net48 y un cliente en net8.0 están ejecutando DLL distintas.
Eso importa en el momento en que proteges el producto. Apunta tu ofuscador a una carpeta de salida —la que tu máquina de desarrollo construye por casualidad— y habrás endurecido exactamente uno de los dos ensamblados que distribuyes. El otro sale en claro. Este artículo trata de hacerlo bien con Nebula.NET: tratar cada target framework como su propia ejecución de ofuscación contra sus propias referencias, mientras una única política gobierna a todos, y el puñado de detalles por TFM que te morderán si no los vigilas.
Qué produce realmente un build “multi-target”
Un build multi-target no es un build con una elección al final. MSBuild ejecuta el build interno una vez por TFM y coloca los resultados uno al lado del otro:
MyLib/bin/Release/
├─ net48/
│ └─ MyLib.dll → references mscorlib, System, System.Core, …
└─ net8.0/
└─ MyLib.dll → references System.Runtime, System.Private.CoreLib, …
Mismo nombre MyLib.dll, dos ensamblados distintos. En un paquete NuGet la misma división aparece como lib/net48/MyLib.dll y lib/net8.0/MyLib.dll, y el motor de restore elige la mejor coincidencia para cada consumidor. Así que “ofuscar MyLib.dll” es ambiguo —hay dos— y ambos tienen que protegerse, cada uno contra el conjunto de referencias para el que se compiló, o la verificación de firmas y la reescritura de referencias serán incorrectas.
La forma correcta: una política, una ejecución por framework
Nebula ofusca ensamblados compilados, no código fuente, así que nada de esto requiere cambios en el código: se engancha a la salida del build. La integración más limpia es el paquete MSBuild, que entiende el multi-targeting de forma nativa. Añade el paquete solo de build y condiciónalo a una señal que exista únicamente en tu agente de build, para que los builds de desarrollo queden intactos:
<ItemGroup>
<PackageReference Include="Delta1Labs.Nebula.MSBuild" Version="1.1.6" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup Condition="'$(NEBULA_OBFUSCATE)' == '1'">
<NebulaObfuscate>true</NebulaObfuscate>
<NebulaConfigFile>$(MSBuildProjectDirectory)\nebula.config.json</NebulaConfigFile>
</PropertyGroup>
Ese es todo el cableado para un proyecto multi-target. El paquete se ejecuta después de cada build interno, así que cada target framework se protege en su propia carpeta de salida nebula por TFM automáticamente: no listas los frameworks. La DLL net48 se ofusca dentro del build net48 con las referencias de net48 a mano; la DLL net8.0 dentro del build net8.0 con sus referencias. Un único nebula.config.json dirige ambos.
Y esa configuración no lleva inputs: es una política solo de ajustes. El build suministra el ensamblado; el archivo suministra las transformaciones y las reglas:
{
"preservePublicApi": true,
"controlFlowObfuscation": true,
"encryptStrings": true,
"obfuscateConstants": true,
"metadataHardening": true,
"antiTamper": true,
"exclude": [
"MyLib.Interop.NativeMethods"
],
"excludeNamespaces": [
"MyLib.Abstractions"
]
}
Esas transformaciones (cadenas/control de flujo ilimitados, enmascaramiento de constantes, endurecimiento de metadatos, anti-tamper) son funciones Pro; el renombrado de identificadores está en todas las ediciones. La clave es que la misma política se aplica de forma idéntica a ambos ensamblados: no estás manteniendo nebula.net48.json y nebula.net8.json.
Si prefieres no tener ninguna referencia de paquete, dirige la CLI desde un Directory.Build.targets y deja que la guarda por TFM haga el reparto por ti:
<Project>
<Target Name="NebulaObfuscate" AfterTargets="Build"
Condition="'$(NEBULA_OBFUSCATE)' == '1' and '$(TargetFramework)' != ''">
<Exec Command="nebula --config "$(MSBuildThisFileDirectory)nebula.config.json" --input "$(TargetPath)" --output "$(TargetDir)nebula"" />
</Target>
</Project>
La condición '$(TargetFramework)' != '' hace un trabajo real aquí. En un build multi-target, el target se ejecuta una vez dentro de cada build interno concreto —donde $(TargetFramework) es net48, y luego net8.0— y $(TargetPath) apunta a la DLL propia de ese framework. La condición se salta el build agregado externo, donde $(TargetFramework) está vacío y no hay un único ensamblado que procesar. Sin ella, o bien ejecutas contra nada, o bien ejecutas una vez y te saltas un framework.
Las trampas por TFM
Las referencias se resuelven por framework: deja que el build las entregue
El renombrado y la pasada final de verificación necesitan ver las referencias reales del ensamblado: para reescribir una referencia cruzada, o para confirmar que un módulo transformado sigue resolviendo. Esas referencias difieren por TFM: mscorlib/System.* para net48, el conjunto de fachadas System.Runtime para net8.0. Ejecutar la ofuscación dentro de cada build interno (ambos enfoques de arriba lo hacen) significa que Nebula enlaza exactamente contra el conjunto de referencias correcto para el framework en cuestión. Mantén verifyOutput activado (lo está, por defecto): vuelve a comprobar el módulo transformado en busca de referencias colgantes antes de escribir y se niega a emitir un módulo que no cargaría, que es precisamente el error que de otro modo descubrirías solo cuando la app del cliente en net48 no arranque.
#if significa que los dos ensamblados no tienen los mismos miembros
La compilación condicional es la trampa sutil. Considera:
public sealed class Cache
{
#if NET8_0_OR_GREATER
public void Store(ReadOnlySpan<byte> data) { /* span path */ }
#else
public void Store(byte[] data) { /* array path */ }
#endif
internal void Evict() { /* both */ }
}
El ensamblado net8.0 tiene un Store(ReadOnlySpan<byte>); el net48 tiene Store(byte[]). Si escribes una entrada de exclude o include para un miembro, coincidirá bajo los TFM donde ese miembro existe y, en silencio, no hará nada bajo los demás. Normalmente eso está bien, pero significa que no puedes asumir que una lista de permitidos completa para net8.0 es completa para net48. Apóyate en autoDetect (activado por defecto) para preservar los nombres necesarios para la corrección —objetivos de reflexión, serialización, DI y XAML— en todos los targets, y reserva las entradas manuales de exclude para la superficie genuinamente específica de cada framework.
Preserva la API pública de forma consistente en una biblioteca distribuida
Para una biblioteca que otros referencian, mantén preservePublicApi: true para que los nombres de tipos y miembros públicos sobrevivan en ambos ensamblados: un consumidor que a su vez hace multi-targeting contra ti debe ver la misma superficie pública sea cual sea la DLL tuya que resuelva. Renombrar solo los internos sigue endureciendo la lógica valiosa mientras deja intacta la API invocable. (Para una app de usuario final que nadie referencia, desactívalo y renombra todo lo elegible.)
Cada framework obtiene su propio mapa de símbolos
Cada ejecución emite un mapa de símbolos (renombrado) junto a su salida, y como los dos ensamblados se renombraron de forma independiente, los mapas difieren por TFM. Cuando desofusques una traza de pila procedente del campo, debes usar el mapa del framework del que vino el fallo: pasar una traza de net8.0 por el mapa de net48 produce basura. Conserva las carpetas de salida nebula por TFM como artefactos de build, nombradas por framework, para que el mapa correcto siempre sea localizable. La mecánica de mapear una traza hacia atrás está en Desofuscar trazas de pila.
Empaqueta después de proteger
Si distribuyes un paquete NuGet, las DLL de lib/net48 y lib/net8.0 dentro del .nupkg deben ser cada una la ofuscada, no la salida cruda del compilador. Ejecuta la protección antes del paso de pack y apunta el empaquetado a los ensamblados protegidos; la receta completa está en Ofuscar un paquete NuGet. Para un producto multi-ensamblado en el que tus TFM se referencian entre sí, ofusca la salida publicada como un solo paso para que las referencias entre ensamblados se mantengan consistentes; consulta Múltiples ensamblados.
Verifica ambos, no uno
El modo de fallo contra el que protege todo este artículo es invisible: tu app sigue funcionando, tus pruebas siguen pasando, y uno de tus dos ensamblados distribuidos va en claro. Así que comprueba ambos. Abre la DLL de salida de cada framework en un decompilador y confirma que los internos están renombrados y las cadenas cifradas; el decompilador gratuito Glass.NET es práctico para un antes/después rápido en cada TFM. Confirma que existe un mapa de símbolos por TFM para cada uno. Y mantén honesta la guarda del agente de build, para que el build protegido sea el que salga por la puerta.
El multi-targeting es ahora la norma para las bibliotecas, y eso significa que tu historia de ofuscación es una historia por framework, lo hayas planeado o no. La buena noticia es que hacerlo bien no te cuesta casi nada: una política solo de ajustes, protección que se ejecuta dentro de cada build interno, y la disciplina de verificar cada TFM que distribuyes. Empieza por la guía de MSBuild y CI y la referencia de configuración para las propiedades y campos exactos.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.