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: private s
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:
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:
// 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):
{
"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.
Before
After
Activate()
Verify()
File.ReadAllText
RSA.VerifyData
CheckLicense
Activate()
Verify()
β¨proxyβ©
target resolved
at runtime
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
ReadAllTextdoesn'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 for how it combines with the other transforms, and which edition is right for you for where it unlocks.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.