# Reference Obfuscation in .NET: Hiding the Call Graph


Run a decompiler over a renamed-but-otherwise-unprotected assembly and the method bodies look like noise: `a`, `b`, `c` for every local, `M0`, `M1` for every private method. Then you read the calls, and the fog lifts:

```csharp
private static bool M3(string a)
{
    byte[] b = File.ReadAllText(a).Split(',').Select(Convert.FromBase64String).First();
    using var c = RSA.Create();
    c.ImportSubjectPublicKeyInfo(Nebula_Helpers.K, out _);
    return c.VerifyData(b, /* … */, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
```

Every identifier you owned was renamed, yet `M3` is obviously *verifying a signed license file*. Nothing you renamed gave it away — the calls did. `File.ReadAllText`, `RSA.Create`, `VerifyData`: these name members of the base class library, and you cannot rename the BCL. The pattern of calls *is* the intent, and renaming never touches it.

This is the gap reference obfuscation closes. Renaming hides the names of things you own; reference obfuscation hides **which code calls what** — the call graph — including the framework calls renaming can't reach.

## What a call looks like in IL

A method call in .NET is a `call` or `callvirt` instruction carrying a single metadata token that points at a `MemberRef` or `MethodDef` row. That token resolves to a fully-qualified name. The `File.ReadAllText` call above is literally:

```
call  string [System.Runtime.IO.FileSystem]System.IO.File::ReadAllText(string)
```

The token is right there in the instruction stream. No amount of renaming your own types removes it, because it names a type in someone else's assembly. A decompiler, or de4dot, or a person with dnSpy, reads it directly.

## Routing the call through a proxy

Reference obfuscation replaces that instruction with a call to a **generated proxy** — an unbranded method Nebula injects — which resolves the real target at runtime and invokes it. The call site stops naming `File.ReadAllText`:

```
// before
call   string System.IO.File::ReadAllText(string)

// after
call   string <Module>:: (string)      // a proxy; the real target is resolved inside
```

Inside, the proxy holds the original target as data — an encrypted or indirected token resolved on first use and cached — and dispatches to it. Conceptually the generated proxy behaves like this, though the real one is itself obfuscated and unnamed:

```csharp
// Generated, one per distinct proxied target (names are illustrative).
static string Proxy_7(string path)
{
    // The target is stored as data, not as a call-site token:
    var mi = TokenCache.Resolve(0x6F03A1);   // → System.IO.File.ReadAllText(string)
    return (string)mi.Invoke(null, new object[] { path });
}
```

The key shift is that the identity of the target has moved from an **instruction operand** — which every reader of the IL sees — to **data** that is resolved at runtime. A static read of the call site now shows a jump into `<Module>::‍`, not a call to `File`. To learn what `Proxy_7` actually calls, you have to resolve the indirection the way the runtime does, which a static decompiler does not do on your behalf.

Nebula exposes this as `proxyReferences` (Pro and above):

```json
{
  "rename": true,
  "controlFlowObfuscation": "aggressive",
  "proxyReferences": true,
  "proxyReferencesInclude": [
    "MyApp.Licensing.LicenseChecker.*",
    "MyApp.Activation.*"
  ]
}
```

As with every transform, you target it. The include list restricts proxying to the namespaces where hiding the call graph earns its keep — licensing, activation, anti-tamper — rather than rewriting every call in the assembly.

## What it hides, concretely

Picture the call graph of a licensing module. Before, it is a readable map: your methods on the left, the BCL and your crypto helpers on the right, every edge a labelled call.

