Obfuscating an assembly your tests still have to pass: InternalsVisibleTo and friend assemblies
Renaming internals is where obfuscation earns its keep — but [InternalsVisibleTo] quietly turns some of those internals into a contract. A test project, a sibling assembly, or a generated mock reaches your internal types by name, and that name only exists at compile time. Obfuscate the producer alone and the friend stops compiling, or worse, a reflection-based mock fails at runtime. Here is exactly what [InternalsVisibleTo] does in metadata, why Nebula.NET preserves the single-assembly case for you, and how to keep a genuine friend assembly green by obfuscating the pair together.
The single most valuable thing an obfuscator does is rename what a consumer can’t see: your internal and private types and members. They’re where your real implementation lives, and nothing outside the assembly is supposed to reference them by name, so renaming internal class PricingEngine to internal class a costs you nothing and costs an attacker a lot.
[InternalsVisibleTo] is the one attribute that breaks that assumption. It makes a named friend assembly able to see your internals by name, at compile time — which silently promotes some of your internals from “implementation detail” to “contract.” The most common friend is your own unit-test project, reaching in to test a class you didn’t want public. Obfuscate the producer on its own and that friend can stop compiling, or, if it binds by reflection, fail at runtime — with no error from the obfuscation step itself.
What [InternalsVisibleTo] actually is
The attribute is assembly-level metadata. In source it reads:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Acme.Pricing.Tests")]
In the compiled module it’s a single custom attribute on the assembly, carrying the friend’s name (and, for a signed producer, the friend’s public key):
.assembly Acme.Pricing
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.InternalsVisibleToAttribute::.ctor(string)
= ( 01 00 11 41 63 6D 65 2E 50 72 69 63 69 6E 67 // ...Acme.Pricing
2E 54 65 73 74 73 00 00 ) // .Tests
}
It does nothing at runtime — the CLR doesn’t consult it. It’s an instruction to the compiler of the friend assembly: “when you build Acme.Pricing.Tests, let it bind to Acme.Pricing’s internals.” So the moment you grant it, this is legal in the friend:
// In Acme.Pricing.Tests — only compiles because of InternalsVisibleTo
[Fact]
public void Discount_is_applied_once()
{
var engine = new PricingEngine(); // internal type
Assert.Equal(90m, engine.Apply(100m, 0.1)); // internal method
}
PricingEngine and Apply are now baked into the test assembly’s metadata as external references to Acme.Pricing. Those references are by name.
Where it breaks — and where it doesn’t
Here’s the trap, drawn out. The producer and the friend are two separate assemblies, and whether a rename is safe depends entirely on whether the friend is rewritten too.
The single-assembly case Nebula keeps working. The [InternalsVisibleTo] attribute and the friend identity it names are preserved by a run — Nebula does not corrupt the attribute or the assembly’s own identity, so friend relationships stay structurally valid (this is the single-assembly scope guarantee in the engine). What a run over the producer alone cannot do is reach into a friend assembly it was never handed and rewrite that friend’s references to the producer’s now-renamed internals. If Acme.Pricing.Tests isn’t an input, it still contains a reference to PricingEngine, and after PricingEngine becomes a, that reference resolves to nothing.
The runtime variant is worse. A mocking framework or a test that pokes internals via reflection — Type.GetType("Acme.Pricing.PricingEngine") or assembly.GetType("...") — compiles fine against the un-obfuscated producer, then throws null/MissingMethodException at runtime once the type is renamed, because the string no longer matches. No build error warns you; only the test run does.
The fix: obfuscate the friend with the producer
A friend assembly is just a cross-assembly reference that happens to cross the internal boundary. Treat it like any other multi-assembly run: give Nebula both assemblies and let it rename the shared internals on both sides consistently.
{
"inputs": [
"out/Acme.Pricing.dll",
"out/Acme.Pricing.Tests.dll"
],
"outputDirectory": "out/obf",
"preservePublicApi": true,
"crossAssemblyRename": true,
"encryptStrings": true
}
crossAssemblyRename is the key line: it renames types and members shared across the input assemblies and rewrites every cross-assembly reference, so when PricingEngine becomes a in the producer, the call site in the test assembly is rewritten to a as well. The attribute keeps naming a real assembly, the reference resolves, and the tests run against the obfuscated internals — which is the ideal acceptance test, because you’re now testing the exact IL you ship.
For this to work every assembly that references the renamed internals must be an input. That’s the general rule for crossAssemblyRename, and [InternalsVisibleTo] is simply the case where the referencing assembly reaches internal names rather than public ones.
When you can’t include the friend
Sometimes the friend genuinely isn’t yours to obfuscate — a partner’s assembly, or a tool you don’t control, that was granted internal access. Then don’t surrender the whole internal surface; preserve only the exact internals it touches. exclude force-preserves a type (and its members) or a single member by full name:
{
"inputs": ["out/Acme.Pricing.dll"],
"preservePublicApi": true,
"exclude": [
"Acme.Pricing.PricingEngine", // whole type the friend constructs
"Acme.Pricing.Internals.FeeTable.Lookup" // one member a partner tool calls
]
}
Everything else internal still gets renamed, flattened and string-encrypted — you’ve paid for access on two types instead of handing it over for the entire assembly. Discover exactly which internals a friend reads with nebula inspect before you decide; preserving a type you didn’t need to is protection you gave away for free.
A note on signing: if your producer is strong-named, [InternalsVisibleTo] carries the friend’s public key, and obfuscation that re-signs the output (strongNameKeyFile) doesn’t change that — the friend clause names the friend’s key, not the producer’s. Re-signing the producer is orthogonal; see re-signing an obfuscated assembly.
The rule of thumb
[InternalsVisibleTo] turns a slice of your implementation into a contract with one specific assembly. The protection you want — renaming internals — is exactly what that contract is sensitive to, so the two have to be reconciled, not avoided:
- Default: obfuscate the friend (usually your test project) together with the product in CI, with
crossAssemblyRenameon. Internals get renamed on both sides and your tests bind to the shipped IL. - When the friend isn’t yours:
excludeonly the specific internal types or members it reaches, never the whole internal surface. - Never: turn off internal renaming because one test reads one class. That trades most of your protection for a problem with a precise fix.
The single-assembly guarantee means Nebula won’t silently corrupt a friend relationship for you; keeping the friend green across a rename is the one thing you own, and including it in the run is how you own it. See Multiple assemblies for the cross-assembly model and Keeping your app working for the broader “what resolves by name” checklist.
Download Nebula.NET free and run your test suite against the obfuscated build.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.