Ofuscar código P/Invoke y DllImport sin romper la interoperabilidad nativa
El renombrado es la columna vertebral de la ofuscación, y se rompe exactamente donde tu código gestionado deja de ser autónomo: en la frontera con el código nativo. Un método [DllImport], un delegado que marshalizas a un puntero de función, un struct que vuelcas a través de la frontera: cada uno lleva un nombre o una disposición de la que algo externo al CLR depende, y renómbralo a ciegas y la llamada falla en tiempo de ejecución con un MarshalDirectiveException o un struct corrompido en silencio. Por eso la interop nativa solo sobrevive a la ofuscación cuando la herramienta entiende el marshaller: qué nombres son portantes, por qué EntryPoint desacopla el nombre gestionado del nativo, cómo deben preservarse el orden de campos y StructLayout, y cómo Nebula renombra todo alrededor de la frontera dejando la frontera misma intacta.
La ofuscación es en su mayoría renombrado. Elimina los identificadores significativos, aplana el flujo de control, cifra las cadenas, y lo que queda le dice muy poco a un atacante. El renombrado funciona porque, dentro de un ensamblado gestionado, los nombres son solo etiquetas que el CLR resuelve a través de tokens de metadatos: cambia cada CustomerValidator.Validate por a.b de forma consistente y el IL sigue enlazando, porque el IL referencia un token, no una cadena.
Esa garantía se mantiene justo hasta que tu código deja de ser autónomo. En la frontera con el código nativo, algunos nombres y disposiciones no son etiquetas internas en absoluto: son un contrato con algo que el CLR no controla: el cargador del sistema operativo, una biblioteca de C, un driver, tu propia DLL no gestionada. Renombra uno de esos a ciegas y nada se queja en tiempo de compilación, porque el IL sigue siendo válido. Falla en tiempo de ejecución, cuando el marshaller intenta enlazar una declaración gestionada con una realidad nativa que ya no coincide, y obtienes un EntryPointNotFoundException, un MarshalDirectiveException o —lo peor de todo— un struct que marshaliza a los bytes equivocados y corrompe en silencio lo que el código nativo escriba en él.
Esta es la razón más común por la que la gente cree que “la ofuscación rompe P/Invoke”. No tiene por qué. Se rompe cuando el ofuscador renombra las partes de la frontera que el lado nativo observa, y sobrevive cuando el ofuscador entiende el marshaller lo bastante bien para saber cuáles son esas partes. Recorramos la frontera y veamos exactamente dónde viven los nombres y disposiciones portantes.
El punto de entrada es un nombre que hace de clave de búsqueda
Empieza por el caso más común, y el que muerde primero. Aquí hay un P/Invoke perfectamente ordinario:
internal static partial class Native
{
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}
(La forma generada por fuente [LibraryImport] se comporta igual que el clásico [DllImport] a nuestros efectos: la regla del punto de entrada es idéntica.) Aquí no hay EntryPoint puesto, así que el runtime usa el nombre del método gestionado —MessageBoxW— como el export nativo a buscar en user32.dll. El nombre gestionado y la clave de búsqueda nativa son la misma cadena. Renombra el método a a y el stub de marshalling generado ahora pide al cargador un export llamado a en user32.dll, que no existe, y la primera llamada lanza EntryPointNotFoundException.
Mira qué registra realmente los metadatos. Un método P/Invoke tiene la bandera pinvokeimpl y una entrada ImplMap que captura el módulo y el nombre de importación. Decompilado, la declaración se lee:
.method private hidebysig static pinvokeimpl("user32.dll" winapi)
int32 MessageBoxW(native int hWnd, string text, string caption, uint32 type) cil managed preservesig
Fíjate en que el ImplMap nombra el módulo (user32.dll) pero no el export, porque sin un EntryPoint explícito, el nombre del export se da por implícito como el nombre del método. Esa implicación es la trampa. Contrástalo con la forma que desacopla ambos:
[LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int ShowMessage(IntPtr hWnd, string text, string caption, uint type);
Ahora el ImplMap lleva EntryPoint = "MessageBoxW" explícitamente, el método gestionado se llama ShowMessage, y ambos son independientes. Renombra ShowMessage a a y la llamada sigue resolviéndose, porque la clave de búsqueda nativa vive en el atributo, no en el nombre del método.
Así que la regla que sigue un ofuscador correcto es precisa: un P/Invoke cuyo nombre de export esté implícito en su nombre de método debe conservar ese nombre; un P/Invoke con un EntryPoint explícito puede renombrarse libremente. Nebula lee esto directamente del ImplMap. Cuando encuentra un método pinvokeimpl, comprueba si hay registrado un nombre de punto de entrada. Si lo hay, el nombre gestionado es solo otro identificador renombrable. Si no lo hay, Nebula o bien conserva el nombre del método o —mejor— sintetiza un EntryPoint explícito igual al nombre antiguo y luego renombra el método, de modo que incluso tus métodos de interop obtienen identificadores ofuscados mientras la búsqueda nativa queda fijada. En cualquier caso, nada queda a merced de una lista de exclusión escrita a mano que alguien olvidará actualizar al añadir la siguiente importación.
La disposición del struct se observa por bytes, no por nombres
La segunda frontera son los datos. Cuando pasas un struct a código nativo, el marshaller lo dispone en memoria según su [StructLayout] y el código nativo lee esos bytes en offsets fijos. Los nombres de campo nunca cruzan la frontera —C no sabe que tu campo se llama cbSize—, pero el orden, el pack, los offsets explícitos y el [MarshalAs] por campo absolutamente sí.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct NativePoint
{
public int X;
public int Y;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public byte[] Tag;
}
Aquí el lado nativo espera, en el offset 0, un entero de 4 bytes; en el offset 4, otro; en el offset 8, un array inline de 4 bytes. Puedes renombrar X, Y y Tag a a, b y c y la imagen marshalizada es byte a byte idéntica, porque el marshalling es posicional. Lo que no puedes hacer es reordenar los campos, quitar el Pack, eliminar el MarshalAs ni cambiar LayoutKind.Sequential por Auto: cualquiera de esos cambia la disposición de bytes y el código nativo lee basura. Un struct LayoutKind.Explicit es aún más estricto: cada campo tiene un [FieldOffset(n)] duro y los offsets son el contrato.
La regla de Nebula aquí es la imagen especular de la regla del punto de entrada: para cualquier tipo que lleve un [StructLayout] que participe en marshalling, los nombres de campo son libres pero la disposición queda congelada —secuencia de campos, pack, offsets explícitos y directivas MarshalAs se preservan exactamente, y al renombrador no se le permite reordenar los campos de un tipo de disposición secuencial ni siquiera cuando reordena miembros de clases ordinarias—. El resultado es un struct cuyos nombres de campo están ofuscados y cuya imagen de memoria queda intacta.
Los delegados marshalizados como punteros de función conservan su forma, no su nombre
La tercera frontera son los callbacks: un delegado gestionado entregado a código nativo que le volverá a llamar. El caso clásico es una API de enumeración:
internal delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[LibraryImport("user32.dll")]
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
internal static partial int EnumWindows(EnumWindowsProc callback, IntPtr lParam);
El EnumWindows nativo invoca tu callback repetidamente, por puntero, contra una convención de llamada acordada (StdCall aquí). Lo que el lado nativo necesita es la firma del delegado —tipos de parámetros, tipo de retorno— y su convención de llamada. Nunca ve el nombre del tipo del delegado. Así que EnumWindowsProc puede renombrarse a f; su firma de Invoke y su convención [UnmanagedFunctionPointer] no pueden cambiar, porque la ABI es el contrato.
Nebula reconoce un delegado como marshalizado cuando se usa como tipo de parámetro de un P/Invoke o fluye a través de Marshal.GetFunctionPointerForDelegate, y preserva su firma y convención de llamada mientras renombra el tipo mismo. El bug genuinamente peligroso en esta área no es el nombrado en absoluto: es el tiempo de vida. Un delegado pasado a código nativo debe estar enraizado por el código gestionado mientras el nativo tenga el puntero; si la única referencia era un local que sale de ámbito, el GC recoge el delegado y el siguiente callback nativo salta a memoria liberada. Ese crash existe con o sin ofuscación, pero la gente a menudo ejecuta su primera build ofuscada, topa con el viejo bug de tiempo de vida con un timing ligeramente distinto y culpa al ofuscador. Mantén el delegado en un campo durante la duración de la llamada nativa y el problema desaparece, en ambas builds.
Configurarlo: confía en los metadatos, verifica los bordes
Como Nebula deriva todo esto de los metadatos de marshalling, la configuración por defecto ya hace lo correcto: no listas tus tipos de interop. Lo que sí haces es decirle a Nebula que sea estricto con la frontera y luego verificar, porque la rotura de interop aparece en tiempo de ejecución, no al compilar.
<!-- Nebula.props — interop-aware obfuscation -->
<ItemGroup>
<NebulaProtect Include="$(TargetPath)">
<!-- Rename everything, including interop internals... -->
<Rename>true</Rename>
<!-- ...but honour marshalling metadata: entry points, struct layout, delegate ABI. -->
<PreserveInteropContracts>true</PreserveInteropContracts>
<!-- Pin implied entry points by synthesising EntryPoint, then rename the method. -->
<PinImpliedEntryPoints>true</PinImpliedEntryPoints>
</NebulaProtect>
</ItemGroup>
Luego el paso innegociable: ejecuta tus rutas de interop contra el binario ofuscado, no contra el limpio. Las pruebas unitarias que simulan la capa nativa no demuestran nada aquí, porque todo el riesgo vive en el marshalling real. Una única prueba de extremo a extremo que llame de verdad a cada [DllImport], dé ida y vuelta a cada struct marshalizado y ejercite cada callback —ejecutada en CI contra la salida protegida— convierte “creemos que la interop sobrevivió” en “la interop sobrevivió, y aquí está la build verde que lo dice”. Es la misma disciplina que este blog ha defendido para reflexión y serialización: ofusca con agresividad, y fija exactamente el contrato que el mundo exterior observa, ni más ni menos.
La conclusión
La interop nativa es donde el código gestionado deja de ser puramente interno, y la ofuscación tiene que respetarlo o corrompe la frontera. Los tres contratos portantes son pequeños y precisos: el nombre del punto de entrada de un P/Invoke de nombre implícito, la disposición de bytes de un struct marshalizado, y la firma-y-convención de un delegado marshalizado. Los nombres en todo lo demás —el nombre del método cuando hay un EntryPoint puesto, cada nombre de campo del struct, cada nombre de tipo del delegado, y toda tu fontanería de interop— son libres de ofuscar. Una herramienta que lee los metadatos de marshalling mantiene estables exactamente esos tres contratos y renombra el resto, así que tu ensamblado cargado de P/Invoke sale del protector tan difícil de leer como el resto de tu código y sigue hablando con el mundo nativo sin cambios.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.