<svg viewBox="0 0 640 230" role="img" aria-label="Before reference obfuscation, the app's methods call named targets like File.ReadAllText, RSA.VerifyData and CheckLicense directly, so the call graph is readable. After, every call routes through an unbranded proxy layer, so the call site no longer names its target." xmlns="http://www.w3.org/2000/svg" style="max-width:640px;width:100%;height:auto;font-family:system-ui,sans-serif">
  <text x="160" y="20" fill="#94a3b8" font-size="12" text-anchor="middle" font-weight="bold">Before</text>
  <text x="480" y="20" fill="#94a3b8" font-size="12" text-anchor="middle" font-weight="bold">After</text>
  <rect x="40" y="40" width="96" height="30" rx="5" fill="#1e293b" stroke="#475569"/>
  <text x="88" y="60" fill="#e2e8f0" font-size="11" text-anchor="middle">Activate()</text>
  <rect x="40" y="100" width="96" height="30" rx="5" fill="#1e293b" stroke="#475569"/>
  <text x="88" y="120" fill="#e2e8f0" font-size="11" text-anchor="middle">Verify()</text>
  <line x1="136" y1="55" x2="208" y2="52" stroke="#64748b" stroke-width="1.5"/>
  <line x1="136" y1="115" x2="208" y2="88" stroke="#64748b" stroke-width="1.5"/>
  <line x1="136" y1="115" x2="208" y2="128" stroke="#64748b" stroke-width="1.5"/>
  <rect x="210" y="38" width="110" height="28" rx="5" fill="#0f172a" stroke="#b45309"/>
  <text x="265" y="56" fill="#fbbf24" font-size="10" text-anchor="middle">File.ReadAllText</text>
  <rect x="210" y="74" width="110" height="28" rx="5" fill="#0f172a" stroke="#b45309"/>
  <text x="265" y="92" fill="#fbbf24" font-size="10" text-anchor="middle">RSA.VerifyData</text>
  <rect x="210" y="114" width="110" height="28" rx="5" fill="#0f172a" stroke="#b45309"/>
  <text x="265" y="132" fill="#fbbf24" font-size="10" text-anchor="middle">CheckLicense</text>
  <rect x="360" y="40" width="96" height="30" rx="5" fill="#1e293b" stroke="#475569"/>
  <text x="408" y="60" fill="#e2e8f0" font-size="11" text-anchor="middle">Activate()</text>
  <rect x="360" y="100" width="96" height="30" rx="5" fill="#1e293b" stroke="#475569"/>
  <text x="408" y="120" fill="#e2e8f0" font-size="11" text-anchor="middle">Verify()</text>
  <line x1="456" y1="55" x2="520" y2="78" stroke="#64748b" stroke-width="1.5"/>
  <line x1="456" y1="115" x2="520" y2="92" stroke="#64748b" stroke-width="1.5"/>
  <rect x="522" y="70" width="96" height="30" rx="5" fill="#14281e" stroke="#15803d"/>
  <text x="570" y="90" fill="#86efac" font-size="11" text-anchor="middle">⟨proxy⟩</text>
  <line x1="570" y1="100" x2="570" y2="130" stroke="#334155" stroke-width="1.5" stroke-dasharray="4 3"/>
  <text x="570" y="146" fill="#475569" font-size="10" text-anchor="middle">target resolved</text>
  <text x="570" y="160" fill="#475569" font-size="10" text-anchor="middle">at runtime</text>
</svg>

After, every edge terminates in the same unbranded proxy layer. The map that told you `Activate` reads a file and `Verify` calls into RSA is gone: statically, both just enter a proxy. The information an attacker most wants early — *where is the license check, and what does it touch* — is no longer legible from the IL.

This matters most for the calls you cannot otherwise hide. You can rename `CheckLicense` to `M3`, but a direct call to `RSA.Create()` sitting next to a read of an embedded public key is a signature verification no matter what the surrounding locals are named. Proxying removes that tell.

## What it does not do

Reference obfuscation is a static-analysis defence, and it is honest about its limits. It raises the cost of *reading* the call graph; it does not stop an attacker from *running* your code and observing it. Anyone who attaches a profiler or sets a breakpoint can watch a proxy resolve to `File.ReadAllText` the first time it executes. That is why it is a layer, not a wall:

- **Control-flow obfuscation** scrambles the order in which proxied calls happen, so even observed calls don't reveal the logic that sequences them.
- **String encryption** hides the literal paths and keys those calls consume, so a resolved call to `ReadAllText` doesn't also hand over the filename.
- **Anti-debug / anti-tamper** raises the cost of the dynamic analysis that would otherwise unwind the proxies at runtime.

Each layer closes a shortcut. Renaming closes the "read the names" shortcut; reference obfuscation closes the "read the call graph" shortcut; together with control flow and encryption they force an attacker off the one-click static path and into slow, manual dynamic analysis — which is the entire goal of obfuscation. You are not making reverse engineering impossible; you are making it expensive enough that it isn't worth it.

Protecting a .NET app where the call graph gives away your licensing or activation logic? Turn on `proxyReferences` for those namespaces — see [resisting automated deobfuscation](https://delta1labs.com/docs/nebula/resisting-deobfuscation/) for how it combines with the other transforms, and [which edition is right for you](https://delta1labs.com/blog/which-nebula-edition-is-right-for-you) for where it unlocks.

