Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETGuía

Ofuscar una solución .NET de varios proyectos: una estrategia de protección coherente

Una solución con una docena de bibliotecas de clases y una app no son una docena de problemas de ofuscación: es uno solo. Protege cada proyecto por separado y duplicarás la protección de copias transitivas, romperás InternalsVisibleTo entre tus propios ensamblados y acabarás con un montón de mapas de renombrado que no cuadran. Esta es la guía: decide qué entregas de verdad y protege solo eso, ejecuta una sola pasada sobre la salida de publicación, gobierna cada ensamblado desde un único nebula.config.json compartido, renombra toda la solución para que el acceso de amigos y las referencias cruzadas sigan sincronizados, mantén un mapa sembrado por versión y cablea todo en CI para que cada build entregada quede protegida igual.

Una solución con diez bibliotecas de clases y una app parece diez problemas de ofuscación. Es uno. Los proyectos se referencian entre sí, se exponen tipos mutuamente, comparten internos mediante [InternalsVisibleTo] y se entregan juntos como una única unidad que el cliente instala o restaura como un solo paquete. Protégelos de uno en uno y obtienes lo peor de cada mundo: copias transitivas ofuscadas dos veces, referencias entre ensamblados que dejan de resolver, acceso de amigos roto entre tus propias DLLs y un cajón de mapas de renombrado que no cuadran cuando un informe de fallo recorre tres de ellos. Esta entrada es la guía para hacerlo como el único problema que es: decidir qué proteger, dónde ejecutar la pasada, cómo gobernar cada ensamblado desde una sola política y cómo demostrar el resultado sobre todo el conjunto en Nebula.NET.

Paso 1 — Protege lo que entregas, y solo eso

Antes de cualquier configuración, fija el alcance. Tu solución compila mucho más de lo que entrega: proyectos de pruebas, bancos de rendimiento, auxiliares de generadores de código fuente, apps de ejemplo. Nada de eso llega a un cliente, así que nada de eso necesita protección —ofuscar un ensamblado de pruebas que nunca distribuyes es coste puro—. Parte de la lista de ensamblados que de verdad salen del edificio, que es exactamente lo que contiene tu dotnet publish (o tu .nupkg).

La trampa más sutil son las copias transitivas. Cuando tu app se publica, cada dependencia que arrastra aterriza en la carpeta de salida —incluidos paquetes de terceros y, si no tienes cuidado, copias de bibliotecas de otras soluciones—. Proteges los ensamblados que tú escribiste y entregas; no vuelves a ofuscar una dependencia que otro ya entregó (no tienes mapa de ella, ni derecho a alterarla, ni nada que ganar). Así que la lista de entradas nombra tus propios ensamblados por su nombre, no «todo lo que haya en la carpeta». Enumera los que llevan tu espacio de nombres y deja el resto en paz.

Paso 2 — Ejecuta la pasada una vez, sobre la salida de publicación

El error multiproyecto más común es ejecutar la ofuscación por proyecto, en el bin/Debug de cada uno, como parte de cada build de desarrollo. Eso está mal en dos ejes. Protege el artefacto equivocado —binarios de Debug que nadie entrega— y los protege en aislamiento, antes de que la solución se haya ensamblado en una sola salida coherente. Los desarrolladores pierden además lo que más necesitan: una build legible y depurable.

Ejecuta la pasada una vez, tarde, sobre la salida de publicación ensamblada, solo en el servidor de build. Publica toda la solución y luego entrega a Nebula los ensamblados publicados como una sola ejecución. Es la misma disciplina que ofuscar solo en tu servidor de build: las máquinas de desarrollo compilan limpio y depurable; el agente que produce el paquete entregable es el único sitio donde ocurre la protección, así que el binario que entregas es el único binario que jamás se ofusca.

proyectos de la soluciónAcme.AppAcme.CoreAcme.RulesAcme.Plugins.AbstractionsAcme.Tests (no se entrega)una ejecución de Nebula4 ensamblados entregablescrossAssemblyRenameuna configuración compartidaensamblados protegidos(publish/protected)un conjunto de mapaspor versión

Paso 3 — Una política compartida, no una configuración por proyecto

Las transformaciones que ejecutas, qué mantienes público en la frontera, dónde vive el mapa de siembra: son propiedades del producto, no de ningún proyecto. Así que pertenecen a un único nebula.config.json a nivel de solución, leído por la única ejecución sobre la salida de publicación. Un fragmento de configuración por proyecto es la forma en que una solución deriva hacia estar protegida de manera incoherente: una biblioteca acaba con cifrado de cadenas, la siguiente sin él, y nadie se da cuenta hasta que lo hace un decompilador.

