Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer le code P/Invoke et DllImport sans casser l'interopérabilité native

Le renommage est l'épine dorsale de l'obfuscation, et il casse exactement là où votre code managé cesse d'être autonome : à la frontière avec le code natif. Une méthode [DllImport], un délégué que vous marshalez en pointeur de fonction, une structure que vous transférez à travers la frontière : chacun porte un nom ou une disposition dont quelque chose d'extérieur au CLR dépend, et renommez-le aveuglément et l'appel échoue à l'exécution avec un MarshalDirectiveException ou une structure silencieusement corrompue. C'est pourquoi l'interop native ne survit à l'obfuscation que lorsque l'outil comprend le marshaleur : quels noms sont porteurs, pourquoi EntryPoint découple le nom managé du natif, comment l'ordre des champs et StructLayout doivent être préservés, et comment Nebula renomme tout autour de la frontière en laissant la frontière elle-même intacte.

L”obfuscation est surtout du renommage. Retirez les identifiants significatifs, aplatissez le flux de contrôle, chiffrez les chaînes, et ce qui reste ne dit presque rien à un attaquant. Le renommage fonctionne parce que, à l”intérieur d”un assembly managé, les noms ne sont que des étiquettes que le CLR résout à travers des jetons de métadonnées — changez chaque CustomerValidator.Validate en a.b de façon cohérente et l”IL se lie toujours, car l”IL référence un jeton, pas une chaîne.

Cette garantie tient jusqu”à ce que votre code cesse d”être autonome. À la frontière avec le code natif, certains noms et dispositions ne sont pas du tout des étiquettes internes — ce sont un contrat avec quelque chose que le CLR ne contrôle pas : le chargeur du système d”exploitation, une bibliothèque C, un pilote, votre propre DLL non managée. Renommez l”un d”eux aveuglément et rien ne se plaint à la compilation, car l”IL reste valide. Cela échoue à l”exécution, quand le marshaleur tente de lier une déclaration managée à une réalité native qui ne correspond plus, et vous obtenez un EntryPointNotFoundException, un MarshalDirectiveException ou — pire que tout — une structure qui marshale vers les mauvais octets et corrompt silencieusement ce que le code natif y écrit.

C”est la raison la plus courante pour laquelle les gens croient que « l”obfuscation casse P/Invoke ». Elle ne le doit pas. Elle casse quand l”obfuscateur renomme les parties de la frontière que le côté natif observe, et elle survit quand l”obfuscateur comprend le marshaleur assez bien pour savoir quelles sont ces parties. Parcourons la frontière et voyons exactement où vivent les noms et dispositions porteurs.

managé — renommablelogique métier → a.b.c()wrappers interop → d()NOMS de champ → x, yNOM du délégué → efrontière — préservernom EntryPointordre · packMarshalAs · offsetssig. · convention dél.natif — observe le contratle chargeur OS résout l''export par nomle C lit la structure par dispositioncallback invoqué par pointeur + ABIaucune connaissance du CLR ni des jetonsune discordance échoue à l''exécution, pas à la compilation

Le point d”entrée est un nom qui sert aussi de clé de recherche

Commençons par le cas le plus courant, et celui qui mord en premier. Voici un P/Invoke parfaitement ordinaire :

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 forme générée par source [LibraryImport] se comporte comme le classique [DllImport] pour nos besoins — la règle du point d”entrée est identique.) Il n”y a pas d”EntryPoint défini ici, donc le runtime utilise le nom de la méthode managée — MessageBoxW — comme export natif à rechercher dans user32.dll. Le nom managé et la clé de recherche native sont la même chaîne. Renommez la méthode en a et le stub de marshaling généré demande désormais au chargeur un export nommé a dans user32.dll, qui n”existe pas, et le premier appel lève EntryPointNotFoundException.

Regardez ce que les métadonnées enregistrent réellement. Une méthode P/Invoke a le drapeau pinvokeimpl et une entrée ImplMap qui capture le module et le nom d”import. Décompilée, la déclaration se lit :

.method private hidebysig static pinvokeimpl("user32.dll" winapi)
    int32 MessageBoxW(native int hWnd, string text, string caption, uint32 type) cil managed preservesig

Remarquez que l”ImplMap nomme le module (user32.dll) mais pas l”export — car sans EntryPoint explicite, le nom de l”export est implicitement le nom de la méthode. Cette implication est le piège. Comparez-la à la forme qui découple les deux :

[LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int ShowMessage(IntPtr hWnd, string text, string caption, uint type);

Maintenant l”ImplMap porte EntryPoint = "MessageBoxW" explicitement, la méthode managée s”appelle ShowMessage, et les deux sont indépendants. Renommez ShowMessage en a et l”appel se résout toujours, car la clé de recherche native vit dans l”attribut, pas dans le nom de la méthode.

Donc la règle que suit un obfuscateur correct est précise : un P/Invoke dont le nom d”export est implicitement son nom de méthode doit garder ce nom ; un P/Invoke avec un EntryPoint explicite peut être renommé librement. Nebula lit cela directement depuis l”ImplMap. Quand il rencontre une méthode pinvokeimpl, il vérifie si un nom de point d”entrée est enregistré. S”il y en a un, le nom managé n”est qu”un autre identifiant renommable. S”il n”y en a pas, Nebula soit conserve le nom de la méthode, soit — mieux — synthétise un EntryPoint explicite égal à l”ancien nom et ensuite renomme la méthode, de sorte que même vos méthodes d”interop obtiennent des identifiants obfusqués tandis que la recherche native reste épinglée. Dans les deux cas, rien n”est laissé à une liste d”exclusion écrite à la main que quelqu”un oubliera de mettre à jour en ajoutant le prochain import.

La disposition de la structure est observée par octets, pas par noms

La deuxième frontière, ce sont les données. Quand vous passez une structure au code natif, le marshaleur la dispose en mémoire selon son [StructLayout] et le code natif lit ces octets à des offsets fixes. Les noms de champ ne traversent jamais la frontière — le C ne sait pas que votre champ s”appelle cbSize — mais l”ordre, le pack, les offsets explicites et le [MarshalAs] par champ, absolument.

[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct NativePoint
{
    public int X;
    public int Y;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
    public byte[] Tag;
}

Ici le côté natif attend, à l”offset 0, un entier de 4 octets ; à l”offset 4, un autre ; à l”offset 8, un tableau inline de 4 octets. Vous pouvez renommer X, Y et Tag en a, b et c et l”image marshalée est octet pour octet identique, car le marshaling est positionnel. Ce que vous ne pouvez pas faire, c”est réordonner les champs, retirer le Pack, supprimer le MarshalAs, ou changer LayoutKind.Sequential en Auto — chacun de ces changements modifie la disposition des octets et le code natif lit du charabia. Une structure LayoutKind.Explicit est encore plus stricte : chaque champ a un [FieldOffset(n)] dur et les offsets sont le contrat.

La règle de Nebula ici est l”image miroir de la règle du point d”entrée : pour tout type portant un [StructLayout] qui participe au marshaling, les noms de champ sont libres mais la disposition est gelée — séquence de champs, pack, offsets explicites et directives MarshalAs sont tous préservés exactement, et le renommeur n”est pas autorisé à réordonner les champs d”un type à disposition séquentielle même quand il réordonne les membres de classes ordinaires. Le résultat est une structure dont les noms de champ sont obfusqués et dont l”image mémoire reste intacte.

Les délégués marshalés en pointeurs de fonction gardent leur forme, pas leur nom

La troisième frontière, ce sont les callbacks — un délégué managé remis au code natif qui le rappellera. Le cas classique est une API d”énumération :

internal delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

[LibraryImport("user32.dll")]
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
internal static partial int EnumWindows(EnumWindowsProc callback, IntPtr lParam);

Le EnumWindows natif invoque votre callback à répétition, par pointeur, contre une convention d”appel convenue (StdCall ici). Ce dont le côté natif dépend, c”est la signature du délégué — types de paramètres, type de retour — et sa convention d”appel. Il ne voit jamais le nom du type du délégué. Donc EnumWindowsProc peut être renommé en f ; sa signature Invoke et sa convention [UnmanagedFunctionPointer] ne peuvent pas changer, car l”ABI est le contrat.

Nebula reconnaît un délégué comme marshalé quand il est utilisé comme type de paramètre d”un P/Invoke ou transite par Marshal.GetFunctionPointerForDelegate, et préserve sa signature et sa convention d”appel tout en renommant le type lui-même. Le bug véritablement dangereux dans ce domaine n”est pas du tout le nommage — c”est la durée de vie. Un délégué passé au code natif doit être enraciné par le code managé tant que le natif détient le pointeur ; si la seule référence était une locale qui sort de portée, le GC collecte le délégué et le callback natif suivant saute en mémoire libérée. Ce plantage existe avec ou sans obfuscation, mais les gens exécutent souvent leur première build obfusquée, heurtent le vieux bug de durée de vie avec un timing légèrement différent et imputent à l”obfuscateur. Gardez le délégué dans un champ pour la durée de l”appel natif et le problème disparaît — dans les deux builds.

Le configurer : fiez-vous aux métadonnées, vérifiez les bords

Comme Nebula dérive tout cela des métadonnées de marshaling, la configuration par défaut fait déjà ce qu”il faut — vous ne listez pas vos types d”interop. Ce que vous faites, c”est dire à Nebula d”être strict sur la frontière puis de vérifier, car la casse d”interop se manifeste à l”exécution, pas à la compilation.

<!-- 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>

Puis l”étape non négociable : exécutez vos chemins d”interop contre le binaire obfusqué, pas contre le propre. Les tests unitaires qui simulent la couche native ne prouvent rien ici, car tout le risque vit dans le marshaling réel. Un seul test de bout en bout qui appelle réellement chaque [DllImport], fait l”aller-retour de chaque structure marshalée et exerce chaque callback — exécuté en CI contre la sortie protégée — transforme « on pense que l”interop a survécu » en « l”interop a survécu, et voici la build verte qui le dit ». C”est la même discipline que ce blog a défendue pour la réflexion et la sérialisation : obfusquez agressivement, et épinglez exactement le contrat que le monde extérieur observe, ni plus ni moins.

À retenir

L”interop native est l”endroit où le code managé cesse d”être purement interne, et l”obfuscation doit le respecter ou elle corrompt la frontière. Les trois contrats porteurs sont petits et précis : le nom du point d”entrée d”un P/Invoke à nom implicite, la disposition des octets d”une structure marshalée, et la signature-et-convention d”un délégué marshalé. Les noms partout ailleurs — le nom de la méthode quand un EntryPoint est défini, chaque nom de champ de structure, chaque nom de type de délégué, et toute votre plomberie d”interop — sont libres d”être obfusqués. Un outil qui lit les métadonnées de marshaling maintient stables exactement ces trois contrats et renomme le reste, de sorte que votre assembly riche en P/Invoke sort du protecteur aussi difficile à lire que le reste de votre code et parle toujours au monde natif sans changement.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.