P/Invoke- und DllImport-Code obfuskieren, ohne die native Interoperabilität zu brechen
Umbenennung ist das Rückgrat der Obfuskierung, und sie bricht genau dort, wo Ihr verwalteter Code aufhört, in sich geschlossen zu sein – an der Grenze zum nativen Code. Eine [DllImport]-Methode, ein Delegat, den Sie zu einem Funktionszeiger marshallen, ein Struct, das Sie über die Grenze schieben: Jedes trägt einen Namen oder ein Layout, von dem etwas außerhalb der CLR abhängt, und benennen Sie es blind um, scheitert der Aufruf zur Laufzeit mit einer MarshalDirectiveException oder einem stillschweigend beschädigten Struct. Deshalb überlebt native Interop die Obfuskierung nur, wenn das Werkzeug den Marshaller versteht: welche Namen tragend sind, warum EntryPoint den verwalteten Namen vom nativen entkoppelt, wie Feldreihenfolge und StructLayout erhalten bleiben müssen, und wie Nebula alles rund um die Grenze umbenennt und die Grenze selbst unangetastet lässt.
Obfuskierung ist größtenteils Umbenennung. Entfernen Sie die bedeutungstragenden Bezeichner, verflachen Sie den Kontrollfluss, verschlüsseln Sie die Zeichenketten, und was übrig bleibt, verrät einem Angreifer sehr wenig. Umbenennung funktioniert, weil Namen innerhalb eines verwalteten Assemblys nur Etiketten sind, die die CLR über Metadaten-Tokens auflöst – ändern Sie jedes CustomerValidator.Validate konsistent zu a.b, und der IL bindet weiterhin, denn der IL referenziert ein Token, keine Zeichenkette.
Diese Garantie hält genau bis zu dem Punkt, an dem Ihr Code aufhört, in sich geschlossen zu sein. An der Grenze zum nativen Code sind manche Namen und Layouts überhaupt keine internen Etiketten – sie sind ein Vertrag mit etwas, das die CLR nicht kontrolliert: dem Loader des Betriebssystems, einer C-Bibliothek, einem Treiber, Ihrer eigenen unverwalteten DLL. Benennen Sie eines davon blind um, und zur Übersetzungszeit beschwert sich nichts, denn der IL bleibt gültig. Es scheitert zur Laufzeit, wenn der Marshaller versucht, eine verwaltete Deklaration an eine native Realität zu binden, die nicht mehr passt, und Sie erhalten eine EntryPointNotFoundException, eine MarshalDirectiveException oder – am schlimmsten – ein Struct, das zu den falschen Bytes marshallt und stillschweigend beschädigt, was der native Code hineinschreibt.
Das ist der häufigste Grund, warum Leute glauben, „Obfuskierung bricht P/Invoke”. Muss sie nicht. Sie bricht, wenn der Obfuskator die Teile der Grenze umbenennt, die die native Seite beobachtet, und sie überlebt, wenn der Obfuskator den Marshaller gut genug versteht, um zu wissen, welche Teile das sind. Gehen wir die Grenze durch und sehen wir genau, wo die tragenden Namen und Layouts leben.
Der Einstiegspunkt ist ein Name, der zugleich als Nachschlageschlüssel dient
Beginnen wir mit dem häufigsten Fall, und dem, der zuerst zubeißt. Hier ein völlig gewöhnliches P/Invoke:
internal static partial class Native
{
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}
(Die quellgenerierte [LibraryImport]-Form verhält sich für unsere Zwecke wie das klassische [DllImport] – die Einstiegspunktregel ist identisch.) Hier ist kein EntryPoint gesetzt, also verwendet die Laufzeit den verwalteten Methodennamen – MessageBoxW – als den nativen Export, den sie in user32.dll nachschlägt. Der verwaltete Name und der native Nachschlageschlüssel sind dieselbe Zeichenkette. Benennen Sie die Methode zu a um, und der generierte Marshalling-Stub fragt den Loader nun nach einem Export namens a in user32.dll, der nicht existiert, und der erste Aufruf wirft EntryPointNotFoundException.
Sehen Sie, was die Metadaten tatsächlich aufzeichnen. Eine P/Invoke-Methode hat das pinvokeimpl-Flag und einen ImplMap-Eintrag, der das Modul und den Importnamen erfasst. Dekompiliert liest sich die Deklaration:
.method private hidebysig static pinvokeimpl("user32.dll" winapi)
int32 MessageBoxW(native int hWnd, string text, string caption, uint32 type) cil managed preservesig
Beachten Sie, dass die ImplMap das Modul (user32.dll) benennt, aber nicht den Export – denn ohne expliziten EntryPoint wird der Exportname als der Methodenname impliziert. Diese Implikation ist die Falle. Vergleichen Sie es mit der Form, die beide entkoppelt:
[LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial int ShowMessage(IntPtr hWnd, string text, string caption, uint type);
Nun trägt die ImplMap EntryPoint = "MessageBoxW" explizit, die verwaltete Methode heißt ShowMessage, und beide sind unabhängig. Benennen Sie ShowMessage zu a um, und der Aufruf löst sich weiterhin auf, denn der native Nachschlageschlüssel lebt im Attribut, nicht im Methodennamen.
Die Regel, der ein korrekter Obfuskator folgt, ist also präzise: ein P/Invoke, dessen Exportname durch seinen Methodennamen impliziert ist, muss diesen Namen behalten; ein P/Invoke mit einem expliziten EntryPoint darf frei umbenannt werden. Nebula liest dies direkt aus der ImplMap. Trifft es auf eine pinvokeimpl-Methode, prüft es, ob ein Einstiegspunktname aufgezeichnet ist. Gibt es einen, ist der verwaltete Name nur ein weiterer umbenennbarer Bezeichner. Gibt es keinen, bewahrt Nebula entweder den Methodennamen oder – besser – synthetisiert einen expliziten EntryPoint gleich dem alten Namen und benennt die Methode dann um, sodass selbst Ihre Interop-Methoden obfuskierte Bezeichner erhalten, während der native Nachschlag festgenagelt bleibt. In beiden Fällen bleibt nichts einer handgeschriebenen Ausschlussliste überlassen, die jemand beim nächsten Import zu aktualisieren vergisst.
Das Struct-Layout wird nach Bytes beobachtet, nicht nach Namen
Die zweite Grenze sind Daten. Wenn Sie ein Struct an nativen Code übergeben, legt der Marshaller es gemäß seinem [StructLayout] im Speicher aus, und der native Code liest diese Bytes an festen Offsets. Die Feld-Namen überqueren die Grenze nie – C weiß nicht, dass Ihr Feld cbSize heißt –, aber die Reihenfolge, das Pack, die expliziten Offsets und das [MarshalAs] pro Feld sehr wohl.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct NativePoint
{
public int X;
public int Y;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public byte[] Tag;
}
Hier erwartet die native Seite an Offset 0 eine 4-Byte-Ganzzahl; an Offset 4 eine weitere; an Offset 8 ein 4-Byte-Inline-Array. Sie können X, Y und Tag zu a, b und c umbenennen, und das gemarshallte Bild ist Byte für Byte identisch, denn Marshalling ist positionell. Was Sie nicht tun dürfen, ist die Felder umordnen, das Pack entfernen, das MarshalAs streichen oder LayoutKind.Sequential zu Auto ändern – jede dieser Änderungen verändert das Byte-Layout, und der native Code liest Müll. Ein LayoutKind.Explicit-Struct ist noch strenger: Jedes Feld hat ein hartes [FieldOffset(n)], und die Offsets sind der Vertrag.
Nebulas Regel hier ist das Spiegelbild der Einstiegspunktregel: Für jeden Typ, der ein am Marshalling beteiligtes [StructLayout] trägt, sind die Feld-Namen frei, aber das Layout ist eingefroren – Feldsequenz, Pack, explizite Offsets und MarshalAs-Direktiven bleiben exakt erhalten, und der Umbenenner darf die Felder eines Typs mit sequenziellem Layout nicht umordnen, selbst wenn er die Mitglieder gewöhnlicher Klassen umordnet. Das Ergebnis ist ein Struct, dessen Feldnamen obfuskiert sind und dessen Speicherbild unangetastet bleibt.
Als Funktionszeiger gemarshallte Delegaten behalten ihre Form, nicht ihren Namen
Die dritte Grenze sind Callbacks – ein verwalteter Delegat, der an nativen Code übergeben wird, der zurückrufen wird. Der klassische Fall ist eine Aufzählungs-API:
internal delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[LibraryImport("user32.dll")]
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
internal static partial int EnumWindows(EnumWindowsProc callback, IntPtr lParam);
Das native EnumWindows ruft Ihr callback wiederholt auf, per Zeiger, gegen eine vereinbarte Aufrufkonvention (hier StdCall). Worauf die native Seite angewiesen ist, ist die Signatur des Delegaten – Parametertypen, Rückgabetyp – und seine Aufrufkonvention. Den Namen des Delegattyps sieht sie nie. Also kann EnumWindowsProc zu f umbenannt werden; seine Invoke-Signatur und seine [UnmanagedFunctionPointer]-Konvention dürfen sich nicht ändern, denn die ABI ist der Vertrag.
Nebula erkennt einen Delegaten als gemarshallt, wenn er als Parametertyp eines P/Invoke verwendet wird oder durch Marshal.GetFunctionPointerForDelegate fließt, und bewahrt seine Signatur und Aufrufkonvention, während es den Typ selbst umbenennt. Der wirklich gefährliche Fehler in diesem Bereich ist gar nicht die Benennung – es ist die Lebensdauer. Ein an nativen Code übergebener Delegat muss vom verwalteten Code verankert werden, solange der native Code den Zeiger hält; war die einzige Referenz eine lokale Variable, die den Gültigkeitsbereich verlässt, sammelt der GC den Delegaten ein, und der nächste native Callback springt in freigegebenen Speicher. Dieser Absturz existiert mit oder ohne Obfuskierung, aber die Leute führen oft ihren ersten obfuskierten Build aus, treffen bei leicht anderem Timing auf den langjährigen Lebensdauer-Fehler und geben dem Obfuskator die Schuld. Halten Sie den Delegaten für die Dauer des nativen Aufrufs in einem Feld, und das Problem verschwindet – in beiden Builds.
Es konfigurieren: vertrauen Sie den Metadaten, prüfen Sie die Ränder
Da Nebula all dies aus den Marshalling-Metadaten ableitet, tut die Standardkonfiguration bereits das Richtige – Sie listen Ihre Interop-Typen nicht auf. Was Sie tun, ist Nebula zu sagen, an der Grenze streng zu sein, und dann zu verifizieren, denn Interop-Brüche zeigen sich zur Laufzeit, nicht beim Build.
<!-- 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>
Dann der nicht verhandelbare Schritt: Führen Sie Ihre Interop-Pfade gegen das obfuskierte Binary aus, nicht gegen das saubere. Unit-Tests, die die native Schicht mocken, beweisen hier nichts, denn das gesamte Risiko lebt im echten Marshalling. Ein einziger End-to-End-Test, der jedes [DllImport] tatsächlich aufruft, jedes gemarshallte Struct hin- und zurückführt und jeden Callback ausübt – in der CI gegen die geschützte Ausgabe ausgeführt –, verwandelt „wir glauben, die Interop hat überlebt” in „die Interop hat überlebt, und hier ist der grüne Build, der es sagt”. Es ist dieselbe Disziplin, für die dieser Blog bei Reflexion und Serialisierung argumentiert hat: obfuskieren Sie aggressiv, und nageln Sie genau den Vertrag fest, den die Außenwelt beobachtet, nicht mehr und nicht weniger.
Das Fazit
Native Interop ist der Ort, an dem verwalteter Code aufhört, rein intern zu sein, und die Obfuskierung muss das respektieren, sonst beschädigt sie die Grenze. Die drei tragenden Verträge sind klein und präzise: der Einstiegspunktname eines P/Invoke mit implizitem Namen, das Byte-Layout eines gemarshallten Structs, und die Signatur-und-Konvention eines gemarshallten Delegaten. Namen überall sonst – der Methodenname, wenn ein EntryPoint gesetzt ist, jeder Struct-Feldname, jeder Delegat-Typname und Ihre gesamte Interop-Installation – dürfen frei obfuskiert werden. Ein Werkzeug, das die Marshalling-Metadaten liest, hält genau diese drei Verträge stabil und benennt den Rest um, sodass Ihr P/Invoke-lastiges Assembly aus dem Protektor so schwer lesbar herauskommt wie der Rest Ihres Codes und weiterhin unverändert mit der nativen Welt spricht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.