Aquí está la política completa de un producto de cuatro ensamblados, en un solo archivo. Nombra los ensamblados entregables como entradas, los renombra como conjunto, mantiene el único espacio de nombres que es un contrato público y siembra a partir del mapa de la versión anterior:

{
  "schemaVersion": 1,
  "inputs": [
    "publish/Acme.App.dll",
    "publish/Acme.Core.dll",
    "publish/Acme.Rules.dll",
    "publish/Acme.Plugins.Abstractions.dll"
  ],
  "outputDirectory": "publish/protected",
  "crossAssemblyRename": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "obfuscateConstants": true,
  "seedMapFile": "maps/Acme-prev.symbols.json",
  "renaming": {
    "rules": [
      { "pattern": "Acme.Plugins.Abstractions.*", "action": "keep" },
      { "pattern": "Acme.*", "action": "rename" }
    ]
  }
}

crossAssemblyRename, el cifrado de cadenas sin límite, la ofuscación del flujo de control y el enmascarado de constantes son funciones Pro; el renombrado de identificadores está en todas las ediciones. Las reglas de renombrado se evalúan de arriba abajo, gana la primera coincidencia: el keep estrecho del contrato de plugins va por encima del amplio Acme.* que lo recoge todo, exactamente como en construir una keep-list en Nebula. Un archivo, una política, cada ensamblado tratado igual.

Paso 4 — Renombra la solución, no por proyecto

crossAssemblyRename: true es la línea que hace que una solución funcione. Tus ensamblados se referencian constantemente: Acme.App llama a un tipo de Acme.Core, que llama a uno de Acme.Rules. Cuando Nebula renombra Acme.Core.PricingEngine a a, tiene que reescribir los sitios de llamada en Acme.App y Acme.Rules a a también, o esas referencias quedan colgando. Una sola ejecución con el renombrado entre ensamblados activado hace exactamente eso: trata el conjunto de entradas como un solo espacio de nombres a renombrar y reescribe cada referencia a lo largo de él.

El filo cortante es [InternalsVisibleTo] entre tus propios ensamblados —los tipos internal que uno de tus proyectos expone a otro (lo más habitual, un proyecto de pruebas, pero también un hermano dividido por capas)—. Esos nombres internos son un contrato en tiempo de compilación; renombra el productor por sí solo y el consumidor sigue referenciando los nombres antiguos y no enlaza. Como cada ensamblado que referencia los internos renombrados es una entrada de la misma ejecución, el renombrado entre ensamblados mantiene ambos lados al unísono: el tipo interno recibe el mismo nombre nuevo en su definición y en cada referencia de amigo. La mecánica completa, incluida la variante de reflexión en tiempo de ejecución que ningún compilador te avisa, está en ofuscar InternalsVisibleTo y los ensamblados amigos. La regla que se desprende es la regla de toda esta entrada: si dos de tus ensamblados comparten nombres, comparten ejecución.

Paso 5 — Un mapa sembrado por versión

Una ofuscación por proyecto te deja con un mapa por proyecto, generado contra las decisiones de renombrado aisladas de ese proyecto, y nada garantiza que concuerden. Una traza de pila de campo rara vez se queda dentro de un solo ensamblado —recorre Acme.App, pasa por Acme.Core y entra en Acme.Rules— y decodificarla exige que el mapa de cada fotograma lo haya producido la misma ejecución. Ejecutar toda la solución una vez te da exactamente eso: un conjunto coherente de mapas .symbols.json para la versión, todos generados contra las mismas decisiones.

Luego lleva los nombres hacia adelante. Apunta seedMapFile a los mapas de la versión anterior para que los miembros que no cambiaron conserven el nombre ofuscado que ya tenían; solo el código nuevo recibe nombres nuevos. A lo largo de una serie de versiones eso significa que un mapa archivado sigue decodificando el mismo método, y un diff binario muestra tu cambio real en vez de una renumeración global. El tratamiento completo —por qué los nombres estables hacen manejables la simbolización de fallos y el parcheo delta, y por qué la siembra es una herramienta operativa y no una palanca de seguridad— está en mantener estables los nombres ofuscados con un mapa de siembra. Archiva una copia estampada con la versión de los mapas de cada release; son lo que decodifica para siempre los informes de fallo de esa build.

