Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscation.NETDeep Dive

Obfuscating P/Invoke and DllImport code without breaking native interop

Renaming is the backbone of obfuscation, and it breaks exactly where your managed code stops being self-contained — at the boundary with native code. A [DllImport] method, a delegate you marshal to a function pointer, a struct you blit across the boundary: each carries a name or a layout that something outside the CLR depends on, and rename it blindly and the call fails at runtime with a MarshalDirectiveException or a silently corrupted struct. This is why native interop survives obfuscation only when the tool understands the marshaller: which names are load-bearing, why EntryPoint decouples the managed name from the native one, how field order and StructLayout must be preserved, and how Nebula renames everything around the boundary while leaving the boundary itself intact.

Obfuscation is mostly renaming. Strip the meaningful identifiers, flatten the control flow, encrypt the strings, and what is left tells an attacker very little. Renaming works because, inside a managed assembly, names are just labels the CLR resolves through metadata tokens — change every CustomerValidator.Validate to a.b consistently and the IL still binds, because the IL references a token, not a string.

That guarantee holds right up until your code stops being self-contained. At the boundary with native code, some names and layouts are not internal labels at all — they are a contract with something the CLR does not control: the operating system’s loader, a C library, a driver, your own unmanaged DLL. Rename one of those blindly and nothing complains at build time, because the IL is still valid. It fails at runtime, when the marshaller tries to bind a managed declaration to a native reality that no longer matches, and you get an EntryPointNotFoundException, a MarshalDirectiveException, or — worst of all — a struct that marshals to the wrong bytes and silently corrupts whatever the native code writes into it.

This is the single most common reason people believe “obfuscation breaks P/Invoke.” It does not have to. It breaks when the obfuscator renames the parts of the boundary the native side observes, and it survives when the obfuscator understands the marshaller well enough to know which parts those are. Let us walk the boundary and see exactly where the load-bearing names and layouts live.

managed — renamablebusiness logic → a.b.c()interop wrappers → d()struct field NAMES → x, ydelegate type NAME → eboundary — preserveEntryPoint namefield order · packMarshalAs · offsetsdeleg. sig · conventionnative — observes the contractOS loader resolves export by nameC code reads struct by byte layoutcallback invoked by pointer + ABIno knowledge of the CLR or tokensa mismatch fails at runtime, not build

The entry point is a name that doubles as a lookup key

Start with the most common case, and the one that bites first. Here is a perfectly ordinary 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);
}

(The source-generated [LibraryImport] form behaves the same as classic [DllImport] for our purposes — the entry-point rule is identical.) There is no EntryPoint set here, so the runtime uses the managed method name — MessageBoxW — as the native export to look up in user32.dll. The managed name and the native lookup key are the same string. Rename the method to a and the generated marshalling stub now asks the loader for an export called a in user32.dll, which does not exist, and the first call throws EntryPointNotFoundException.

Look at what the metadata actually records. A P/Invoke method has the pinvokeimpl flag and an ImplMap entry that captures the module and the import name. Decompiled, the declaration reads:

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

Notice the ImplMap names the module (user32.dll) but not the export — because without an explicit EntryPoint, the export name is implied to be the method name. That implication is the trap. Contrast it with the form that decouples the two:

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

Now the ImplMap carries EntryPoint = "MessageBoxW" explicitly, the managed method is called ShowMessage, and the two are independent. Rename ShowMessage to a and the call still resolves, because the native lookup key lives in the attribute, not in the method name.

So the rule a correct obfuscator follows is precise: a P/Invoke whose export name is implied by its method name must keep that name; a P/Invoke with an explicit EntryPoint may be renamed freely. Nebula reads this straight from the ImplMap. When it encounters a pinvokeimpl method, it checks whether an entry-point name is recorded. If one is, the managed name is just another renamable identifier. If one is not, Nebula either preserves the method name or — better — synthesises an explicit EntryPoint equal to the old name and then renames the method, so even your interop methods get obfuscated identifiers while the native lookup stays pinned. Either way, nothing is left to a hand-written exclude list that someone will forget to update when they add the next import.

