Cifrar recursos incrustados: los datos que tu ofuscador olvida
Renombraste cada tipo, cifraste cada cadena y virtualizaste los métodos calientes, y luego alguien abrió tu ensamblado en un visor de recursos y leyó tu SQL incrustado, tu material de claves de licenciamiento y el texto completo de tus mensajes de error directamente del blob .resources. Los recursos gestionados no son código, así que la protección a nivel de método nunca los toca; se sientan en el manifiesto como valores con prefijo de longitud que cualquiera puede volcar. Así se almacenan realmente los recursos incrustados en un PE, por qué el renombrado los deja intactos, y cómo Nebula los cifra en tiempo de compilación y los descifra de forma transparente a través de un resolver para que tus llamadas a ResourceManager y GetManifestResourceStream sigan funcionando sin cambios.
Un paso de protección tiende a centrarse en la superficie emocionante: los cuerpos de los métodos. Renombras el grafo de tipos, aplanas el flujo de control, cifras los literales de cadena, quizá virtualizas los métodos que imponen el licenciamiento. Luego envías, y una semana después alguien te manda por correo el texto exacto de tus mensajes de error internos, la plantilla de una cadena de conexión y un archivo de datos que considerabas propietario, todo extraído del ensamblado sin descompilar un solo método. No rompieron tu ofuscación. Abrieron los recursos, que tu ofuscación nunca tocó, porque los recursos no son código.
Este es el punto ciego. Los recursos incrustados se almacenan como datos en una parte del PE que las transformaciones a nivel de método no tienen razón para visitar. Este artículo trata de qué hay realmente en ese blob, por qué toda protección centrada en el código lo deja a la vista, y cómo Nebula cifra los recursos en tiempo de compilación manteniendo tus llamadas a ResourceManager y GetManifestResourceStream funcionando exactamente como las escribiste.
Dónde viven realmente los recursos
Un recurso gestionado no es un miembro. Cuando marcas un archivo como recurso incrustado, el compilador escribe sus bytes en un blob de recursos y añade una fila a la tabla de metadatos ManifestResource que lo nombra y da su desplazamiento. Un archivo .resx se compila en un flujo binario .resources y se incrusta igual. Nada de este proceso involucra IL, cuerpos de métodos ni nombres de miembros; los dos subsistemas apenas se conocen.
Como el blob de recursos es una región separada, cada transformación de código que ejecutas —renombrado, flujo de control, cifrado de cadenas, virtualización— lo pasa de largo. No hay nombre que destrozar ni IL que reescribir. El blob que enviaste es byte a byte el blob que produjo el compilador.
Lo que ve un lector
El formato .resources es deliberadamente simple y la BCL incluye el lector. No necesitas un descompilador; necesitas System.Resources.ResourceReader, la misma clase que el framework usa internamente:
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// ConnectionTemplate = Server={0};Database=acme;User Id={1};Password={2};
// ActivationSeed = 7f3a9c... (your "secret" byte[] in plain hex)
// Error_LicenseVoid = This build's licence was revoked on {0:d}.
Para un archivo incrustado en bruto (no un .resx), es aún más directo: GetManifestResourceStream devuelve los bytes exactos:
var asm = Assembly.LoadFrom("MyApp.dll");
using var s = asm.GetManifestResourceStream("MyApp.Data.rules.bin");
using var ms = new MemoryStream();
s!.CopyTo(ms);
File.WriteAllBytes("rules.bin", ms.ToArray()); // your proprietary file, extracted
Nada aquí toca tus métodos protegidos. Los nombres de los recursos están listados en el índice hash del flujo, los valores de cadena son UTF-8 con prefijo de longitud de 7 bits, y los objetos arbitrarios vuelven a través de su serializador. Un visor de recursos es solo una GUI sobre esta API. “Lo incrusté en la DLL” no te compró nada contra él.
Cifrar el blob en tiempo de compilación
Nebula trata los recursos como un objetivo de protección de primera clase. Habilitado en la configuración, el paso de cifrado de recursos recorre las entradas ManifestResource que seleccionaste, reemplaza los bytes en texto plano por texto cifrado e inyecta un resolver que descifra bajo demanda. La configuración nombra qué proteger:
<Nebula>
<ResourceEncryption Enabled="true">
<!-- Raw embedded files -->
<Include Pattern="MyApp.Data.*" />
<!-- .resx-backed resource sets -->
<Include Pattern="MyApp.Strings.resources" />
<!-- Leave framework/designer resources that must stay plain -->
<Exclude Pattern="*.g.resources" />
</ResourceEncryption>
</Nebula>
En tiempo de compilación el paso reescribe cada recurso coincidente. Conceptualmente la transformación es esta —leer el texto plano, cifrarlo bajo una clave que el código protegido pueda reproducir, y escribir el texto cifrado de vuelta en el blob:
// Build-time transform (inside the Nebula pass, shown for intuition)
foreach (var res in module.Resources.OfType<EmbeddedResource>())
{
if (!selector.Matches(res.Name)) continue;
byte[] plain = res.GetResourceData();
byte[] cipher = ResourceCrypto.Encrypt(plain, deriveKeyFor(res.Name));
module.Replace(res, new EmbeddedResource(res.Name, cipher));
}
La clave no se escribe como un campo constante. La produce una pequeña rutina de descifrado que Nebula inyecta y luego somete a las mismas protecciones que el resto de tu código —cifrado de cadenas en sus constantes, aplanamiento de flujo de control, opcionalmente virtualización— de modo que un atacante no pueda buscar con grep una clave estática ni un identificador de algoritmo reconocible. Ese es justo el punto: el texto cifrado del recurso es solo tan recuperable como el código ofuscado que lo desenvuelve.
El descifrado sigue siendo transparente para tu código
El paso sería inútil si te forzara a reescribir cada llamada a GetString y GetManifestResourceStream. No lo hace. Para archivos incrustados en bruto, Nebula instala un hook para que los nombres protegidos devuelvan un flujo descifrador, y tu punto de llamada existente no cambia:
// Your code — unchanged. The resolver decrypts behind GetManifestResourceStream.
using var s = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MyApp.Data.rules.bin");
var rules = RuleSet.Parse(s!); // receives plaintext
Para búsquedas respaldadas por .resx, Nebula suministra un ResourceManager cuyo conjunto de recursos descifra los valores a medida que se leen, de modo que GetString y GetObject sigan entregando texto plano a tu código:
// Your code — unchanged. The injected ResourceManager decrypts on read.
var rm = Strings.ResourceManager; // Nebula-supplied, decrypting
string msg = rm.GetString("Error_LicenseVoid")!; // plaintext at the call site
La mecánica que hace esto posible son los mismos puntos de extensión del framework que ya usa la localización: se construye un conjunto de recursos por cada flujo de recursos, y el lector detrás de él es tuyo para suministrarlo. Nebula encaja un lector descifrador en ese pipeline. Como los ensamblados satélite (localizados) son solo ensamblados con sus propios flujos .resources, el tratamiento idéntico los cubre: cada .resources localizado se cifra y se resuelve de la misma forma, así que tus cadenas específicas de cultura también están protegidas, no solo el conjunto neutral.
Ejecuta ahora el lector de recursos contra el ensamblado protegido y la diferencia es palpable:
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// __nebula_enc_0 = System.Byte[] (opaque ciphertext; no names, no plaintext)
// __nebula_enc_1 = System.Byte[]
Los nombres desaparecieron, los valores son texto cifrado, y lo único que puede volver a convertirlos en cadenas es el camino de código ofuscado dentro de tu ensamblado, que es justo donde ya invertiste esfuerzo en hacer la recuperación costosa.
Qué llevarse
La protección de código protege el código. Los recursos son datos, y se sientan en una región separada del ensamblado que el renombrado, la ofuscación de flujo de control, el cifrado de cadenas y la virtualización nunca visitan, razón por la cual un visor de recursos puede leer tu SQL incrustado, tu material de claves y tu texto de error de una DLL completamente ofuscada en un clic. Trata los recursos como un objetivo de protección por derecho propio: cifra el blob en tiempo de compilación, deriva la clave a través del mismo código protegido que guarda tus métodos para que no haya una clave estática que buscar con grep, y descifra de forma transparente a través del hook de GetManifestResourceStream y un ResourceManager descifrador para que tus puntos de llamada nunca cambien. Nada enviado a un cliente es irrompible, los recursos incluidos, pero puedes eliminar la divulgación trivial y forzar cualquier recuperación a través del código ofuscado, donde le cuesta trabajo real al atacante. Si las cadenas en tu código son la otra mitad de este problema, combínalo con cifrado de cadenas en .NET; para el panorama más amplio de lo que la ofuscación cubre y lo que no, consulta ofuscación más allá del renombrado.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.