Paso 6 — Mantén la frontera de la solución, renombra libre dentro de ella

Un producto de varios proyectos tiene exactamente una superficie que debe sobrevivir al renombrado: aquello a lo que el mundo exterior se enlaza por nombre. Para un SDK entregado es la API pública; para una app con modelo de plugins es el contrato de plugins —el ensamblado de abstracciones contra el que un tercero compila su plugin—. Todo lo que queda dentro de la frontera de la solución, por muchos ensamblados que abarque, es tuyo para renombrar libremente, porque nada de fuera lo referencia.

Por eso la configuración de arriba mantiene Acme.Plugins.Abstractions.* entero y renombra todo lo demás, en vez de activar preservePublicApi para todo el conjunto. preservePublicApi mantiene cada tipo público de cada entrada, pero dentro de tu propia solución «público» es solo el modificador de acceso que tus ensamblados usan para verse entre sí; no es un contrato externo. Mantenerlo todo renunciaría a la mayor parte de tu protección sin motivo. Acota el keep al único ensamblado que es de verdad una frontera, deja que el renombrado entre ensamblados renombre la superficie pública-pero-interna-al-producto y proteges el máximo mientras mantienes intacto el contrato del que dependen los autores de tus plugins.

Paso 7 — Cablearlo en CI

Ensambla todo en un paso de pipeline que se ejecute en el agente y en ningún otro sitio: publica la solución, trae los mapas de la versión anterior para sembrar, ejecuta la única pasada de protección y luego archiva los mapas de esta build para que la siguiente versión pueda sembrar de ellos.

# Pipeline de release — se ejecuta solo en el agente de build.
steps:
  - run: dotnet publish Acme.sln -c Release -o publish
  # Siembra desde los mapas de la última versión (nulo en la primera release).
  - run: fetch-artifact Acme-latest.symbols/ -> maps/Acme-prev.symbols.json
  - run: nebula --config nebula.config.json      # una ejecución, todos los ensamblados entregables
  # Archiva los mapas de esta build para sembrar la siguiente versión y para decodificar fallos.
  - run: publish-artifact publish/protected/*.symbols.json as Acme-latest.symbols/
  - run: publish-artifact publish/protected/*.symbols.json as Acme-${VERSION}.symbols/
  - run: pack-and-sign publish/protected

Condiciónalo a una señal que solo exista en el agente —la variable de entorno NEBULA_OBFUSCATE del montaje de solo-servidor-de-build— para que un desarrollador que ejecute dotnet publish en local siga obteniendo una build limpia y depurable. El paso de empaquetar y firmar apunta a publish/protected, nunca a la salida de publicación cruda, así que el artefacto que sale del pipeline es el protegido. Cada build entregada ejecuta ahora la política idéntica sobre el conjunto de entradas idéntico: la protección es una propiedad de la release, no de quien la haya compilado.

Verifica a lo largo de los ensamblados en Glass.NET

El modo de fallo contra el que protege esta guía es invisible en tiempo de build —la app funciona, las pruebas pasan, y un ensamblado se entregó en silencio en texto claro o una referencia cruzada quedó colgando en silencio—. Así que comprueba la salida, y compruébala toda. Glass.NET es el compañero gratuito y de solo análisis estático para esto: lee metadatos e IL y nunca ejecuta el binario, así que abre cada ensamblado protegido de publish/protected por turno.

Confirma tres cosas a lo largo del conjunto. Primero, que cada ensamblado entregable está de verdad protegido: los internos se leen como a, b, c y tus cadenas están cifradas en cada uno, no solo en el primero. Segundo, que un tipo renombrado en un ensamblado se renombra al mismo nombre allí donde otro ensamblado lo referencia: el renombrado entre ensamblados es coherente, sin ninguna referencia colgando a un nombre antiguo. Tercero, que la frontera sobrevivió: tu ensamblado de contrato de plugins aún se lee con sus nombres públicos reales. Luego ejecuta la suite de integración contra la salida protegida, porque las roturas por resolución de nombres entre ensamblados solo son visibles una vez que se ejecuta una ruta. El procedimiento completo de antes/después está en ¿de verdad funcionó tu ofuscación?.

Trata la solución como una sola unidad y todo se vuelve más simple, no más difícil: una configuración, una ejecución, un conjunto de mapas por versión, una frontera mantenida, una compuerta de CI. Descarga Nebula.NET gratis y ejecútalo sobre tu salida de publicación como una sola pasada.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.