Struct layout is observed by bytes, not by names

The second boundary is data. When you pass a struct to native code, the marshaller lays it out in memory according to its [StructLayout] and the native code reads those bytes at fixed offsets. The field names never cross the boundary — C does not know your field is called cbSize — but the order, the pack, the explicit offsets, and the per-field [MarshalAs] absolutely do.

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

Here the native side expects, at offset 0, a 4-byte integer; at offset 4, another; at offset 8, a 4-byte inline array. You can rename X, Y, and Tag to a, b, and c and the marshalled image is byte-for-byte identical, because marshalling is positional. What you may not do is reorder the fields, drop the Pack, remove the MarshalAs, or change LayoutKind.Sequential to Auto — any of those changes the byte layout and the native code reads garbage. A LayoutKind.Explicit struct is even stricter: every field has a hard [FieldOffset(n)] and the offsets are the contract.

Nebula’s rule here is the mirror image of the entry-point rule: for any type carrying a [StructLayout] that participates in marshalling, the field names are free but the layout is frozen — field sequence, pack, explicit offsets, and MarshalAs directives are all preserved exactly, and the renamer is not permitted to reorder fields of a sequential-layout type even when it reorders members of ordinary classes. The result is a struct whose field names are obfuscated and whose memory image is untouched.

Delegates marshalled as function pointers keep their shape, not their name

The third boundary is callbacks — a managed delegate handed to native code that will call back into it. The classic case is an enumeration API:

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

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

The native EnumWindows invokes your callback repeatedly, by pointer, against an agreed calling convention (StdCall here). What the native side relies on is the delegate’s signature — parameter types, return type — and its calling convention. It never sees the delegate’s type name. So EnumWindowsProc can be renamed to f; its Invoke signature and its [UnmanagedFunctionPointer] convention cannot change, because the ABI is the contract.

Nebula recognises a delegate as marshalled when it is used as a P/Invoke parameter type or flows through Marshal.GetFunctionPointerForDelegate, and preserves its signature and calling convention while renaming the type itself. The genuinely dangerous bug in this area is not naming at all — it is lifetime. A delegate passed to native code must be rooted by managed code for as long as native holds the pointer; if the only reference was a local that goes out of scope, the GC collects the delegate and the next native callback jumps into freed memory. That crash exists with or without obfuscation, but people often run their first obfuscated build, hit the long-standing lifetime bug on a slightly different timing, and blame the obfuscator. Keep the delegate in a field for the duration of the native call and the problem disappears — in both builds.

Configuring it: trust the metadata, verify the edges

Because Nebula derives all of this from marshalling metadata, the default configuration already does the right thing — you do not list your interop types. What you do is tell Nebula to be strict about the boundary and then verify, because interop breakage shows up at runtime, not build time.

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

Then the non-negotiable step: run your interop paths against the obfuscated binary, not the clean one. Unit tests that mock the native layer prove nothing here, because the whole risk lives in the real marshalling. A single end-to-end test that actually calls each [DllImport], round-trips each marshalled struct, and exercises each callback — run in CI against the protected output — turns “we think interop survived” into “interop survived, and here is the green build that says so.” That is the same discipline this blog has argued for reflection and serialization: obfuscate aggressively, and pin exactly the contract the outside world observes, no more and no less.

The takeaway

Native interop is where managed code stops being purely internal, and obfuscation has to respect that or it corrupts the boundary. The three load-bearing contracts are small and precise: the entry-point name of an implied-name P/Invoke, the byte layout of a marshalled struct, and the signature-and-convention of a marshalled delegate. Names everywhere else — the method name when an EntryPoint is set, every struct field name, every delegate type name, and all of your interop plumbing — are free to obfuscate. A tool that reads the marshalling metadata holds exactly those three contracts stable and renames the rest, so your P/Invoke-heavy assembly comes out of the protector as hard to read as the rest of your code and still talks to the native world unchanged.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.