# Obfuscating P/Invoke and DllImport code without breaking native interop


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.

<figure class="my-8"><svg viewBox="0 0 720 230" role="img" aria-label="The managed to native boundary. On the left, managed code: business logic and interop internals, all freely renamable. In the middle, a vertical boundary line with three load-bearing items that cross it: the p/invoke entry point name, the struct field layout and pack, and the delegate signature and calling convention. On the right, native code: the OS loader and C library that observe those three items by name, layout, and ABI. An arrow from the managed side labelled rename freely stops at the boundary; the three crossing items are marked preserve." style="width:100%;height:auto"><defs><style>.t{font:600 12px system-ui;fill:#1547e0}.x{font:11px system-ui;fill:#475569}.m{font:600 10px ui-monospace,monospace;fill:#0f172a}.man{fill:#eef4ff;stroke:#8eb4ff}.nat{fill:#f1f5f9;stroke:#cbd5e1}.keep{fill:#fef9c3;stroke:#fde047}.free{fill:#dcfce7;stroke:#86efac}.b{stroke:#dc2626;stroke-width:2.5}</style></defs><rect class="man" x="12" y="30" width="250" height="170" rx="6"/><text class="t" x="24" y="52">managed — renamable</text><rect class="free" x="24" y="64" width="226" height="28" rx="4"/><text class="m" x="34" y="82">business logic → a.b.c()</text><rect class="free" x="24" y="98" width="226" height="28" rx="4"/><text class="m" x="34" y="116">interop wrappers → d()</text><rect class="free" x="24" y="132" width="226" height="28" rx="4"/><text class="m" x="34" y="150">struct field NAMES → x, y</text><rect class="free" x="24" y="166" width="226" height="26" rx="4"/><text class="m" x="34" y="183">delegate type NAME → e</text><line class="b" x1="366" y1="24" x2="366" y2="206"/><text class="t" x="300" y="20" fill="#dc2626">boundary — preserve</text><rect class="keep" x="300" y="40" width="132" height="30" rx="4"/><text class="m" x="310" y="59">EntryPoint name</text><rect class="keep" x="300" y="80" width="132" height="30" rx="4"/><text class="m" x="310" y="99">field order · pack</text><rect class="keep" x="300" y="120" width="132" height="30" rx="4"/><text class="m" x="310" y="139">MarshalAs · offsets</text><rect class="keep" x="300" y="160" width="132" height="30" rx="4"/><text class="m" x="310" y="179">deleg. sig · convention</text><rect class="nat" x="470" y="30" width="238" height="170" rx="6"/><text class="t" x="482" y="52">native — observes the contract</text><text class="x" x="482" y="78">OS loader resolves export by name</text><text class="x" x="482" y="100">C code reads struct by byte layout</text><text class="x" x="482" y="122">callback invoked by pointer + ABI</text><text class="x" x="482" y="150">no knowledge of the CLR or tokens</text><text class="x" x="482" y="172">a mismatch fails at runtime, not build</text></svg></figure>

## 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:

```csharp
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:

```csharp
[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.

```csharp
[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:

```csharp
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.

```xml
<!-- 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.

